Files
feadulta/scripts/phase_a_enrique_preview.py
T
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

152 lines
7.9 KiB
Python

#!/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()