Compare commits

...

11 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
rafa 159080f0c8 TTS backlog: no dar falsa alarma los dias sin cron (#188)
El informe avisaba de "ninguna ventana dejo rastro en 24 h" los miercoles y
jueves, que son dias de carta y no tienen cron por diseno. Ahora lee el crontab,
cuenta cuantas ventanas TENIAN que correr y solo avisa si de verdad tocaba.
Si Rafa cambia los dias del cron, el informe le sigue solo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:10:15 -04:00
rafa e4d2073eeb fix(tts): exclude donations and accounting entries 2026-08-03 09:40:18 -04:00
rafa 6dc847a151 TTS backlog: nota sobre el wrapper de Hermes (#188)
Hermes bloquea los scripts cuyo path resuelve fuera de ~/.hermes/scripts y
resuelve los symlinks antes de comprobarlo, asi que el enlace que habia puesto
al script del repo hacia fallar el job diario. Sustituido por un wrapper fino
que lo llama por subproceso, igual que feadulta_ga4_daily.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:36:26 -04:00
rafa e1a14ec3fc TTS backlog: tamano de tanda adaptativo y reparto de la cuota semanal (#188)
Un tamano fijo de tanda no maximiza: deja la ventana de 5h a medias cuando esta
libre y no cabe cuando esta medio usada. Ahora cada corrida mide la cuota y
calcula cuantos audios caben.

Coste medido (2 tandas de 10 el 2-ago): 4,4 puntos de la ventana de 5h y 0,4 de
la semanal por audio. Con la ventana limpia salen 20, no 10.

Pero la semanal NO se gasta a tope en cada ventana: se REPARTE entre las que
quedan hasta su reset. Llenar cada ventana de 5h al 90% son ~8 puntos de
semanal, y hay 20 ventanas activas por semana = 160 puntos para un presupuesto
de 85. Sin reparto, domingo y lunes se lo comen y el fin de semana se queda a
cero (simulado: se seca en la ventana 12 de 20). Con reparto salen ~10 por
ventana, 204 audios/semana, y la semanal acaba justo en el 85% al llegar el
reset. En la ultima ventana el reparto vale todo lo que sobre, asi que tampoco
queda cuota sin usar: eso sustituye al apano anterior de "apurar en las ultimas
12h", que se retira.

El gate binario tambien desaparece: si otro esta usando MiniMax, en vez de
saltarse la ventana entera se hace una tanda pequena con lo que quede.

Validado en vivo: "reset semanal en 166h, 20 ventanas por delante · Caben: 10
por la de 5h, 10 por el reparto semanal".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:15:41 -04:00
rafa c5dcbdb997 TTS backlog: arreglar el gate de cuota y anadir el reporte diario (#188)
Gate: `int(v or 100)` trataba `five_h_pct = 0.0` como ausencia de dato y
devolvia 100, o sea que la tanda abortaba justo cuando la ventana de 5h estaba
entera libre. Cazado en la primera ventana real. Ahora se distingue 0.0 de None.

Reporte diario (F2): scripts/fea_tts_backlog_report.py, solo lectura. Cuenta
los audios de las ultimas 24h por autor (atribuidos por fea_audio_voice), lo que
queda por autor, la cuota de MiniMax y las ventanas ejecutadas/saltadas. Avisa
aparte si en 24h no hubo NI ventana ejecutada NI saltada, que es el sintoma de
que el cron no llego a correr (fue lo que paso el 2-ago con el bit +x).

Vive en el repo y ~/.hermes/scripts/ lo ve por symlink: sin copia duplicada que
se desincronice. Job: `hermes cron feadulta-tts-backlog-daily`, 07:30, no-agent,
deliver origin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 18:53:26 -04:00
rafa 4bc86b9494 TTS backlog: marcar tts_backlog_cron.sh como ejecutable
Se subio sin el bit +x y cron fallaba en silencio (permission denied, sin log
y sin MTA que avise): 4 ventanas perdidas el 2-ago. La entrada de crontab pasa
a invocarlo con 'bash ...' para que no vuelva a depender del modo del fichero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 18:50:16 -04:00
rafa 7c5330a528 TTS: cola de backlog por autor con gate de cuota MiniMax (#188)
Aprovechar la cuota ociosa de MiniMax para locutar el backlog historico,
empezando por Fray Marcos (2026 pendientes + 2025 = 86 articulos).

- fea_post_io.php: accion `listpending <autor> <desde> <hasta> <limite> [voz]`.
  Devuelve los posts ES publicados de un autor que no tienen audio, mas
  recientes primero. Si se pasa la voz clonada del autor, tambien salen los
  locutados con otra voz, para rehacerlos.
  Esa consulta ES la idempotencia: no hay fichero de estado que mantener, lo ya
  locutado deja de salir solo. Relanzar o solapar no duplica ni gasta cuota.

- tts_produce.py: flags --autor/--desde/--hasta/--max/--dry-run. Sin flags, el
  modo cartas de la carta semanal no cambia (retrocompatible).
  --max acota la tanda a la ventana de 5h.

- tts_backlog_cron.sh: wrapper de cron con flock y gate de cuota MiniMax
  (semanal >=85% o ventana de 5h >=55% -> se salta la ventana, para no comerle
  la cuota a la carta semanal). Cola configurable por entorno.
  Crontab: 0 */5 * * 1,5,6,0 (lunes, viernes, sabado y domingo).

SOLO LOCAL: no toca produccion. Publicar el backlog en prod
(sync_audio_to_prod.py) queda bloqueado hasta despues del cutover a
Hetzner (#180).

De paso entran al historial los cambios de tts_produce.py que estaban sin
commitear desde el 24-jul (argparse con --ids/--cartas y QUOTA_OR_RATE_ERRORS
para no reintentar ante rc 2056/1039).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 07:56:16 -04:00
24 changed files with 1525 additions and 120 deletions
+4
View File
@@ -17,6 +17,10 @@ joomla-php83/
backups/*
!backups/README.md
# Backups pesados (GBs) bajo docs/ — mismo motivo que backups/*, ruta distinta
docs/backups/*
!docs/backups/README.md
# Capturas de pantalla
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.
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 / {
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"
+58 -1
View File
@@ -3,11 +3,16 @@
* IO mínimo de posts WP para el reprocesador EN.
* get <id> -> escribe /tmp/fea_es.json {title, content, status}
* update <id> <titlef> <bodyf> -> actualiza post_title/post_content desde ficheros
* listpending <autor> ... -> cola de backlog TTS pendiente (ver abajo)
* Carga wp-load; portable (local docker o prod via FEA_WP_LOAD).
*/
$WP = getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
require $WP;
// Por debajo de esto el post_content no da para locutar (prefiltro barato en SQL;
// tts_produce.py vuelve a medir el texto ya extraído y marca fea_audio_skip).
const FEA_TTS_MIN_CONTENT = 400;
$action = $argv[1] ?? '';
if ($action === 'get') {
@@ -19,6 +24,8 @@ if ($action === 'get') {
'title' => $p->post_title,
'content' => $p->post_content,
'status' => $p->post_status,
'post_type' => $p->post_type,
'post_name' => $p->post_name,
'author' => (int)$p->post_author,
], JSON_UNESCAPED_UNICODE));
exit(0);
@@ -70,5 +77,55 @@ if ($action === 'unsetaudio') { // unsetaudio <id> (rollback: despublica el au
exit(0);
}
fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag|unsetaudio\n");
if ($action === 'listpending') { // listpending <autor> <desde> <hasta> <limite> [voz_esperada]
// Cola del backlog de TTS: posts ES publicados de un autor que todavía no tienen
// audio. La consulta ES la idempotencia — no hay fichero de estado que mantener:
// lo ya locutado deja de salir solo. Si se pasa la voz clonada del autor, también
// salen los que se locutaron en su día con otra voz (p. ej. Nico), para rehacerlos.
$autor = (int)($argv[2] ?? 0);
$desde = (int)($argv[3] ?? 0);
$hasta = (int)($argv[4] ?? 9999);
$limite = (int)($argv[5] ?? 50);
$voz = (string)($argv[6] ?? '');
if (!$autor) {
fwrite(STDERR, "uso: listpending <autor> <desde> <hasta> <limite> [voz_esperada]\n");
exit(2);
}
if ($limite <= 0) { $limite = 100000; }
global $wpdb;
$es = (int)$wpdb->get_var("
SELECT tt.term_taxonomy_id FROM {$wpdb->term_taxonomy} tt
JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
WHERE tt.taxonomy = 'language' AND t.slug = 'es' LIMIT 1");
if (!$es) { fwrite(STDERR, "no encuentro el idioma 'es' de polylang\n"); exit(1); }
$ids = $wpdb->get_col($wpdb->prepare("
SELECT p.ID
FROM {$wpdb->posts} p
JOIN {$wpdb->term_relationships} tr
ON tr.object_id = p.ID AND tr.term_taxonomy_id = %d
LEFT JOIN {$wpdb->postmeta} done
ON done.post_id = p.ID AND done.meta_key = 'fea_audio_done'
LEFT JOIN {$wpdb->postmeta} voz
ON voz.post_id = p.ID AND voz.meta_key = 'fea_audio_voice'
LEFT JOIN {$wpdb->postmeta} skip
ON skip.post_id = p.ID AND skip.meta_key = 'fea_audio_skip'
WHERE p.post_author = %d
AND p.post_type = 'post'
AND p.post_status = 'publish'
AND YEAR(p.post_date) BETWEEN %d AND %d
AND CHAR_LENGTH(p.post_content) >= %d
AND (skip.meta_value IS NULL OR skip.meta_value <> '1')
AND (done.meta_value IS NULL
OR done.meta_value <> '1'
OR (%s <> '' AND COALESCE(voz.meta_value, '') <> %s))
ORDER BY p.post_date DESC
LIMIT %d", $es, $autor, $desde, $hasta, FEA_TTS_MIN_CONTENT, $voz, $voz, $limite));
foreach ($ids as $id) { echo (int)$id . "\n"; }
exit(0);
}
fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag|unsetaudio|listpending\n");
exit(2);
+41
View File
@@ -262,6 +262,47 @@ switch ($cmd) {
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': {
// Descubre el cluster de una carta parseando sus propios enlaces internos
// (misma lógica que pinta la portada, fea-carta-portada.php). No depende
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""Reporte diario del backlog de TTS de Fe Adulta (issue rafa/feadulta#188).
SOLO LECTURA: no genera audio ni toca la BD. Cuenta lo hecho en las últimas 24 h,
lo que queda por autor, la cuota de MiniMax y las ventanas que se saltaron.
Entregado por Hermes en modo no-agent (el stdout va directo a Rafa).
Silencio deliberado si no hay nada que contar y todo está en orden.
Hermes solo ejecuta scripts que resuelvan DENTRO de ~/.hermes/scripts, y resuelve
los symlinks antes de comprobarlo: un enlace a este fichero se bloquea. Por eso
~/.hermes/scripts/fea_tts_backlog_report.py es un wrapper que lo llama por
subproceso (mismo patrón que feadulta_ga4_daily.py). Este de aquí es el único
sitio donde se edita la lógica.
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
REPO = Path("/home/rafa/joomla-migration")
TTS_DIR = REPO / "wordpress/wp-content/uploads/tts"
LOG_DIR = Path("/tmp/fea-tts-backlog")
QUOTA = Path("/home/rafa/ytsummaries/scripts/quota.py")
CONTAINER = "wordpress-web"
CRON = "/home/rafa/joomla-migration/scripts/tts_backlog_cron.sh"
# WP user_id -> (nombre, voz clonada). Mismo mapping que AUTHOR_VOICES en
# scripts/minimax_tts.py; si se clona una voz nueva, añadirla en los dos sitios.
AUTORES = {
382: ("Fray Marcos", "FrayMarcosFeadulta2026"),
383: ("Pagola", "PagolaFeadulta2026"),
774: ("Sicre", "SicreFeadulta2026"),
386: ("Arregi", "ArregiFeadulta2026"),
}
# Autor y rango que está locutando el cron ahora mismo. Debe seguir el crontab:
# solo afecta a qué línea se marca como «en curso» en el informe diario.
ACTIVO = (774, 2000, 2026)
def php(*args: str) -> str:
"""Llama al helper WP sin convertir un fallo de lectura en una cola vacía."""
r = subprocess.run(
["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", *args],
capture_output=True, text=True,
)
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:
salida = php("listpending", str(autor), str(desde), str(hasta), "0", voz)
return len([x for x in salida.split() if x.strip().isdigit()])
def hechos_24h() -> dict[int, list[tuple[int, str]]]:
"""mp3 escritos en las últimas 24 h, agrupados por autor.
El mtime del fichero es la fuente: es lo que se acaba de escribir, sin
depender de metas que puedan venir de una sincronización antigua.
"""
corte = time.time() - 24 * 3600
por_autor: dict[int, list[tuple[int, str]]] = {}
if not TTS_DIR.is_dir():
return por_autor
recientes = [f for f in TTS_DIR.glob("*.mp3")
if f.stat().st_mtime >= corte and f.stem.isdigit()]
for f in sorted(recientes, key=lambda p: p.stat().st_mtime):
pid = int(f.stem)
# No hay meta de autor; la voz sí se guarda (fea_audio_voice) y basta
# para atribuirlo, porque cada autor clonado tiene la suya.
voz = php("getmeta", str(pid), "fea_audio_voice").strip()
aid = next((a for a, (_, v) in AUTORES.items() if v == voz), 0)
por_autor.setdefault(aid, []).append((pid, voz or "?"))
return por_autor
def cuota() -> tuple[int | None, int | None]:
try:
r = subprocess.run([sys.executable, str(QUOTA), "--json", "--no-local"],
capture_output=True, text=True, timeout=40)
d = json.loads(r.stdout)
m = next(p for p in d["providers"] if p["provider"] == "minimax" and p.get("ok"))
return m.get("five_h_pct"), m.get("week_pct")
except Exception: # noqa: BLE001
return None, None
def logs_24h() -> tuple[int, int, list[str]]:
"""(ventanas ejecutadas, ventanas saltadas por gate, líneas de fallo)."""
hoy = datetime.now()
ficheros = [LOG_DIR / f"cron-{(hoy - timedelta(days=d)).strftime('%Y-%m-%d')}.log"
for d in (0, 1)]
corridas = saltadas = 0
fallos: list[str] = []
corte = hoy - timedelta(hours=24)
for f in ficheros:
if not f.is_file():
continue
for linea in f.read_text(errors="replace").splitlines():
m = re.match(r"\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)\]", linea)
if not m:
continue
try:
if datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S") < corte:
continue
except ValueError:
continue
if "cron TTS backlog start" in linea:
corridas += 1
elif "ABORT:" in linea:
saltadas += 1
fallos.append(linea.split("ABORT:", 1)[1].strip())
elif "FALLO rc=" in linea or "listpending falló" in linea:
fallos.append(linea.split("] ", 1)[-1].strip())
return corridas, saltadas, fallos
def _campo_cron(campo: str, valores: range) -> set[int]:
"""Expande un campo de crontab ('*', '*/5', '1,5,6,0', '0-4') a un set."""
if campo == "*":
return set(valores)
out: set[int] = set()
for trozo in campo.split(","):
paso = 1
if "/" in trozo:
trozo, p = trozo.split("/", 1)
paso = int(p)
if trozo == "*":
base = list(valores)
elif "-" in trozo:
a, b = (int(x) for x in trozo.split("-", 1))
base = list(range(a, b + 1))
else:
base = [int(trozo)]
out.update(base[::paso] if paso > 1 else base)
return out
def previstas_24h(ahora: datetime | None = None) -> int | None:
"""Cuántas ventanas tenían que haber corrido en las últimas 24 horas.
El backlog usa varias líneas de crontab (ritmo normal + sábado + domingo).
Se calcula la unión de sus instantes previstos, sin contar dos veces una
coincidencia entre líneas.
"""
try:
lineas = [
l for l in subprocess.run(["crontab", "-l"], text=True, capture_output=True,
check=True).stdout.splitlines()
if "tts_backlog_cron.sh" in l and not l.lstrip().startswith("#")
]
except Exception: # noqa: BLE001
return None
if not lineas:
return None
ahora = ahora or datetime.now()
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):
t = (ahora - timedelta(hours=h)).replace(second=0, microsecond=0)
for m in minutos:
cand = t.replace(minute=m)
if inicio < cand <= ahora and cand.hour in horas and (cand.weekday() + 1) % 7 in dows:
previstos.add(cand)
return len(previstos)
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()
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())
corridas, saltadas, fallos = logs_24h()
p5, pw = cuota()
lineas = [f"Fe Adulta — backlog TTS (últimas 24 h): {total} audios"]
if hechos:
for aid, items in sorted(hechos.items(), key=lambda kv: -len(kv[1])):
nombre = AUTORES.get(aid, ("otros", ""))[0]
lineas.append(f" {nombre}: {len(items)} "
f"({', '.join('#%d' % p for p, _ in items[:8])}"
f"{'' if len(items) > 8 else ''})")
lineas.append("")
lineas.append("Pendientes:")
for aid, (nombre, voz) in AUTORES.items():
falta_todo, falta_lote = faltantes[aid]
marca = ""
if aid == ACTIVO[0]:
marca = f" ← en curso, {falta_lote} del lote {ACTIVO[1]}-{ACTIVO[2]}"
lineas.append(f" {nombre}: {falta_todo}{marca}")
lineas.append("")
if p5 is None:
lineas.append("Cuota MiniMax: no se pudo leer")
else:
lineas.append(f"Cuota MiniMax: 5h {p5:.0f}% · semana {pw:.0f}%")
previstas = previstas_24h()
de = "" if previstas is None else f" de {previstas} previstas"
lineas.append(f"Ventanas 24 h: {corridas} ejecutadas{de}, {saltadas} saltadas por cuota")
if previstas == 0:
lineas.append(" (sin ventanas previstas: día sin cron, toca carta)")
# Una ventana que ni arranca ni se salta es un fallo mudo: el cron no llegó a
# correr (bit +x, WSL apagada...). Es exactamente lo que pasó el 2-ago.
# Solo es alarma si de verdad tocaba correr; si no, es martes.
if corridas == 0 and saltadas == 0 and previstas != 0:
lineas.append("")
lineas.append("⚠️ Ninguna ventana dejó rastro en 24 h. Si tocaba que corriera, "
f"comprobar: crontab -l | grep tts_backlog · ls -l {CRON}")
if fallos:
lineas.append("")
lineas.append("Avisos:")
for f in fallos[:10]:
lineas.append(f" {f}")
print("\n".join(lineas))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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())
+17 -7
View File
@@ -93,7 +93,15 @@ def get_post_text(pid):
check=True, capture_output=True)
subprocess.run(["docker", "cp", f"{CONTAINER}:/tmp/fea_es.json", "/tmp/fea_es.json"], check=True)
d = json.load(open("/tmp/fea_es.json"))
raw = re.sub(r"(?i)</p>|<br\s*/?>|</h[1-6]>", "\n", d["content"])
# Hard gate: pages and operational/accounting entries are never TTS input.
# This also protects explicit --ids queues, which bypass author-backlog SQL.
raw_content = d.get("content", "")
blocked_markers = ("fea-don-wrap", "fea-ledger", "Haz tu donación")
if d.get("post_type") != "post":
raise ValueError(f"post #{pid} no es un artículo (post_type={d.get('post_type')!r}); TTS excluido")
if d.get("post_name") == "numeros" or any(marker in raw_content for marker in blocked_markers):
raise ValueError(f"post #{pid} es contenido de cuentas/donaciones; TTS excluido")
raw = re.sub(r"(?i)</p>|<br\s*/?>|</h[1-6]>", "\n", raw_content)
raw = re.sub(r"<[^>]+>", "", raw)
raw = re.sub(r"\[[^\]]+\]", "", raw)
raw = html.unescape(raw)
@@ -353,12 +361,12 @@ def _split_for_tts(text, limit=CHAR_LIMIT):
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)."""
body = {
"model": model,
"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},
"language_boost": "Spanish",
}
@@ -373,13 +381,15 @@ def _synth_chunk(text, voice_id, model):
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)
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)
raw = OUT / f"{name}.raw.mp3"
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:
return rc
raw.write_bytes(audio)
@@ -390,7 +400,7 @@ def t2a(text, voice_id, model, name):
import os as _os, time as _t
_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)
audio, rc, _ = _synth_chunk(ch, voice_id, model)
audio, rc, _ = _synth_chunk(ch, voice_id, model, speed)
if audio is None:
for p in parts:
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"
+104 -42
View File
@@ -3,9 +3,10 @@
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.
Prod (134.0.10.170) tiene glibc rota: scp/sftp NO funcionan (connection closed).
Workaround: subir el binario por stdin de ssh ("cat > ruta"), igual que el resto
de scripts que tocan ese servidor (ver feadulta-server-glibc-rota.md).
Prod vive en Hetzner (Coolify/Docker) desde el cutover de agosto 2026. Subida
del binario por stdin de ssh ("cat > ruta" dentro del contenedor vía
`docker exec -i`), sin depender de scp/sftp — mismo patrón que
sync_translations_to_prod.py y sync_carta_from_prod.py.
Uso:
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
import argparse
import base64
import json
import os
import shlex
import subprocess
import time
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_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
PROD_HELPER = "/tmp/fea_post_io.php"
PROD_UPLOADS_TTS = "/web/wp-content/uploads/tts"
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
# define, el helper 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"
# 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"
LOG_FILE = Path(os.environ.get(
@@ -74,6 +84,17 @@ def local_meta(post_id: int, key: str) -> str:
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]:
q = ("SELECT post_id FROM wp_postmeta "
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()]
# ── 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:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
if r.returncode != 0:
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}\n{r.stderr.strip()[:400]}")
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:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, f"cat > {remote_path}"]
remote_cmd = _remote_wrap(f"cat > {remote_path}")
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
sh(cmd, input_bytes=data, timeout=timeout)
_prod_helper_ready = False
def prod_helper(subcmd: str, *args: str) -> str:
global _prod_helper_ready
if not _prod_helper_ready:
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
_prod_helper_ready = True
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
return _ssh_text(inner, timeout=60)
"""Ejecuta el helper PHP en memoria; no deja ficheros temporales en prod."""
argv = " ".join(shlex.quote(x) for x in (subcmd, *args))
inner = (
f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
f"php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
)
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:
src = LOCAL_TTS_DIR / f"{post_id}.mp3"
def prod_upload_mp3(local_id: int, prod_id: int) -> None:
src = LOCAL_TTS_DIR / f"{local_id}.mp3"
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)
# Verificación de tamaño (glibc rota => sin fiarse ciegamente del rc=0 de ssh)
remote_size = int(_ssh_text(f"wc -c < {remote_path}").strip())
# Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe
# 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):
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:
_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 ───────────────────────────────────────────────────────────────────
@@ -149,25 +203,27 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
return "sin-audio-local"
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
return "mp3-local-ausente"
prod_id = prod_id_for_local(post_id)
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"
prod_upload_mp3(post_id)
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
if post_id not in state["synced"]:
state["synced"].append(post_id)
prod_upload_mp3(post_id, prod_id)
prod_helper("setaudio", str(prod_id), f"/wp-content/uploads/tts/{prod_id}.mp3", voice)
if prod_id not in state["synced"]:
state["synced"].append(prod_id)
save_state(state)
return "ok"
return f"ok prod#{prod_id}"
def rollback_one(post_id: int, state: dict) -> str:
prod_helper("unsetaudio", str(post_id))
prod_remove_mp3(post_id)
if post_id in state["synced"]:
state["synced"].remove(post_id)
prod_id = prod_id_for_local(post_id)
prod_helper("unsetaudio", str(prod_id))
prod_remove_mp3(prod_id)
if prod_id in state["synced"]:
state["synced"].remove(prod_id)
save_state(state)
return "ok"
return f"ok prod#{prod_id}"
def main() -> int:
@@ -194,7 +250,7 @@ def main() -> int:
for pid in ids:
try:
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"
skip += 1
else:
@@ -202,7 +258,13 @@ def main() -> int:
ok += 1
else:
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
else:
skip += 1
+104 -37
View File
@@ -23,6 +23,8 @@ el mismo fea_translate_helper.php sin tocar su lógica, solo invertido
Uso:
python3 sync_carta_from_prod.py --carta 54495 --dry-run
python3 sync_carta_from_prod.py --carta 54495
python3 sync_carta_from_prod.py --ids 54875,54902,54903 --dry-run
python3 sync_carta_from_prod.py --ids 54875,54902,54903
Tras esto, el resto del ciclo ya existente no cambia:
translate_post.py --carta 54495 --langs en,fr,it,pt --status draft
@@ -34,8 +36,10 @@ Tras esto, el resto del ciclo ya existente no cambia:
from __future__ import annotations
import argparse
import base64
import json
import os
import shlex
import subprocess
import time
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_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"
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_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]
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
return sh(cmd, stdin=stdin, timeout=timeout)
def prod_helper(subcmd: str, *args: str, 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_helper(subcmd: str, *args: str) -> str:
"""Run the helper in prod memory; never upload a temporary file to prod."""
helper_php = HELPER_SRC.read_text(encoding="utf-8").replace("<?php", "", 1)
encoded = base64.b64encode(helper_php.encode("utf-8")).decode("ascii")
code = f"eval(base64_decode('{encoded}'));"
if PROD_DOCKER_CONTAINER:
remote = (
f"docker exec -i -e FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
f"{shlex.quote(PROD_DOCKER_CONTAINER)} php -r {shlex.quote(code)} -- "
+ " ".join(shlex.quote(part) for part in (subcmd, *args))
)
else:
remote = (
f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} php -r {shlex.quote(code)} -- "
+ " ".join(shlex.quote(part) for part in (subcmd, *args))
)
return _ssh(remote, timeout=180)
def prod_read_full(post_id: int) -> dict:
@@ -110,6 +131,17 @@ def seed_ids_from_carta(carta_id: int) -> list[int]:
return sorted(ids)
def seed_ids_from_csv(raw_ids: str) -> list[int]:
"""Parse a deliberate prod→local subset for Mixbot --parte1 (#181)."""
try:
ids = sorted({int(part.strip()) for part in raw_ids.split(",") if part.strip()})
except ValueError as exc:
raise ValueError("--ids debe ser una lista CSV de IDs numéricos") from exc
if not ids or any(pid <= 0 for pid in ids):
raise ValueError("--ids debe contener al menos un ID positivo")
return ids
def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[dict[str, int]]]:
posts: dict[int, dict] = {}
groups: dict[tuple[tuple[str, int], ...], dict[str, int]] = {}
@@ -153,7 +185,11 @@ def local_read_safe(post_id: int) -> dict | 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 = {
"title": post["title"],
"content": post.get("content", ""),
@@ -166,10 +202,14 @@ def local_clone(post: dict) -> int:
"status": post.get("status"),
"cats": post.get("cats", []),
"cat_slugs": post.get("cat_slugs", []),
"meta": post.get("meta", {}),
"meta": meta,
}
out = local_helper("clone", str(post["id"]), post.get("lang") or "es", STATUS,
stdin=json.dumps(payload)).strip()
if preserve_id:
out = local_helper("clone", str(post["id"]), post.get("lang") or "es", STATUS,
stdin=json.dumps(payload)).strip()
else:
out = local_helper("clone_new", post.get("lang") or "es", STATUS,
stdin=json.dumps(payload)).strip()
return int(out)
@@ -179,13 +219,9 @@ def local_save_group(group: dict[str, int]) -> dict[str, int]:
# ── 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.")
def sync_posts(seed_ids: list[int], *, dry_run: bool, source_label: str,
remap_conflicts: bool = False) -> int:
log(f"Lote {source_label}: {len(seed_ids)} post(s) -> {seed_ids}")
posts, groups = collect_related_posts(seed_ids)
@@ -196,51 +232,82 @@ def sync_carta(carta_id: int, *, dry_run: bool) -> int:
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:")
log(f" ⚠️ {len(conflicts)} CONFLICTO(S): el ID ya existe en local con OTRO contenido:")
for pid, old_title, new_title in conflicts:
log(f" #{pid}: local actual «{old_title[:50]}» -> prod «{new_title[:50]}»")
log(f" #{pid}: local actual «{old_title[:50]}» | prod «{new_title[:50]}»")
if not dry_run and not remap_conflicts:
raise RuntimeError(
"Importación cancelada: usar --remap-conflicts para crear IDs locales nuevos; "
"nunca se sobrescriben posts locales por un choque de IDs entre entornos."
)
if dry_run:
for pid, p in posts.items():
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]}»")
for group in groups:
log(f" GROUP {group}")
log("DRY-RUN: nada escrito en local.")
return 0
conflict_ids = {pid for pid, _, _ in conflicts}
id_map: dict[int, int] = {}
for pid, p in posts.items():
new_id = local_clone(p)
new_id = local_clone(p, preserve_id=pid not in conflict_ids)
id_map[pid] = new_id
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:
log(f" clone prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»")
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
saved = local_save_group(group)
saved = local_save_group(local_group)
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
def sync_carta(carta_id: int, *, dry_run: bool, remap_conflicts: bool = False) -> int:
seed_ids = seed_ids_from_carta(carta_id)
if not seed_ids or seed_ids == [carta_id]:
log(" ⚠️ El parser no resolvió ningún artículo enlazado (¿carta sin publicar aún, "
"o secciones sin encabezados reconocibles?). Revisa antes de continuar.")
return sync_posts(seed_ids, dry_run=dry_run, source_label=f"carta prod#{carta_id}",
remap_conflicts=remap_conflicts)
def sync_ids(raw_ids: str, *, dry_run: bool, remap_conflicts: bool = False) -> int:
return sync_posts(seed_ids_from_csv(raw_ids), dry_run=dry_run,
source_label="IDs explícitos de Mixbot --parte1",
remap_conflicts=remap_conflicts)
def main() -> int:
ap = argparse.ArgumentParser(
description="Copia una carta (y su cluster de artículos) de PROD a LOCAL preservando IDs.")
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("--remap-conflicts", action="store_true",
help="Ante IDs ocupados localmente, crea IDs nuevos y guarda fea_prod_source_id; nunca sobrescribe.")
args = ap.parse_args()
if not PROD_HOST or not PROD_PASS:
if not PROD_HOST:
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"
)
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__":
+63 -20
View File
@@ -16,8 +16,10 @@ coincidencia local↔prod cuando prod va por detrás.
from __future__ import annotations
import argparse
import base64
import json
import os
import shlex
import subprocess
import time
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_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
PROD_HELPER = "/tmp/fea_translate_helper.php"
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
# define, el helper se ejecuta en memoria dentro del contenedor; no se escribe
# ningún fichero temporal en prod. 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"
# 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"
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"))
@@ -107,8 +117,20 @@ def local_read_full(post_id: int) -> dict:
def local_translation_pairs() -> list[tuple[int, int]]:
q = ("SELECT post_id, meta_value FROM wp_postmeta "
"WHERE meta_key='traduccion_origen' ORDER BY CAST(meta_value AS UNSIGNED), post_id;")
"""Devuelve (traducción_local, origen_ES_en_prod).
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}",
DB_NAME, "-N", "-e", q])
pairs = []
@@ -155,21 +177,31 @@ def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[di
# ── Prod ─────────────────────────────────────────────────────────────────────
_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]
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
return sh(cmd, stdin=stdin, timeout=timeout)
def prod_helper(subcmd: str, *args: str, 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)
"""Ejecuta el helper PHP en memoria; no crea ficheros temporales en prod."""
argv = " ".join(shlex.quote(x) for x in (subcmd, *args))
if PROD_DOCKER_CONTAINER:
inner = (
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)
@@ -256,7 +288,12 @@ def deploy_fixed_ids(seed_ids: list[int], *, keep_existing: set[int], dry_run: b
# ── 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()
pairs = local_translation_pairs()
if origin:
@@ -280,6 +317,10 @@ def legacy_sync(limit: int, origin: int) -> int:
if key in state["done"]:
n_skip += 1
continue
if dry_run:
log(f" PLAN {key}: crearía traducción [{lang}] «{t['title'][:45]}»")
n_ok += 1
continue
try:
new_id = prod_create(src_origin, lang, t["title"], t["content"])
state["done"][key] = new_id
@@ -292,8 +333,10 @@ def legacy_sync(limit: int, origin: int) -> int:
n_err += 1
log(f" {key} ERROR: {exc}")
save_state(state)
log(f"FIN sync legado. nuevos={n_ok} saltados={n_skip} errores={n_err}. Estado: {STATE_FILE}")
if not dry_run:
save_state(state)
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.")
return 0
@@ -318,7 +361,7 @@ def main() -> int:
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 legacy_sync(args.limit, args.origin)
return legacy_sync(args.limit, args.origin, dry_run=args.dry_run)
if __name__ == "__main__":
+14 -4
View File
@@ -271,7 +271,8 @@ def save_state(state: dict) -> None:
# ── 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)
if src.get("lang") and src["lang"] != "es":
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
continue
if existing and force:
if dry_run:
log(f" {lang}: PLAN regenerar traducción previa #{existing}")
continue
php_helper("unlink", str(post_id), lang)
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:
t0 = time.time()
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("--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("--dry-run", action="store_true", help="Muestra el plan sin llamar al LLM ni escribir en WordPress/estado.")
args = ap.parse_args()
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()
for pid in ids:
process_post(pid, langs, args.status, args.force, state)
save_state(state)
log(f"FIN. {len(state['done'])} traducciones registradas, "
process_post(pid, langs, args.status, args.force, state, dry_run=args.dry_run)
if not args.dry_run:
save_state(state)
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}")
return 0
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# Cron del backlog de TTS por autor (issue rafa/feadulta#188).
# Corre cada 5 h los lunes, viernes, sábados y domingos, aprovechando la cuota
# ociosa de MiniMax para locutar artículos antiguos. Por ventana:
# 1) Mide la cuota y CALCULA el tamaño de la tanda para llenar la ventana hasta
# el objetivo. Un tamaño fijo desaprovecha: deja la ventana a medias cuando
# está libre, y no cabe cuando está medio usada. Al dimensionar por hueco
# libre, además, deja de importar dónde caiga el cron respecto a la ventana.
# 2) tts_produce.py --autor ... --max N: la cola sale de la BD, así que esto es
# idempotente por construcción — lo ya locutado no vuelve a salir.
# SOLO LOCAL: no toca producción. Publicar en prod es sync_audio_to_prod.py, que
# está bloqueado hasta después del cutover a Hetzner (#180).
# flock evita solapes si una ventana se alargara. Log por día.
set -uo pipefail
export PATH="/home/rafa/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export HOME="/home/rafa"
# 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"
QUOTA="/home/rafa/ytsummaries/scripts/quota.py"
WORK="/tmp/fea-tts-backlog"
LOG="$WORK/cron-$(date +%F).log"
LOCK="$WORK/cron.lock"
# Cola: cambiar aquí (o por entorno) para pasar de un autor a otro.
# 382 Fray Marcos · 383 Pagola · 774 Sicre · 386 Arregi
AUTOR="${FEA_TTS_AUTOR:-382}"
DESDE="${FEA_TTS_DESDE:-2025}"
HASTA="${FEA_TTS_HASTA:-2026}"
# Coste medido de un audio, en DÉCIMAS de punto porcentual (aritmética entera en
# bash). Dos tandas de 10 el 2026-08-02: la ventana de 5 h fue 0→45→88 (~4,4 pts
# por audio) y la semanal 10→14→18 (~0,4 pts). Artículos de Fray Marcos de
# 4.000-5.000 caracteres; si se locuta a otro autor con textos mucho más largos,
# revisar estos números con un par de tandas.
COSTE_5H="${FEA_TTS_COSTE_5H:-44}"
COSTE_SEM="${FEA_TTS_COSTE_SEM:-4}"
# Objetivos de llenado (%). Dejar cuota sin usar al llegar el reset es tirarla.
OBJ_5H="${FEA_TTS_OBJ_5H:-90}"
OBJ_SEM="${FEA_TTS_OBJ_SEM:-85}"
# La semanal NO se gasta a tope en cada ventana: se REPARTE entre las ventanas
# que quedan hasta su reset. Llenar cada ventana de 5 h al 90 % son ~8 puntos de
# semanal, y hay 20 ventanas activas por semana: 160 puntos para un presupuesto
# de 85. Sin reparto, domingo y lunes se lo comen y el fin de semana se queda a
# cero. Con reparto sale ~10 audios por ventana, y en la última ventana de la
# semana el reparto vale todo lo que sobre, así que tampoco queda cuota sin usar.
# Tope de seguridad por tanda y override manual (FEA_TTS_BATCH fija el tamaño y
# se salta el cálculo).
MAX_BATCH="${FEA_TTS_MAX_BATCH:-25}"
mkdir -p "$WORK"
cd "$REPO" || exit 1
ts() { date +'%F %T'; }
exec 9>"$LOCK"
if ! flock -n 9; then
echo "[$(ts)] otra corrida en curso, salto." >> "$LOG"; exit 0
fi
echo "[$(ts)] === cron TTS backlog start (autor=$AUTOR $DESDE-$HASTA) ===" >> "$LOG"
# 1) Medir la cuota, y contar cuántas ventanas del cron quedan hasta que se
# reinicie la semanal — es el denominador del reparto.
read -r PCT5 PCTW HSEM VENTANAS <<< "$(python3 "$QUOTA" --json --no-local 2>/dev/null | python3 -c '
import json, sys
from datetime import datetime, timedelta, timezone
# DEBEN COINCIDIR CON EL CRONTAB: 0 */5 * * 1,5,6,0
HORAS = {0, 5, 10, 15, 20}
DIAS = {0, 4, 5, 6} # lun, vie, sab, dom en datetime.weekday()
def pct(v):
# OJO: 0.0 es un valor legítimo (ventana entera libre) y es falsy en Python.
# Un `v or 100` aquí aborta la tanda justo cuando hay toda la cuota disponible.
return int(v) if v is not None else 100
try:
d = json.load(sys.stdin)
m = next(p for p in d["providers"] if p["provider"] == "minimax" and p.get("ok"))
horas, ventanas = 999, 1
try:
fin = datetime.fromisoformat(m["week_reset"]).astimezone()
ahora = datetime.now().astimezone()
horas = max(int((fin - ahora).total_seconds() // 3600), 0)
# Esta corrida cuenta como una; se suman las que quedan programadas.
t = (ahora + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
while t < fin:
if t.hour in HORAS and t.weekday() in DIAS:
ventanas += 1
t += timedelta(hours=1)
except Exception:
pass
print(pct(m.get("five_h_pct")), pct(m.get("week_pct")), horas, ventanas)
except Exception:
print(100, 100, 999, 1) # sin lectura fiable de cuota, no se gasta
' 2>/dev/null || echo "100 100 999 1")"
[ "${VENTANAS:-0}" -lt 1 ] && VENTANAS=1
# 2) Dimensionar la tanda. Dos límites, manda el más restrictivo:
# - la ventana de 5 h: se llena hasta OBJ_5H aquí y ahora;
# - la semanal: solo la parte que le toca a esta ventana de lo que queda.
CABE_5H=$(( ((OBJ_5H - PCT5) * 10) / COSTE_5H ))
CABE_SEM=$(( (((OBJ_SEM - PCTW) * 10) / VENTANAS) / COSTE_SEM ))
[ "$CABE_5H" -lt 0 ] && CABE_5H=0
[ "$CABE_SEM" -lt 0 ] && CABE_SEM=0
BATCH=$CABE_5H
[ "$CABE_SEM" -lt "$BATCH" ] && BATCH=$CABE_SEM
[ "$BATCH" -gt "$MAX_BATCH" ] && BATCH=$MAX_BATCH
# Override manual: fija el tamaño y se salta todo el cálculo.
[ -n "${FEA_TTS_BATCH:-}" ] && BATCH="$FEA_TTS_BATCH"
echo "[$(ts)] MiniMax 5h=${PCT5}% semana=${PCTW}% · reset semanal en ${HSEM}h, ${VENTANAS} ventanas por delante" >> "$LOG"
echo "[$(ts)] Caben: ${CABE_5H} por la de 5h, ${CABE_SEM} por el reparto semanal → tanda de ${BATCH}" >> "$LOG"
if [ "$BATCH" -lt 1 ]; then
echo "[$(ts)] ABORT: no cabe ni un audio sin pasarse del objetivo; salto esta ventana." >> "$LOG"
exit 0
fi
# 3) Tanda. tts_produce.py ya para solo ante rc 2056/1039 (cuota/rate limit).
# Una tanda larga puede desbordar el reset de 5 h (~2,6 min por audio): no pasa
# nada, lo que sobra lo absorbe la ventana siguiente y la próxima corrida la
# mide y se redimensiona sola. Cortar por tiempo dejaría cuota sin gastar.
echo "[$(ts)] tts_produce --autor $AUTOR --desde $DESDE --hasta $HASTA --max $BATCH ..." >> "$LOG"
"$PY" scripts/tts_produce.py --autor "$AUTOR" --desde "$DESDE" --hasta "$HASTA" \
--max "$BATCH" >> "$LOG" 2>&1
# 4) Cuánto queda tras la tanda (recuento fresco de la BD, barato y sin cuota).
QUEDAN="$("$PY" scripts/tts_produce.py --autor "$AUTOR" --desde "$DESDE" --hasta "$HASTA" \
--dry-run 2>/dev/null | sed -n 's/.*Cola: \([0-9]*\) posts.*/\1/p' | tail -1)"
echo "[$(ts)] === cron TTS backlog done. Pendientes autor $AUTOR $DESDE-$HASTA: ${QUEDAN:-?} ===" >> "$LOG"
+84 -9
View File
@@ -6,9 +6,18 @@ Reanudable (meta fea_audio_done) y con freno ante la cuota (para tras N fallos
seguidos). NO toca el front; solo genera el mp3 y asocia la URL al post (meta
fea_audio_url).
Dos modos de cola:
- cartas (por defecto): FEA_TTS_CARTAS / --cartas / --ids. Es el flujo de la
carta semanal, que tiene prioridad y no cambia.
- backlog por autor: --autor 382 [--desde 2025] [--hasta 2026] [--max 15].
La cola sale de `listpending` en fea_post_io.php (posts ES publicados sin
audio, más recientes primero). Esa consulta ES la idempotencia: no hay
fichero de estado, lo ya locutado deja de salir solo.
Lanzar: nohup ~/tts-local/xtts-venv/bin/python scripts/tts_produce.py > /tmp/feadulta-tts-prod.out 2>&1 &
Log: /tmp/feadulta-tts-prod.log
"""
import argparse
import os
import shutil
import subprocess
@@ -20,14 +29,15 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import minimax_tts as mm # get_post_text, add_pauses, t2a, OUT
import translate_post as tp # carta_article_ids
VOICE = "NicoFeadulta2026"
VOICE = os.environ.get("FEA_TTS_VOICE", "NicoFeadulta2026")
MODEL = "speech-2.8-hd"
CONTAINER = "wordpress-web"
PROD = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
LOG = Path("/tmp/feadulta-tts-prod.log")
INTERVAL = 180 # s entre cartas exitosas (reparte el ritmo)
BACKOFF = 1800 # s de espera ante fallo de cuota antes de reintentar
MAX_CONSEC_FAIL = 3 # fallos seguidos → parar (cuota probablemente agotada)
BACKOFF = 1800 # s de espera ante errores transitorios no clasificados
MAX_CONSEC_FAIL = 3 # fallos seguidos no clasificados → parar
QUOTA_OR_RATE_ERRORS = {2056, 1039} # MiniMax: no reintentar en este proceso
MIN_CHARS = 200 # por debajo, se considera sin contenido locutable
# Cola de cartas a locutar. Override por entorno (FEA_TTS_CARTAS) para priorizar
@@ -52,6 +62,18 @@ def meta(pid, key):
return php("getmeta", str(pid), key).stdout.strip()
def backlog_ids(autor, desde, hasta, limite):
"""Cola del backlog de un autor, delegada a la BD (ver listpending)."""
# Voz clonada del autor, si la tiene: los locutados con otra voz también
# cuentan como pendientes. Sin clon (""), pendiente = simplemente sin audio.
voz = mm.voice_for_author(autor, "")
r = php("listpending", str(autor), str(desde), str(hasta), str(limite), voz)
if r.returncode != 0:
log(f"listpending falló (rc={r.returncode}): {r.stderr.strip()[:200]}")
return []
return [int(x) for x in r.stdout.split() if x.strip().isdigit()]
def build_queue():
# Cola literal de IDs (ya filtrada/ordenada) para priorizar la carta nueva.
ids_override = os.environ.get("FEA_TTS_IDS", "").replace(",", " ").split()
@@ -67,11 +89,48 @@ def build_queue():
def main():
global CARTAS
parser = argparse.ArgumentParser(
description="Locuta posts ES de Fe Adulta con MiniMax; sin --ids conserva la cola programada."
)
parser.add_argument("--ids", help="CSV de IDs ES concretos, en el orden de locución deseado")
parser.add_argument("--cartas", help="CSV de cartas para construir la cola; sustituye FEA_TTS_CARTAS")
parser.add_argument("--autor", type=int,
help="WP user_id: cola del backlog de ese autor en vez de cartas")
parser.add_argument("--desde", type=int, default=0, help="año inicial del backlog (con --autor)")
parser.add_argument("--hasta", type=int, default=9999, help="año final del backlog (con --autor)")
parser.add_argument("--max", type=int, default=0,
help="para tras N audios OK en esta ejecución (0 = sin tope)")
parser.add_argument("--dry-run", action="store_true",
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()
if args.ids:
os.environ["FEA_TTS_IDS"] = args.ids
if args.cartas:
os.environ["FEA_TTS_CARTAS"] = args.cartas
CARTAS = args.cartas.replace(",", " ").split()
PROD.mkdir(parents=True, exist_ok=True)
subprocess.run(["docker", "cp", "scripts/fea_post_io.php", f"{CONTAINER}:/tmp/fea_post_io.php"],
capture_output=True)
queue = build_queue()
log(f"=== INICIO orquestador TTS. Cola: {len(queue)} posts ES del gap ===")
if args.autor:
# Pide holgura sobre --max: parte de la cola puede caerse por contenido corto.
limite = args.max * 3 if args.max else 0
queue = backlog_ids(args.autor, args.desde, args.hasta, limite)
origen = (f"backlog autor {args.autor} ({args.desde}-{args.hasta}), "
f"voz {mm.voice_for_author(args.autor, VOICE)}")
else:
queue = build_queue()
origen = "cartas"
tope = f", tope {args.max} esta tanda" if args.max else ""
log(f"=== INICIO orquestador TTS. Cola: {len(queue)} posts ES [{origen}]{tope} ===")
if args.dry_run:
log("--dry-run: no sintetizo. Cola = " + (",".join(str(x) for x in queue) or "(vacía)"))
return
i = consec = ok = 0
while i < len(queue):
@@ -92,7 +151,15 @@ def main():
i += 1
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}")
if rc == 0:
src = mm.OUT / f"prod-{pid}.mp3"
@@ -104,16 +171,24 @@ def main():
voice_tag = f" [{voice}]" if voice != VOICE else ""
log(f"#{pid} OK «{title[:45]}»{voice_tag} → tts/{pid}.mp3 (total {ok})")
i += 1
if args.max and ok >= args.max:
log(f"Tope de la tanda alcanzado ({args.max}). PARO. "
"Reanudable: la próxima ventana recalcula la cola y sigue.")
break
time.sleep(INTERVAL)
else:
consec += 1
log(f"#{pid} FALLO rc={rc} (fallo seguido {consec}/{MAX_CONSEC_FAIL})")
php("setflag", str(pid), "fea_audio_error", str(rc))
if consec >= MAX_CONSEC_FAIL:
log("Demasiados fallos seguidos → cuota agotada probablemente. PARO. "
if rc in QUOTA_OR_RATE_ERRORS:
log(f"MiniMax rc={rc}: cuota/rate limit explícito. PARO sin reintentar. "
"Reanudable: relanzar el script más tarde (salta lo ya hecho).")
break
time.sleep(BACKOFF) # reintenta el mismo post tras esperar
if consec >= MAX_CONSEC_FAIL:
log("Demasiados fallos seguidos no clasificados. PARO. "
"Reanudable: relanzar el script más tarde (salta lo ya hecho).")
break
time.sleep(BACKOFF) # solo errores transitorios no clasificados
log(f"=== FIN tanda. {ok} audios generados esta ejecución. ===")