Sincronizar mu-plugins/ y scripts/ con el estado real del sitio (2026-07-16)
Este repo llevaba desde el 28-jun sin actualizarse (salvo sync_carta_from_prod.py).
Se pone al dia con 3 semanas de trabajo que solo vivian en el checkout local
completo del proyecto:
- Fix del buscador (issue #178): AND obligatorio en FULLTEXT (antes devolvia
practicamente todo el sitio con cualquier busqueda), exclusion de paginas
indice residuales de la migracion K2, soporte de frase exacta entre comillas.
- Nuevos mu-plugins ya desplegados a prod: fea-carta-id-api, fea-cloudflare-realip,
fea-crear-autor-api, fea-gsc-verification, fea-legacy-redirect (cutover
Joomla->WP, ver issue #162).
- TTS multi-voz por autor, scripts de traduccion, sync de audio a prod,
homenajes Mardones/Galarreta.
Se mantiene la estructura plana del repo (mu-plugins/ + scripts/, sin el
wordpress/wp-content/ del checkout completo) tal y como documenta el README:
es la convencion establecida para este repo, mas limpia para quien solo
necesita leer el codigo. Anadido .gitignore (cache de Python que no debia
commitearse).
Fuente: checkout local completo del proyecto, rama main (commit 69e849d).
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
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).
|
||||
|
||||
Uso:
|
||||
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
|
||||
python3 sync_audio_to_prod.py --ids 912,919,3001 # ids concretos
|
||||
python3 sync_audio_to_prod.py --carta 54254 --dry-run # solo plan, no toca prod
|
||||
|
||||
Rollback (despublica en prod lo que este script publicó):
|
||||
python3 sync_audio_to_prod.py --rollback --carta 54254
|
||||
python3 sync_audio_to_prod.py --rollback --ids 912,919
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
|
||||
DB_CONTAINER = os.environ.get("FEA_DB_CONTAINER", "wordpress-mysql")
|
||||
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"
|
||||
|
||||
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
|
||||
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
||||
|
||||
LOG_FILE = Path(os.environ.get(
|
||||
"FEA_AUDIO_SYNC_LOG",
|
||||
str(Path(__file__).resolve().parent.parent / "logs/feadulta-audio-sync.log"),
|
||||
))
|
||||
STATE_FILE = Path(os.environ.get(
|
||||
"FEA_AUDIO_SYNC_STATE",
|
||||
str(Path(__file__).resolve().parent.parent / "logs/feadulta-audio-sync-state.json"),
|
||||
))
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
|
||||
print(line, flush=True)
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with LOG_FILE.open("a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
|
||||
def sh(cmd: list[str], *, input_bytes: bytes | None = None, timeout: int = 120) -> str:
|
||||
r = subprocess.run(cmd, input=input_bytes, capture_output=True, timeout=timeout)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"cmd falló ({r.returncode}): {' '.join(cmd[:4])}…\n{r.stderr.decode(errors='replace')[:400]}")
|
||||
return r.stdout.decode(errors="replace")
|
||||
|
||||
|
||||
# ── Local ────────────────────────────────────────────────────────────────────
|
||||
def local_meta(post_id: int, key: str) -> str:
|
||||
r = subprocess.run(
|
||||
["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
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;")
|
||||
r = subprocess.run(
|
||||
["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
|
||||
DB_NAME, "-N", "-e", q],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
return [int(x) for x in r.stdout.split() if x.isdigit()]
|
||||
|
||||
|
||||
# ── Prod (glibc rota: nada de scp/sftp, todo por ssh + 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]
|
||||
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 _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}"]
|
||||
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)
|
||||
|
||||
|
||||
def prod_upload_mp3(post_id: int) -> None:
|
||||
src = LOCAL_TTS_DIR / f"{post_id}.mp3"
|
||||
data = src.read_bytes()
|
||||
remote_path = f"{PROD_UPLOADS_TTS}/{post_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())
|
||||
if remote_size != len(data):
|
||||
raise RuntimeError(f"tamaño no coincide tras subir #{post_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")
|
||||
|
||||
|
||||
# ── Estado ───────────────────────────────────────────────────────────────────
|
||||
def load_state() -> dict:
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {"synced": []}
|
||||
|
||||
|
||||
def save_state(state: dict) -> None:
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
# ── Sync ─────────────────────────────────────────────────────────────────────
|
||||
def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
|
||||
if local_meta(post_id, "fea_audio_done") != "1":
|
||||
return "sin-audio-local"
|
||||
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
|
||||
return "mp3-local-ausente"
|
||||
if dry_run:
|
||||
return "PLAN: subiría 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)
|
||||
save_state(state)
|
||||
return "ok"
|
||||
|
||||
|
||||
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)
|
||||
save_state(state)
|
||||
return "ok"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Sincroniza audio TTS local→prod (o lo despublica con --rollback).")
|
||||
ap.add_argument("--carta", type=int, default=0, help="WP post ID de la carta local; sincroniza toda su cola.")
|
||||
ap.add_argument("--ids", default="", help="Lista CSV de post IDs concretos.")
|
||||
ap.add_argument("--rollback", action="store_true", help="Despublica en prod en vez de publicar.")
|
||||
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no toca prod.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.ids:
|
||||
ids = [int(x) for x in args.ids.split(",") if x.strip().isdigit()]
|
||||
elif args.carta:
|
||||
ids = carta_article_ids(args.carta)
|
||||
else:
|
||||
ap.error("hace falta --carta o --ids")
|
||||
return 2
|
||||
|
||||
state = load_state()
|
||||
mode = "ROLLBACK" if args.rollback else "SYNC"
|
||||
log(f"=== INICIO {mode} audio→prod. {len(ids)} posts candidatos: {ids} ===")
|
||||
|
||||
ok = skip = err = 0
|
||||
for pid in ids:
|
||||
try:
|
||||
if args.rollback:
|
||||
if pid not in state["synced"] and not args.ids:
|
||||
res = "no-estaba-sincronizado"
|
||||
skip += 1
|
||||
else:
|
||||
res = rollback_one(pid, state)
|
||||
ok += 1
|
||||
else:
|
||||
res = sync_one(pid, state, dry_run=args.dry_run)
|
||||
if res == "ok" or res.startswith("PLAN"):
|
||||
ok += 1
|
||||
else:
|
||||
skip += 1
|
||||
log(f" #{pid}: {res}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
err += 1
|
||||
log(f" #{pid}: ERROR {exc}")
|
||||
|
||||
log(f"=== FIN {mode}. ok={ok} skip={skip} error={err}. Estado: {STATE_FILE} ===")
|
||||
return 1 if err else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user