#!/usr/bin/env python3 """Guion verificado para la infografía de un post (Tool G). Por qué existe: las tres infografías falladas del 2026-07-27 no fallaron al dibujar, fallaron al DECIDIR qué era cierto. ChatGPT tenía el artículo anterior en el contexto de la conversación y coló datos de un caso en la infografía de otro (la ficha de Vilas-Boas dentro de Talavera, los 84 expedientes españoles dentro de Vilas-Boas) y llegó a inventarse un dominio, bibliotecavirtual.defesa .gov.br, que no existe. La respuesta no es pedirle al modelo que se porte bien, es no dejarle decidir: aquí se extrae el guion del artículo y se VERIFICA contra el propio artículo antes de que ChatGPT vea nada. Lo que no case, no llega a la imagen. Severidades, calcadas de cómo falló esto en la vida real: · nombre propio que no está en el artículo → SE CAE (fue el 100% de la contaminación observada: São Francisco de Sales, Fontes, Bühler, O Cruzeiro) · URL o dominio que no está en el artículo → SE CAE, sin excepciones · cifra que no está en el artículo → AVISA, pero se queda: el artículo puede decir "mil novecientas páginas" y el guion "1.900", y tumbar eso automáticamente daría más falsos positivos que aciertos Solo lectura sobre Ghost. No publica, no toca el post, no genera imágenes. """ import argparse import json import os import re import string import subprocess import sys import tempfile sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) WORK = os.path.expanduser("~/.cache/infografia") TEMPLATE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "infografia_prompt.md") # Mismos dos sitios que el resto de ~/seo-tools. El idioma NO se adivina del # texto: lo fija el sitio, que es un dato duro y no se equivoca nunca. SITES = { "en": {"cli": "ghst-en", "idioma": "inglés", "fecha": "November 12, 1976", "decimal": "punto"}, "es": {"cli": "ghst-es", "idioma": "español", "fecha": "12 de noviembre de 1976", "decimal": "coma"}, } EXTRACT_PROMPT = """Lee el artículo que va al final y devuelve SOLO un objeto JSON (sin ```, sin texto alrededor) con el guion de una infografía. Reglas innegociables: - Cada dato debe salir del artículo. No uses tu conocimiento del caso. - Copia los nombres propios, cifras y fechas TAL CUAL aparecen en el artículo. - No inventes horas, referencias de archivo ni URLs. - Si un bloque no tiene material suficiente en el artículo, déjalo con menos viñetas o devuélvelo vacío. Prefiero tres viñetas ciertas que seis dudosas. - Escribe el guion en el mismo idioma que el artículo. Estructura exacta: {{ "titular": "caso y año, máx 60 car.", "subtitulo": "gancho corto, máx 60 car.", "entradilla": "3-4 líneas resumiendo el artículo", "cronologia": [{{"marca": "hora o duración literal del artículo", "texto": "qué pasó"}}], "paneles": [{{"titulo": "...", "vinetas": ["...", "..."]}}], "franja": [{{"titulo": "...", "vinetas": ["...", "..."]}}] }} "paneles" son la secuencia de los hechos (entre 4 y 8). "franja" son los bloques de cierre: datos del caso, lugar, argumentos a favor, argumentos escépticos, legado y lo que el artículo sostenga. ARTÍCULO: {articulo} """ # Palabras que van en mayúscula por posición, no por ser nombre propio. Solo se # usan para no dar la lata: si una de éstas no está en el artículo, es que el # artículo es muy corto, no que haya contaminación. RUIDO = {"el", "la", "los", "las", "un", "una", "y", "de", "del", "en", "the", "a", "an"} def sh(cmd, timeout=600): return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) def fetch(slug, cli): """Baja el post por el wrapper ghst-*, igual que seo_finish.fetch.""" fd, path = tempfile.mkstemp(suffix=".json", prefix="infografia_") os.close(fd) try: sh(f"{cli} post list --limit 100 --formats html --json > {path}") with open(path) as f: data = json.load(f) posts = data.get("posts", data) if isinstance(data, dict) else data finally: os.unlink(path) for p in posts: if p.get("slug") == slug: return p sys.exit(f"ERROR: no encuentro el post '{slug}' en {cli}") def plano(html): return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html or "")).strip() # ── verificación ────────────────────────────────────────────────────────────── # Nada de esto usa un modelo: son regex sobre el texto del artículo. Un modelo # comprobándose a sí mismo es justo lo que ya falló tres veces. RE_TOKEN = re.compile(r"[A-Za-zÁÉÍÓÚÑÜÀÂÃÇáéíóúñüàâãç][\wÁÉÍÓÚÑÜÀÂÃÇáéíóúñüàâãç'’\-]*") RE_CIFRA = re.compile(r"\d[\d.,:]*") RE_URL = re.compile(r"(?:https?://|www\.)\S+|\b[\w\-]+\.(?:com|es|org|net|gov|br|gob)\b\S*", re.I) # Un token en mayúscula que va detrás de uno de estos signos lo está por # posición, no por ser nombre propio. RE_ARRANQUE = re.compile(r"[.:;!?¡¿—–\-(\[«\"]\s*$") def pliega(s): """Minúsculas y sin tildes. Comparar con tildes generaba falsos positivos tontos (el modelo escribe «Neron» donde el artículo dice «Nerón») sin aportar nada: la contaminación de verdad son palabras que NO están en el artículo de ninguna forma. """ tabla = str.maketrans("áéíóúüàâãçñÁÉÍÓÚÜÀÂÃÇÑ", "aeiouuaaacnAEIOUUAAACN") return s.translate(tabla).lower() def _en_articulo(token, articulo): return pliega(token).strip(string.punctuation + "’'") in articulo def revisa(texto, articulo): """Devuelve (nombres_fuera, cifras_fuera, urls_fuera) de una cadena del guion. Solo se persiguen los tokens que parecen nombre propio de verdad: · mayúscula inicial que NO esté al principio de la frase · siglas en versalitas en cualquier posición (SBEDV, APRO, OVNI) Sin lo primero, cualquier viñeta que empiece por un verbo («Llegan…», «Comienza…») se marcaba como forastera y el informe se volvía inservible. """ urls = [u for u in RE_URL.findall(texto) if not _en_articulo(u, articulo)] nombres = [] for m in RE_TOKEN.finditer(texto): t = m.group() sigla = len(t) > 1 and t.isupper() propio = t[:1].isupper() and not t.isupper() if propio and RE_ARRANQUE.search(texto[:m.start()] or " "): continue # va en mayúscula por ir detrás de punto if propio and m.start() == 0: continue # ídem, principio de la cadena if not (sigla or propio) or t.lower() in RUIDO: continue if not _en_articulo(t, articulo): nombres.append(t) cifras = [c for c in RE_CIFRA.findall(texto) if len(c.strip(".,:")) > 1 and not _en_articulo(c, articulo)] return nombres, cifras, urls def filtra(guion, articulo_low, informe): """Poda el guion en sitio. Devuelve el guion limpio. Una viñeta con un nombre propio o una URL forastera se cae entera: no se puede "arreglar" media viñeta sin inventar, y ése es justo el pecado que esta herramienta existe para evitar. """ def cadena_ok(texto, donde): nombres, cifras, urls = revisa(texto, articulo_low) for c in cifras: informe["avisos"].append(f"{donde}: cifra «{c}» no aparece literal en el artículo") if urls: informe["caidas"].append(f"{donde}: URL forastera {urls} → «{texto[:70]}»") return False if nombres: informe["caidas"].append(f"{donde}: nombre(s) {nombres} fuera del artículo → «{texto[:70]}»") return False return True for campo in ("titular", "subtitulo", "entradilla"): if guion.get(campo) and not cadena_ok(guion[campo], campo): guion[campo] = "" # Marca y texto se revisan por separado: concatenarlos le quitaba a la # primera palabra del texto su condición de inicio de frase, y cualquier # entrada que empezara por un verbo salía marcada como forastera. guion["cronologia"] = [ h for h in guion.get("cronologia", []) if cadena_ok(h.get("marca", ""), "cronología/marca") and cadena_ok(h.get("texto", ""), "cronología") ] for bloque in ("paneles", "franja"): limpios = [] for p in guion.get(bloque, []): # Los títulos de panel NO se verifican: son etiquetas editoriales # («DATOS DEL CASO», «ARGUMENTOS ESCÉPTICOS»), no afirmaciones sobre # el caso, y no tienen por qué aparecer en el artículo. titulo = p.get("titulo", "") p["vinetas"] = [v for v in p.get("vinetas", []) if cadena_ok(v, f"{bloque}/«{titulo[:24]}»")] if p["vinetas"]: limpios.append(p) else: informe["caidas"].append(f"{bloque}: panel «{titulo[:30]}» se queda sin viñetas y se elimina") guion[bloque] = limpios return guion # ── guion a texto ───────────────────────────────────────────────────────────── def formatea(guion): out = [f"TITULAR: {guion.get('titular','')}", f"SUBTÍTULO: {guion.get('subtitulo','')}", f"ENTRADILLA: {guion.get('entradilla','')}", ""] if guion.get("cronologia"): out.append("CRONOLOGÍA") for h in guion["cronologia"]: out.append(f" {h.get('marca','')} — {h.get('texto','')}") out.append("") for bloque, etiqueta in (("paneles", "PANELES (secuencia)"), ("franja", "FRANJA INFERIOR")): if not guion.get(bloque): continue out.append(etiqueta) for i, p in enumerate(guion[bloque], 1): marca = f"{i:02d}. " if bloque == "paneles" else " · " out.append(f"{marca}{p['titulo']}") for v in p["vinetas"]: out.append(f" - {v}") out.append("") return "\n".join(out).strip() def main(): ap = argparse.ArgumentParser(description="Guion verificado para la infografía de un post") ap.add_argument("slug") ap.add_argument("--site", choices=("en", "es"), required=True) ap.add_argument("--out", help="guardar el prompt final aquí (además de imprimirlo)") ap.add_argument("--claude-bin", default=os.environ.get("CLAUDE_BIN", "claude")) ap.add_argument("--guion-json", help="saltarse el modelo y usar este guion ya extraído") a = ap.parse_args() cfg = SITES[a.site] post = fetch(a.slug, cfg["cli"]) articulo = plano(post.get("html")) if len(articulo.split()) < 200: sys.exit(f"ERROR: el artículo tiene {len(articulo.split())} palabras; ¿seguro que es el slug bueno?") print(f"═══ [{a.site.upper()}] {a.slug}") print(f" {post.get('status')} · {len(articulo.split())} palabras · idioma: {cfg['idioma']}") if a.guion_json: with open(a.guion_json) as f: guion = json.load(f) else: print(" extrayendo el guion con claude -p (coste cero, plan Pro)…") fd, pfile = tempfile.mkstemp(suffix=".txt", prefix="infografia_prompt_") os.close(fd) with open(pfile, "w") as f: f.write(EXTRACT_PROMPT.format(articulo=articulo)) try: r = sh(f'{a.claude_bin} -p < "{pfile}"') finally: os.unlink(pfile) if r.returncode != 0: sys.exit(f"ERROR: claude -p falló ({r.returncode}): {r.stderr[:300]}") crudo = r.stdout.strip() m = re.search(r"\{.*\}", crudo, re.S) if not m: sys.exit(f"ERROR: no encuentro JSON en la respuesta del modelo:\n{crudo[:400]}") try: guion = json.loads(m.group()) except json.JSONDecodeError as e: sys.exit(f"ERROR: JSON inválido del modelo ({e}):\n{m.group()[:400]}") os.makedirs(WORK, exist_ok=True) with open(os.path.join(WORK, f"{a.site}-{a.slug[:40]}-crudo.json"), "w") as f: json.dump(guion, f, ensure_ascii=False, indent=1) informe = {"caidas": [], "avisos": []} antes = json.dumps(guion, ensure_ascii=False) guion = filtra(guion, pliega(articulo), informe) print(f"\n─── verificación contra el artículo ───") print(f" descartado: {len(informe['caidas'])} | avisos: {len(informe['avisos'])}") for c in informe["caidas"]: print(f" ✗ {c}") for v in informe["avisos"]: print(f" ⚠ {v}") if not informe["caidas"] and not informe["avisos"]: print(" ✓ todo el guion está anclado al artículo") if len(json.dumps(guion, ensure_ascii=False)) < len(antes) * 0.5: print("\n ⚠️ se ha caído más de la mitad del guion: revísalo antes de usarlo") with open(TEMPLATE) as f: plantilla = string.Template(f.read()) prompt = plantilla.safe_substitute( idioma_nombre=cfg["idioma"], formato_fecha=cfg["fecha"], formato_decimal=cfg["decimal"], guion=formatea(guion), ) destino = a.out or os.path.join(WORK, f"{a.site}-{a.slug[:40]}-prompt.txt") with open(destino, "w") as f: f.write(prompt) print(f"\n─── prompt listo para pegar en ChatGPT ───") print(f" {destino}\n") print(prompt) if __name__ == "__main__": main()