Files
feadulta/scripts/import_human_pagola_translations.py
T
rafa df7bf98ef1 Apunta los scripts de sync prod al Hetzner nuevo y suma trabajo pendiente
- sync_translations_to_prod.py / sync_audio_to_prod.py / sync_carta_from_prod.py:
  migran de FEA_PROD_HOST/PASS (CDMON, password) a FEA_PROD_SSH_HOST/PASS +
  FEA_PROD_DOCKER_CONTAINER (Hetzner/Coolify, auth por clave), con wrapping
  docker exec y el fix del bug de redirecciones (wc -c/cat) resuelto en el host
  en vez de dentro del contenedor.
- fea_translate_helper.php: subcomando clone_new para clonar en ID local nuevo
  cuando el ID de prod ya está ocupado localmente.
- translate_post.py: --dry-run.
- tts_produce.py: --allow-default-voice (voz Nico solo si se permite
  explícitamente para autores sin voz clonada) + fix voice_for_author.
- minimax_tts.py: parámetro speed en t2a/_synth_chunk.
- mirror-antiguo/deploy/nginx-mirror.conf: redirects de URLs históricas de
  catálogo y vista imprimible EFFA.
- Importaciones humanas de Pagola (carta 738) + scripts/manifest de la
  fase A Enrique, y release_issue181_prod.sh / cdmon-retire-fewp1.sh.
- .gitignore: excluye docs/backups/ (dumps SQL, mismo motivo que backups/*).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 07:09:29 -04:00

136 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""Importa las traducciones editoriales humanas adjuntas a un issue de carta.
Por defecto solo valida y muestra el plan. --apply-local crea borradores en el
WordPress Docker local; nunca publica ni toca producción.
"""
from __future__ import annotations
import argparse
import hashlib
import html
import json
import re
import subprocess
import tempfile
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
ROOT = Path(__file__).resolve().parent
HELPER = ROOT / "fea_translate_helper.php"
CONTAINER = "wordpress-web"
SOURCE_ID = 55465 # Pagola, Carta 738
LANG_BY_NAME = {"2_eng": "en", "3_fr": "fr", "4_it": "it", "5_pt": "pt"}
TRANSLATOR_LABELS = ("Translator:", "Traducteur:", "Traduzzione:", "Tradutor:")
def docx_lines(path: Path) -> list[str]:
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
with zipfile.ZipFile(path) as zf:
root = ET.fromstring(zf.read("word/document.xml"))
return [
"".join(t.text or "" for t in p.findall(".//w:t", ns)).strip()
for p in root.findall(".//w:p", ns)
if "".join(t.text or "" for t in p.findall(".//w:t", ns)).strip()
]
def value_after(lines: list[str], label: str) -> str:
try:
return lines[lines.index(label) + 1]
except (ValueError, IndexError) as exc:
raise ValueError(f"Falta {label!r}") from exc
def parse_doc(path: Path) -> dict:
lines = docx_lines(path)
title = value_after(lines, "Título:")
excerpt = value_after(lines, "Entradilla:")
author = value_after(lines, "Autor:")
start = lines.index("Cuerpo:") + 1
end = next((i for i in range(start, len(lines))
if any(lines[i].startswith(label) for label in TRANSLATOR_LABELS)), len(lines))
body = lines[start:end]
if not body:
raise ValueError("Cuerpo vacío")
translator = ""
if end < len(lines):
label = next(label for label in TRANSLATOR_LABELS if lines[end].startswith(label))
translator = lines[end][len(label):].strip()
if not translator and end + 1 < len(lines):
translator = lines[end + 1]
# La fuente española incluye el crédito/origen como último párrafo.
body.append('Publicado en: <a href="https://www.gruposdejesus.com">https://www.gruposdejesus.com</a>')
content = "\n".join(
f"<p>{line if line.startswith('Publicado en: <a ') else html.escape(line)}</p>" for line in body
)
return {
"title": title,
"excerpt": excerpt,
"author": author,
"translator": translator,
"content": content,
"source_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
}
def run(cmd: list[str], stdin: str | None = None) -> str:
result = subprocess.run(cmd, input=stdin, text=True, capture_output=True)
if result.returncode:
raise RuntimeError(f"rc={result.returncode}: {result.stderr.strip()} {result.stdout.strip()}")
return result.stdout.strip()
def helper(*args: str, stdin: str | None = None) -> str:
return run(["docker", "exec", "-i", CONTAINER, "php", "/tmp/fea_translate_helper.php", *args], stdin)
def lang_for(path: Path) -> str:
for marker, lang in LANG_BY_NAME.items():
if marker in path.name:
return lang
raise ValueError(f"No reconozco idioma en {path.name}")
def apply_meta(post_id: int, doc: Path, parsed: dict) -> None:
payload = json.dumps({"id": post_id, "doc": doc.name, "sha": parsed["source_sha256"],
"translator": parsed["translator"]}, ensure_ascii=False)
php = r'''$p=json_decode(base64_decode(getenv('PAYLOAD')),true); update_post_meta($p['id'],'traduccion_automatica','0'); update_post_meta($p['id'],'traduccion_modelo','editorial-humana'); update_post_meta($p['id'],'traduccion_fuente_doc',$p['doc']); update_post_meta($p['id'],'traduccion_fuente_sha256',$p['sha']); update_post_meta($p['id'],'traduccion_editor',$p['translator']); echo 'ok';'''
import base64
encoded = base64.b64encode(payload.encode()).decode()
run(["docker", "exec", "-i", "-e", f"PAYLOAD={encoded}", CONTAINER,
"wp", "--allow-root", "eval", php])
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--dir", type=Path, required=True)
ap.add_argument("--apply-local", action="store_true")
args = ap.parse_args()
docs = sorted(args.dir.glob("*.docx"))
if len(docs) != 4:
raise SystemExit(f"Se esperaban 4 DOCX, encontrados {len(docs)}")
run(["docker", "cp", str(HELPER), f"{CONTAINER}:/tmp/fea_translate_helper.php"])
for doc in docs:
lang, parsed = lang_for(doc), parse_doc(doc)
existing = helper("exists", str(SOURCE_ID), lang)
plan = {"lang": lang, "doc": doc.name, "existing": int(existing or "0"),
"title": parsed["title"], "excerpt_chars": len(parsed["excerpt"]),
"content_chars": len(parsed["content"]), "translator": parsed["translator"]}
print(json.dumps(plan, ensure_ascii=False))
if not args.apply_local:
continue
if int(existing or "0"):
raise RuntimeError(f"{lang} ya existe como #{existing}; no sobrescribo")
payload = json.dumps({"title": parsed["title"], "excerpt": parsed["excerpt"],
"content": parsed["content"], "model": "editorial-humana"}, ensure_ascii=False)
post_id = int(helper("create", str(SOURCE_ID), lang, "draft", stdin=payload))
apply_meta(post_id, doc, parsed)
print(json.dumps({"created": post_id, "lang": lang, "status": "draft"}))
return 0
if __name__ == "__main__":
raise SystemExit(main())