El hub del EN estaba bien escrito y por eso mismo se pudrió: decía «all 31
cases» con 33 publicados, y los dos últimos artículos (Ariel School,
Vilas-Boas) no estaban enlazados desde ningún sitio. El ES no tenía índice
ninguno. Un índice que se actualiza a mano caduca el día que dejas de
acordarte, así que lo que envejece lo calcula la herramienta y lo que hay que
pensar sigue en un fichero curado — el mismo reparto que hreflang-parejas.md.
La herramienta NUNCA escribe una frase. 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 en fila con otras
treinta. `revisa` solo AVISA de lo que falta, y sale con código 1 para poder
colgarlo del vigilante.
Lo que sí calcula: el recuento ({N} en el texto, que era justo el dato falso),
publicados fuera del índice, entradas que apuntan a un post inexistente, y
enlaces PREMATUROS a programados — que darían 404 hasta que publiquen, la misma
piedra que ya documenta seo_link.py.
EXCLUIDOS existe por un caso concreto: el stub [Preview] de Grusch está
canonizado al artículo bueno y el hub enlazaba LOS DOS. Eso es sembrar
canibalización a mano. Sin una lista de exclusiones a propósito, la única forma
de callar a `revisa` sería volver a enlazarlo.
Cicatrices del CLI, documentadas donde muerden:
- `page list` sin --formats html devuelve la página SIN html: la primera
verificación contó cero enlaces sobre una página perfectamente escrita
- `page create|update` no expone meta_title ni meta_description, y --slug es
LOOKUP, no asignación. Se escribe lo que se puede y se AVISA del resto en
vez de dar por hechas unas metas que no están
Estado: EN aplicado y verificado (32 enlaces). ES creado como BORRADOR con 31
enlaces — publicar es decisión de Jose, y antes hay que ponerle el slug y las
metas a mano en el editor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
335 lines
14 KiB
Python
335 lines
14 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 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())
|