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:
2026-08-05 07:09:29 -04:00
parent 159080f0c8
commit df7bf98ef1
21 changed files with 980 additions and 110 deletions
+104 -37
View File
@@ -23,6 +23,8 @@ el mismo fea_translate_helper.php sin tocar su lógica, solo invertido
Uso:
python3 sync_carta_from_prod.py --carta 54495 --dry-run
python3 sync_carta_from_prod.py --carta 54495
python3 sync_carta_from_prod.py --ids 54875,54902,54903 --dry-run
python3 sync_carta_from_prod.py --ids 54875,54902,54903
Tras esto, el resto del ciclo ya existente no cambia:
translate_post.py --carta 54495 --langs en,fr,it,pt --status draft
@@ -34,8 +36,10 @@ Tras esto, el resto del ciclo ya existente no cambia:
from __future__ import annotations
import argparse
import base64
import json
import os
import shlex
import subprocess
import time
from pathlib import Path
@@ -44,7 +48,11 @@ WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
# define, el helper se ejecuta en memoria dentro del contenedor; no se escribe
# ningún fichero temporal en prod.
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
PROD_HELPER = "/tmp/fea_translate_helper.php"
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
@@ -74,22 +82,35 @@ def sh(cmd: list[str], *, stdin: str | None = None, timeout: int = 120) -> str:
# ── Prod (origen, solo lectura) ─────────────────────────────────────────────
_prod_ready = False
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
return sh(cmd, stdin=stdin, timeout=timeout)
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
global _prod_ready
if not _prod_ready:
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8"))
_prod_ready = True
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
return _ssh(inner, stdin=stdin, timeout=180)
def prod_helper(subcmd: str, *args: str) -> str:
"""Run the helper in prod memory; never upload a temporary file to prod."""
helper_php = HELPER_SRC.read_text(encoding="utf-8").replace("<?php", "", 1)
encoded = base64.b64encode(helper_php.encode("utf-8")).decode("ascii")
code = f"eval(base64_decode('{encoded}'));"
if PROD_DOCKER_CONTAINER:
remote = (
f"docker exec -i -e FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
f"{shlex.quote(PROD_DOCKER_CONTAINER)} php -r {shlex.quote(code)} -- "
+ " ".join(shlex.quote(part) for part in (subcmd, *args))
)
else:
remote = (
f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} php -r {shlex.quote(code)} -- "
+ " ".join(shlex.quote(part) for part in (subcmd, *args))
)
return _ssh(remote, timeout=180)
def prod_read_full(post_id: int) -> dict:
@@ -110,6 +131,17 @@ def seed_ids_from_carta(carta_id: int) -> list[int]:
return sorted(ids)
def seed_ids_from_csv(raw_ids: str) -> list[int]:
"""Parse a deliberate prod→local subset for Mixbot --parte1 (#181)."""
try:
ids = sorted({int(part.strip()) for part in raw_ids.split(",") if part.strip()})
except ValueError as exc:
raise ValueError("--ids debe ser una lista CSV de IDs numéricos") from exc
if not ids or any(pid <= 0 for pid in ids):
raise ValueError("--ids debe contener al menos un ID positivo")
return ids
def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[dict[str, int]]]:
posts: dict[int, dict] = {}
groups: dict[tuple[tuple[str, int], ...], dict[str, int]] = {}
@@ -153,7 +185,11 @@ def local_read_safe(post_id: int) -> dict | None:
return None
def local_clone(post: dict) -> int:
def local_clone(post: dict, *, preserve_id: bool = True) -> int:
# Conserva el ID remoto como trazabilidad incluso cuando haya que crear un
# ID local nuevo por una colisión entre entornos.
meta = dict(post.get("meta", {}))
meta["fea_prod_source_id"] = [str(post["id"])]
payload = {
"title": post["title"],
"content": post.get("content", ""),
@@ -166,10 +202,14 @@ def local_clone(post: dict) -> int:
"status": post.get("status"),
"cats": post.get("cats", []),
"cat_slugs": post.get("cat_slugs", []),
"meta": post.get("meta", {}),
"meta": meta,
}
out = local_helper("clone", str(post["id"]), post.get("lang") or "es", STATUS,
stdin=json.dumps(payload)).strip()
if preserve_id:
out = local_helper("clone", str(post["id"]), post.get("lang") or "es", STATUS,
stdin=json.dumps(payload)).strip()
else:
out = local_helper("clone_new", post.get("lang") or "es", STATUS,
stdin=json.dumps(payload)).strip()
return int(out)
@@ -179,13 +219,9 @@ def local_save_group(group: dict[str, int]) -> dict[str, int]:
# ── Main ─────────────────────────────────────────────────────────────────────
def sync_carta(carta_id: int, *, dry_run: bool) -> int:
seed_ids = seed_ids_from_carta(carta_id)
log(f"Cluster descubierto desde fea_parse_carta_sections(prod#{carta_id}): "
f"{len(seed_ids)} post(s) -> {seed_ids}")
if not seed_ids or seed_ids == [carta_id]:
log(" ⚠️ El parser no resolvió ningún artículo enlazado (¿carta sin publicar aún, "
"o secciones sin encabezados reconocibles?). Revisa antes de continuar.")
def sync_posts(seed_ids: list[int], *, dry_run: bool, source_label: str,
remap_conflicts: bool = False) -> int:
log(f"Lote {source_label}: {len(seed_ids)} post(s) -> {seed_ids}")
posts, groups = collect_related_posts(seed_ids)
@@ -196,51 +232,82 @@ def sync_carta(carta_id: int, *, dry_run: bool) -> int:
conflicts.append((pid, existing.get("title", ""), p.get("title", "")))
if conflicts:
log(f" ⚠️ {len(conflicts)} CONFLICTO(S): el ID ya existe en local con OTRO contenido "
f"y va a ser SOBRESCRITO:")
log(f" ⚠️ {len(conflicts)} CONFLICTO(S): el ID ya existe en local con OTRO contenido:")
for pid, old_title, new_title in conflicts:
log(f" #{pid}: local actual «{old_title[:50]}» -> prod «{new_title[:50]}»")
log(f" #{pid}: local actual «{old_title[:50]}» | prod «{new_title[:50]}»")
if not dry_run and not remap_conflicts:
raise RuntimeError(
"Importación cancelada: usar --remap-conflicts para crear IDs locales nuevos; "
"nunca se sobrescriben posts locales por un choque de IDs entre entornos."
)
if dry_run:
for pid, p in posts.items():
log(f" PULL prod#{pid} [{p.get('lang','?')}] status={p.get('status')} "
action = "REMAPPING to new local ID" if pid in {c[0] for c in conflicts} else "PULL"
log(f" {action} prod#{pid} [{p.get('lang','?')}] status={p.get('status')} "
f"slug={p.get('slug','')} «{p.get('title','')[:50]}»")
for group in groups:
log(f" GROUP {group}")
log("DRY-RUN: nada escrito en local.")
return 0
conflict_ids = {pid for pid, _, _ in conflicts}
id_map: dict[int, int] = {}
for pid, p in posts.items():
new_id = local_clone(p)
new_id = local_clone(p, preserve_id=pid not in conflict_ids)
id_map[pid] = new_id
if new_id != pid:
log(f" ⚠️ prod#{pid} se clonó como local#{new_id} — ID NO preservado, revisar a mano.")
log(f" remap prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»")
else:
log(f" clone prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»")
for group in groups:
if len(group) < 2:
local_group = {lang: id_map.get(pid, pid) for lang, pid in group.items()}
if len(local_group) < 2:
continue
saved = local_save_group(group)
saved = local_save_group(local_group)
log(f" group enlazado en local {saved}")
log(f"FIN sync prod->local. carta={carta_id} posts={len(posts)} conflictos_previos={len(conflicts)}")
log(f"FIN sync prod->local. fuente={source_label} posts={len(posts)} conflictos_previos={len(conflicts)}")
return 0
def sync_carta(carta_id: int, *, dry_run: bool, remap_conflicts: bool = False) -> int:
seed_ids = seed_ids_from_carta(carta_id)
if not seed_ids or seed_ids == [carta_id]:
log(" ⚠️ El parser no resolvió ningún artículo enlazado (¿carta sin publicar aún, "
"o secciones sin encabezados reconocibles?). Revisa antes de continuar.")
return sync_posts(seed_ids, dry_run=dry_run, source_label=f"carta prod#{carta_id}",
remap_conflicts=remap_conflicts)
def sync_ids(raw_ids: str, *, dry_run: bool, remap_conflicts: bool = False) -> int:
return sync_posts(seed_ids_from_csv(raw_ids), dry_run=dry_run,
source_label="IDs explícitos de Mixbot --parte1",
remap_conflicts=remap_conflicts)
def main() -> int:
ap = argparse.ArgumentParser(
description="Copia una carta (y su cluster de artículos) de PROD a LOCAL preservando IDs.")
ap.add_argument("--carta", type=int, required=True, help="ID del post ES de la carta en PROD.")
group = ap.add_mutually_exclusive_group(required=True)
group.add_argument("--carta", type=int, help="ID del post ES de la carta en PROD.")
group.add_argument("--ids", help="Lista CSV explícita de posts ES para Mixbot --parte1 (#181).")
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.")
ap.add_argument("--remap-conflicts", action="store_true",
help="Ante IDs ocupados localmente, crea IDs nuevos y guarda fea_prod_source_id; nunca sobrescribe.")
args = ap.parse_args()
if not PROD_HOST or not PROD_PASS:
if not PROD_HOST:
raise SystemExit(
"Faltan FEA_PROD_SSH_HOST / FEA_PROD_SSH_PASS en el entorno.\n"
"Falta FEA_PROD_SSH_HOST en el entorno (FEA_PROD_SSH_PASS es opcional: "
"el Hetzner nuevo usa auth por clave).\n"
"Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env"
)
return sync_carta(args.carta, dry_run=args.dry_run)
if args.carta:
return sync_carta(args.carta, dry_run=args.dry_run, remap_conflicts=args.remap_conflicts)
return sync_ids(args.ids, dry_run=args.dry_run, remap_conflicts=args.remap_conflicts)
if __name__ == "__main__":