#!/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"
{html.escape(date_line)}
", f"{html.escape(bible)}
"] + [f"{html.escape(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 = '\n
Para unas eucaristías más participativas y actuales
' 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'Enrique Martínez Lozano: ' f'{html.escape(source["title"])}. {html.escape(source["intro"])}
') 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()