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:
@@ -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);
|
||||
}
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user