Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37dd1153a8 | |||
| 1333a2bb64 | |||
| 654c3d3c35 | |||
| 7af2173014 | |||
| 483c68bf5f |
@@ -32,8 +32,40 @@ scripts/ — scripts Python/PHP de migración, traducción, TTS y mantenimi
|
||||
| `translate_haiku.py` | Traducción automática vía Claude Haiku |
|
||||
| `tts_produce.py` | Generación de audio TTS (MiniMax) |
|
||||
| `sync_translations_to_prod.py` | Sincronizar traducciones local → producción |
|
||||
| `sync_audio_to_prod.py` | Sincronizar UN post/carta de audio TTS local → producción (sube el mp3, verifica SHA-256 remoto y persiste `fea_audio_sha256`) |
|
||||
| `sync_audio_bulk.sh` | Envoltorio de `sync_audio_to_prod.py` para lotes: para en el primer fallo, sin reintentos ciegos, resumen ok/skip |
|
||||
| `detect_untranslated.php` | Detectar posts sin traducir |
|
||||
|
||||
## Sincronizar audio TTS a producción (local → prod)
|
||||
|
||||
`sync_audio_to_prod.py` sube el mp3 de UN post/carta y, desde el #213, calcula y verifica el
|
||||
SHA-256 en cada subida (reproducción verificada automática, sin backfill manual — ver #212).
|
||||
|
||||
Para lotes de varios posts, usar `sync_audio_bulk.sh`: es el envoltorio que añade "parar en el
|
||||
primer fallo" (el script Python no lo hace por sí solo, recorre toda la lista y acumula errores).
|
||||
Carga los IDs con `mapfile`/mediante array y redirige el stdin de cada invocación a `/dev/null` a
|
||||
propósito — un `while read id; do ...ssh...; done < fichero` sin esa precaución se rompe en
|
||||
silencio porque `ssh` se come el resto del fichero de IDs (ver
|
||||
`feedback-bash-while-read-ssh-stdin` en la memoria del proyecto).
|
||||
|
||||
```bash
|
||||
export FEA_PROD_SSH_HOST="rafa@188.40.120.157"
|
||||
export FEA_PROD_DOCKER_CONTAINER="wordpress-r2ssjifwj0r0ghyoqd528uaa"
|
||||
|
||||
# Dry-run primero, siempre
|
||||
scripts/sync_audio_bulk.sh --ids 55519,55520,822 --dry-run
|
||||
|
||||
# Aplicar de verdad
|
||||
scripts/sync_audio_bulk.sh --ids 55519,55520,822
|
||||
|
||||
# Desde un fichero (un ID por línea) o toda la cola de una carta
|
||||
scripts/sync_audio_bulk.sh --file /tmp/ids_pendientes.txt
|
||||
scripts/sync_audio_bulk.sh --carta 54254
|
||||
```
|
||||
|
||||
Rollback de un post concreto: `python3 scripts/sync_audio_to_prod.py --rollback --ids <id>`
|
||||
(borra el meta en prod, no toca el mp3 local).
|
||||
|
||||
## Servidor local de desarrollo
|
||||
|
||||
WordPress corre en Docker local, accesible vía Tailscale:
|
||||
|
||||
@@ -87,6 +87,15 @@ FEA_TTS_CARTAS="<ID_CARTA>" python3 scripts/tts_produce.py
|
||||
Reanudable (no repite lo ya hecho, meta `fea_audio_done`) y con freno automático si la cuota de
|
||||
MiniMax se agota (para tras fallos seguidos, no se queda colgado).
|
||||
|
||||
**Alcance del TTS — qué NO llevar a audio (confusión real en la carta 741, 2026-08-26):**
|
||||
- **Solo el artículo original en español.** Las traducciones (EN/FR/IT/PT) nunca llevan audio
|
||||
propio — no meter los posts de traducción en la cola (`FEA_TTS_IDS`/`FEA_TTS_CARTAS`).
|
||||
- **Un audio por artículo, nunca uno combinado de toda la carta.** `tts_produce.py` es correcto
|
||||
(genera un mp3 por post). No usar `scripts/tts_carta.py` (legacy, concatena todos los artículos
|
||||
de la carta en un único audio) — no es el flujo que se usa hoy.
|
||||
- **Nunca la categoría `multimedia`** (término `multimedia`, id 1649 — ver
|
||||
`scripts/aplicar_clasificacion_a_bd.py`). Esas piezas no se locutan.
|
||||
|
||||
**Publicar el audio en prod** (el paso anterior solo escribe en el WordPress local):
|
||||
```bash
|
||||
python3 scripts/sync_audio_to_prod.py --carta <ID_CARTA>
|
||||
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
#!/bin/bash
|
||||
# sync_audio_bulk.sh — Sincroniza a producción, uno por uno, un lote de posts con
|
||||
# audio TTS ya generado en local. Envoltorio de sync_audio_to_prod.py que añade
|
||||
# "parar en el primer fallo" (el script Python no lo hace: recorre toda la
|
||||
# lista y acumula errores, ver #212/#213).
|
||||
#
|
||||
# Motivo del wrapper en bash con mapfile: un `while read id; do ...; done <
|
||||
# fichero` normal comparte el file descriptor de stdin con cualquier `ssh`
|
||||
# lanzado dentro del bucle. sync_audio_to_prod.py llama a ssh internamente; si
|
||||
# el bucle no está protegido, ssh se come el resto del fichero de IDs y el
|
||||
# bucle termina "sin error" tras procesar solo el primero. Detectado y
|
||||
# documentado en memoria (feedback-bash-while-read-ssh-stdin) el 2026-08-24
|
||||
# tras un lote real que solo sincronizó 1 de 184 posts sin ningún error visible.
|
||||
#
|
||||
# Uso:
|
||||
# scripts/sync_audio_bulk.sh --ids 55519,55520,822
|
||||
# scripts/sync_audio_bulk.sh --file /tmp/ids.txt # un ID por línea
|
||||
# scripts/sync_audio_bulk.sh --carta 54254 # cola completa de la carta
|
||||
# scripts/sync_audio_bulk.sh --ids 55519,55520 --dry-run # solo plan, no toca prod
|
||||
#
|
||||
# Requiere las mismas variables de entorno que sync_audio_to_prod.py para
|
||||
# apuntar a producción (ver ese script): FEA_PROD_SSH_HOST,
|
||||
# FEA_PROD_DOCKER_CONTAINER. Ejemplo:
|
||||
# export FEA_PROD_SSH_HOST="rafa@188.40.120.157"
|
||||
# export FEA_PROD_DOCKER_CONTAINER="wordpress-r2ssjifwj0r0ghyoqd528uaa"
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SYNC_PY="$SCRIPT_DIR/sync_audio_to_prod.py"
|
||||
|
||||
usage() {
|
||||
echo "Uso: $0 (--ids id1,id2,... | --file ruta | --carta post_id) [--dry-run]" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
ids_csv=""
|
||||
ids_file=""
|
||||
carta=""
|
||||
dry_run=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--ids) ids_csv="$2"; shift 2 ;;
|
||||
--file) ids_file="$2"; shift 2 ;;
|
||||
--carta) carta="$2"; shift 2 ;;
|
||||
--dry-run) dry_run="--dry-run"; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Carga explícita de IDs en un array ANTES de cualquier llamada a ssh — es la
|
||||
# parte que evita el bug de stdin. No usar `while read ... < fichero`.
|
||||
ids=()
|
||||
if [ -n "$ids_csv" ]; then
|
||||
IFS=',' read -r -a ids <<< "$ids_csv"
|
||||
elif [ -n "$ids_file" ]; then
|
||||
mapfile -t ids < "$ids_file"
|
||||
elif [ -n "$carta" ]; then
|
||||
# No usar `mapfile < <(cmd)`: la sustitución de procesos no deja comprobar
|
||||
# de forma fiable el exit code de `cmd` (mapfile ve su propio $?, no el de
|
||||
# la tubería). Si la consulta falla, un array vacío se confundiría con
|
||||
# "la carta no tiene artículos" y el lote saldría "éxito" sin sincronizar
|
||||
# nada. Se resuelve a fichero temporal y se comprueba el rc de python
|
||||
# explícitamente antes de poblar el array.
|
||||
carta_tmpfile="$(mktemp)"
|
||||
python3 - "$carta" "$SCRIPT_DIR" > "$carta_tmpfile" <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, sys.argv[2])
|
||||
from sync_audio_to_prod import carta_article_ids
|
||||
for pid in carta_article_ids(int(sys.argv[1])):
|
||||
print(pid)
|
||||
PYEOF
|
||||
py_rc=$?
|
||||
if [ $py_rc -ne 0 ]; then
|
||||
echo "ERROR: fallo consultando la cola de la carta $carta (exit $py_rc), no se sincroniza nada." >&2
|
||||
rm -f "$carta_tmpfile"
|
||||
exit 1
|
||||
fi
|
||||
mapfile -t ids < "$carta_tmpfile"
|
||||
rm -f "$carta_tmpfile"
|
||||
else
|
||||
usage
|
||||
fi
|
||||
|
||||
total=${#ids[@]}
|
||||
if [ "$total" -eq 0 ]; then
|
||||
echo "Nada que sincronizar (lista de IDs vacía)." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ok=0
|
||||
skip=0
|
||||
n=0
|
||||
for id in "${ids[@]}"; do
|
||||
id="$(echo "$id" | tr -d '[:space:]')"
|
||||
[ -z "$id" ] && continue
|
||||
n=$((n + 1))
|
||||
# stdin del hijo explícitamente en /dev/null: cinturón y tirantes además del
|
||||
# array en memoria, por si algún día se cambia el bucle.
|
||||
out="$(python3 "$SYNC_PY" --ids "$id" $dry_run < /dev/null)"
|
||||
rc=$?
|
||||
echo "$out"
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "=== ABORT: fallo en #$id ($n/$total procesados, $ok ok). Deteniendo lote, sin reintentar. ==="
|
||||
exit 1
|
||||
fi
|
||||
if echo "$out" | grep -q "PLAN: subiría\|#$id: ok"; then
|
||||
ok=$((ok + 1))
|
||||
else
|
||||
skip=$((skip + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=== FIN LOTE. ok=$ok skip=$skip de $total candidatos. ==="
|
||||
@@ -75,6 +75,8 @@ def local_meta(post_id: int, key: str) -> str:
|
||||
["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"docker exec {WP_CONTAINER} falló leyendo meta '{key}' de #{post_id} (rc={r.returncode}): {r.stderr.strip()[:300]}")
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
@@ -86,6 +88,8 @@ def carta_article_ids(carta_id: int) -> list[int]:
|
||||
DB_NAME, "-N", "-e", q],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"consulta de _carta_id={carta_id} falló (rc={r.returncode}): {r.stderr.strip()[:300]}")
|
||||
return [int(x) for x in r.stdout.split() if x.isdigit()]
|
||||
|
||||
|
||||
@@ -183,6 +187,13 @@ def save_state(state: dict) -> None:
|
||||
|
||||
# ── Sync ─────────────────────────────────────────────────────────────────────
|
||||
def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
|
||||
# issue #190: páginas de cuentas/donaciones (ej. #18036 "numeros") llevan
|
||||
# fea_audio_skip=1 desde el hotfix del 3-ago, pero pueden conservar un
|
||||
# fea_audio_done=1 y un mp3 locales obsoletos de antes del guard. Sin este
|
||||
# chequeo, cualquier --carta cuyo cluster las incluya vuelve a publicar el
|
||||
# audio improcedente en prod (nos pasó el 26-ago con la carta 741).
|
||||
if local_meta(post_id, "fea_audio_skip") == "1":
|
||||
return "excluido (fea_audio_skip)"
|
||||
if local_meta(post_id, "fea_audio_done") != "1":
|
||||
return "sin-audio-local"
|
||||
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
|
||||
|
||||
Reference in New Issue
Block a user