init: scripts diversos (crawlers, conversores, scrapers)
This commit is contained in:
285
translate-wp-plugin/translate_missing.py
Executable file
285
translate-wp-plugin/translate_missing.py
Executable file
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
translate_missing.py — Preenche strings em falta em ficheiros .po via DeepL (PT-PT).
|
||||
|
||||
Traduz apenas msgstr vazios (msgstr ""). Não sobrescreve traduções existentes.
|
||||
Preserva placeholders WordPress (%s, %d, %1$s, {var}, ##var##) e HTML.
|
||||
Recompila .mo automaticamente após tradução via polib.
|
||||
Suporta batching (50 strings/pedido) — muito mais rápido que LibreTranslate.
|
||||
|
||||
Uso:
|
||||
python3 translate_missing.py plugin-pt_PT.po [plugin2-pt_PT.po ...]
|
||||
python3 translate_missing.py plugin-pt_PT.po --dry-run
|
||||
find /path/to/library -name "*-pt_PT.po" | xargs python3 translate_missing.py
|
||||
|
||||
Author: Descomplicar®
|
||||
Version: 2.1.0 — DeepL PT-PT + suporte plural (msgstr[0]/msgstr[1])
|
||||
"""
|
||||
import sys
|
||||
import re
|
||||
import time
|
||||
import shutil
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Carregar .env do mesmo directório
|
||||
_env_file = Path(__file__).parent / '.env'
|
||||
if _env_file.exists():
|
||||
for _line in _env_file.read_text().splitlines():
|
||||
if '=' in _line and not _line.startswith('#'):
|
||||
_k, _v = _line.split('=', 1)
|
||||
os.environ.setdefault(_k.strip(), _v.strip())
|
||||
|
||||
try:
|
||||
import deepl
|
||||
except ImportError:
|
||||
print("ERRO: biblioteca 'deepl' não instalada. Correr: pip3 install deepl", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import polib
|
||||
except ImportError:
|
||||
print("ERRO: biblioteca 'polib' não instalada. Correr: pip3 install polib", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Configuração
|
||||
DEEPL_AUTH_KEY = os.environ.get('DEEPL_API_KEY', '')
|
||||
DEEPL_GLOSSARY_ID = os.environ.get('DEEPL_GLOSSARY_ID', '')
|
||||
TARGET_LANG = 'PT-PT'
|
||||
BATCH_SIZE = 50 # DeepL suporta até 50 textos por pedido
|
||||
RATE_LIMIT = 0.1 # segundos entre pedidos (muito mais rápido que LibreTranslate)
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY = 3
|
||||
|
||||
# Padrão para placeholder WordPress — proteger durante tradução
|
||||
PH_PATTERN = re.compile(
|
||||
r'(%(?:\d+\$)?[sdfu]|%\.\d+[fF]|\{[^}]+\}|##[^#]+##|<[^>]+>|&[a-z]+;|&#\d+;)',
|
||||
re.UNICODE
|
||||
)
|
||||
|
||||
|
||||
def protect_placeholders(text: str) -> tuple[str, list[str]]:
|
||||
"""
|
||||
Substitui placeholders por tokens ⟦0⟧, ⟦1⟧, etc. (Unicode, não XML).
|
||||
DeepL preserva estes tokens sem necessitar de tag_handling.
|
||||
Devolve (texto_protegido, lista_de_placeholders).
|
||||
"""
|
||||
placeholders = []
|
||||
def replace(m):
|
||||
token = f'⟦{len(placeholders)}⟧'
|
||||
placeholders.append(m.group(0))
|
||||
return token
|
||||
protected = PH_PATTERN.sub(replace, text)
|
||||
return protected, placeholders
|
||||
|
||||
|
||||
def restore_placeholders(text: str, placeholders: list[str]) -> str:
|
||||
"""Restaura placeholders após tradução."""
|
||||
for i, ph in enumerate(placeholders):
|
||||
text = text.replace(f'⟦{i}⟧', ph)
|
||||
return text
|
||||
|
||||
|
||||
def escape_po(text: str) -> str:
|
||||
"""Escapa caracteres especiais para formato .po."""
|
||||
text = text.replace('\\', '\\\\')
|
||||
text = text.replace('"', '\\"')
|
||||
text = text.replace('\n', '\\n')
|
||||
text = text.replace('\t', '\\t')
|
||||
return text
|
||||
|
||||
|
||||
def translate_batch(translator: 'deepl.Translator', texts: list[str]) -> list[str | None]:
|
||||
"""
|
||||
Traduz uma lista de textos via DeepL com protecção de placeholders.
|
||||
Devolve lista de traduções (None em caso de falha individual).
|
||||
"""
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
# Proteger placeholders em todos os textos
|
||||
protected_texts = []
|
||||
all_placeholders = []
|
||||
for text in texts:
|
||||
protected, phs = protect_placeholders(text)
|
||||
protected_texts.append(protected)
|
||||
all_placeholders.append(phs)
|
||||
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
kwargs = dict(
|
||||
source_lang='EN',
|
||||
target_lang=TARGET_LANG,
|
||||
preserve_formatting=True,
|
||||
)
|
||||
if DEEPL_GLOSSARY_ID:
|
||||
kwargs['glossary'] = DEEPL_GLOSSARY_ID
|
||||
results = translator.translate_text(protected_texts, **kwargs)
|
||||
translations = []
|
||||
for i, result in enumerate(results):
|
||||
restored = restore_placeholders(result.text, all_placeholders[i])
|
||||
translations.append(restored)
|
||||
return translations
|
||||
except deepl.exceptions.QuotaExceededException:
|
||||
print("\n ATENÇÃO: Quota DeepL esgotada! Limite mensal atingido.", file=sys.stderr)
|
||||
return [None] * len(texts)
|
||||
except Exception as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
time.sleep(RETRY_DELAY)
|
||||
else:
|
||||
print(f" ERRO API DeepL: {e}", file=sys.stderr)
|
||||
return [None] * len(texts)
|
||||
|
||||
return [None] * len(texts)
|
||||
|
||||
|
||||
def process_file(filepath: str, translator: 'deepl.Translator', dry_run: bool = False) -> tuple[int, int]:
|
||||
"""
|
||||
Processa um ficheiro .po e traduz strings em falta via DeepL.
|
||||
Suporta entradas simples (msgstr) e plurais (msgstr[0]/msgstr[1]).
|
||||
Devolve (strings traduzidas, erros).
|
||||
"""
|
||||
path = Path(filepath)
|
||||
if not path.exists():
|
||||
print(f" ERRO: ficheiro não encontrado: {filepath}", file=sys.stderr)
|
||||
return 0, 1
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {path.parent.name}/{path.name}")
|
||||
|
||||
po = polib.pofile(filepath, encoding='utf-8')
|
||||
|
||||
# Recolher entradas simples vazias
|
||||
# item: ('simple', entry, None)
|
||||
# item: ('plural_s', entry, None) — singular do plural
|
||||
# item: ('plural_p', entry, None) — plural do plural
|
||||
to_translate = [] # (tipo, entry, texto_a_traduzir)
|
||||
|
||||
for entry in po:
|
||||
if entry.msgid_plural:
|
||||
# Entrada plural — verificar cada forma
|
||||
s_empty = not entry.msgstr_plural.get(0, '').strip()
|
||||
p_empty = not entry.msgstr_plural.get(1, '').strip()
|
||||
if s_empty and entry.msgid.strip():
|
||||
to_translate.append(('plural_s', entry, entry.msgid))
|
||||
if p_empty and entry.msgid_plural.strip():
|
||||
to_translate.append(('plural_p', entry, entry.msgid_plural))
|
||||
else:
|
||||
# Entrada simples
|
||||
if not entry.msgstr.strip() and entry.msgid.strip():
|
||||
to_translate.append(('simple', entry, entry.msgid))
|
||||
|
||||
if not to_translate:
|
||||
print(f" Sem strings em falta — a saltar")
|
||||
return 0, 0
|
||||
|
||||
n_simple = sum(1 for t, _, _ in to_translate if t == 'simple')
|
||||
n_plural = sum(1 for t, _, _ in to_translate if t != 'simple')
|
||||
print(f" {len(to_translate)} strings em falta ({n_simple} simples, {n_plural} plurais) — DeepL PT-PT...")
|
||||
|
||||
if dry_run:
|
||||
print(f" [DRY-RUN] sem alterações gravadas")
|
||||
return len(to_translate), 0
|
||||
|
||||
# Traduzir em batches
|
||||
translated_count = 0
|
||||
errors = 0
|
||||
texts = [texto for _, _, texto in to_translate]
|
||||
|
||||
for batch_start in range(0, len(texts), BATCH_SIZE):
|
||||
batch_items = to_translate[batch_start:batch_start + BATCH_SIZE]
|
||||
batch_texts = [texto for _, _, texto in batch_items]
|
||||
|
||||
results = translate_batch(translator, batch_texts)
|
||||
time.sleep(RATE_LIMIT)
|
||||
|
||||
for (tipo, entry, _), translation in zip(batch_items, results):
|
||||
if translation:
|
||||
if tipo == 'simple':
|
||||
entry.msgstr = translation
|
||||
elif tipo == 'plural_s':
|
||||
entry.msgstr_plural[0] = translation
|
||||
elif tipo == 'plural_p':
|
||||
entry.msgstr_plural[1] = translation
|
||||
translated_count += 1
|
||||
else:
|
||||
errors += 1
|
||||
|
||||
done = min(batch_start + BATCH_SIZE, len(texts))
|
||||
print(f" [{done}/{len(texts)}] batch OK")
|
||||
|
||||
if translated_count == 0:
|
||||
print(f" Nenhuma string traduzida")
|
||||
return 0, errors
|
||||
|
||||
shutil.copy2(filepath, filepath + '.bak_translate')
|
||||
po.save(filepath)
|
||||
|
||||
# Compilar .mo
|
||||
mo_path = filepath.replace('.po', '.mo')
|
||||
try:
|
||||
po.save_as_mofile(mo_path)
|
||||
print(f" .mo compilado: OK ({len(po.translated_entries())} strings traduzidas)")
|
||||
except Exception as e:
|
||||
print(f" ERRO .mo: {e}", file=sys.stderr)
|
||||
errors += 1
|
||||
|
||||
print(f" Traduzidas: {translated_count} | Erros: {errors}")
|
||||
return translated_count, errors
|
||||
|
||||
|
||||
def main():
|
||||
if not DEEPL_AUTH_KEY:
|
||||
print("ERRO: DEEPL_API_KEY não definida. Criar .env com DEEPL_API_KEY=...", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
args = sys.argv[1:]
|
||||
dry_run = '--dry-run' in args
|
||||
files = [a for a in args if not a.startswith('--')]
|
||||
|
||||
if not files:
|
||||
print("Uso: python3 translate_missing.py <ficheiro.po> [...] [--dry-run]")
|
||||
sys.exit(1)
|
||||
|
||||
# Inicializar DeepL e verificar quota
|
||||
try:
|
||||
translator = deepl.Translator(DEEPL_AUTH_KEY)
|
||||
usage = translator.get_usage()
|
||||
if usage.character.valid:
|
||||
used = usage.character.count
|
||||
limit = usage.character.limit
|
||||
remaining = limit - used
|
||||
print(f"\nDeepL API — Quota: {used:,}/{limit:,} chars usados | {remaining:,} restantes")
|
||||
if remaining < 10000:
|
||||
print(" ATENÇÃO: quota quase esgotada!", file=sys.stderr)
|
||||
if DEEPL_GLOSSARY_ID:
|
||||
print(f"Glossário activo: {DEEPL_GLOSSARY_ID}")
|
||||
else:
|
||||
print("Glossário: não configurado (definir DEEPL_GLOSSARY_ID em .env)")
|
||||
except Exception as e:
|
||||
print(f"ERRO ao ligar à API DeepL: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
total_translated = 0
|
||||
total_errors = 0
|
||||
|
||||
for f in files:
|
||||
t, e = process_file(f, translator, dry_run)
|
||||
total_translated += t
|
||||
total_errors += e
|
||||
|
||||
# Quota final
|
||||
try:
|
||||
usage = translator.get_usage()
|
||||
if usage.character.valid:
|
||||
print(f"\nQuota usada nesta sessão: {usage.character.count:,}/{usage.character.limit:,} chars")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"TOTAL: {total_translated} traduzidas | {total_errors} erros | {len(files)} ficheiro(s)")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user