hub: el índice de los dos blogs, generado desde el corpus y curado a mano
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>
This commit is contained in:
+334
@@ -0,0 +1,334 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user