Files
feadulta/scripts/fea_post_io.php
T
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

130 lines
4.9 KiB
PHP

<?php
/**
* 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') {
$id = (int)$argv[2];
$p = get_post($id);
if (!$p) { fwrite(STDERR, "no existe $id\n"); exit(1); }
file_put_contents('/tmp/fea_es.json', json_encode([
'id' => $id,
'title' => $p->post_title,
'content' => $p->post_content,
'status' => $p->post_status,
'author' => (int)$p->post_author,
], JSON_UNESCAPED_UNICODE));
exit(0);
}
if ($action === 'update') {
$id = (int)$argv[2];
$title = rtrim(file_get_contents($argv[3]), "\r\n");
$body = file_get_contents($argv[4]);
if (!get_post($id)) { fwrite(STDERR, "no existe $id\n"); exit(1); }
$r = wp_update_post([
'ID' => $id,
'post_title' => $title,
'post_content' => $body,
], true);
if (is_wp_error($r)) { fwrite(STDERR, "error: " . $r->get_error_message() . "\n"); exit(1); }
fwrite(STDOUT, "ok actualizado $id\n");
exit(0);
}
if ($action === 'getmeta') {
echo get_post_meta((int)$argv[2], $argv[3], true);
exit(0);
}
if ($action === 'setaudio') { // setaudio <id> <relpath> [voice_id]
$id = (int)$argv[2];
$voice = $argv[4] ?? 'NicoFeadulta2026';
update_post_meta($id, 'fea_audio_url', home_url($argv[3]));
update_post_meta($id, 'fea_audio_voice', $voice);
update_post_meta($id, 'fea_audio_done', '1');
delete_post_meta($id, 'fea_audio_error');
fwrite(STDOUT, "ok " . home_url($argv[3]) . "\n");
exit(0);
}
if ($action === 'setflag') { // setflag <id> <key> <value>
update_post_meta((int)$argv[2], $argv[3], $argv[4]);
exit(0);
}
if ($action === 'unsetaudio') { // unsetaudio <id> (rollback: despublica el audio)
$id = (int)$argv[2];
delete_post_meta($id, 'fea_audio_url');
delete_post_meta($id, 'fea_audio_voice');
delete_post_meta($id, 'fea_audio_done');
delete_post_meta($id, 'fea_audio_error');
fwrite(STDOUT, "ok despublicado $id\n");
exit(0);
}
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);