feat: sync_carta_from_prod.py para copiar el cluster de una carta prod->local (#174)

Con Mixbot publicando la carta ES directamente en prod, el WP local dejaba de
recibir el contenido nuevo y se desalineaba (visto con la carta 735, ID 54495
apuntando a otro post en local). El nuevo script descubre el cluster de la
carta parseando sus propios enlaces (fea_parse_carta_sections, sin depender de
_carta_id) y clona cada post a local preservando ID, reutilizando
fea_translate_helper.php en la dirección contraria a sync_translations_to_prod.py.

Añade el subcomando 'carta_sections' al helper (aditivo, no toca el resto).
This commit is contained in:
2026-07-14 22:49:43 -04:00
parent f059ed17be
commit f747ec30c7
2 changed files with 548 additions and 0 deletions
+301
View File
@@ -0,0 +1,301 @@
<?php
/**
* Helper PHP para translate_post.py — corre DENTRO del contenedor WP cargando wp-load.php
* (no necesita wp-cli ni proc_open). Centraliza la lógica de WordPress/Polylang.
*
* Uso (vía `docker exec wordpress-web php /tmp/fea_translate_helper.php <subcomando> ...`):
* read <id> → JSON {id,title,content,excerpt,lang,status,author,date,cats}
* read_full <id> → JSON con slug, metas, categorías y grupo Polylang
* exists <es_id> <lang> → imprime el ID de la traducción en <lang> (0 si no hay)
* create <es_id> <lang> <status> (lee {title,content} por stdin)
* → crea el post traducido, lo enlaza con Polylang y mete metas;
* imprime el nuevo ID.
* clone <target_id> <lang> <status> (lee payload JSON por stdin)
* → inserta/actualiza un post con ID explícito, categorías y metas.
* save_translations → guarda un grupo Polylang exacto leído por stdin.
*
* Ver issue rafa/feadulta#75.
*/
// Bootstrap portable. Si WP no está cargado (modo standalone), cargar wp-load.
// Local (docker): /var/www/html/wp-load.php (por defecto).
// Prod: export FEA_WP_LOAD=/web/wp-load.php
// (Si se ejecuta vía `wp eval-file`, ABSPATH ya está definido y no se recarga.)
if (!defined('ABSPATH')) {
$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/';
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? 'localhost';
require_once (getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php');
}
if (!function_exists('pll_set_post_language')) {
fwrite(STDERR, "Polylang no disponible\n");
exit(2);
}
$cmd = $argv[1] ?? '';
function out_json($data): void { echo wp_json_encode($data); }
function meta_payload(int $id): array {
$raw = get_post_meta($id);
$out = [];
foreach ($raw as $key => $values) {
if (in_array($key, ['_edit_lock', '_edit_last'], true)) {
continue;
}
$out[$key] = array_map('maybe_unserialize', (array) $values);
}
return $out;
}
function normalize_meta_input(array $payload): array {
$meta = $payload['meta'] ?? [];
if (!is_array($meta)) {
return [];
}
$out = [];
foreach ($meta as $key => $values) {
if (!is_string($key) || $key === '') {
continue;
}
if (!is_array($values)) {
$values = [$values];
}
$out[$key] = $values;
}
return $out;
}
function set_meta_payload(int $id, array $meta): void {
foreach ($meta as $key => $values) {
delete_post_meta($id, $key);
foreach ($values as $value) {
add_post_meta($id, $key, maybe_serialize($value));
}
}
}
switch ($cmd) {
case 'read': {
$id = (int) ($argv[2] ?? 0);
$p = get_post($id);
if (!$p) { fwrite(STDERR, "post $id no existe\n"); exit(3); }
out_json([
'id' => $p->ID,
'title' => $p->post_title,
'content' => $p->post_content,
'excerpt' => $p->post_excerpt,
'lang' => function_exists('pll_get_post_language') ? pll_get_post_language($id) : '',
'status' => $p->post_status,
'author' => (int) $p->post_author,
'date' => $p->post_date,
'cats' => wp_get_post_categories($id),
]);
break;
}
case 'read_full': {
$id = (int) ($argv[2] ?? 0);
$p = get_post($id);
if (!$p) { fwrite(STDERR, "post $id no existe\n"); exit(3); }
out_json([
'id' => $p->ID,
'title' => $p->post_title,
'content' => $p->post_content,
'excerpt' => $p->post_excerpt,
'slug' => $p->post_name,
'lang' => function_exists('pll_get_post_language') ? pll_get_post_language($id) : '',
'status' => $p->post_status,
'author' => (int) $p->post_author,
'date' => $p->post_date,
'date_gmt' => $p->post_date_gmt,
'type' => $p->post_type,
'cats' => wp_get_post_categories($id),
'cat_slugs' => array_values(array_map(static fn($t) => $t->slug, get_the_terms($id, 'category') ?: [])),
'meta' => meta_payload($id),
'translations' => function_exists('pll_get_post_translations') ? pll_get_post_translations($id) : [],
]);
break;
}
case 'exists': {
$es = (int) ($argv[2] ?? 0);
$lang = (string) ($argv[3] ?? '');
$t = (int) pll_get_post($es, $lang);
if ($t && !get_post($t)) $t = 0; // enlace colgado a un post borrado
echo $t;
break;
}
case 'unlink': {
// Borra la traducción en <lang> y la saca del grupo (para --force / limpieza).
$es = (int) ($argv[2] ?? 0);
$lang = (string) ($argv[3] ?? '');
$t = (int) pll_get_post($es, $lang);
if ($t && get_post($t)) wp_delete_post($t, true);
$tr = function_exists('pll_get_post_translations') ? pll_get_post_translations($es) : ['es' => $es];
unset($tr[$lang]);
if ($tr) pll_save_post_translations($tr);
echo $t;
break;
}
case 'create': {
$es = (int) ($argv[2] ?? 0);
$lang = (string) ($argv[3] ?? '');
$status = (string) ($argv[4] ?? 'draft');
$src = get_post($es);
if (!$src) { fwrite(STDERR, "post fuente $es no existe\n"); exit(3); }
$payload = json_decode(file_get_contents('php://stdin'), true);
if (!is_array($payload) || empty($payload['title'])) {
fwrite(STDERR, "payload inválido por stdin\n"); exit(4);
}
// ¿ya existe (y vivo)? idempotencia dura.
$existing = (int) pll_get_post($es, $lang);
if ($existing && !get_post($existing)) $existing = 0;
if ($existing) { echo $existing; break; }
$new_id = wp_insert_post([
'post_title' => wp_slash($payload['title']),
'post_content' => wp_slash($payload['content'] ?? ''),
'post_excerpt' => wp_slash($payload['excerpt'] ?? ''),
'post_name' => sanitize_title($payload['title']),
'post_status' => $status,
'post_type' => 'post',
'post_author' => (int) $src->post_author,
'post_date' => $src->post_date,
'to_ping' => '',
'pinged' => '',
], true);
if (is_wp_error($new_id)) { fwrite(STDERR, $new_id->get_error_message() . "\n"); exit(5); }
// Idioma primero, para que las categorías traducidas casen con el idioma del post.
pll_set_post_language($new_id, $lang);
// Categorías: mapea cada categoría ES a su traducción en el idioma destino
// (las categorías de carta ya están traducidas: cartasemana 6→en 3077, fr 3083…).
$cats = wp_get_post_categories($es);
$mapped = [];
foreach ($cats as $c) {
$tc = function_exists('pll_get_term') ? (int) pll_get_term($c, $lang) : 0;
$mapped[] = $tc ?: $c; // traducida si existe; si no, la ES (fallback)
}
if ($mapped) wp_set_post_categories($new_id, array_values(array_unique($mapped)));
// Enlace de traducción (preservando el grupo existente).
$tr = function_exists('pll_get_post_translations') ? pll_get_post_translations($es) : ['es' => $es];
if (!$tr) $tr = ['es' => $es];
$tr[$lang] = $new_id;
pll_save_post_translations($tr);
// Metas de trazabilidad.
update_post_meta($new_id, 'traduccion_automatica', '1');
update_post_meta($new_id, 'traduccion_origen', $es);
update_post_meta($new_id, 'traduccion_modelo', $payload['model'] ?? '');
update_post_meta($new_id, 'traduccion_fecha', gmdate('c'));
echo $new_id;
break;
}
case 'clone': {
$target = (int) ($argv[2] ?? 0);
$lang = (string) ($argv[3] ?? '');
$status = (string) ($argv[4] ?? 'draft');
if ($target <= 0 || $lang === '') {
fwrite(STDERR, "uso: clone <target_id> <lang> <status>\n"); exit(6);
}
$payload = json_decode(file_get_contents('php://stdin'), true);
if (!is_array($payload) || empty($payload['title'])) {
fwrite(STDERR, "payload inválido por stdin\n"); exit(4);
}
$postarr = [
'post_title' => wp_slash($payload['title']),
'post_content' => wp_slash($payload['content'] ?? ''),
'post_excerpt' => wp_slash($payload['excerpt'] ?? ''),
'post_status' => $status ?: ($payload['status'] ?? 'draft'),
'post_type' => $payload['type'] ?? 'post',
'post_author' => (int) ($payload['author'] ?? 1),
'post_date' => $payload['date'] ?? current_time('mysql'),
'post_date_gmt'=> $payload['date_gmt'] ?? current_time('mysql', true),
'post_name' => $payload['slug'] ?? '',
'to_ping' => '',
'pinged' => '',
];
$existing = get_post($target);
if ($existing) {
$postarr['ID'] = $target;
$new_id = wp_update_post($postarr, true);
} else {
$postarr['import_id'] = $target;
$new_id = wp_insert_post($postarr, true);
}
if (is_wp_error($new_id)) { fwrite(STDERR, $new_id->get_error_message() . "\n"); exit(5); }
if ((int) $new_id !== $target) {
fwrite(STDERR, "ID preservado falló: esperado $target, creado $new_id\n"); exit(7);
}
pll_set_post_language($new_id, $lang);
$cats = [];
foreach ((array) ($payload['cat_slugs'] ?? []) as $slug) {
$term = get_term_by('slug', (string) $slug, 'category');
if ($term && !is_wp_error($term)) {
$cats[] = (int) $term->term_id;
}
}
if (!$cats) {
$cats = array_values(array_unique(array_map('intval', (array) ($payload['cats'] ?? []))));
}
wp_set_post_categories($new_id, $cats);
set_meta_payload($new_id, normalize_meta_input($payload));
clean_post_cache($new_id);
echo $new_id;
break;
}
case 'carta_sections': {
// Descubre el cluster de una carta parseando sus propios enlaces internos
// (misma lógica que pinta la portada, fea-carta-portada.php). No depende
// de _carta_id -> sirve aunque ese meta todavía no se haya puesto.
$id = (int) ($argv[2] ?? 0);
if (!$id || !function_exists('fea_parse_carta_sections')) {
fwrite(STDERR, "fea_parse_carta_sections no disponible o id inválido\n"); exit(9);
}
out_json(fea_parse_carta_sections($id));
break;
}
case 'save_translations': {
$payload = json_decode(file_get_contents('php://stdin'), true);
if (!is_array($payload) || empty($payload['translations']) || !is_array($payload['translations'])) {
fwrite(STDERR, "payload inválido por stdin\n"); exit(4);
}
$tr = [];
foreach ($payload['translations'] as $lang => $id) {
$id = (int) $id;
if (!is_string($lang) || $lang === '' || $id <= 0 || !get_post($id)) {
continue;
}
$tr[$lang] = $id;
}
if (count($tr) < 2) {
fwrite(STDERR, "grupo insuficiente\n"); exit(8);
}
pll_save_post_translations($tr);
out_json($tr);
break;
}
default:
fwrite(STDERR, "subcomando desconocido: '$cmd'\n");
exit(1);
}
+247
View File
@@ -0,0 +1,247 @@
#!/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
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 json
import os
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", "/web/wp-load.php")
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) ─────────────────────────────────────────────
_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]
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_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 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) -> int:
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": post.get("meta", {}),
}
out = local_helper("clone", str(post["id"]), 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_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.")
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 "
f"y va a ser SOBRESCRITO:")
for pid, old_title, new_title in conflicts:
log(f" #{pid}: local actual «{old_title[:50]}» -> prod «{new_title[:50]}»")
if dry_run:
for pid, p in posts.items():
log(f" PULL 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
for pid, p in posts.items():
new_id = local_clone(p)
if new_id != pid:
log(f" ⚠️ prod#{pid} se clonó como local#{new_id} — ID NO preservado, revisar a mano.")
else:
log(f" clone prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»")
for group in groups:
if len(group) < 2:
continue
saved = local_save_group(group)
log(f" group enlazado en local {saved}")
log(f"FIN sync prod->local. carta={carta_id} posts={len(posts)} conflictos_previos={len(conflicts)}")
return 0
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.")
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.")
args = ap.parse_args()
if not PROD_HOST or not PROD_PASS:
raise SystemExit(
"Faltan FEA_PROD_SSH_HOST / FEA_PROD_SSH_PASS en el entorno.\n"
"Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env"
)
return sync_carta(args.carta, dry_run=args.dry_run)
if __name__ == "__main__":
raise SystemExit(main())