init: scripts diversos (crawlers, conversores, scrapers)
This commit is contained in:
958
translate-wp-plugin/translate-wordpress-plugin.py
Executable file
958
translate-wp-plugin/translate-wordpress-plugin.py
Executable file
@@ -0,0 +1,958 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WordPress Plugin .po Translation Script
|
||||
Translates WordPress .po files to PT-PT using LibreTranslate with medical/business glossary.
|
||||
|
||||
Author: Descomplicar® Crescimento Digital
|
||||
Date: 2026-02-11
|
||||
Version: 1.0.0
|
||||
|
||||
Usage:
|
||||
python3 translate-wordpress-plugin.py input.po [--batch-size 50] [--api-url https://translate.descomplicar.pt]
|
||||
|
||||
Features:
|
||||
- Translates only empty msgstr entries
|
||||
- Preserves placeholders (%s, %d, %1$s, etc.)
|
||||
- Preserves HTML tags
|
||||
- Applies PT-PT glossary post-translation
|
||||
- Handles plural forms correctly
|
||||
- Validates translations before saving
|
||||
- Compiles .mo file automatically
|
||||
- Progress tracking with ETA
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
import time
|
||||
|
||||
# PT-PT Glossary (EN→PT-PT) - Source: Guia-PT-PT.md v2.0
|
||||
GLOSSARY = {
|
||||
# --- Medical terminology ---
|
||||
"appointment": "consulta",
|
||||
"appointments": "consultas",
|
||||
"encounter": "encontro clínico",
|
||||
"encounters": "encontros clínicos",
|
||||
"patient": "paciente",
|
||||
"patients": "pacientes",
|
||||
"doctor": "médico",
|
||||
"doctors": "médicos",
|
||||
"clinic": "clínica",
|
||||
"clinics": "clínicas",
|
||||
"prescription": "receita",
|
||||
"prescriptions": "receitas",
|
||||
"medical record": "registo clínico",
|
||||
"medical records": "registos clínicos",
|
||||
"health center": "centro de saúde",
|
||||
"family doctor": "médico de família",
|
||||
"general practitioner": "médico de família",
|
||||
"emergency room": "consulta de urgência",
|
||||
"ultrasound": "ecografia",
|
||||
"CT scan": "TAC",
|
||||
"blood tests": "análises",
|
||||
"blood test": "análises",
|
||||
"medical certificate": "atestado médico",
|
||||
"vaccination record": "boletim de vacinas",
|
||||
"clinical report": "relatório clínico",
|
||||
"orthopedics": "ortopedia",
|
||||
"ophthalmology": "oftalmologia",
|
||||
"disorder": "perturbação",
|
||||
"psychological assessment": "avaliação psicológica",
|
||||
"therapy": "psicoterapia",
|
||||
|
||||
# --- Billing / Finance ---
|
||||
"billing": "facturação",
|
||||
"invoice": "factura",
|
||||
"invoices": "facturas",
|
||||
"bill": "factura",
|
||||
"bills": "facturas",
|
||||
"credit note": "nota de crédito",
|
||||
"credit notes": "notas de crédito",
|
||||
"receipt": "recibo",
|
||||
"receipts": "recibos",
|
||||
"purchase order": "nota de encomenda",
|
||||
"delivery note": "guia de remessa",
|
||||
"installment": "prestação",
|
||||
"installments": "prestações",
|
||||
"tax": "IVA",
|
||||
"VAT": "IVA",
|
||||
"bank transfer": "transferência bancária",
|
||||
"balance sheet": "balanço",
|
||||
"profit and loss": "demonstração de resultados",
|
||||
"budget": "orçamento",
|
||||
"quote": "orçamento",
|
||||
"quotation": "orçamento",
|
||||
"interest rate": "taxa de juro",
|
||||
"stock exchange": "bolsa de valores",
|
||||
"overdraft": "descoberto autorizado",
|
||||
|
||||
# --- UI Common ---
|
||||
"dashboard": "painel",
|
||||
"settings": "definições",
|
||||
"preferences": "preferências",
|
||||
"save": "guardar",
|
||||
"delete": "eliminar",
|
||||
"remove": "remover",
|
||||
"cancel": "cancelar",
|
||||
"search": "pesquisar",
|
||||
"filter": "filtrar",
|
||||
"sort": "ordenar",
|
||||
"add new": "adicionar novo",
|
||||
"add": "adicionar",
|
||||
"edit": "editar",
|
||||
"view": "ver",
|
||||
"preview": "pré-visualizar",
|
||||
"status": "estado",
|
||||
"login": "iniciar sessão",
|
||||
"log in": "iniciar sessão",
|
||||
"sign in": "iniciar sessão",
|
||||
"logout": "terminar sessão",
|
||||
"log out": "terminar sessão",
|
||||
"sign out": "terminar sessão",
|
||||
"sign up": "registar-se",
|
||||
"register": "registar",
|
||||
"password": "palavra-passe",
|
||||
"username": "nome de utilizador",
|
||||
"display name": "nome de apresentação",
|
||||
"permalink": "ligação permanente",
|
||||
"upload": "carregar",
|
||||
"download": "transferir",
|
||||
"file": "ficheiro",
|
||||
"files": "ficheiros",
|
||||
"folder": "pasta",
|
||||
"folders": "pastas",
|
||||
"screen": "ecrã",
|
||||
"share": "partilhar",
|
||||
"shared": "partilhado",
|
||||
"link": "hiperligação",
|
||||
"hyperlink": "hiperligação",
|
||||
"mouse": "rato",
|
||||
"keyboard": "teclado",
|
||||
"hard drive": "disco rígido",
|
||||
"database": "base de dados",
|
||||
"backup": "cópia de segurança",
|
||||
"restore": "restaurar",
|
||||
"update": "actualizar",
|
||||
"updates": "actualizações",
|
||||
"upgrade": "actualizar",
|
||||
"submit": "submeter",
|
||||
"confirm": "confirmar",
|
||||
"approve": "aprovar",
|
||||
"reject": "rejeitar",
|
||||
"close": "fechar",
|
||||
"open": "abrir",
|
||||
"enable": "activar",
|
||||
"disable": "desactivar",
|
||||
"enabled": "activado",
|
||||
"disabled": "desactivado",
|
||||
"select": "seleccionar",
|
||||
"selected": "seleccionado",
|
||||
"collapse": "recolher",
|
||||
"expand": "expandir",
|
||||
"loading": "a carregar",
|
||||
"processing": "a processar",
|
||||
"pending": "pendente",
|
||||
"completed": "concluído",
|
||||
"failed": "falhado",
|
||||
"error": "erro",
|
||||
"warning": "aviso",
|
||||
"success": "sucesso",
|
||||
"notification": "notificação",
|
||||
"notifications": "notificações",
|
||||
"overview": "visão geral",
|
||||
"details": "detalhes",
|
||||
"summary": "resumo",
|
||||
"history": "histórico",
|
||||
"report": "relatório",
|
||||
"reports": "relatórios",
|
||||
"export": "exportar",
|
||||
"import": "importar",
|
||||
|
||||
# --- E-commerce / WooCommerce ---
|
||||
"cart": "carrinho",
|
||||
"checkout": "finalizar compra",
|
||||
"shipping": "envio",
|
||||
"delivery": "entrega",
|
||||
"coupon": "cupão",
|
||||
"coupons": "cupões",
|
||||
"discount": "desconto",
|
||||
"discounts": "descontos",
|
||||
"order": "encomenda",
|
||||
"orders": "encomendas",
|
||||
"product": "produto",
|
||||
"products": "produtos",
|
||||
"category": "categoria",
|
||||
"categories": "categorias",
|
||||
"tag": "etiqueta",
|
||||
"tags": "etiquetas",
|
||||
"stock": "stock",
|
||||
"in stock": "em stock",
|
||||
"out of stock": "sem stock",
|
||||
"wishlist": "lista de desejos",
|
||||
"refund": "reembolso",
|
||||
"refunds": "reembolsos",
|
||||
"return": "devolução",
|
||||
"returns": "devoluções",
|
||||
|
||||
# --- Professional / Business ---
|
||||
"employee": "colaborador",
|
||||
"employees": "colaboradores",
|
||||
"staff": "pessoal",
|
||||
"salary": "ordenado",
|
||||
"role": "função",
|
||||
"meeting": "reunião",
|
||||
"meetings": "reuniões",
|
||||
"resume": "curriculum vitae",
|
||||
"company": "empresa",
|
||||
"team": "equipa",
|
||||
"teams": "equipas",
|
||||
"customer": "cliente",
|
||||
"customers": "clientes",
|
||||
"client": "cliente",
|
||||
"clients": "clientes",
|
||||
"supplier": "fornecedor",
|
||||
"suppliers": "fornecedores",
|
||||
"lead": "contacto",
|
||||
"leads": "contactos",
|
||||
"deal": "negócio",
|
||||
"deals": "negócios",
|
||||
"pipeline": "pipeline",
|
||||
"task": "tarefa",
|
||||
"tasks": "tarefas",
|
||||
"project": "projecto",
|
||||
"projects": "projectos",
|
||||
"milestone": "marco",
|
||||
"milestones": "marcos",
|
||||
"deadline": "prazo",
|
||||
"deadlines": "prazos",
|
||||
"priority": "prioridade",
|
||||
"assigned to": "atribuído a",
|
||||
"workflow": "fluxo de trabalho",
|
||||
|
||||
# --- Technology / IT ---
|
||||
"machine learning": "aprendizagem automática",
|
||||
"big data": "dados massivos",
|
||||
"neural network": "rede neuronal",
|
||||
"neural networks": "redes neuronais",
|
||||
"cloud computing": "computação em nuvem",
|
||||
"cybersecurity": "cibersegurança",
|
||||
"encryption": "encriptação",
|
||||
"firewall": "firewall",
|
||||
"authentication": "autenticação",
|
||||
"container": "contentor",
|
||||
"containers": "contentores",
|
||||
"deployment": "implantação",
|
||||
"version control": "controlo de versões",
|
||||
"unit tests": "testes unitários",
|
||||
"continuous integration": "integração contínua",
|
||||
"continuous delivery": "entrega contínua",
|
||||
"scalability": "escalabilidade",
|
||||
"user experience": "experiência do utilizador",
|
||||
"accessibility": "acessibilidade",
|
||||
"responsive": "responsivo",
|
||||
|
||||
# --- Marketing / Digital ---
|
||||
"target audience": "público-alvo",
|
||||
"website": "sítio web",
|
||||
"website traffic": "tráfego do sítio web",
|
||||
"conversion rate": "taxa de conversão",
|
||||
"market share": "quota de mercado",
|
||||
"SEO": "SEO",
|
||||
"campaign": "campanha",
|
||||
"campaigns": "campanhas",
|
||||
"newsletter": "boletim informativo",
|
||||
"subscriber": "subscritor",
|
||||
"subscribers": "subscritores",
|
||||
"unsubscribe": "cancelar subscrição",
|
||||
|
||||
# --- Common phrases ---
|
||||
"is required": "é obrigatório",
|
||||
"must be": "deve ser",
|
||||
"cannot be empty": "não pode estar vazio",
|
||||
"at least": "pelo menos",
|
||||
"cannot exceed": "não pode exceder",
|
||||
"already exists": "já existe",
|
||||
"not found": "não encontrado",
|
||||
"retrieved successfully": "obtido com sucesso",
|
||||
"saved successfully": "guardado com sucesso",
|
||||
"updated successfully": "actualizado com sucesso",
|
||||
"deleted successfully": "eliminado com sucesso",
|
||||
"created successfully": "criado com sucesso",
|
||||
"failed to": "falha ao",
|
||||
"are you sure": "tem a certeza",
|
||||
"this action cannot be undone": "esta acção não pode ser desfeita",
|
||||
"no results found": "nenhum resultado encontrado",
|
||||
"please try again": "por favor tente novamente",
|
||||
"access denied": "acesso negado",
|
||||
"permission denied": "permissão negada",
|
||||
"you do not have permission": "não tem permissão",
|
||||
"invalid": "inválido",
|
||||
"optional": "opcional",
|
||||
"learn more": "saber mais",
|
||||
"read more": "ler mais",
|
||||
"show more": "mostrar mais",
|
||||
"show less": "mostrar menos",
|
||||
"go back": "voltar",
|
||||
"next": "seguinte",
|
||||
"previous": "anterior",
|
||||
"first": "primeiro",
|
||||
"last": "último",
|
||||
|
||||
# --- Date/time ---
|
||||
"today": "hoje",
|
||||
"yesterday": "ontem",
|
||||
"tomorrow": "amanhã",
|
||||
"this week": "esta semana",
|
||||
"last week": "semana passada",
|
||||
"this month": "este mês",
|
||||
"last month": "mês passado",
|
||||
"this year": "este ano",
|
||||
"daily": "diário",
|
||||
"weekly": "semanal",
|
||||
"monthly": "mensal",
|
||||
"yearly": "anual",
|
||||
"schedule": "agenda",
|
||||
"scheduled": "agendado",
|
||||
}
|
||||
|
||||
# PT-PT orthography fixes (BR→PT-PT) - Source: Guia-PT-PT.md v2.0
|
||||
ORTHOGRAPHY_FIXES = {
|
||||
# --- Orthographic differences (AO90 BR vs traditional PT-PT) ---
|
||||
"atualizar": "actualizar",
|
||||
"atualizad": "actualizad",
|
||||
"atualização": "actualização",
|
||||
"atualizações": "actualizações",
|
||||
"fatura": "factura",
|
||||
"faturas": "facturas",
|
||||
"faturação": "facturação",
|
||||
"faturamento": "facturação",
|
||||
"selecionar": "seleccionar",
|
||||
"selecionad": "seleccionad",
|
||||
"seleção": "selecção",
|
||||
"seleções": "selecções",
|
||||
"ótimo": "óptimo",
|
||||
"ação": "acção",
|
||||
"ações": "acções",
|
||||
"direção": "direcção",
|
||||
"direções": "direcções",
|
||||
"proteção": "protecção",
|
||||
"coleção": "colecção",
|
||||
"correção": "correcção",
|
||||
"conexão": "conexão",
|
||||
"detecção": "detecção",
|
||||
"inspecção": "inspecção",
|
||||
"recepção": "recepção",
|
||||
"exceção": "excepção",
|
||||
"infecção": "infecção",
|
||||
"objeção": "objecção",
|
||||
"projeção": "projecção",
|
||||
"projecto": "projecto",
|
||||
"contacto": "contacto",
|
||||
"facto": "facto",
|
||||
"exacto": "exacto",
|
||||
"directo": "directo",
|
||||
"correcto": "correcto",
|
||||
|
||||
# --- Vocabulary differences (BR→PT-PT) ---
|
||||
# Tech / UI
|
||||
"acessar": "aceder",
|
||||
"arquivo": "ficheiro",
|
||||
"arquivos": "ficheiros",
|
||||
"diretório": "pasta",
|
||||
"senha": "palavra-passe",
|
||||
"senhas": "palavras-passe",
|
||||
"tela": "ecrã",
|
||||
"telas": "ecrãs",
|
||||
"compartilhar": "partilhar",
|
||||
"compartilhad": "partilhad",
|
||||
"compartilhamento": "partilha",
|
||||
"baixar": "transferir",
|
||||
"fazer download": "transferir",
|
||||
"configuração": "definição",
|
||||
"configurações": "definições",
|
||||
"configurar": "definir",
|
||||
"colapsar": "recolher",
|
||||
"banco de dados": "base de dados",
|
||||
|
||||
# Common verbs
|
||||
"salvar": "guardar",
|
||||
"deletar": "eliminar",
|
||||
"deletad": "eliminad",
|
||||
"cancelamento": "cancelamento",
|
||||
"clicar": "clicar",
|
||||
"apertar": "premir",
|
||||
"digitar": "escrever",
|
||||
"rodar": "executar",
|
||||
"fechar sessão": "terminar sessão",
|
||||
"fazer login": "iniciar sessão",
|
||||
"fazer logout": "terminar sessão",
|
||||
"cadastrar": "registar",
|
||||
"cadastro": "registo",
|
||||
|
||||
# Everyday / general
|
||||
"ônibus": "autocarro",
|
||||
"trem": "comboio",
|
||||
"banheiro": "casa de banho",
|
||||
"café da manhã": "pequeno-almoço",
|
||||
"geladeira": "frigorífico",
|
||||
"celular": "telemóvel",
|
||||
"pedágio": "portagem",
|
||||
"açougue": "talho",
|
||||
"estacionamento": "parque de estacionamento",
|
||||
"carteira de motorista": "carta de condução",
|
||||
"fila": "fila",
|
||||
"nota fiscal": "factura",
|
||||
"cupom fiscal": "talão",
|
||||
"caixa eletrônico": "Multibanco",
|
||||
"parcela": "prestação",
|
||||
"parcelas": "prestações",
|
||||
|
||||
# Professional
|
||||
"funcionário": "colaborador",
|
||||
"funcionários": "colaboradores",
|
||||
"salário": "ordenado",
|
||||
"cargo": "função",
|
||||
"currículo": "curriculum vitae",
|
||||
"time": "equipa",
|
||||
"equipe": "equipa",
|
||||
|
||||
# Education
|
||||
"mensalidade": "propina",
|
||||
"graduação": "licenciatura",
|
||||
"doutorado": "doutoramento",
|
||||
"matéria": "disciplina",
|
||||
|
||||
# Sports / Culture
|
||||
"goleiro": "guarda-redes",
|
||||
"chute": "pontapé",
|
||||
"gol": "golo",
|
||||
"pôster": "cartaz",
|
||||
"temporada": "época",
|
||||
|
||||
# Food / Services
|
||||
"cardápio": "ementa",
|
||||
"garçom": "empregado de mesa",
|
||||
"conta": "conta",
|
||||
"aluguel": "aluguer",
|
||||
|
||||
# Legal / Admin
|
||||
"réu": "arguido",
|
||||
"cartório": "conservatória",
|
||||
"prefeitura": "Câmara Municipal",
|
||||
"subprefeitura": "Junta de Freguesia",
|
||||
|
||||
# Weather
|
||||
"neblina": "nevoeiro",
|
||||
"pancadas de chuva": "aguaceiros",
|
||||
|
||||
# Health
|
||||
"exames de sangue": "análises",
|
||||
"pronto-socorro": "consulta de urgência",
|
||||
"posto de saúde": "centro de saúde",
|
||||
"clínico geral": "médico de família",
|
||||
"ultrassom": "ecografia",
|
||||
"transtorno": "perturbação",
|
||||
"transtornos": "perturbações",
|
||||
|
||||
# Environment / Science
|
||||
"mudanças climáticas": "alterações climáticas",
|
||||
"pegada de carbono": "pegada carbónica",
|
||||
"rede neural": "rede neuronal",
|
||||
"redes neurais": "redes neuronais",
|
||||
"impressão 3D": "fabricação aditiva",
|
||||
}
|
||||
|
||||
|
||||
class LibreTranslateClient:
|
||||
"""LibreTranslate API client."""
|
||||
|
||||
def __init__(self, api_url: str = "https://translate.descomplicar.pt"):
|
||||
self.api_url = api_url.rstrip("/")
|
||||
self.translate_endpoint = f"{self.api_url}/translate"
|
||||
|
||||
def translate(self, text: str, source: str = "en", target: str = "pt") -> str:
|
||||
"""Translate text using LibreTranslate API."""
|
||||
data = json.dumps({
|
||||
"q": text,
|
||||
"source": source,
|
||||
"target": target,
|
||||
"format": "text"
|
||||
}).encode('utf-8')
|
||||
|
||||
req = Request(
|
||||
self.translate_endpoint,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
try:
|
||||
with urlopen(req, timeout=30) as response:
|
||||
result = json.loads(response.read().decode('utf-8'))
|
||||
return result.get("translatedText", "")
|
||||
except URLError as e:
|
||||
print(f"❌ Translation API error: {e}")
|
||||
return ""
|
||||
except Exception as e:
|
||||
print(f"❌ Unexpected error: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
class PoFileTranslator:
|
||||
"""WordPress .po file translator with validation."""
|
||||
|
||||
def __init__(self, po_file: Path, api_url: str, batch_size: int = 50):
|
||||
self.po_file = po_file
|
||||
self.batch_size = batch_size
|
||||
self.translator = LibreTranslateClient(api_url)
|
||||
self.stats = {
|
||||
"total": 0,
|
||||
"translated": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0
|
||||
}
|
||||
|
||||
def extract_entries(self) -> List[Dict]:
|
||||
"""Extract all msgid/msgstr entries from .po file."""
|
||||
entries = []
|
||||
current_entry = {}
|
||||
|
||||
with open(self.po_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line = line.rstrip('\n')
|
||||
|
||||
# Comment/reference line
|
||||
if line.startswith('#'):
|
||||
if 'reference' not in current_entry:
|
||||
current_entry['reference'] = []
|
||||
current_entry['reference'].append(line)
|
||||
current_entry['line_start'] = i
|
||||
|
||||
# msgid
|
||||
elif line.startswith('msgid '):
|
||||
current_entry['msgid'] = self._extract_string(line)
|
||||
current_entry['msgid_line'] = i
|
||||
|
||||
# msgid_plural
|
||||
elif line.startswith('msgid_plural '):
|
||||
current_entry['msgid_plural'] = self._extract_string(line)
|
||||
|
||||
# msgstr
|
||||
elif line.startswith('msgstr ') or line.startswith('msgstr['):
|
||||
msgstr_value = self._extract_string(line)
|
||||
|
||||
if 'msgstr' not in current_entry:
|
||||
current_entry['msgstr'] = {}
|
||||
|
||||
# Handle plural forms
|
||||
if line.startswith('msgstr['):
|
||||
plural_index = int(line[7])
|
||||
current_entry['msgstr'][plural_index] = msgstr_value
|
||||
else:
|
||||
current_entry['msgstr'][0] = msgstr_value
|
||||
|
||||
current_entry['msgstr_line'] = i
|
||||
|
||||
# Continuation line
|
||||
elif line.startswith('"') and current_entry:
|
||||
# Append to last field
|
||||
continuation = self._extract_string(line)
|
||||
if 'msgstr_line' in current_entry and i > current_entry['msgstr_line']:
|
||||
last_index = max(current_entry['msgstr'].keys())
|
||||
current_entry['msgstr'][last_index] += continuation
|
||||
elif 'msgid_line' in current_entry and i > current_entry['msgid_line']:
|
||||
current_entry['msgid'] += continuation
|
||||
|
||||
# Empty line - entry complete
|
||||
elif line == '' and current_entry:
|
||||
if 'msgid' in current_entry and current_entry['msgid']:
|
||||
entries.append(current_entry)
|
||||
current_entry = {}
|
||||
|
||||
# Add last entry if exists
|
||||
if current_entry and 'msgid' in current_entry:
|
||||
entries.append(current_entry)
|
||||
|
||||
return entries
|
||||
|
||||
def _extract_string(self, line: str) -> str:
|
||||
"""Extract string from msgid/msgstr line."""
|
||||
match = re.search(r'"(.*?)"', line)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
def needs_translation(self, entry: Dict) -> bool:
|
||||
"""Check if entry needs translation."""
|
||||
if not entry.get('msgid'):
|
||||
return False
|
||||
|
||||
# Check if msgstr is empty
|
||||
msgstr_dict = entry.get('msgstr', {})
|
||||
if not msgstr_dict:
|
||||
return True
|
||||
|
||||
# Check if all plural forms are empty
|
||||
return all(not v for v in msgstr_dict.values())
|
||||
|
||||
def extract_placeholders(self, text: str) -> List[str]:
|
||||
"""Extract placeholders like %s, %d, %1$s from text."""
|
||||
return re.findall(r'%(?:\d+\$)?[sdifuxX]', text)
|
||||
|
||||
def extract_html_tags(self, text: str) -> List[str]:
|
||||
"""Extract HTML tags from text."""
|
||||
return re.findall(r'<[^>]+>', text)
|
||||
|
||||
def apply_glossary(self, text: str) -> str:
|
||||
"""Apply PT-PT glossary to translated text."""
|
||||
for en, pt in GLOSSARY.items():
|
||||
# Case-insensitive replacement
|
||||
text = re.sub(r'\b' + re.escape(en) + r'\b', pt, text, flags=re.IGNORECASE)
|
||||
|
||||
return text
|
||||
|
||||
def apply_orthography(self, text: str) -> str:
|
||||
"""Fix Brazilian Portuguese to European Portuguese."""
|
||||
for br, pt in ORTHOGRAPHY_FIXES.items():
|
||||
text = re.sub(r'\b' + re.escape(br), pt, text, flags=re.IGNORECASE)
|
||||
|
||||
return text
|
||||
|
||||
def validate_translation(self, original: str, translated: str, strict_html: bool = True) -> Tuple[bool, str]:
|
||||
"""Validate translated text preserves placeholders and HTML."""
|
||||
# Check placeholders (always strict)
|
||||
orig_placeholders = self.extract_placeholders(original)
|
||||
trans_placeholders = self.extract_placeholders(translated)
|
||||
|
||||
if sorted(orig_placeholders) != sorted(trans_placeholders):
|
||||
return False, f"Placeholder mismatch: {orig_placeholders} vs {trans_placeholders}"
|
||||
|
||||
# Check HTML tags (optional strict mode)
|
||||
if strict_html:
|
||||
orig_html = self.extract_html_tags(original)
|
||||
trans_html = self.extract_html_tags(translated)
|
||||
|
||||
if sorted(orig_html) != sorted(trans_html):
|
||||
# Warning only - try to auto-fix first
|
||||
print(f" ⚠️ HTML tags differ but auto-fixing...")
|
||||
|
||||
return True, "OK"
|
||||
|
||||
def translate_text(self, text: str) -> str:
|
||||
"""Translate text with glossary and validation."""
|
||||
if not text or text.isspace():
|
||||
return text
|
||||
|
||||
# Translate via LibreTranslate
|
||||
translated = self.translator.translate(text)
|
||||
|
||||
if not translated:
|
||||
return ""
|
||||
|
||||
# Apply glossary aggressively (multiple passes)
|
||||
for _ in range(2): # Two passes to catch variations
|
||||
translated = self.apply_glossary(translated)
|
||||
translated = self.apply_orthography(translated)
|
||||
|
||||
# Fix common LibreTranslate mistakes
|
||||
translated = self.fix_common_mistakes(translated)
|
||||
|
||||
# Fix translated HTML tags
|
||||
translated = self.fix_html_tags(translated)
|
||||
|
||||
# Validate (only placeholders, HTML fixing is automatic)
|
||||
valid, error = self.validate_translation(text, translated, strict_html=False)
|
||||
|
||||
if not valid:
|
||||
print(f"⚠️ Validation failed: {error}")
|
||||
print(f" Original: {text[:50]}...")
|
||||
print(f" Translation: {translated[:50]}...")
|
||||
return ""
|
||||
|
||||
return translated
|
||||
|
||||
def fix_html_tags(self, text: str) -> str:
|
||||
"""Fix common HTML tag translations."""
|
||||
html_fixes = {
|
||||
# LibreTranslate often translates these
|
||||
r'<forte>': '<strong>',
|
||||
r'</forte>': '</strong>',
|
||||
r'<em>': '<em>', # Keep
|
||||
r'</em>': '</em>', # Keep
|
||||
r'<quebra>': '<br>',
|
||||
r'<parágrafo>': '<p>',
|
||||
r'</parágrafo>': '</p>',
|
||||
}
|
||||
|
||||
for wrong, correct in html_fixes.items():
|
||||
text = re.sub(wrong, correct, text, flags=re.IGNORECASE)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def fix_common_mistakes(self, text: str) -> str:
|
||||
"""Fix common translation mistakes from LibreTranslate."""
|
||||
fixes = {
|
||||
# --- BR vocabulary that LibreTranslate produces ---
|
||||
r'\bcompromissos\b': 'consultas', # appointments
|
||||
r'\bsenha\b': 'palavra-passe', # password
|
||||
r'\bsenhas\b': 'palavras-passe', # passwords
|
||||
r'\bconfiguração\b': 'definição', # setting
|
||||
r'\bconfigurações\b': 'definições', # settings
|
||||
r'\bsalvar\b': 'guardar', # save
|
||||
r'\bsalvo\b': 'guardado', # saved
|
||||
r'\bsalva\b': 'guardada', # saved (f)
|
||||
r'\bdeletar\b': 'eliminar', # delete
|
||||
r'\bdeletado\b': 'eliminado', # deleted
|
||||
r'\bdeletada\b': 'eliminada', # deleted (f)
|
||||
r'\bacessar\b': 'aceder', # access
|
||||
r'\bacessado\b': 'acedido', # accessed
|
||||
r'\bcompartilhar\b': 'partilhar', # share
|
||||
r'\bcompartilhado\b': 'partilhado', # shared
|
||||
r'\bbaixar\b': 'transferir', # download
|
||||
r'\bcadastrar\b': 'registar', # register
|
||||
r'\bcadastro\b': 'registo', # registration
|
||||
r'\bcadastrado\b': 'registado', # registered
|
||||
r'\bcelular\b': 'telemóvel', # mobile phone
|
||||
r'\bgerenciar\b': 'gerir', # manage
|
||||
r'\bgerenciamento\b': 'gestão', # management
|
||||
r'\bgerenciado\b': 'gerido', # managed
|
||||
r'\bimplementar\b': 'implementar', # implement (keep)
|
||||
r'\botimizar\b': 'optimizar', # optimize
|
||||
|
||||
# --- "required" variations ---
|
||||
r'\bnecessária\b': 'obrigatória',
|
||||
r'\bnecessário\b': 'obrigatório',
|
||||
r'\brequerido\b': 'obrigatório',
|
||||
r'\brequerida\b': 'obrigatória',
|
||||
|
||||
# --- Gerund → "estar a + infinitivo" (most common LibreTranslate error) ---
|
||||
r'\bestá sendo\b': 'está a ser',
|
||||
r'\bestão sendo\b': 'estão a ser',
|
||||
r'\bestá fazendo\b': 'está a fazer',
|
||||
r'\bestá criando\b': 'está a criar',
|
||||
r'\bestá processando\b': 'está a processar',
|
||||
r'\bestá carregando\b': 'está a carregar',
|
||||
r'\bestá enviando\b': 'está a enviar',
|
||||
r'\bestá atualizando\b': 'está a actualizar',
|
||||
r'\bestá gerando\b': 'está a gerar',
|
||||
r'\bestá excluindo\b': 'está a eliminar',
|
||||
r'\bestá salvando\b': 'está a guardar',
|
||||
r'\bestá deletando\b': 'está a eliminar',
|
||||
r'\bestá baixando\b': 'está a transferir',
|
||||
r'\bestá importando\b': 'está a importar',
|
||||
r'\bestá exportando\b': 'está a exportar',
|
||||
r'\bestá verificando\b': 'está a verificar',
|
||||
r'\bestá calculando\b': 'está a calcular',
|
||||
r'\bestá sincronizando\b': 'está a sincronizar',
|
||||
r'\bestá conectando\b': 'está a ligar',
|
||||
|
||||
# --- Generic gerund catch-all (careful, only common patterns) ---
|
||||
r'\bprocessando\b': 'a processar',
|
||||
r'\bcarregando\b': 'a carregar',
|
||||
r'\batualizando\b': 'a actualizar',
|
||||
r'\bgerando\b': 'a gerar',
|
||||
|
||||
# --- Pronoun placement (proclisis→enclisis) ---
|
||||
r'\bse registrar\b': 'registar-se',
|
||||
r'\bse cadastrar\b': 'registar-se',
|
||||
r'\bse conectar\b': 'ligar-se',
|
||||
r'\bse inscrever\b': 'inscrever-se',
|
||||
|
||||
# --- Other common LibreTranslate mistakes ---
|
||||
r'\bvocê\b': 'o utilizador', # "você" → neutral
|
||||
r'\bnenhum resultado\b': 'nenhum resultado', # keep
|
||||
r'\bpor favor\b': 'por favor', # keep
|
||||
}
|
||||
|
||||
for wrong, correct in fixes.items():
|
||||
text = re.sub(wrong, correct, text, flags=re.IGNORECASE)
|
||||
|
||||
return text
|
||||
|
||||
def translate_entries(self) -> List[Dict]:
|
||||
"""Translate all entries needing translation."""
|
||||
entries = self.extract_entries()
|
||||
self.stats['total'] = len(entries)
|
||||
|
||||
print(f"\n📊 Found {self.stats['total']} entries in {self.po_file.name}")
|
||||
|
||||
to_translate = [e for e in entries if self.needs_translation(e)]
|
||||
print(f"🔄 {len(to_translate)} entries need translation\n")
|
||||
|
||||
if not to_translate:
|
||||
print("✅ All entries already translated!")
|
||||
return entries
|
||||
|
||||
# Translate in batches
|
||||
start_time = time.time()
|
||||
|
||||
for i, entry in enumerate(to_translate, 1):
|
||||
msgid = entry['msgid']
|
||||
|
||||
# Progress
|
||||
elapsed = time.time() - start_time
|
||||
rate = i / elapsed if elapsed > 0 else 0
|
||||
eta = (len(to_translate) - i) / rate if rate > 0 else 0
|
||||
|
||||
print(f"[{i}/{len(to_translate)}] Translating... (ETA: {eta:.0f}s)")
|
||||
print(f" EN: {msgid[:60]}...")
|
||||
|
||||
# Translate msgid
|
||||
translated = self.translate_text(msgid)
|
||||
|
||||
if translated:
|
||||
entry['msgstr'][0] = translated
|
||||
print(f" PT: {translated[:60]}...")
|
||||
self.stats['translated'] += 1
|
||||
else:
|
||||
print(f" ❌ Translation failed")
|
||||
self.stats['failed'] += 1
|
||||
|
||||
# Translate plural form if exists
|
||||
if 'msgid_plural' in entry:
|
||||
msgid_plural = entry['msgid_plural']
|
||||
translated_plural = self.translate_text(msgid_plural)
|
||||
|
||||
if translated_plural:
|
||||
entry['msgstr'][1] = translated_plural
|
||||
|
||||
# Rate limiting
|
||||
time.sleep(0.1)
|
||||
|
||||
print(f"\n✅ Translation complete in {time.time() - start_time:.1f}s")
|
||||
|
||||
return entries
|
||||
|
||||
def write_po_file(self, entries: List[Dict], output_file: Path):
|
||||
"""Write translated entries back to .po file."""
|
||||
with open(self.po_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Update msgstr lines
|
||||
for entry in entries:
|
||||
if 'msgstr_line' in entry and entry.get('msgstr'):
|
||||
msgstr_dict = entry['msgstr']
|
||||
|
||||
# Single msgstr
|
||||
if len(msgstr_dict) == 1 and 0 in msgstr_dict:
|
||||
msgstr_line = entry['msgstr_line']
|
||||
lines[msgstr_line] = f'msgstr "{msgstr_dict[0]}"\n'
|
||||
|
||||
# Plural msgstr
|
||||
else:
|
||||
base_line = entry['msgstr_line']
|
||||
for idx, value in msgstr_dict.items():
|
||||
# Find or create msgstr[N] line
|
||||
line_offset = idx
|
||||
if base_line + line_offset < len(lines):
|
||||
lines[base_line + line_offset] = f'msgstr[{idx}] "{value}"\n'
|
||||
|
||||
# Write to output
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
print(f"\n💾 Saved to: {output_file}")
|
||||
|
||||
def compile_mo(self, po_file: Path):
|
||||
"""Compile .po to .mo using msgfmt."""
|
||||
mo_file = po_file.with_suffix('.mo')
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['msgfmt', '-cv', '-o', str(mo_file), str(po_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
print(f"\n✅ Compiled .mo file: {mo_file}")
|
||||
print(result.stdout)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"\n❌ Compilation failed: {e.stderr}")
|
||||
except FileNotFoundError:
|
||||
print("\n⚠️ msgfmt not found. Install gettext: sudo apt install gettext")
|
||||
|
||||
def print_stats(self):
|
||||
"""Print translation statistics."""
|
||||
print("\n" + "="*60)
|
||||
print("📊 TRANSLATION STATISTICS")
|
||||
print("="*60)
|
||||
print(f"Total entries: {self.stats['total']}")
|
||||
print(f"Translated: {self.stats['translated']} ✅")
|
||||
print(f"Failed: {self.stats['failed']} ❌")
|
||||
print(f"Skipped: {self.stats['total'] - self.stats['translated'] - self.stats['failed']}")
|
||||
print(f"Success rate: {self.stats['translated'] / self.stats['total'] * 100:.1f}%")
|
||||
print("="*60)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Translate WordPress .po files to PT-PT using LibreTranslate"
|
||||
)
|
||||
parser.add_argument(
|
||||
"po_file",
|
||||
type=Path,
|
||||
help="Path to .po file to translate"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=50,
|
||||
help="Number of entries to translate per batch (default: 50)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-url",
|
||||
type=str,
|
||||
default="https://translate.descomplicar.pt",
|
||||
help="LibreTranslate API URL"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
help="Output .po file (default: overwrite input)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate input file
|
||||
if not args.po_file.exists():
|
||||
print(f"❌ File not found: {args.po_file}")
|
||||
sys.exit(1)
|
||||
|
||||
if not args.po_file.suffix == '.po':
|
||||
print(f"❌ Not a .po file: {args.po_file}")
|
||||
sys.exit(1)
|
||||
|
||||
# Output file
|
||||
output_file = args.output or args.po_file
|
||||
|
||||
print("="*60)
|
||||
print("🌍 WordPress Plugin Translation Script")
|
||||
print("="*60)
|
||||
print(f"Input: {args.po_file}")
|
||||
print(f"Output: {output_file}")
|
||||
print(f"API: {args.api_url}")
|
||||
print("="*60)
|
||||
|
||||
# Translate
|
||||
translator = PoFileTranslator(
|
||||
po_file=args.po_file,
|
||||
api_url=args.api_url,
|
||||
batch_size=args.batch_size
|
||||
)
|
||||
|
||||
entries = translator.translate_entries()
|
||||
translator.write_po_file(entries, output_file)
|
||||
translator.compile_mo(output_file)
|
||||
translator.print_stats()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user