Files
chemavx-seo-tools/seo_hub.py
T
ChemaVXandClaude Opus 5 5b91895ef1 estado: cerrar los siete puntos ciegos del vigilante, y pasarlo a diario
Recuento de los fallos de esta semana: diecisiete. Ni uno solo fue un caso que
el vigilante juzgara mal — los diecisiete estaban en sitios donde el vigilante
no mira. Eso es una buena noticia: los puntos ciegos son enumerables y el
juicio equivocado no.

Lo que no miraba nadie, con el fallo real que lo motiva:

  tags        5 duplicados vivieron TRES SEMANAS repartiendo 7 posts entre dos
              archivos flacos, los dos ofrecidos a Google en sitemap-tags.xml
  páginas     todas las reglas eran de posts; /about no tiene metas en ninguno
              de los dos sitios
  <head>      el EN emite DOS Article con titulares distintos, y sigue con
              twitter:site=@ghost y article:publisher=facebook.com/ghost
  robots.txt  el bloque «Cloudflare Managed» apareció solo en el EN y nadie lo
              decidió; cambia sin pasar por git
  hreflang    lo comprobaba SOLO seo_audit.py, que NO TIENE TIMER
  hub         se publicó una vez y se pudrió: «31 casos» con 33
  programados check_rules hace `if status != "published": continue`, así que un
              programado con la meta mal es invisible hasta el día DESPUÉS de
              publicarse — cuando ya lo ha visto Google

Decisiones:
  - módulo aparte y no dentro de seo_watch.py (ya pasa de 500 líneas), pero
    colgando del MISMO vigilante: una huella por sitio y un solo aviso. Dos
    notificadores compitiendo es como se deja de leer un informe
  - robots.txt se compara contra una INSTANTÁNEA versionada. Sin una foto
    contra la que comparar, un cambio que nadie hizo es invisible para siempre
  - un chequeo que revienta se convierte en HALLAZGO, no tumba a los demás. Lo
    peligroso de un vigilante no es que falle: es que falle y parezca limpio
  - og_*/twitter_* vacíos en una PÁGINA quedan fuera por COMPROBACIÓN, no por
    comodidad: Ghost los deriva de las metas al renderizar, verificado sobre el
    HTML público del hub ES. Avisar de eso sería mandar a arreglar algo que ya
    está bien

Timer semanal → DIARIO. Con 2 posts/semana más programados y deriva de tema y
robots, el lunes dejaba hasta seis días de exposición. No añade ruido: solo
avisa si hay hallazgos Y han cambiado, así que un día limpio no manda nada.

8 tests, cada uno la cicatriz de un fallo real, incluidos los dos que hacen
creíble a un vigilante: que DETECTA la avería (probado con deriva simulada de
robots) y que un chequeo roto se oye.

Primera ejecución real: 8 hallazgos, todos preexistentes, 32 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:21:46 +00:00

357 lines
15 KiB
Python

#!/usr/bin/env python3
"""Tool I — el hub: la página índice de cada blog, generada desde el corpus.
Por qué existe. El EN tenía un hub escrito a mano y bien escrito, y por eso
mismo se pudrió: decía «all 31 cases» cuando ya había 33 publicados, y los dos
últimos artículos no estaban enlazados desde ninguna parte. Un índice que se
actualiza a mano se queda viejo el día que dejas de acordarte. El ES,
directamente, no tenía ninguno.
Qué NO se genera solo. Las frases. La descripción de una línea de cada caso es
lo único que hace útil un índice, y sale de haber leído el artículo — la
meta_description está escrita para el SERP y suena a catálogo cuando la pones
en fila con otras treinta. Así que el reparto es el mismo que en
hreflang-parejas.md: el fichero curado manda, y la herramienta solo AVISA de lo
que le falta. Nunca inventa una frase ni rellena con la meta.
Lo que sí se calcula solo, porque envejece: el recuento de casos ({N} en el
texto), qué publicados no están en el índice, y qué entradas del índice apuntan
a un post que ya no existe o que aún no ha publicado (enlace prematuro → 404).
Formato del fichero curado (hub-en.md / hub-es.md):
SLUG: complete-guide-uap-cases
TITULO: The Complete Guide to UAP Cases
META_TITLE: ...
META_DESC: ...
INTRO: ... covers all {N} cases published on ...
CIERRE: párrafo final, opcional
## Military Encounters
> Intro de la sección, opcional.
- slug-del-post | Kecksburg 1965 | — An acorn-shaped object crashed…
Subcomandos:
seo_hub.py revisa [--site en|es] qué le falta o le sobra al índice
seo_hub.py render --site en|es el HTML que subiría, a pantalla
seo_hub.py aplica --site en|es lo sube a la página de Ghost
`revisa` sale con código 1 si hay algo que arreglar, para poder colgarlo del
vigilante. `aplica` hace copia previa y verifica releyendo de Ghost.
"""
import argparse
import datetime
import html as htmlmod
import json
import os
import re
import subprocess
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from seo_audit import fetch_posts # noqa: E402
SITES = {
"en": {"cli": "ghst-en", "base": "https://www.theexclusionzone.com",
"curado": "hub-en.md"},
"es": {"cli": "ghst-es", "base": "https://zonadeexclusion.com",
"curado": "hub-es.md"},
}
AQUI = os.path.dirname(os.path.abspath(__file__))
BDIR = os.path.expanduser("~/link-batch-backup")
# El nodo lexical de tarjeta HTML, copiado de un post real del corpus. No
# inventar campos: Ghost valida y un nodo mal formado se pierde por el camino.
NODO_HTML = {
"type": "html", "version": 1, "html": "",
"visibility": {"web": {"nonMember": True,
"memberSegment": "status:free,status:-free"},
"email": {"memberSegment": "status:free,status:-free"}},
}
def sh(cmd, timeout=240):
return subprocess.run(cmd, shell=True, capture_output=True, text=True,
timeout=timeout)
# ──────────────────────────────── el curado ────────────────────────────────
def lee_curado(site):
ruta = os.path.join(AQUI, SITES[site]["curado"])
if not os.path.exists(ruta):
sys.exit(f"✗ falta el fichero curado {ruta}")
cab, secciones, sec = {}, [], None
for n, linea in enumerate(open(ruta, encoding="utf-8"), 1):
linea = linea.rstrip("\n")
if not linea.strip() or linea.lstrip().startswith("#!"):
continue
if linea.startswith("## "):
sec = {"titulo": linea[3:].strip(), "intro": "", "items": []}
secciones.append(sec)
elif linea.startswith("> "):
if sec is None:
sys.exit(f"✗ {ruta}:{n}: intro de sección antes de ninguna sección")
sec["intro"] = linea[2:].strip()
elif linea.startswith("- "):
if sec is None:
sys.exit(f"✗ {ruta}:{n}: entrada antes de ninguna sección")
partes = [x.strip() for x in linea[2:].split("|")]
if len(partes) != 3:
sys.exit(f"✗ {ruta}:{n}: se esperaban 3 campos "
f"'slug | nombre | frase', hay {len(partes)}")
sec["items"].append(tuple(partes))
elif ":" in linea and sec is None:
k, v = linea.split(":", 1)
cab[k.strip().upper()] = v.strip()
else:
sys.exit(f"✗ {ruta}:{n}: línea que no entiendo: {linea[:60]}")
for k in ("SLUG", "TITULO", "META_TITLE", "META_DESC", "INTRO"):
if k not in cab:
sys.exit(f"✗ {ruta}: falta la cabecera {k}")
# EXCLUIDOS: publicados que se dejan fuera A PROPÓSITO. Sin esto, `revisa`
# no sabe distinguir un post olvidado de uno que no debe estar, y la única
# forma de callarlo sería enlazarlo. El caso que lo motiva: el stub
# [Preview] de Grusch, que está canonizado al artículo bueno — enlazar los
# dos desde el índice es sembrar canibalización a mano.
cab["EXCLUIDOS"] = [s for s in re.split(r"[,\s]+", cab.get("EXCLUIDOS", ""))
if s]
if not secciones:
sys.exit(f"✗ {ruta}: ni una sección")
return cab, secciones
def slugs_del_curado(secciones):
return [it[0] for s in secciones for it in s["items"]]
# ─────────────────────────────── la revisión ───────────────────────────────
def problemas_de(site):
"""[líneas] con lo que no cuadra. Sin imprimir: lo consume seo_estado.py,
que es quien lo mete en el vigilante semanal."""
cab, secciones = lee_curado(site)
posts = fetch_posts(SITES[site]["cli"])
pub = {p["slug"] for p in posts if p.get("status") == "published"}
prog = {p["slug"] for p in posts if p.get("status") == "scheduled"}
en_indice = slugs_del_curado(secciones)
out = []
for s in sorted({s for s in en_indice if en_indice.count(s) > 1}):
out.append(f"repetido en el índice: {s}")
for s in sorted(s for s in en_indice if s in prog):
out.append(f"PREMATURO (aún no publicado, daría 404): {s}")
for s in sorted(s for s in en_indice if s not in pub and s not in prog):
out.append(f"apunta a un post que no existe: {s}")
for s in sorted(pub - set(en_indice) - set(cab["EXCLUIDOS"])):
out.append(f"publicado FUERA del índice: {s}")
for s in sorted(x for x in cab["EXCLUIDOS"] if x not in pub and x not in prog):
out.append(f"EXCLUIDO que ya no existe, quita la línea: {s}")
return out
def revisa_site(site):
cab, secciones = lee_curado(site)
posts = fetch_posts(SITES[site]["cli"])
pub = {p["slug"] for p in posts if p.get("status") == "published"}
prog = {p["slug"] for p in posts if p.get("status") == "scheduled"}
en_indice = slugs_del_curado(secciones)
problemas = []
repes = sorted({s for s in en_indice if en_indice.count(s) > 1})
if repes:
problemas.append(("repetidos en el índice", repes))
# Enlazar un programado da 404 hasta que publique — la misma piedra que
# seo_link.py llama «enlace PREMATURO».
prematuros = sorted(s for s in en_indice if s in prog)
if prematuros:
problemas.append(("PREMATUROS: aún no publicados, darían 404", prematuros))
fantasmas = sorted(s for s in en_indice if s not in pub and s not in prog)
if fantasmas:
problemas.append(("no existe ese post", fantasmas))
faltan = sorted(pub - set(en_indice) - set(cab["EXCLUIDOS"]))
if faltan:
problemas.append(("publicados FUERA del índice", faltan))
# Un excluido que ya no existe es una exclusión que sobra: sin esto, la
# lista se llena de slugs muertos que nadie se atreve a quitar.
sobran = sorted(s for s in cab["EXCLUIDOS"] if s not in pub and s not in prog)
if sobran:
problemas.append(("EXCLUIDOS que ya no existen: quita la línea", sobran))
print(f"═══ [{site.upper()}] {len(en_indice)} entradas en el índice, "
f"{len(pub)} publicados, {len(cab['EXCLUIDOS'])} excluidos a propósito")
for titulo, lista in problemas:
print(f"\n ── {titulo} ({len(lista)}) ──")
for s in lista:
print(f" {s}")
if not problemas:
print(" ✓ el índice cuadra con el corpus")
return bool(problemas)
# ─────────────────────────────── el renderizado ───────────────────────────────
def e(t):
return htmlmod.escape(t, quote=False)
def render(site):
cab, secciones = lee_curado(site)
base = SITES[site]["base"]
n = len(slugs_del_curado(secciones))
out = [f"<p>{e(cab['INTRO'].replace('{N}', str(n)))}</p>"]
for s in secciones:
out.append(f"<h2>{e(s['titulo'])}</h2>")
if s["intro"]:
out.append(f"<p>{e(s['intro'])}</p>")
out.append("<ul>")
for slug, nombre, frase in s["items"]:
out.append(f'<li><a href="{base}/{slug}/">{e(nombre)}</a> {e(frase)}</li>')
out.append("</ul>")
if cab.get("CIERRE"):
out.append(f"<p>{e(cab['CIERRE'].replace('{N}', str(n)))}</p>")
return cab, "\n".join(out)
def lexical_de(html):
nodo = dict(NODO_HTML)
nodo["html"] = html
return json.dumps({"root": {"children": [nodo], "direction": None,
"format": "", "indent": 0, "type": "root",
"version": 1}})
# ──────────────────────────────── la escritura ────────────────────────────────
def pagina_actual(site, slug):
# ⚠️ sin --formats html, `page list` devuelve la página SIN el campo html y
# la verificación cuenta cero enlaces sobre una página perfectamente bien
# escrita. Falso negativo, no fallo de escritura.
r = sh(f'{SITES[site]["cli"]} page list --limit all --formats html --json')
if r.returncode != 0:
sys.exit(f"✗ no puedo listar páginas:\n{r.stderr[:300]}")
d = json.loads(r.stdout)
ps = d["pages"] if isinstance(d, dict) and "pages" in d else d
return next((p for p in ps if p.get("slug") == slug), None)
def cmd_aplica(a):
if revisa_site(a.site):
print("\n✗ el índice no cuadra con el corpus. Arregla el fichero curado "
"antes de subirlo.")
return 1
cab, secciones = lee_curado(a.site)
cab, html = render(a.site)
cli = SITES[a.site]["cli"]
actual = pagina_actual(a.site, cab["SLUG"])
os.makedirs(BDIR, exist_ok=True)
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
fd, lexfile = tempfile.mkstemp(suffix=".json")
os.close(fd)
with open(lexfile, "w", encoding="utf-8") as f:
f.write(lexical_de(html))
# ⚠️ `ghst page create|update` NO expone meta_title ni meta_description: solo
# --title, --status y el cuerpo. No se rodea el wrapper llamando a la API a
# pelo; se escribe lo que se puede y se AVISA de lo que queda a mano, que es
# lo que evita que alguien dé por hechas unas metas que no están.
tit = cab["TITULO"].replace('"', '\\"')
try:
if actual:
with open(os.path.join(BDIR, f"{a.site}-hub-pristine-{ts}.json"), "w") as f:
json.dump({k: actual.get(k) for k in
("id", "slug", "title", "lexical", "meta_title",
"meta_description")}, f, ensure_ascii=False, indent=1)
r = sh(f'{cli} page update {actual["id"]} --title "{tit}" '
f'--lexical-file "{lexfile}"')
else:
print(f"la página {cab['SLUG']} no existe: se crea como BORRADOR")
r = sh(f'{cli} page create --title "{tit}" --status draft '
f'--lexical-file "{lexfile}"')
finally:
os.unlink(lexfile)
if "Slug:" not in r.stdout:
print(r.stdout[:400], r.stderr[:400])
sys.exit(f"✗ {cli} no confirmó la escritura")
print("✓ escrito")
despues = pagina_actual(a.site, cab["SLUG"])
if not despues:
print("⚠ la página no aparece con el slug esperado; busco por título…")
r2 = sh(f'{cli} page list --limit all --formats html --json')
d = json.loads(r2.stdout)
ps = d["pages"] if isinstance(d, dict) and "pages" in d else d
despues = next((p for p in ps if p.get("title") == cab["TITULO"]), None)
if not despues:
sys.exit("✗ no encuentro la página tras escribirla")
# `page create` no acepta --slug y `page update --slug` es LOOKUP, no
# asignación: Ghost deriva el slug del título y el CLI no deja tocarlo.
# Se avisa en vez de callarlo, porque el slug de una página índice es
# justo lo que no quieres que se decida solo.
print(f" ⚠ Ghost lo derivó del título: '{despues['slug']}'")
print(f" el curado pide '{cab['SLUG']}'")
print(f" el CLI no puede cambiarlo: hay que ponerlo en el editor de")
print(f" Ghost antes de publicar, o esta herramienta no volverá a")
print(f" encontrar la página por su slug.")
fallos = []
if (despues.get("title") or "") != cab["TITULO"]:
fallos.append("el título no cuadra")
enlaces = re.findall(r'href="([^"]+)"', despues.get("html") or "")
esperados = len(slugs_del_curado(secciones))
if len(enlaces) != esperados:
fallos.append(f"enlaces: {len(enlaces)} en Ghost vs {esperados} esperados")
if fallos:
print(f"⚠ verificación: {fallos}")
return 1
print(f"✓ verificado contra Ghost: {len(enlaces)} enlaces y el título correcto")
for campo, quiero in (("meta_title", cab["META_TITLE"]),
("meta_description", cab["META_DESC"])):
if (despues.get(campo) or "") != quiero:
print(f"\n{campo} NO coincide y el CLI no puede escribirlo. A mano:")
print(f" ahora: {despues.get(campo) or '(vacío)'}")
print(f" debe: {quiero}")
if not actual:
print(f"\n⚠ queda en BORRADOR. Publicar es decisión tuya.")
return 0
def cmd_revisa(a):
malo = False
for site in (["en", "es"] if a.site == "todos" else [a.site]):
malo = revisa_site(site) or malo
print()
return 1 if malo else 0
def cmd_render(a):
_, html = render(a.site)
print(html)
return 0
def main():
ap = argparse.ArgumentParser(description="El hub índice de cada blog")
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("revisa", help="¿cuadra el índice con el corpus?")
p.add_argument("--site", choices=("en", "es", "todos"), default="todos")
p.set_defaults(func=cmd_revisa)
p = sub.add_parser("render", help="el HTML que subiría")
p.add_argument("--site", choices=("en", "es"), required=True)
p.set_defaults(func=cmd_render)
p = sub.add_parser("aplica", help="sube el índice a Ghost")
p.add_argument("--site", choices=("en", "es"), required=True)
p.set_defaults(func=cmd_aplica)
a = ap.parse_args()
return a.func(a) or 0
if __name__ == "__main__":
sys.exit(main())