df7bf98ef1
- 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>
315 lines
13 KiB
Python
315 lines
13 KiB
Python
#!/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("<?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:
|
|
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())
|