Files
chemavx-seo-tools/infografia_brief.py
T
ChemaVXandClaude Opus 5 8d1ffa4f6d infografia_brief: guion verificado para la infografía de un post
Las tres infografías falladas del 27-jul 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ó la ficha de Vilas-Boas dentro de la infografía de Talavera,
y los 84 expedientes del Ejército del Aire español dentro de la de Vilas-Boas.
En una llegó a inventarse un dominio, bibliotecavirtual.defesa.gov.br, que no
existe: una brasileñización del español, a un clic de publicarse.

La respuesta no es pedirle al modelo que se porte bien, es no dejarle decidir.
El guion se extrae del artículo (claude -p, coste cero) y se VERIFICA contra el
propio artículo antes de que ChatGPT vea nada. A ChatGPT le llega "maqueta este
texto", no "lee esto y decide qué es cierto".

Decisiones que no son de adorno:

- La verificación es regex sobre el artículo, sin modelo de por medio. Un
  modelo autoevaluándose es exactamente lo que ya falló tres veces.
- Tres severidades, sacadas de cómo falló esto en la vida real: nombre propio
  fuera del artículo se cae, URL fuera del artículo se cae sin excepciones, y
  cifra que no aparece literal solo avisa. Lo tercero porque el artículo dice
  "mil novecientas páginas" donde el guion escribe "1.900", y tumbar eso
  automáticamente daría más falsos positivos que aciertos. Los nombres propios
  fueron el 100% de la contaminación observada.
- Los títulos de panel NO se verifican: "DATOS DEL CASO" es una etiqueta
  editorial, no una afirmación sobre el caso, y no tiene por qué aparecer en el
  artículo. Verificarlos hacía caer todos los paneles y el informe se volvía
  inservible.
- Una mayúscula en inicio de frase no cuenta como nombre propio, o cualquier
  viñeta que empiece por verbo ("Llegan…", "Comienza…") sale marcada como
  forastera. Las siglas (SBEDV, APRO, OVNI) sí se persiguen en cualquier
  posición: son marcador de contaminación.
- Se compara plegando minúsculas y tildes. El modelo escribe "Neron" donde el
  artículo dice "Nerón" y eso no es contaminación; la de verdad son palabras
  que no están en el artículo de ninguna forma.
- El idioma lo fija --site, no se adivina del texto: es un dato duro que no se
  equivoca, y el idioma del guion arrastra el de la imagen.

Verificado con la avería real, no con un caso de laboratorio: al guion
contaminado de Talavera se le caen Vilas-Boas, Martins, Fontes, Bühler, SBEDV,
Creighton, São Francisco de Sales y el dominio inventado, y se eliminan enteros
los dos paneles que se quedan sin viñetas, mientras los legítimos sobreviven
sin un solo falso positivo. Sobre los dos artículos publicados hoy, extrayendo
de cero: 0 descartes y 0 avisos en ambos, guion inglés íntegro en inglés y
español íntegro en español.

Solo lectura sobre Ghost: no toca el post, no publica, no genera imágenes.
El wrapper ~/.local/bin/infografia queda fuera del repo, como seo-check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:20:15 +00:00

315 lines
13 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()