Compare commits

...

4 Commits

Author SHA1 Message Date
rafa bda4a753c9 Report TTS WordPress read failures instead of false zero backlog 2026-08-28 04:20:15 -04:00
rafa de91485854 sync audio: el recuento final contaba las subidas buenas como saltadas
sync_one devuelve "ok prod#<id>" desde que el id de prod puede diferir del
local, pero el contador seguia comparando con la igualdad exacta "ok". Resultado:
el 8-ago la subida de los 90 audios de Fray Marcos, sin un solo fallo, cerro con
"ok=0 skip=90". El susto es lo de menos; lo malo es que un fallo real se camufla
en ese ruido.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:57:04 -04:00
rafa 2fa50a8600 TTS backlog: el cron deduce su repo, y corre desde un worktree fijo (#188)
El cron apuntaba al checkout principal, que cambia de rama a menudo. Al pasar a
docs/rescate-gap-194 (5-ago) los scripts del backlog dejaron de existir en el
working tree y el cron fallo en silencio: 7 ventanas perdidas hasta el 8-ago.
Ahora REPO se deduce de la ubicacion del script y el cron/informe apuntan a
~/worktrees/fea-tts-backlog, fijado a esta rama, inmune a los checkouts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 07:39:24 -04:00
rafa df7bf98ef1 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>
2026-08-05 07:09:29 -04:00
23 changed files with 1046 additions and 141 deletions
+4
View File
@@ -17,6 +17,10 @@ joomla-php83/
backups/* backups/*
!backups/README.md !backups/README.md
# Backups pesados (GBs) bajo docs/ — mismo motivo que backups/*, ruta distinta
docs/backups/*
!docs/backups/README.md
# Capturas de pantalla # Capturas de pantalla
capturas/ capturas/
@@ -0,0 +1,71 @@
# Release dry-run — Enrique Martínez Lozano / carta «Hacia el corazón»
**Estado:** preparado localmente; **no ejecutado en producción**.
## Validación local completada
- Comentario ES: `#55041``El tesoro está ya en nosotros`
- Categorías: `Comentarios al evangelio` + `Feadulta`.
- Audio: `tts/55041.mp3`, 2,221,101 bytes, voz `NicoFeadulta2026`.
- QA Haiku: aprobado tras correcciones objetivas EN/FR/IT.
- Traducciones del comentario: EN `#55047`, FR `#55048`, IT `#55049`, PT `#55050`.
- Carta local ES: `#55042``Hacia el corazón` (slug técnico de preview conservado).
- Traducciones: EN `#55051`, FR `#55052`, IT `#55053`, PT `#55054`.
- QA Haiku: contenido aprobado tras correcciones; 24 enlaces con destino traducido se repuntaron. Los 12 enlaces externos que quedan son fallback ES: no existe post destino traducido en local (verificado por `post_title` exacto y Polylang).
- Render comprobado en Tailscale:
- Comentario ES: título correcto, categorías correctas y reproductor HTML5 visible.
- Carta FR: título correcto; Enrique aparece tras Fray Marcos y antes de Pagola.
## Evidencia de producción (sólo lectura server-side)
- Carta real: `#54914`, título `Hacia el corazón`, estado `publish`.
- Hash actual de contenido prod: `5e7eb1b880bfc6311edb78ac8b6e81639f5b4502282a1b182c610b1d01308b69`.
- Hash candidato local: `6fdb4929fcb5b49b7f2dc1deb7c6f5be70983af866d44c5d5c27d2866c09418c`.
- La carta candidata lleva `fea_phase_a_source_prod_id=54914`; por tanto debe **actualizar #54914**, no crearse una carta nueva.
## Dry-runs que pasaron
```bash
# Crea solamente el grupo nuevo del comentario (ES + EN/FR/IT/PT): 5 posts draft
python3 scripts/sync_translations_to_prod.py --ids 55041 --dry-run
# Sube y asocia solamente el MP3 de Enrique
python3 scripts/sync_audio_to_prod.py --ids 55041 --dry-run
```
Ambos devolvieron `ok/error=1/0` (audio) y un grupo Polylang de cinco posts (comentario).
## Guardarraíl crítico
**NO ejecutar:**
```bash
python3 scripts/sync_translations_to_prod.py --ids 55041,55042
```
El dry-run demostró que clonaría `#55042` como una segunda carta ES. No actualiza la carta real `#54914`.
## Release que debe ejecutar Rafa (producción)
1. Backup de producción y snapshot server-side de `#54914`.
2. Crear el grupo nuevo del comentario desde `#55041` (ES + EN/FR/IT/PT), inicialmente en `draft`; anotar los IDs reales de producción.
3. Subir `tts/55041.mp3` y asociarlo al ID ES real creado en producción.
4. Actualizar **solamente** título/contenido de la carta real `#54914` con el candidato local `#55042`; mantener título `Hacia el corazón`, autor, fecha y estado existentes.
5. Crear EN/FR/IT/PT de la carta contra el origen real `#54914` y enlazarlas en Polylang. No clonar otra ES.
6. Releer por servidor los IDs creados y `#54914`; comprobar:
- Enrique sólo está en `Evangelio y comentarios al Evangelio`, tras Fray Marcos y antes de Pagola.
- No aparece en `Artículos seleccionados`.
- Cada carta enlaza a Enrique en su mismo idioma.
- El reproductor del ES carga `tts/55041.mp3`.
7. Sólo tras esas lecturas, promover los drafts de producción a `publish`.
## Rollback
- Restaurar el backup de producción previo.
- Alternativamente, restaurar el contenido/hash previo de `#54914` y despublicar el nuevo grupo + audio de Enrique.
## Backups locales relacionados
- `docs/backups/enrique-translations-20260725T004921Z/`
- `docs/backups/enrique-carta-fr-final-20260725T012649Z/`
- `docs/backups/enrique-pre-tts-20260725T013021Z/`
+50
View File
@@ -0,0 +1,50 @@
<?php
/**
* Issue #62 — Reapunta foto_perfil de cada autor a su avatar nuevo.
* Crea un attachment por autor y guarda el foto_perfil antiguo en _foto_perfil_pre62 (revertible).
* Uso: wp eval-file import_avatars_62.php [--apply]
* sin --apply => dry run (no escribe nada).
*/
require_once ABSPATH . 'wp-admin/includes/image.php';
$apply = getenv('APPLY') === '1';
$mfile = getenv('MANIFEST') ?: 'manifest_62.json';
$updir = wp_get_upload_dir();
$manifest = json_decode(file_get_contents($updir['basedir'] . '/avatares/' . $mfile), true);
$done = $skip = $err = 0;
foreach ($manifest as $row) {
list($uid, $name, $orig, $kind, $old_attach) = $row;
if (!in_array($kind, ['initials','trim','logo','qs'], true)) { $skip++; continue; }
$rel = "avatares/autores/autor-{$uid}.png";
$abs = $updir['basedir'] . '/' . $rel;
if (!file_exists($abs)) { echo "MISSING file uid=$uid\n"; $err++; continue; }
// si foto_perfil ya apunta a este fichero, solo se ha sobrescrito el PNG: no crear attachment nuevo
$cur = (int) get_user_meta($uid, 'foto_perfil', true);
if ($cur && get_post_meta($cur, '_wp_attached_file', true) === $rel) {
if ($apply) wp_update_attachment_metadata($cur, wp_generate_attachment_metadata($cur, $abs));
$skip++; continue;
}
if (!$apply) { $done++; continue; }
// guardar foto_perfil antiguo una sola vez (idempotente)
if (get_user_meta($uid, '_foto_perfil_pre62', true) === '') {
update_user_meta($uid, '_foto_perfil_pre62', $old_attach);
}
$attach = [
'post_mime_type' => 'image/png',
'post_title' => "Avatar {$name}",
'post_status' => 'inherit',
'guid' => $updir['baseurl'] . '/' . $rel,
];
$aid = wp_insert_attachment($attach, $abs, 0, true);
if (is_wp_error($aid)) { echo "ERR insert uid=$uid: ".$aid->get_error_message()."\n"; $err++; continue; }
wp_update_attachment_metadata($aid, wp_generate_attachment_metadata($aid, $abs));
update_user_meta($uid, 'foto_perfil', $aid);
$done++;
}
echo ($apply ? "APLICADO" : "DRY-RUN") . ": procesados=$done omitidos=$skip errores=$err\n";
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
File diff suppressed because one or more lines are too long
+15
View File
@@ -13,6 +13,21 @@ server {
# application/octet-stream y el navegador se los descarga en vez de mostrarlos. # application/octet-stream y el navegador se los descarga en vez de mostrarlos.
default_type text/html; default_type text/html;
# URLs históricas de catálogo: el catálogo vigente vive en Ediciones Fe Adulta.
# 302 primero: evita cachear un destino externo de forma irreversible durante el soak.
location ~ ^/(?:es/)?catalogo-de-libros-feadulta(?:/|\.html)?$ {
return 302 https://edicionesfeadulta.com/;
}
# La vista imprimible Joomla de esta página EFFA no existe en el archivo estático.
# Conserva la página normal del archivo; sólo la variante ?print=1 va al contenido WP vigente.
location = /es/effa/95-secc5cat/3304-seccion5col00.html {
if ($arg_print = 1) {
return 302 https://www.feadulta.com/seccion5col00-2/;
}
try_files $uri $uri.html $uri/index.html =404;
}
location / { location / {
try_files $uri $uri.html $uri/index.html =404; try_files $uri $uri.html $uri/index.html =404;
} }
@@ -0,0 +1,10 @@
portfolio-tracker
yt-api-bridge
wordpress-web
joomla-web-php83
jellyfin
triptyk-local-wordpress-1
triptyk-local-db-1
yt-summaries
joomla-web
wordpress-mysql
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Retira exclusivamente el esquema vacío fewp1 de CDMON.
# Por defecto solo hace dry-run. La eliminación real exige --apply y el token exacto.
set -euo pipefail
mode="dry-run"
if [[ "${1:-}" == "--apply" ]]; then
mode="apply"
fi
if [[ "$mode" == "apply" && "${CONFIRM_DROP_FEWP1:-}" != "DROP_FEWP1" ]]; then
echo "Refusing apply: export CONFIRM_DROP_FEWP1=DROP_FEWP1 first." >&2
exit 2
fi
set -a
source "$HOME/.hermes/profiles/feadulta/.env"
set +a
php='global $wpdb;
$count=(int)$wpdb->get_var("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=\"fewp1\"");
if ($count !== 0) { fwrite(STDERR, "ABORT: fewp1 has ".$count." tables; it is not empty.\n"); exit(3); }
echo "PASS: fewp1 exists and has 0 tables.\n";'
if [[ "$mode" == "apply" ]]; then
php+=' $ok=$wpdb->query("DROP DATABASE `fewp1`");
if ($ok === false) { fwrite(STDERR, "ABORT: DROP DATABASE failed.\n"); exit(4); }
$remaining=(int)$wpdb->get_var("SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name=\"fewp1\"");
if ($remaining !== 0) { fwrite(STDERR, "ABORT: fewp1 remains after DROP.\n"); exit(5); }
echo "APPLIED: fewp1 removed; verified absent.\n";'
else
php+=' echo "DRY-RUN: would execute DROP DATABASE `fewp1`; no production change made.\n";'
fi
printf -v quoted_php '%q' "$php"
sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=accept-new "$FEA_PROD_SSH_HOST" \
"cd /web && wp eval $quoted_php"
+41
View File
@@ -262,6 +262,47 @@ switch ($cmd) {
break; break;
} }
case 'clone_new': {
// Clona en un ID local NUEVO. Se usa al importar desde prod cuando el
// mismo ID ya está ocupado por contenido local distinto.
$lang = (string) ($argv[2] ?? '');
$status = (string) ($argv[3] ?? '');
if ($lang === '') {
fwrite(STDERR, "uso: clone_new <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' => '',
];
$new_id = wp_insert_post($postarr, true);
if (is_wp_error($new_id)) { fwrite(STDERR, $new_id->get_error_message() . "\n"); exit(5); }
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': { case 'carta_sections': {
// Descubre el cluster de una carta parseando sus propios enlaces internos // Descubre el cluster de una carta parseando sus propios enlaces internos
// (misma lógica que pinta la portada, fea-carta-portada.php). No depende // (misma lógica que pinta la portada, fea-carta-portada.php). No depende
+47 -24
View File
@@ -39,18 +39,21 @@ AUTORES = {
386: ("Arregi", "ArregiFeadulta2026"), 386: ("Arregi", "ArregiFeadulta2026"),
} }
# Autor y rango que está locutando el cron ahora mismo (los valores por defecto # Autor y rango que está locutando el cron ahora mismo. Debe seguir el crontab:
# de tts_backlog_cron.sh). Solo afecta a qué línea se marca como "en curso". # solo afecta a qué línea se marca como «en curso» en el informe diario.
ACTIVO = (382, 2025, 2026) ACTIVO = (774, 2000, 2026)
def php(*args: str) -> str: def php(*args: str) -> str:
"""fea_post_io.php dentro del contenedor. stderr fuera: WP escupe warnings.""" """Llama al helper WP sin convertir un fallo de lectura en una cola vacía."""
r = subprocess.run( r = subprocess.run(
["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", *args], ["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", *args],
capture_output=True, text=True, capture_output=True, text=True,
) )
return r.stdout if r.returncode == 0 else "" if r.returncode != 0:
detail = (r.stderr or r.stdout).strip().replace("\n", " ")[:300]
raise RuntimeError(f"consulta WP {' '.join(args)} falló (rc={r.returncode}): {detail}")
return r.stdout
def pendientes(autor: int, desde: int, hasta: int, voz: str) -> int: def pendientes(autor: int, desde: int, hasta: int, voz: str) -> int:
@@ -143,41 +146,62 @@ def _campo_cron(campo: str, valores: range) -> set[int]:
def previstas_24h(ahora: datetime | None = None) -> int | None: def previstas_24h(ahora: datetime | None = None) -> int | None:
"""Cuántas ventanas TENÍA que haber corrido el cron en las últimas 24 h. """Cuántas ventanas tenían que haber corrido en las últimas 24 horas.
Sin esto, los martes/miércoles/jueves (días de carta, sin cron) el informe El backlog usa varias líneas de crontab (ritmo normal + sábado + domingo).
daba la alarma de 'ninguna ventana dejó rastro' estando todo correcto. La Se calcula la unión de sus instantes previstos, sin contar dos veces una
verdad está en el crontab, no aquí: si Rafa cambia los días, esto le sigue. coincidencia entre líneas.
""" """
try: try:
linea = next( lineas = [
l for l in subprocess.run(["crontab", "-l"], text=True, capture_output=True, l for l in subprocess.run(["crontab", "-l"], text=True, capture_output=True,
check=True).stdout.splitlines() check=True).stdout.splitlines()
if "tts_backlog_cron.sh" in l and not l.lstrip().startswith("#")) if "tts_backlog_cron.sh" in l and not l.lstrip().startswith("#")
]
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
return None return None
campos = linea.split(None, 5) if not lineas:
if len(campos) < 5:
return None return None
minutos = _campo_cron(campos[0], range(60))
horas = _campo_cron(campos[1], range(24))
dows = {d % 7 for d in _campo_cron(campos[4], range(7))} # cron: 0 y 7 = domingo
ahora = ahora or datetime.now() ahora = ahora or datetime.now()
n = 0 inicio = ahora - timedelta(hours=24)
previstos: set[datetime] = set()
for linea in lineas:
campos = linea.split(None, 5)
if len(campos) < 5:
continue
try:
minutos = _campo_cron(campos[0], range(60))
horas = _campo_cron(campos[1], range(24))
dows = {d % 7 for d in _campo_cron(campos[4], range(7))}
except (TypeError, ValueError):
continue
for h in range(25): for h in range(25):
t = (ahora - timedelta(hours=h)).replace(second=0, microsecond=0) t = (ahora - timedelta(hours=h)).replace(second=0, microsecond=0)
for m in minutos: for m in minutos:
cand = t.replace(minute=m) cand = t.replace(minute=m)
if not (ahora - timedelta(hours=24) < cand <= ahora): if inicio < cand <= ahora and cand.hour in horas and (cand.weekday() + 1) % 7 in dows:
continue previstos.add(cand)
if cand.hour in horas and (cand.weekday() + 1) % 7 in dows: return len(previstos)
n += 1
return n
def main() -> int: def main() -> int:
# Nunca informar «0 pendientes» cuando la lectura de WordPress ha fallado.
# El cron no-agent entrega stdout/errores tal cual: un aviso explícito permite
# arreglar Docker/helper sin que parezca que el backlog está terminado.
try:
hechos = hechos_24h() hechos = hechos_24h()
faltantes = {
aid: (pendientes(aid, 0, 9999, voz),
pendientes(aid, ACTIVO[1], ACTIVO[2], voz) if aid == ACTIVO[0] else None)
for aid, (_nombre, voz) in AUTORES.items()
}
except RuntimeError as exc:
print("⚠️ Fe Adulta — informe TTS inválido: no se pudo leer WordPress.")
print(f" {exc}")
print(" Los ceros no son datos reales; revisar wordpress-web y /tmp/fea_post_io.php.")
return 1
total = sum(len(v) for v in hechos.values()) total = sum(len(v) for v in hechos.values())
corridas, saltadas, fallos = logs_24h() corridas, saltadas, fallos = logs_24h()
p5, pw = cuota() p5, pw = cuota()
@@ -194,10 +218,9 @@ def main() -> int:
lineas.append("") lineas.append("")
lineas.append("Pendientes:") lineas.append("Pendientes:")
for aid, (nombre, voz) in AUTORES.items(): for aid, (nombre, voz) in AUTORES.items():
falta_todo = pendientes(aid, 0, 9999, voz) falta_todo, falta_lote = faltantes[aid]
marca = "" marca = ""
if aid == ACTIVO[0]: if aid == ACTIVO[0]:
falta_lote = pendientes(aid, ACTIVO[1], ACTIVO[2], voz)
marca = f" ← en curso, {falta_lote} del lote {ACTIVO[1]}-{ACTIVO[2]}" marca = f" ← en curso, {falta_lote} del lote {ACTIVO[1]}-{ACTIVO[2]}"
lineas.append(f" {nombre}: {falta_todo}{marca}") lineas.append(f" {nombre}: {falta_todo}{marca}")
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Importa las traducciones editoriales humanas adjuntas a un issue de carta.
Por defecto solo valida y muestra el plan. --apply-local crea borradores en el
WordPress Docker local; nunca publica ni toca producción.
"""
from __future__ import annotations
import argparse
import hashlib
import html
import json
import re
import subprocess
import tempfile
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
ROOT = Path(__file__).resolve().parent
HELPER = ROOT / "fea_translate_helper.php"
CONTAINER = "wordpress-web"
SOURCE_ID = 55465 # Pagola, Carta 738
LANG_BY_NAME = {"2_eng": "en", "3_fr": "fr", "4_it": "it", "5_pt": "pt"}
TRANSLATOR_LABELS = ("Translator:", "Traducteur:", "Traduzzione:", "Tradutor:")
def docx_lines(path: Path) -> list[str]:
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
with zipfile.ZipFile(path) as zf:
root = ET.fromstring(zf.read("word/document.xml"))
return [
"".join(t.text or "" for t in p.findall(".//w:t", ns)).strip()
for p in root.findall(".//w:p", ns)
if "".join(t.text or "" for t in p.findall(".//w:t", ns)).strip()
]
def value_after(lines: list[str], label: str) -> str:
try:
return lines[lines.index(label) + 1]
except (ValueError, IndexError) as exc:
raise ValueError(f"Falta {label!r}") from exc
def parse_doc(path: Path) -> dict:
lines = docx_lines(path)
title = value_after(lines, "Título:")
excerpt = value_after(lines, "Entradilla:")
author = value_after(lines, "Autor:")
start = lines.index("Cuerpo:") + 1
end = next((i for i in range(start, len(lines))
if any(lines[i].startswith(label) for label in TRANSLATOR_LABELS)), len(lines))
body = lines[start:end]
if not body:
raise ValueError("Cuerpo vacío")
translator = ""
if end < len(lines):
label = next(label for label in TRANSLATOR_LABELS if lines[end].startswith(label))
translator = lines[end][len(label):].strip()
if not translator and end + 1 < len(lines):
translator = lines[end + 1]
# La fuente española incluye el crédito/origen como último párrafo.
body.append('Publicado en: <a href="https://www.gruposdejesus.com">https://www.gruposdejesus.com</a>')
content = "\n".join(
f"<p>{line if line.startswith('Publicado en: <a ') else html.escape(line)}</p>" for line in body
)
return {
"title": title,
"excerpt": excerpt,
"author": author,
"translator": translator,
"content": content,
"source_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
}
def run(cmd: list[str], stdin: str | None = None) -> str:
result = subprocess.run(cmd, input=stdin, text=True, capture_output=True)
if result.returncode:
raise RuntimeError(f"rc={result.returncode}: {result.stderr.strip()} {result.stdout.strip()}")
return result.stdout.strip()
def helper(*args: str, stdin: str | None = None) -> str:
return run(["docker", "exec", "-i", CONTAINER, "php", "/tmp/fea_translate_helper.php", *args], stdin)
def lang_for(path: Path) -> str:
for marker, lang in LANG_BY_NAME.items():
if marker in path.name:
return lang
raise ValueError(f"No reconozco idioma en {path.name}")
def apply_meta(post_id: int, doc: Path, parsed: dict) -> None:
payload = json.dumps({"id": post_id, "doc": doc.name, "sha": parsed["source_sha256"],
"translator": parsed["translator"]}, ensure_ascii=False)
php = r'''$p=json_decode(base64_decode(getenv('PAYLOAD')),true); update_post_meta($p['id'],'traduccion_automatica','0'); update_post_meta($p['id'],'traduccion_modelo','editorial-humana'); update_post_meta($p['id'],'traduccion_fuente_doc',$p['doc']); update_post_meta($p['id'],'traduccion_fuente_sha256',$p['sha']); update_post_meta($p['id'],'traduccion_editor',$p['translator']); echo 'ok';'''
import base64
encoded = base64.b64encode(payload.encode()).decode()
run(["docker", "exec", "-i", "-e", f"PAYLOAD={encoded}", CONTAINER,
"wp", "--allow-root", "eval", php])
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--dir", type=Path, required=True)
ap.add_argument("--apply-local", action="store_true")
args = ap.parse_args()
docs = sorted(args.dir.glob("*.docx"))
if len(docs) != 4:
raise SystemExit(f"Se esperaban 4 DOCX, encontrados {len(docs)}")
run(["docker", "cp", str(HELPER), f"{CONTAINER}:/tmp/fea_translate_helper.php"])
for doc in docs:
lang, parsed = lang_for(doc), parse_doc(doc)
existing = helper("exists", str(SOURCE_ID), lang)
plan = {"lang": lang, "doc": doc.name, "existing": int(existing or "0"),
"title": parsed["title"], "excerpt_chars": len(parsed["excerpt"]),
"content_chars": len(parsed["content"]), "translator": parsed["translator"]}
print(json.dumps(plan, ensure_ascii=False))
if not args.apply_local:
continue
if int(existing or "0"):
raise RuntimeError(f"{lang} ya existe como #{existing}; no sobrescribo")
payload = json.dumps({"title": parsed["title"], "excerpt": parsed["excerpt"],
"content": parsed["content"], "model": "editorial-humana"}, ensure_ascii=False)
post_id = int(helper("create", str(SOURCE_ID), lang, "draft", stdin=payload))
apply_meta(post_id, doc, parsed)
print(json.dumps({"created": post_id, "lang": lang, "status": "draft"}))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+8 -6
View File
@@ -361,12 +361,12 @@ def _split_for_tts(text, limit=CHAR_LIMIT):
return chunks return chunks
def _synth_chunk(text, voice_id, model): def _synth_chunk(text, voice_id, model, speed=1.0):
"""Una petición t2a. Devuelve (audio_bytes|None, rc, usage_chars).""" """Una petición t2a. Devuelve (audio_bytes|None, rc, usage_chars)."""
body = { body = {
"model": model, "model": model,
"text": text, "text": text,
"voice_setting": {"voice_id": voice_id, "speed": 1.0, "vol": 1.0, "pitch": 0}, "voice_setting": {"voice_id": voice_id, "speed": speed, "vol": 1.0, "pitch": 0},
"audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1},
"language_boost": "Spanish", "language_boost": "Spanish",
} }
@@ -381,13 +381,15 @@ def _synth_chunk(text, voice_id, model):
return bytes.fromhex(audio_hex), 0, usage return bytes.fromhex(audio_hex), 0, usage
def t2a(text, voice_id, model, name): def t2a(text, voice_id, model, name, speed=1.0):
if not 0.5 <= float(speed) <= 2.0:
raise ValueError(f"speed fuera de rango: {speed} (permitido 0.52.0)")
chunks = _split_for_tts(text) chunks = _split_for_tts(text)
print(f"Sintetizando {len(text)} car con {model} / {voice_id} " print(f"Sintetizando {len(text)} car con {model} / {voice_id} a {float(speed):.2f}× "
f"({len(chunks)} petición/es)…", flush=True) f"({len(chunks)} petición/es)…", flush=True)
raw = OUT / f"{name}.raw.mp3" raw = OUT / f"{name}.raw.mp3"
if len(chunks) == 1: if len(chunks) == 1:
audio, rc, _ = _synth_chunk(chunks[0], voice_id, model) audio, rc, _ = _synth_chunk(chunks[0], voice_id, model, speed)
if audio is None: if audio is None:
return rc return rc
raw.write_bytes(audio) raw.write_bytes(audio)
@@ -398,7 +400,7 @@ def t2a(text, voice_id, model, name):
import os as _os, time as _t import os as _os, time as _t
_t.sleep(int(_os.environ.get("FEA_CHUNK_PAUSE", "35"))) # respetar TPM de MiniMax _t.sleep(int(_os.environ.get("FEA_CHUNK_PAUSE", "35"))) # respetar TPM de MiniMax
print(f" trozo {k + 1}/{len(chunks)} ({len(ch)} car)…", flush=True) print(f" trozo {k + 1}/{len(chunks)} ({len(ch)} car)…", flush=True)
audio, rc, _ = _synth_chunk(ch, voice_id, model) audio, rc, _ = _synth_chunk(ch, voice_id, model, speed)
if audio is None: if audio is None:
for p in parts: for p in parts:
p.unlink(missing_ok=True) p.unlink(missing_ok=True)
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Fase A local: borrador de Enrique + copia draft de la carta para revisión.
Por defecto no escribe WordPress. --apply-local sólo modifica el WordPress Docker local.
Nunca publica ni toca producción.
"""
import argparse
import base64
import hashlib
import html
import json
import os
import re
import subprocess
import sys
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_DOC = Path("/home/rafa/.hermes/cache/documents/doc_7909db597c74_7r_Enrique_Mart_nez_Lozano_tesoro_esta_ya_en_nosotros.docx")
CARTA_PROD_ID = 54914
AUTHOR_ID = 384 # Enrique Martínez Lozano, verificado localmente.
CATEGORIES = [1650, 71] # Artículos + Feadulta
SLUG = "el-tesoro-esta-ya-en-nosotros"
def docx_text(path: Path) -> list[str]:
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
with zipfile.ZipFile(path) as zf:
root = ET.fromstring(zf.read("word/document.xml"))
out = []
for p in root.findall(".//w:p", ns):
text = "".join(t.text or "" for t in p.findall(".//w:t", ns)).strip()
if text:
out.append(text)
return out
def parse_source(path: Path) -> dict:
lines = docx_text(path)
def after(label: str) -> str:
return lines[lines.index(label) + 1]
title = after("Título:")
author = after("Autor:")
assert title == "EL TESORO ESTÁ YA EN NOSOTROS", title
assert author == "ENRIQUE MARTÍNEZ LOZANO", author
body_start = lines.index("Cuerpo:") + 1
body = lines[body_start:]
# Última firma del boletín no es contenido del post: el autor va en WP.
if body[-1].startswith("ENRIQUE MARTÍNEZ LOZANO"):
body = body[:-1]
date_line, bible, *paragraphs = body
intro = lines[lines.index("Entradilla:") + 1]
content = "\n".join(
[f"<p><strong>{html.escape(date_line)}</strong></p>",
f"<p><strong>{html.escape(bible)}</strong></p>"]
+ [f"<p>{html.escape(p)}</p>" for p in paragraphs]
)
return {"title": title.lower().capitalize(), "author": author.title(), "intro": intro,
"content": content, "source_sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
def prod_card() -> dict:
sys.path.insert(0, str(ROOT / "scripts"))
import sync_translations_to_prod as sync
return json.loads(sync.prod_helper("read_full", str(CARTA_PROD_ID)))
def carta_candidate(card: dict, anchor: str) -> str:
marker = '<p>&nbsp;</p>\n<p><span style="color: #ff0000;"><strong>Para unas eucaristías más participativas y actuales</strong></span></p>'
assert card["content"].count(marker) == 1, "Marcador de sección no único/no encontrado"
return card["content"].replace(marker, anchor + "\n" + marker, 1)
def run_wp(payload: dict, apply: bool) -> dict:
encoded = base64.b64encode(json.dumps(payload, ensure_ascii=False).encode()).decode()
php = r'''
$p=json_decode(base64_decode(getenv('FEA_PAYLOAD')), true);
$apply=getenv('FEA_APPLY') === '1';
$existing=get_page_by_path($p['slug'], OBJECT, 'post');
$author=get_user_by('id',(int)$p['author_id']);
$cats=array_map('intval',$p['categories']);
$cat_ok=count(array_filter($cats, fn($id)=>term_exists($id,'category')))===count($cats);
$preview=get_posts(['post_type'=>'post','post_status'=>'any','meta_key'=>'fea_phase_a_source_hash','meta_value'=>$p['source_sha256'],'numberposts'=>1]);
$out=['dry_run'=>!$apply,'existing_slug'=>$existing?['id'=>$existing->ID,'status'=>$existing->post_status]:null,
'author'=>$author?['id'=>$author->ID,'name'=>$author->display_name]:null,'categories_ok'=>$cat_ok,
'preview_existing'=>$preview?['id'=>$preview[0]->ID,'status'=>$preview[0]->post_status]:null,
'candidate_anchor'=>$p['anchor']];
if (!$author || !$cat_ok) { $out['error']='Autor o categorías no válidos'; echo wp_json_encode($out,JSON_UNESCAPED_UNICODE); exit(3); }
if ($existing || $preview) { $out['error']='Ya existe un borrador/slug para esta Fase A; no se duplica'; echo wp_json_encode($out,JSON_UNESCAPED_UNICODE); exit(4); }
if (!$apply) { echo wp_json_encode($out,JSON_UNESCAPED_UNICODE); exit; }
$article_id=wp_insert_post(['post_type'=>'post','post_status'=>'draft','post_author'=>(int)$p['author_id'],
'post_title'=>$p['article_title'],'post_name'=>$p['slug'],'post_excerpt'=>$p['intro'],
'post_content'=>$p['article_content'],'post_category'=>$cats],true);
if (is_wp_error($article_id)) { $out['error']=$article_id->get_error_message(); echo wp_json_encode($out,JSON_UNESCAPED_UNICODE); exit(5); }
if (function_exists('pll_set_post_language')) pll_set_post_language($article_id,'es');
update_post_meta($article_id,'fea_phase_a_source_hash',$p['source_sha256']);
update_post_meta($article_id,'fea_phase_a_source_doc','7r_Enrique Martínez Lozano_tesoro_esta_ya_en_nosotros.docx');
update_post_meta($article_id,'fea_phase_a_status','preview-only');
$preview_id=wp_insert_post(['post_type'=>'post','post_status'=>'draft','post_author'=>(int)$p['card_author_id'],
'post_title'=>'PREVIEW — Hacia el corazón (+ Enrique Martínez Lozano)','post_excerpt'=>'Copia local de validación; no publicar.',
'post_content'=>$p['candidate_card_content']],true);
if (is_wp_error($preview_id)) { wp_delete_post($article_id,true); $out['error']=$preview_id->get_error_message(); echo wp_json_encode($out,JSON_UNESCAPED_UNICODE); exit(6); }
if (function_exists('pll_set_post_language')) pll_set_post_language($preview_id,'es');
update_post_meta($preview_id,'fea_phase_a_source_hash',$p['source_sha256']);
update_post_meta($preview_id,'fea_phase_a_source_prod_id',(int)$p['carta_prod_id']);
update_post_meta($preview_id,'fea_phase_a_preview_article_id',$article_id);
update_post_meta($preview_id,'fea_phase_a_status','preview-only');
$out['article']=['id'=>$article_id,'status'=>'draft','slug'=>get_post_field('post_name',$article_id),'author'=>get_the_author_meta('display_name',(int)$p['author_id'])];
$out['preview_card']=['id'=>$preview_id,'status'=>'draft','source_prod_id'=>(int)$p['carta_prod_id']];
echo wp_json_encode($out,JSON_UNESCAPED_UNICODE);
'''
cmd = [
"docker", "exec", "-i", "-e", f"FEA_PAYLOAD={encoded}",
"-e", f"FEA_APPLY={'1' if apply else '0'}", "wordpress-web",
"wp", "--allow-root", "eval", php,
]
result = subprocess.run(cmd, text=True, capture_output=True, check=False)
if result.returncode:
raise RuntimeError(f"WP local rc={result.returncode}: {result.stderr}\n{result.stdout}")
return json.loads(result.stdout)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--doc", type=Path, default=DEFAULT_DOC)
ap.add_argument("--apply-local", action="store_true")
ap.add_argument("--output", type=Path, required=True)
args = ap.parse_args()
source = parse_source(args.doc)
card = prod_card() # lectura server-side únicamente.
article_url = "http://localhost:8080/" + SLUG + "/"
anchor = (f'<p><strong><a href="{article_url}">Enrique Martínez Lozano: '
f'{html.escape(source["title"])}.</a></strong> {html.escape(source["intro"])}</p>')
payload = {
"source_sha256": source["source_sha256"], "article_title": source["title"],
"article_content": source["content"], "intro": source["intro"], "slug": SLUG,
"author_id": AUTHOR_ID, "categories": CATEGORIES, "anchor": anchor,
"candidate_card_content": carta_candidate(card, anchor), "card_author_id": card["author"],
"carta_prod_id": CARTA_PROD_ID,
}
result = run_wp(payload, args.apply_local)
report = {"source": source, "prod_card": {"id": card["id"], "title": card["title"], "status": card["status"]},
"result": result, "mode": "apply-local" if args.apply_local else "dry-run"}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
# Release Fase 1+2, issue #181: 68 traducciones publicadas + 16 MP3 TTS.
# Por defecto es sólo dry-run. --apply requiere confirmación explícita de Rafa.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
MODE="dry-run"
if [[ "${1:-}" == "--apply" ]]; then
MODE="apply"
elif [[ "${1:-}" == "--verify" ]]; then
MODE="verify"
elif [[ "${1:-}" != "" && "${1:-}" != "--dry-run" ]]; then
echo "Uso: $0 [--dry-run|--verify|--apply]" >&2
exit 2
fi
# shellcheck disable=SC1091
source ~/.hermes/profiles/feadulta/.env
: "${FEA_PROD_WPLOAD:=/web/wp-load.php}"
if [[ "$FEA_PROD_WPLOAD" != "/web/wp-load.php" ]]; then
echo "ABORT: FEA_PROD_WPLOAD debe ser /web/wp-load.php (recibido: $FEA_PROD_WPLOAD)" >&2
exit 2
fi
RELEASE_DIR="logs/release-issue-181"
mkdir -p "$RELEASE_DIR/backups"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
ORIGINS=(54875 54902 54903 54883 54884 54885 54886 54866 54867 54868 54869 54870 54871 54872 54873 54880 54914)
AUDIO_IDS=(54875 54902 54903 54883 54884 54885 54886 54866 54867 54868 54869 54870 54871 54872 54873 54880)
if [[ "$MODE" == "apply" && "${FEA_RELEASE_CONFIRM:-}" != "PUBLISH_ISSUE_181" ]]; then
echo "ABORT: para escribir en producción usa:" >&2
echo " FEA_RELEASE_CONFIRM=PUBLISH_ISSUE_181 $0 --apply" >&2
exit 2
fi
preflight() {
python3 - "${ORIGINS[@]}" <<'PY'
import json, sys
sys.path.insert(0, 'scripts')
import sync_translations_to_prod as sync
ids = [int(x) for x in sys.argv[1:]]
rows = []
for pid in ids:
d = json.loads(sync.prod_helper('read_full', str(pid)))
rows.append({'id': pid, 'status': d['status'], 'lang': d['lang'], 'translations': d.get('translations', {})})
assert len(rows) == 17
assert all(r['status'] == 'publish' and r['lang'] == 'es' for r in rows), rows
# Los 16 artículos no deben tener traducciones aún; la carta tampoco antes del primer release.
assert all(r['translations'] == {'es': r['id']} for r in rows), rows
print(json.dumps(rows, ensure_ascii=False, indent=2))
print('preflight=PASS sources=17')
PY
}
backup_prod_state() {
python3 - "$RELEASE_DIR/backups/prod-before-$STAMP.json" "${ORIGINS[@]}" -- "${AUDIO_IDS[@]}" <<'PY'
import json, sys
out = sys.argv[1]
sep = sys.argv.index('--')
origins = [int(x) for x in sys.argv[2:sep]]
audio_ids = [int(x) for x in sys.argv[sep+1:]]
sys.path.insert(0, 'scripts')
import sync_translations_to_prod as translations
import sync_audio_to_prod as audio
payload = {
'origins': {str(pid): json.loads(translations.prod_helper('read_full', str(pid))) for pid in origins},
'audio_before': {
str(pid): {
'url': audio.prod_helper('getmeta', str(pid), 'fea_audio_url').strip(),
'voice': audio.prod_helper('getmeta', str(pid), 'fea_audio_voice').strip(),
'done': audio.prod_helper('getmeta', str(pid), 'fea_audio_done').strip(),
} for pid in audio_ids
},
}
with open(out, 'w', encoding='utf-8') as fh:
json.dump(payload, fh, ensure_ascii=False, indent=2)
print(out)
PY
}
verify_release() {
python3 - "${ORIGINS[@]}" -- "${AUDIO_IDS[@]}" <<'PY'
import json, sys, time
sep = sys.argv.index('--')
origins = [int(x) for x in sys.argv[1:sep]]
audio_ids = [int(x) for x in sys.argv[sep+1:]]
sys.path.insert(0, 'scripts')
import sync_translations_to_prod as translations
import sync_audio_to_prod as audio
def read_post(pid):
"""Lectura server-side resiliente ante una respuesta SSH/PHP vacía transitoria."""
last = ''
for attempt in range(1, 4):
raw = translations.prod_helper('read_full', str(pid)).strip()
if raw:
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
last = f'JSON inválido intento {attempt}: {exc}; prefijo={raw[:160]!r}'
else:
last = f'respuesta vacía intento {attempt}'
time.sleep(attempt)
raise AssertionError(f'No se pudo leer post #{pid}: {last}')
for pid in origins:
d = read_post(pid)
group = d.get('translations', {})
assert set(group) == {'es', 'en', 'fr', 'it', 'pt'}, (pid, group)
for lang, tid in group.items():
td = read_post(tid)
assert td['lang'] == lang and td['status'] == 'publish', (pid, lang, tid, td['lang'], td['status'])
for pid in audio_ids:
url = audio.prod_helper('getmeta', str(pid), 'fea_audio_url').strip()
done = audio.prod_helper('getmeta', str(pid), 'fea_audio_done').strip()
assert url.endswith(f'/wp-content/uploads/tts/{pid}.mp3') and done == '1', (pid, url, done)
print('verification=PASS groups=17 translations=68 audio=16')
PY
}
if [[ "$MODE" == "verify" ]]; then
echo "== Verificación server-side posterior (solo lectura) =="
verify_release | tee "$RELEASE_DIR/verification-$STAMP.txt"
echo "VERIFICACIÓN ISSUE #181 COMPLETADA"
exit 0
fi
echo "== Preflight server-side ($MODE): 17 fuentes ES =="
preflight | tee "$RELEASE_DIR/preflight-$MODE-$STAMP.json"
if [[ "$MODE" == "dry-run" ]]; then
: > "$RELEASE_DIR/translations-dry-run-$STAMP.log"
for origin in "${ORIGINS[@]}"; do
FEA_SYNC_STATUS=publish \
FEA_SYNC_LOG="$RELEASE_DIR/translations-dry-run-$STAMP.log" \
FEA_SYNC_STATE="$RELEASE_DIR/translations-$origin-state.json" \
python3 scripts/sync_translations_to_prod.py --origin "$origin" --dry-run
done
FEA_AUDIO_SYNC_LOG="$RELEASE_DIR/audio-dry-run-$STAMP.log" \
FEA_AUDIO_SYNC_STATE="$RELEASE_DIR/audio-state.json" \
python3 scripts/sync_audio_to_prod.py --ids "$(IFS=,; echo "${AUDIO_IDS[*]}")" --dry-run | tee "$RELEASE_DIR/audio-dry-run-$STAMP.log"
echo "DRY-RUN terminado: 68 traducciones publicables + 16 audios planificados."
exit 0
fi
echo "== Backup server-side previo =="
backup_prod_state | tee "$RELEASE_DIR/backup-path-$STAMP.txt"
echo "== Subiendo 68 traducciones como publish =="
for origin in "${ORIGINS[@]}"; do
FEA_SYNC_STATUS=publish \
FEA_SYNC_LOG="$RELEASE_DIR/translations-apply-$STAMP.log" \
FEA_SYNC_STATE="$RELEASE_DIR/translations-$origin-state.json" \
python3 scripts/sync_translations_to_prod.py --origin "$origin"
done
echo "== Subiendo 16 audios =="
FEA_AUDIO_SYNC_LOG="$RELEASE_DIR/audio-apply-$STAMP.log" \
FEA_AUDIO_SYNC_STATE="$RELEASE_DIR/audio-state.json" \
python3 scripts/sync_audio_to_prod.py --ids "$(IFS=,; echo "${AUDIO_IDS[*]}")"
echo "== Verificación server-side posterior =="
verify_release | tee "$RELEASE_DIR/verification-$STAMP.txt"
echo "RELEASE ISSUE #181 COMPLETADO"
+101 -39
View File
@@ -3,9 +3,10 @@
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
local (fea_audio_done=1) y fija el meta fea_audio_url en prod. local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
Prod (134.0.10.170) tiene glibc rota: scp/sftp NO funcionan (connection closed). Prod vive en Hetzner (Coolify/Docker) desde el cutover de agosto 2026. Subida
Workaround: subir el binario por stdin de ssh ("cat > ruta"), igual que el resto del binario por stdin de ssh ("cat > ruta" dentro del contenedor vía
de scripts que tocan ese servidor (ver feadulta-server-glibc-rota.md). `docker exec -i`), sin depender de scp/sftp — mismo patrón que
sync_translations_to_prod.py y sync_carta_from_prod.py.
Uso: Uso:
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
@@ -19,8 +20,10 @@ Rollback (despublica en prod lo que este script publicó):
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import base64
import json import json
import os import os
import shlex
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
@@ -31,13 +34,20 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user") DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass") DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170") PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF") 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")
PROD_HELPER = "/tmp/fea_post_io.php" # Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
PROD_UPLOADS_TTS = "/web/wp-content/uploads/tts" # define, el helper y la subida de mp3 se ejecutan dentro del contenedor.
# Espejo de sync_translations_to_prod.py / sync_carta_from_prod.py.
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
PROD_UPLOADS_TTS = os.environ.get("FEA_PROD_UPLOADS_TTS", "/var/www/html/wp-content/uploads/tts")
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php" HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
# Se evalúa en memoria con `php -r`: no se copia un helper temporal a prod.
HELPER_EVAL_CODE = "eval(base64_decode(" + repr(
base64.b64encode(HELPER_SRC.read_text(encoding="utf-8").removeprefix("<?php").encode("utf-8")).decode("ascii")
) + "));"
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts" LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
LOG_FILE = Path(os.environ.get( LOG_FILE = Path(os.environ.get(
@@ -74,6 +84,17 @@ def local_meta(post_id: int, key: str) -> str:
return r.stdout.strip() return r.stdout.strip()
def prod_id_for_local(local_id: int) -> int:
"""Resolve a remapped local post to its original production ID.
``sync_carta_from_prod.py --remap-conflicts`` keeps this mapping in
``fea_prod_source_id``. Audio files remain named with the local ID, but
remote paths and production post meta must use the source ID.
"""
source_id = local_meta(local_id, "fea_prod_source_id")
return int(source_id) if source_id.isdigit() else local_id
def carta_article_ids(carta_id: int) -> list[int]: def carta_article_ids(carta_id: int) -> list[int]:
q = ("SELECT post_id FROM wp_postmeta " q = ("SELECT post_id FROM wp_postmeta "
f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;") f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;")
@@ -85,47 +106,80 @@ def carta_article_ids(carta_id: int) -> list[int]:
return [int(x) for x in r.stdout.split() if x.isdigit()] return [int(x) for x in r.stdout.split() if x.isdigit()]
# ── Prod (glibc rota: nada de scp/sftp, todo por ssh + cat) ──────────────────── # ── Prod (Hetzner/Docker: todo por ssh, mp3 por stdin de "cat") ────────────────
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str: def _ssh_text(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", cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd] "-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout) r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
if r.returncode != 0: if r.returncode != 0:
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}\n{r.stderr.strip()[:400]}") raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}\n{r.stderr.strip()[:400]}")
return r.stdout return r.stdout
def _remote_wrap(inner_cmd: str) -> str:
"""Envuelve un comando para que corra dentro del contenedor Docker de prod
si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.
Importante: el wrapping (incluidas redirecciones como `< ruta`) debe quedar
DENTRO de la shell del contenedor (`sh -c '...'`), porque `docker exec
CONTENEDOR cmd < ruta` resuelve esa redirección en el filesystem del HOST,
no dentro del contenedor.
"""
if PROD_DOCKER_CONTAINER:
return f"docker exec -i {shlex.quote(PROD_DOCKER_CONTAINER)} sh -c {shlex.quote(inner_cmd)}"
return inner_cmd
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None: def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
remote_cmd = _remote_wrap(f"cat > {remote_path}")
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new", cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, f"cat > {remote_path}"] "-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
sh(cmd, input_bytes=data, timeout=timeout) sh(cmd, input_bytes=data, timeout=timeout)
_prod_helper_ready = False
def prod_helper(subcmd: str, *args: str) -> str: def prod_helper(subcmd: str, *args: str) -> str:
global _prod_helper_ready """Ejecuta el helper PHP en memoria; no deja ficheros temporales en prod."""
if not _prod_helper_ready: argv = " ".join(shlex.quote(x) for x in (subcmd, *args))
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER) inner = (
_prod_helper_ready = True f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args) f"php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
return _ssh_text(inner, timeout=60) )
if PROD_DOCKER_CONTAINER:
remote_cmd = (
f"docker exec -i -e FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
f"{shlex.quote(PROD_DOCKER_CONTAINER)} php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
)
else:
remote_cmd = inner
return _ssh_text(remote_cmd, timeout=60)
def prod_upload_mp3(post_id: int) -> None: def prod_upload_mp3(local_id: int, prod_id: int) -> None:
src = LOCAL_TTS_DIR / f"{post_id}.mp3" src = LOCAL_TTS_DIR / f"{local_id}.mp3"
data = src.read_bytes() data = src.read_bytes()
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3" remote_path = f"{PROD_UPLOADS_TTS}/{prod_id}.mp3"
_ssh_upload_bytes(data, remote_path) _ssh_upload_bytes(data, remote_path)
# Verificación de tamaño (glibc rota => sin fiarse ciegamente del rc=0 de ssh) # Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe
remote_size = int(_ssh_text(f"wc -c < {remote_path}").strip()) # correr (y resolver la redirección) DENTRO del contenedor — ver _remote_wrap.
remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip())
if remote_size != len(data): if remote_size != len(data):
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}") raise RuntimeError(
f"tamaño no coincide tras subir prod#{prod_id}: local={len(data)} remoto={remote_size}"
)
def prod_remove_mp3(post_id: int) -> None: def prod_remove_mp3(post_id: int) -> None:
_ssh_text(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3") _ssh_text(_remote_wrap(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3"))
# ── Estado ─────────────────────────────────────────────────────────────────── # ── Estado ───────────────────────────────────────────────────────────────────
@@ -149,25 +203,27 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
return "sin-audio-local" return "sin-audio-local"
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists(): if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
return "mp3-local-ausente" return "mp3-local-ausente"
prod_id = prod_id_for_local(post_id)
if dry_run: if dry_run:
return "PLAN: subiría mp3 + setaudio" return f"PLAN: local#{post_id}.mp3 → prod#{prod_id}.mp3 + setaudio"
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026" voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
prod_upload_mp3(post_id) prod_upload_mp3(post_id, prod_id)
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice) prod_helper("setaudio", str(prod_id), f"/wp-content/uploads/tts/{prod_id}.mp3", voice)
if post_id not in state["synced"]: if prod_id not in state["synced"]:
state["synced"].append(post_id) state["synced"].append(prod_id)
save_state(state) save_state(state)
return "ok" return f"ok prod#{prod_id}"
def rollback_one(post_id: int, state: dict) -> str: def rollback_one(post_id: int, state: dict) -> str:
prod_helper("unsetaudio", str(post_id)) prod_id = prod_id_for_local(post_id)
prod_remove_mp3(post_id) prod_helper("unsetaudio", str(prod_id))
if post_id in state["synced"]: prod_remove_mp3(prod_id)
state["synced"].remove(post_id) if prod_id in state["synced"]:
state["synced"].remove(prod_id)
save_state(state) save_state(state)
return "ok" return f"ok prod#{prod_id}"
def main() -> int: def main() -> int:
@@ -194,7 +250,7 @@ def main() -> int:
for pid in ids: for pid in ids:
try: try:
if args.rollback: if args.rollback:
if pid not in state["synced"] and not args.ids: if prod_id_for_local(pid) not in state["synced"] and not args.ids:
res = "no-estaba-sincronizado" res = "no-estaba-sincronizado"
skip += 1 skip += 1
else: else:
@@ -202,7 +258,13 @@ def main() -> int:
ok += 1 ok += 1
else: else:
res = sync_one(pid, state, dry_run=args.dry_run) res = sync_one(pid, state, dry_run=args.dry_run)
if res == "ok" or res.startswith("PLAN"): # `startswith`, no `==`: sync_one devuelve "ok prod#<id>" desde que
# el id de prod puede diferir del local. Con la igualdad exacta, una
# subida perfecta se contaba entera como saltada — el 8-ago-2026 el
# pie del log dijo "ok=0 skip=90" tras subir los 90 audios de Fray
# Marcos sin un solo fallo. Peor que el susto: así un fallo real se
# confunde con este ruido y pasa desapercibido.
if res.startswith(("ok", "PLAN")):
ok += 1 ok += 1
else: else:
skip += 1 skip += 1
+100 -33
View File
@@ -23,6 +23,8 @@ el mismo fea_translate_helper.php sin tocar su lógica, solo invertido
Uso: Uso:
python3 sync_carta_from_prod.py --carta 54495 --dry-run python3 sync_carta_from_prod.py --carta 54495 --dry-run
python3 sync_carta_from_prod.py --carta 54495 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: Tras esto, el resto del ciclo ya existente no cambia:
translate_post.py --carta 54495 --langs en,fr,it,pt --status draft 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 from __future__ import annotations
import argparse import argparse
import base64
import json import json
import os import os
import shlex
import subprocess import subprocess
import time import time
from pathlib import Path 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_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "") 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" PROD_HELPER = "/tmp/fea_translate_helper.php"
HELPER_SRC = Path(__file__).resolve().parent / "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 (origen, solo lectura) ─────────────────────────────────────────────
_prod_ready = False
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str: 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", cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd] "-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) return sh(cmd, stdin=stdin, timeout=timeout)
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str: def prod_helper(subcmd: str, *args: str) -> str:
global _prod_ready """Run the helper in prod memory; never upload a temporary file to prod."""
if not _prod_ready: helper_php = HELPER_SRC.read_text(encoding="utf-8").replace("<?php", "", 1)
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8")) encoded = base64.b64encode(helper_php.encode("utf-8")).decode("ascii")
_prod_ready = True code = f"eval(base64_decode('{encoded}'));"
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args) if PROD_DOCKER_CONTAINER:
return _ssh(inner, stdin=stdin, timeout=180) 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: 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) 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]]]: def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[dict[str, int]]]:
posts: dict[int, dict] = {} posts: dict[int, dict] = {}
groups: dict[tuple[tuple[str, int], ...], dict[str, int]] = {} groups: dict[tuple[tuple[str, int], ...], dict[str, int]] = {}
@@ -153,7 +185,11 @@ def local_read_safe(post_id: int) -> dict | None:
return 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 = { payload = {
"title": post["title"], "title": post["title"],
"content": post.get("content", ""), "content": post.get("content", ""),
@@ -166,10 +202,14 @@ def local_clone(post: dict) -> int:
"status": post.get("status"), "status": post.get("status"),
"cats": post.get("cats", []), "cats": post.get("cats", []),
"cat_slugs": post.get("cat_slugs", []), "cat_slugs": post.get("cat_slugs", []),
"meta": post.get("meta", {}), "meta": meta,
} }
if preserve_id:
out = local_helper("clone", str(post["id"]), post.get("lang") or "es", STATUS, out = local_helper("clone", str(post["id"]), post.get("lang") or "es", STATUS,
stdin=json.dumps(payload)).strip() 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) return int(out)
@@ -179,13 +219,9 @@ def local_save_group(group: dict[str, int]) -> dict[str, int]:
# ── Main ───────────────────────────────────────────────────────────────────── # ── Main ─────────────────────────────────────────────────────────────────────
def sync_carta(carta_id: int, *, dry_run: bool) -> int: def sync_posts(seed_ids: list[int], *, dry_run: bool, source_label: str,
seed_ids = seed_ids_from_carta(carta_id) remap_conflicts: bool = False) -> int:
log(f"Cluster descubierto desde fea_parse_carta_sections(prod#{carta_id}): " log(f"Lote {source_label}: {len(seed_ids)} post(s) -> {seed_ids}")
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) 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", ""))) conflicts.append((pid, existing.get("title", ""), p.get("title", "")))
if conflicts: if conflicts:
log(f" ⚠️ {len(conflicts)} CONFLICTO(S): el ID ya existe en local con OTRO contenido " 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: 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: if dry_run:
for pid, p in posts.items(): 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]}»") f"slug={p.get('slug','')} «{p.get('title','')[:50]}»")
for group in groups: for group in groups:
log(f" GROUP {group}") log(f" GROUP {group}")
log("DRY-RUN: nada escrito en local.") log("DRY-RUN: nada escrito en local.")
return 0 return 0
conflict_ids = {pid for pid, _, _ in conflicts}
id_map: dict[int, int] = {}
for pid, p in posts.items(): 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: 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: else:
log(f" clone prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»") log(f" clone prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»")
for group in groups: 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 continue
saved = local_save_group(group) saved = local_save_group(local_group)
log(f" group enlazado en local {saved}") 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 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: def main() -> int:
ap = argparse.ArgumentParser( ap = argparse.ArgumentParser(
description="Copia una carta (y su cluster de artículos) de PROD a LOCAL preservando IDs.") 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("--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() args = ap.parse_args()
if not PROD_HOST or not PROD_PASS: if not PROD_HOST:
raise SystemExit( 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" "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__": if __name__ == "__main__":
+60 -17
View File
@@ -16,8 +16,10 @@ coincidencia local↔prod cuando prod va por detrás.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import base64
import json import json
import os import os
import shlex
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
@@ -29,12 +31,20 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user") DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass") DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170") PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF") 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")
PROD_HELPER = "/tmp/fea_translate_helper.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. Espejo de sync_carta_from_prod.py.
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php" HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
# El helper se ejecuta en memoria con `php -r` en prod. Así no se copia ningún
# fichero a /tmp remoto y stdin queda disponible para el payload JSON.
HELPER_EVAL_CODE = "eval(base64_decode(" + repr(
base64.b64encode(HELPER_SRC.read_text(encoding="utf-8").removeprefix("<?php").encode("utf-8")).decode("ascii")
) + "));"
LOCAL_HELPER_DST = "/tmp/fea_translate_helper.php" LOCAL_HELPER_DST = "/tmp/fea_translate_helper.php"
STATE_FILE = Path(os.environ.get("FEA_SYNC_STATE", "/tmp/feadulta-sync-state.json")) STATE_FILE = Path(os.environ.get("FEA_SYNC_STATE", "/tmp/feadulta-sync-state.json"))
LOG_FILE = Path(os.environ.get("FEA_SYNC_LOG", "/tmp/feadulta-sync.log")) LOG_FILE = Path(os.environ.get("FEA_SYNC_LOG", "/tmp/feadulta-sync.log"))
@@ -107,8 +117,20 @@ def local_read_full(post_id: int) -> dict:
def local_translation_pairs() -> list[tuple[int, int]]: def local_translation_pairs() -> list[tuple[int, int]]:
q = ("SELECT post_id, meta_value FROM wp_postmeta " """Devuelve (traducción_local, origen_ES_en_prod).
"WHERE meta_key='traduccion_origen' ORDER BY CAST(meta_value AS UNSIGNED), post_id;")
Normalmente ambos entornos compartían IDs. Para importaciones remapeadas,
la carta ES local guarda `fea_prod_source_id`; esa referencia remota tiene
prioridad y evita enlazar una traducción con un post distinto de prod.
"""
q = (
"SELECT tr.post_id, COALESCE(NULLIF(src.meta_value,''), tr.meta_value) "
"FROM wp_postmeta tr "
"LEFT JOIN wp_postmeta src ON src.post_id=CAST(tr.meta_value AS UNSIGNED) "
"AND src.meta_key='fea_prod_source_id' "
"WHERE tr.meta_key='traduccion_origen' "
"ORDER BY CAST(COALESCE(NULLIF(src.meta_value,''), tr.meta_value) AS UNSIGNED), tr.post_id;"
)
out = sh(["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}", out = sh(["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
DB_NAME, "-N", "-e", q]) DB_NAME, "-N", "-e", q])
pairs = [] pairs = []
@@ -155,21 +177,31 @@ def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[di
# ── Prod ───────────────────────────────────────────────────────────────────── # ── Prod ─────────────────────────────────────────────────────────────────────
_prod_ready = False
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str: 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", cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd] "-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) return sh(cmd, stdin=stdin, timeout=timeout)
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str: def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
global _prod_ready """Ejecuta el helper PHP en memoria; no crea ficheros temporales en prod."""
if not _prod_ready: argv = " ".join(shlex.quote(x) for x in (subcmd, *args))
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8")) if PROD_DOCKER_CONTAINER:
_prod_ready = True inner = (
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args) f"docker exec -i -e FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
f"{shlex.quote(PROD_DOCKER_CONTAINER)} php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
)
else:
inner = (
f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
f"php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
)
return _ssh(inner, stdin=stdin, timeout=180) return _ssh(inner, stdin=stdin, timeout=180)
@@ -256,7 +288,12 @@ def deploy_fixed_ids(seed_ids: list[int], *, keep_existing: set[int], dry_run: b
# ── Main legado ────────────────────────────────────────────────────────────── # ── Main legado ──────────────────────────────────────────────────────────────
def legacy_sync(limit: int, origin: int) -> int: def legacy_sync(limit: int, origin: int, *, dry_run: bool = False) -> int:
"""Sincroniza por origen ES+idioma, sin reutilizar IDs locales en prod.
Este es el modo seguro si producción ha avanzado y sus IDs ya pueden
colisionar con traducciones creadas en el entorno local.
"""
state = load_state() state = load_state()
pairs = local_translation_pairs() pairs = local_translation_pairs()
if origin: if origin:
@@ -280,6 +317,10 @@ def legacy_sync(limit: int, origin: int) -> int:
if key in state["done"]: if key in state["done"]:
n_skip += 1 n_skip += 1
continue continue
if dry_run:
log(f" PLAN {key}: crearía traducción [{lang}] «{t['title'][:45]}»")
n_ok += 1
continue
try: try:
new_id = prod_create(src_origin, lang, t["title"], t["content"]) new_id = prod_create(src_origin, lang, t["title"], t["content"])
state["done"][key] = new_id state["done"][key] = new_id
@@ -292,8 +333,10 @@ def legacy_sync(limit: int, origin: int) -> int:
n_err += 1 n_err += 1
log(f" {key} ERROR: {exc}") log(f" {key} ERROR: {exc}")
if not dry_run:
save_state(state) save_state(state)
log(f"FIN sync legado. nuevos={n_ok} saltados={n_skip} errores={n_err}. Estado: {STATE_FILE}") mode = "DRY-RUN" if dry_run else "SYNC"
log(f"FIN sync legado {mode}. nuevos/plan={n_ok} saltados={n_skip} errores={n_err}. Estado: {STATE_FILE}")
log("Recuerda en prod: ejecutar remap_translation_cats.php si alguna quedó sin categoría traducida.") log("Recuerda en prod: ejecutar remap_translation_cats.php si alguna quedó sin categoría traducida.")
return 0 return 0
@@ -318,7 +361,7 @@ def main() -> int:
keep_existing = set(parse_csv_ints(args.keep_existing)) keep_existing = set(parse_csv_ints(args.keep_existing))
return deploy_fixed_ids(seed_ids, keep_existing=keep_existing, dry_run=args.dry_run) return deploy_fixed_ids(seed_ids, keep_existing=keep_existing, dry_run=args.dry_run)
return legacy_sync(args.limit, args.origin) return legacy_sync(args.limit, args.origin, dry_run=args.dry_run)
if __name__ == "__main__": if __name__ == "__main__":
+13 -3
View File
@@ -271,7 +271,8 @@ def save_state(state: dict) -> None:
# ── Orquestación ───────────────────────────────────────────────────────────── # ── Orquestación ─────────────────────────────────────────────────────────────
def process_post(post_id: int, langs: list[str], status: str, force: bool, state: dict) -> None: def process_post(post_id: int, langs: list[str], status: str, force: bool, state: dict,
*, dry_run: bool = False) -> None:
src = read_post(post_id) src = read_post(post_id)
if src.get("lang") and src["lang"] != "es": if src.get("lang") and src["lang"] != "es":
log(f"#{post_id} no es ES (lang={src['lang']}) — saltado") log(f"#{post_id} no es ES (lang={src['lang']}) — saltado")
@@ -286,8 +287,14 @@ def process_post(post_id: int, langs: list[str], status: str, force: bool, state
state["done"][key] = existing state["done"][key] = existing
continue continue
if existing and force: if existing and force:
if dry_run:
log(f" {lang}: PLAN regenerar traducción previa #{existing}")
continue
php_helper("unlink", str(post_id), lang) php_helper("unlink", str(post_id), lang)
log(f" {lang}: --force, eliminada traducción previa #{existing}") log(f" {lang}: --force, eliminada traducción previa #{existing}")
if dry_run:
log(f" {lang}: PLAN traducir con {ENGINE} y crear como {status}")
continue
try: try:
t0 = time.time() t0 = time.time()
title = translate_text(src["title"], lang, is_title=True) title = translate_text(src["title"], lang, is_title=True)
@@ -312,6 +319,7 @@ def main() -> int:
ap.add_argument("--langs", default="en,fr,it,pt", help="Idiomas destino separados por coma.") ap.add_argument("--langs", default="en,fr,it,pt", help="Idiomas destino separados por coma.")
ap.add_argument("--status", default="draft", choices=["draft", "publish"], help="Estado de la traducción.") ap.add_argument("--status", default="draft", choices=["draft", "publish"], help="Estado de la traducción.")
ap.add_argument("--force", action="store_true", help="Regenera aunque ya exista la traducción.") ap.add_argument("--force", action="store_true", help="Regenera aunque ya exista la traducción.")
ap.add_argument("--dry-run", action="store_true", help="Muestra el plan sin llamar al LLM ni escribir en WordPress/estado.")
args = ap.parse_args() args = ap.parse_args()
langs = [l.strip() for l in args.langs.split(",") if l.strip() in LANG_NAMES] langs = [l.strip() for l in args.langs.split(",") if l.strip() in LANG_NAMES]
@@ -329,9 +337,11 @@ def main() -> int:
state = load_state() state = load_state()
for pid in ids: for pid in ids:
process_post(pid, langs, args.status, args.force, state) process_post(pid, langs, args.status, args.force, state, dry_run=args.dry_run)
if not args.dry_run:
save_state(state) save_state(state)
log(f"FIN. {len(state['done'])} traducciones registradas, " mode = "DRY-RUN" if args.dry_run else "FIN"
log(f"{mode}. {len(state['done'])} traducciones registradas, "
f"{len(state.get('errors', {}))} errores. Estado: {STATE_FILE}") f"{len(state.get('errors', {}))} errores. Estado: {STATE_FILE}")
return 0 return 0
+7 -1
View File
@@ -16,7 +16,13 @@ set -uo pipefail
export PATH="/home/rafa/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" export PATH="/home/rafa/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export HOME="/home/rafa" export HOME="/home/rafa"
REPO="/home/rafa/joomla-migration" # El repo NO se fija a mano: se deduce de dónde vive este script. El checkout
# principal cambia de rama a menudo, y en las ramas que no llevan estos scripts
# el cron se quedaba llamando a un fichero inexistente y fallaba en silencio
# (pasó del 5 al 8 de agosto de 2026: 7 ventanas perdidas). Por eso el cron
# apunta al worktree fijo ~/worktrees/fea-tts-backlog, y desde aquí se
# autodetecta: donde esté el script, ahí está su repo.
REPO="${FEA_TTS_REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
PY="/home/rafa/tts-local/xtts-venv/bin/python" PY="/home/rafa/tts-local/xtts-venv/bin/python"
QUOTA="/home/rafa/ytsummaries/scripts/quota.py" QUOTA="/home/rafa/ytsummaries/scripts/quota.py"
WORK="/tmp/fea-tts-backlog" WORK="/tmp/fea-tts-backlog"
+12 -2
View File
@@ -29,7 +29,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import minimax_tts as mm # get_post_text, add_pauses, t2a, OUT import minimax_tts as mm # get_post_text, add_pauses, t2a, OUT
import translate_post as tp # carta_article_ids import translate_post as tp # carta_article_ids
VOICE = "NicoFeadulta2026" VOICE = os.environ.get("FEA_TTS_VOICE", "NicoFeadulta2026")
MODEL = "speech-2.8-hd" MODEL = "speech-2.8-hd"
CONTAINER = "wordpress-web" CONTAINER = "wordpress-web"
PROD = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts" PROD = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
@@ -103,6 +103,8 @@ def main():
help="para tras N audios OK en esta ejecución (0 = sin tope)") help="para tras N audios OK en esta ejecución (0 = sin tope)")
parser.add_argument("--dry-run", action="store_true", parser.add_argument("--dry-run", action="store_true",
help="imprime la cola y sale, sin sintetizar ni gastar cuota") help="imprime la cola y sale, sin sintetizar ni gastar cuota")
parser.add_argument("--allow-default-voice", action="store_true",
help="permite Nico para autores sin voz digitalizada (desactivado por defecto)")
args = parser.parse_args() args = parser.parse_args()
if args.ids: if args.ids:
os.environ["FEA_TTS_IDS"] = args.ids os.environ["FEA_TTS_IDS"] = args.ids
@@ -149,7 +151,15 @@ def main():
i += 1 i += 1
continue continue
voice = mm.voice_for_author(author, VOICE) cloned_voice = mm.voice_for_author(author, "")
if cloned_voice:
voice = cloned_voice
elif args.allow_default_voice:
voice = VOICE
else:
log(f"#{pid}: autor sin voz digitalizada (author_id={author}); omitido")
i += 1
continue
rc = mm.t2a(mm.add_pauses(text), voice, MODEL, f"prod-{pid}") rc = mm.t2a(mm.add_pauses(text), voice, MODEL, f"prod-{pid}")
if rc == 0: if rc == 0:
src = mm.OUT / f"prod-{pid}.mp3" src = mm.OUT / f"prod-{pid}.mp3"