Files
chemavx-seo-tools/infografia_brief.py
T
ChemaVXandClaude Opus 5 1f07e88c12 infografía: el verificador no veía un cargo falso, y por ahí salió a la imagen
La infografía de Socorro salió con «Lt. Colonel Hector Quintanilla» cuando
el artículo dice «Major» las cinco veces que lo nombra. El rango NO lo
inventó el modelo que maqueta: venía en el guion, y el verificador lo dejó
pasar por dos motivos independientes.

1. RE_ARRANQUE veía el punto de «Lt.» y daba por hecho que «Colonel» iba en
   mayúscula por empezar frase, así que la eximía de comprobar. La única
   palabra capaz de cazar el error era justo la que se saltaba. Ahora una
   lista de abreviaturas conocidas anula esa exención.

2. _en_articulo comparaba por SUBCADENA: «Lt» daba positivo por estar dentro
   de built, adults y consultant. Pasa a exigir frontera de palabra por la
   izquierda — se conserva la laxitud por la derecha para que «Zamora's» del
   artículo siga casando con «Zamora» del guion.

Y se añade la comprobación que faltaba, cargos_fuera(): los cargos y rangos
se verifican en FRASE ENTERA. El fallo real no fue una palabra inventada
sino una COMBINACIÓN falsa de dos palabras que sí estaban por separado, y
eso un verificador token a token no puede verlo nunca.

Medido antes de dejarlo puesto, sobre los DOS guiones reales de hoy (156
líneas): caza la avería, deja pasar la versión corregida, y cero falsos
positivos de cargo. Por el camino de producción, filtra() tira solo las dos
frases que llevan el rango malo y no toca el resto.

Once tests. Incluye los dos lados: que pode lo que debe y que NO pode lo
que no debe, porque un verificador que se pasa deja el guion cojo y obliga a
rehacerlo a mano.

También: hreflang-parejas.md con los slugs nuevos de los dos remates de hoy
(socorro-1964-zamora-ufo-landing y cash-landrum-1980-demanda-ovni-tribunal-federal),
corregidos por el propio agente del remate al leer la nota que lo pedía.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:36:34 +00:00

473 lines
20 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", "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*$")
# …salvo que el punto sea el de una ABREVIATURA. «Lt. Colonel» no es una frase
# nueva que empiece en «Colonel»: es un cargo. Sin esta excepción, el punto de
# la abreviatura eximía de comprobación a la palabra siguiente, y por ahí entró
# «Lt. Colonel Quintanilla» en la infografía de Socorro el 2026-07-30, cuando el
# artículo dice «Major» las cinco veces que lo nombra. La palabra que habría
# cazado el error («Colonel») era justo la que se saltaba.
RE_ABREV = re.compile(
r"\b(?:Lt|Col|Gen|Sgt|Capt|Cmdr|Maj|Dr|Mr|Mrs|Ms|St|Jr|Sr|Prof|Rev|Hon"
r"|EE|UU|EEUU|Ud|Uds|Sta|Sto|Ing|Lic)\.\s*$", re.I)
# Cargos y rangos. Se comprueban en FRASE, no palabra a palabra: «Colonel» puede
# estar en el artículo (otro oficial) y «Quintanilla» también, y aun así
# «Colonel Quintanilla» ser falso. Un token a token no puede ver eso nunca.
RE_CARGO = re.compile(
r"\b(?:Lt\.?\s*Col(?:onel)?|Colonel|Lieutenant|Major|Captain|Cmdr\.?|Commander"
r"|Sergeant|Sgt\.?|General|Admiral|Corporal|Airman|Special Agent|Agent"
r"|Officer|Chief|Detective|Judge|Dr\.?|Professor|Prof\.?|Senator|Congressman"
r"|Coronel|Teniente|Comandante|Capitán|Capitan|Sargento|Cabo|Almirante"
r"|Juez|Jueza|Fiscal|Doctor|Doctora|Profesor|Profesora|Agente|Inspector"
r"|Senador|Diputado|Ministro|Presidente|Alcalde)\.?"
r"(?:\s+[A-ZÁÉÍÓÚÑ][\wÁÉÍÓÚÑáéíóúñ'\-]*\.?){1,3}")
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):
"""¿Aparece el token en el artículo, como PALABRA?
Era una comparación por subcadena y eso regalaba pases absurdos: «Lt» daba
positivo por estar dentro de *built*, *adults* y *consultant*. Cuanto más
corto el token, más fácil esconderse dentro de otra palabra — justo al revés
de lo que interesa, porque los tokens cortos son las abreviaturas de cargo.
Se conserva la comparación laxa por los extremos (plurales, genitivos: el
artículo dice «Zamora's» y el guion «Zamora»), exigiendo solo que el token
empiece en frontera de palabra.
"""
t = pliega(token).strip(string.punctuation + "'")
if not t:
return True
return re.search(r"(?<![\w])" + re.escape(t), articulo) is not None
def cargos_fuera(texto, articulo):
"""Cargos y rangos del guion que NO aparecen LITERALMENTE en el artículo.
Es la comprobación que faltaba, y es de frase entera a propósito: el fallo
real no fue una palabra inventada sino una COMBINACIÓN falsa de dos palabras
que sí estaban. Se normalizan los espacios y el punto de la abreviatura para
que «Lt. Col.» y «Lt Col» no cuenten como distintos.
"""
fuera = []
for m in RE_CARGO.finditer(texto):
frase = " ".join(m.group(0).split())
plana = pliega(frase).replace(".", "")
patron = r"\s*".join(re.escape(p) for p in plana.split())
if not re.search(patron, articulo.replace(".", "")):
fuera.append(frase)
return fuera
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()
previo = texto[:m.start()] or " "
if propio and RE_ARRANQUE.search(previo) and not RE_ABREV.search(previo):
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
# Un cargo falso se trata como un nombre forastero y tira la viñeta: no
# se puede degradar «Lt. Colonel Quintanilla» a «Quintanilla» sin
# reescribir la frase, y reescribir es inventar.
cargos = cargos_fuera(texto, articulo_low)
if cargos:
informe["caidas"].append(
f"{donde}: cargo/rango {cargos} no aparece así en el 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()