#!/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", "host": "theexclusionzone.com", "idioma": "inglés", "fecha": "November 12, 1976", "decimal": "punto"}, "es": {"cli": "ghst-es", "host": "zonadeexclusion.com", "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 _post_list(cli, extra="", timeout=300): """Vuelca `ghst-* post list` a fichero y lo parsea. A fichero y no a PIPE, copiando el patrón de seo_watch/remate_watch: un PIPE se trunca a 64 KB en silencio, y con --formats html los cuerpos son largos. """ fd, path = tempfile.mkstemp(suffix=".json", prefix="infografia_") os.close(fd) try: r = sh(f"{cli} post list --limit all {extra} --json > {path}", timeout=timeout) if r.returncode != 0: raise RuntimeError(f"{cli} post list falló: {r.stderr.strip()[:200]}") with open(path) as f: data = json.load(f) return data.get("posts", data) if isinstance(data, dict) else data finally: os.unlink(path) def fetch(slug, cli): """Baja el post por el wrapper ghst-*, igual que seo_finish.fetch.""" for p in _post_list(cli, "--formats html"): if p.get("slug") == slug: return p return None def candidatos(sites=("es", "en")): """Borradores de los dos sitios, del más recién tocado al más viejo. Solo borradores: un post publicado ya tiene su imagen, y pedir el guion de algo publicado es la excepción (para eso está pasar el slug a mano). """ out = [] for s in sites: try: posts = _post_list(SITES[s]["cli"], '--filter "status:draft" --fields id,slug,title,status,feature_image,updated_at') except Exception as e: print(f" (aviso: no pude listar {s}: {e})") continue for p in posts: if p.get("status") == "draft": p["_site"] = s out.append(p) return sorted(out, key=lambda p: p.get("updated_at") or "", reverse=True) def de_url(texto): """Saca (site, slug) de una URL pegada del navegador. None si no lo es.""" m = re.match(r"https?://(?:www\.)?([\w.-]+)/([\w-]+)/?", texto.strip()) if not m: return None host, slug = m.group(1), m.group(2) for s, cfg in SITES.items(): if cfg["host"] in host: return s, slug return None def elige(arg_slug, arg_site): """Resuelve qué post hay que procesar. Devuelve (site, slug). Tres formas de pedirlo, de más cómoda a más explícita: · sin argumentos → el borrador más recién tocado de los dos sitios · una URL pegada → el sitio sale del dominio · slug [--site] → si no se da el sitio, se busca en los dos """ if arg_slug: url = de_url(arg_slug) if url: return url if arg_site: return arg_site, arg_slug # Slug suelto: mirar en los dos sitios. Los slugs no se repiten entre # blogs, así que si aparece en uno solo no hay ambigüedad posible. hallados = [s for s in SITES if fetch(arg_slug, SITES[s]["cli"])] if len(hallados) == 1: return hallados[0], arg_slug if not hallados: sys.exit(f"ERROR: el slug '{arg_slug}' no está ni en ES ni en EN") sys.exit(f"ERROR: '{arg_slug}' existe en {hallados}; desambigua con --site") borradores = candidatos([arg_site] if arg_site else ("es", "en")) if not borradores: sys.exit("No hay borradores. Pasa un slug o una URL si quieres el guion de un publicado.") p = borradores[0] print(f" sin slug: cojo el borrador más reciente → [{p['_site'].upper()}] {p['title'][:60]}") if len(borradores) > 1: print(f" (hay {len(borradores)} borradores; `infografia --lista` para ver los demás)") return p["_site"], p["slug"] def cmd_lista(site): borradores = candidatos([site] if site else ("es", "en")) if not borradores: print("No hay borradores en ninguno de los dos sitios.") return print(f"─── borradores ({len(borradores)}), del más recién tocado ───") for p in borradores: img = "con imagen" if p.get("feature_image") else "SIN imagen" print(f" [{p['_site'].upper()}] {str(p.get('updated_at'))[:16].replace('T',' ')} {img:11} {p['title'][:58]}") print(f" {p['slug']}") 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", nargs="?", help="slug o URL; si se omite, el borrador más reciente") ap.add_argument("--site", choices=("en", "es"), help="normalmente se deduce solo") ap.add_argument("--lista", action="store_true", help="enumerar los borradores y salir") 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() if a.lista: return cmd_lista(a.site) site, slug = elige(a.slug, a.site) a.site, a.slug = site, slug cfg = SITES[site] post = fetch(slug, cfg["cli"]) if not post: sys.exit(f"ERROR: no encuentro el post '{slug}' en {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()