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>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user