Files
chemavx-seo-tools/infografia_brief.py
T
ChemaVXandClaude Opus 5 0580357333 infografia_brief: slug y sitio opcionales
Escribir un slug de 40 caracteres y acordarse del --site era casi toda la
fricción de la herramienta, y el caso normal es siempre el mismo: quiero la
infografía del borrador que acabo de terminar.

    infografia            → el borrador más recién tocado de los dos sitios
    infografia --lista    → los enumera, con si tienen imagen ya o no
    infografia <slug>     → busca en los dos sitios; los slugs no se repiten
                            entre blogs, así que no hay ambigüedad posible
    infografia <URL>      → el sitio sale del dominio (pegada del navegador)

--site sigue existiendo, ahora solo para desambiguar o para acotar la búsqueda
de borradores a un blog.

Solo se ofrecen borradores al elegir automáticamente: un post publicado ya
tiene su imagen, y pedir el guion de uno publicado es la excepción, para la que
está pasar el slug o la URL a mano.

_post_list vuelca a fichero y no a PIPE, copiando el patrón de seo_watch y
remate_watch: un PIPE se trunca a 64 KB en silencio y con --formats html los
cuerpos son largos.

Verificado: las dos formas de URL (EN en www, ES en apex) resuelven su sitio,
el slug suelto encuentra el blog correcto, el slug inexistente sale con un
error claro, y la selección automática coge el más reciente y avisa de cuántos
más hay. El caso "no hay borradores" sale limpio en vez de reventar.

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

411 lines
17 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*$")
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()