#!/usr/bin/env python3 """ sync_carta_from_prod.py — Copia una carta semanal (y su cluster de artículos) de PROD a LOCAL preservando IDs, para poder traducir/locutar sobre un entorno local alineado con prod. Por qué existe: desde que Inma/Mixbot componen y publican la carta ES directamente en WordPress prod (issue #165, #174), el WP local de Rafa deja de recibir ese contenido — solo lo tenía porque antes todo pasaba por el delta Joomla->local->prod. Sin este script, local queda desalineado (visto con la carta 735 / ID 54495: ese ID en local correspondía a OTRO post) y traducir/locutar ahí es peligroso (contenido cruzado). Ver gitea.feadulta.com/rafa/feadulta#174. Descubre el cluster parseando los enlaces internos de la propia carta (fea_parse_carta_sections, la misma función que pinta la portada) en vez de depender de _carta_id, que puede no estar puesto todavía. Es el espejo de sync_translations_to_prod.py (modo IDs preservados): reutiliza el mismo fea_translate_helper.php sin tocar su lógica, solo invertido (lee de prod por SSH, escribe en local por docker). 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 publish_carta.php rotate_cartas.php CARTA=54495 APPLY=1 (en PROD, por idioma ya publicado) tts_produce.py + sync_audio_to_prod.py """ from __future__ import annotations import argparse import base64 import json import os import shlex import subprocess import time from pathlib import Path 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", "/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" LOCAL_HELPER_DST = "/tmp/fea_translate_helper.php" # Por defecto NO se fuerza status: cada post se clona con el status que tiene # en prod (publish/draft). Ver fea_translate_helper.php:clone (status vacío -> # usa payload['status']). STATUS = os.environ.get("FEA_SYNC_DOWN_STATUS", "") LOG_FILE = Path(os.environ.get("FEA_SYNC_DOWN_LOG", "/tmp/feadulta-sync-down.log")) def log(msg: str) -> None: line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}" print(line, flush=True) try: LOG_FILE.open("a", encoding="utf-8").write(line + "\n") except OSError: pass def sh(cmd: list[str], *, stdin: str | None = None, timeout: int = 120) -> str: r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout) if r.returncode != 0: raise RuntimeError(f"cmd falló ({r.returncode}): {' '.join(cmd[:3])}…\n{r.stderr.strip()[:400]}") return r.stdout # ── Prod (origen, solo lectura) ───────────────────────────────────────────── def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str: # 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) -> str: """Run the helper in prod memory; never upload a temporary file to prod.""" helper_php = HELPER_SRC.read_text(encoding="utf-8").replace(" dict: return json.loads(prod_helper("read_full", str(post_id))) def prod_carta_sections(carta_id: int) -> dict: raw = prod_helper("carta_sections", str(carta_id)).strip() return json.loads(raw) if raw else {} def seed_ids_from_carta(carta_id: int) -> list[int]: sections = prod_carta_sections(carta_id) ids = {carta_id} for arr in sections.values(): for pid in arr: ids.add(int(pid)) 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]] = {} for seed in seed_ids: info = prod_read_full(seed) posts[seed] = info raw_group = info.get("translations") or {} group = {lang: int(pid) for lang, pid in raw_group.items() if str(pid).isdigit()} if not group: lang = info.get("lang") or "es" group = {lang: seed} sig = tuple(sorted(group.items())) groups[sig] = group all_ids = sorted({pid for group in groups.values() for pid in group.values()}) for pid in all_ids: if pid not in posts: posts[pid] = prod_read_full(pid) return posts, list(groups.values()) # ── Local (destino) ────────────────────────────────────────────────────────── _local_ready = False def local_helper(subcmd: str, *args: str, stdin: str | None = None) -> str: global _local_ready if not _local_ready: sh(["docker", "cp", str(HELPER_SRC), f"{WP_CONTAINER}:{LOCAL_HELPER_DST}"]) _local_ready = True return sh(["docker", "exec", "-i", WP_CONTAINER, "php", LOCAL_HELPER_DST, subcmd, *args], stdin=stdin, timeout=180) def local_read_safe(post_id: int) -> dict | None: try: return json.loads(local_helper("read", str(post_id))) except RuntimeError: return None 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", ""), "excerpt": post.get("excerpt", ""), "slug": post.get("slug", ""), "type": post.get("type", "post"), "author": post.get("author", 1), "date": post.get("date"), "date_gmt": post.get("date_gmt"), "status": post.get("status"), "cats": post.get("cats", []), "cat_slugs": post.get("cat_slugs", []), "meta": meta, } 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) def local_save_group(group: dict[str, int]) -> dict[str, int]: out = local_helper("save_translations", stdin=json.dumps({"translations": group})).strip() return json.loads(out) # ── Main ───────────────────────────────────────────────────────────────────── 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) conflicts = [] for pid, p in posts.items(): existing = local_read_safe(pid) if existing and existing.get("title") != p.get("title"): 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:") for pid, old_title, new_title in conflicts: 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(): 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, preserve_id=pid not in conflict_ids) id_map[pid] = new_id if new_id != pid: 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: local_group = {lang: id_map.get(pid, pid) for lang, pid in group.items()} if len(local_group) < 2: continue saved = local_save_group(local_group) log(f" group enlazado en local {saved}") 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.") 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: raise SystemExit( "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" ) 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__": raise SystemExit(main())