#!/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"
{e(cab['INTRO'].replace('{N}', str(n)))}
"] for s in secciones: out.append(f"{e(s['intro'])}
") out.append("{e(cab['CIERRE'].replace('{N}', str(n)))}
") 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())