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:
ChemaVX
2026-07-29 16:36:29 +00:00
co-authored by Claude Opus 5
parent 66540c12e6
commit 2afe52b731
3 changed files with 446 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
SLUG: complete-guide-uap-cases
TITULO: The Complete Guide to UAP Cases: Every Investigation, Every Case
META_TITLE: The Complete Guide to UAP Cases: Every Case, Investigated
META_DESC: Every UAP case The Exclusion Zone has investigated, by category: military encounters, declassified Pentagon files, classics and tested claims.
INTRO: Everything we know about unidentified aerial phenomena — organized by category, sourced from declassified records, military testimony, and investigative research. This guide covers all {N} cases and investigations published on The Exclusion Zone.
CIERRE: The Exclusion Zone publishes investigative UAP research twice weekly. Every case covered here is sourced from declassified government documents, official military records, and on-the-record testimony. We don't dismiss and we don't sensationalize — we investigate. New cases are added as they are published.
EXCLUIDOS: grusch-capitol-june-9-2026-uap-disclosure
## Military Encounters
> Documented UAP events involving active military personnel, official investigations, and declassified defense records.
- kecksburg-1965-acorn-ufo-missing-nasa-files | Kecksburg 1965 | — An acorn-shaped object crashed into the Pennsylvania woods. NASA found nothing — until a FOIA lawsuit forced it to admit it had analyzed fragments, then lost the records.
- malmstrom-1967-ufo-nuclear-missiles-incident | Malmstrom 1967 | — Twenty Minuteman nuclear missiles went offline at once while UFOs were reported overhead. Boeing investigated and found no technical explanation.
- cash-landrum-1980-ufo-federal-lawsuit | Cash-Landrum 1980 | — Three Texans, a diamond-shaped craft and radiation-like injuries. The only UFO encounter ever litigated in a U.S. federal court — dismissed without ruling on what happened.
- rendlesham-forest-incident-1980 | Rendlesham Forest 1980 | — Three nights of encounters at a NATO air base in Suffolk, England. The most documented military UAP case in European history.
- jal-1628-alaska-1986-ufo-radar-encounter | JAL 1628 (1986) | — Fifty minutes over Alaska, tracked simultaneously by onboard radar, air traffic control and military radar. The best-documented encounter in aviation history.
- belgium-1989-1990-nato-fighters-ufo-wave | Belgium 19891990 | — Belgian F-16s got radar locks on objects making impossible maneuvers and never saw a thing. The raw data is still classified.
- uss-nimitz-2004-tic-tac-uap-encounter | USS Nimitz 2004 | — Navy pilots intercept a white tic-tac shaped object off the California coast. The video that forced the Pentagon to acknowledge UAP.
- gimbal-gofast-pentagon-analysis-uap-videos | GIMBAL and GOFAST | — Five years after the Pentagon released both videos they remain officially unidentified — and the radar data and full telemetry that would settle them has never been declassified.
## Classic Cases
> The cases that shaped public understanding of UAP encounters — investigated, contested, and never fully resolved.
- roswell-1947-ufo-incident | Roswell 1947 | — A crash in the New Mexico desert. An Army press release retracted within hours. Eight decades of official explanations that never quite closed the case.
- betty-barney-hill-1961-abduction | Betty & Barney Hill 1961 | — The first widely documented alien abduction case in the United States. Recovered under hypnosis, disputed ever since.
- ariel-school-1994-zimbabwe-ufo-encounter | Ariel School 1994 | — Sixty-two children in Ruwa, Zimbabwe described a landed craft and its occupants. Thirty years on, scattered across the world, they have never recanted — and there is still no physical evidence.
- vilas-boas-1957-abduction-before-the-hills | Vilas-Boas 1957 | — A Brazilian farmer reported the template abduction four years before Betty and Barney Hill. The debunking everyone cites rests on an anachronism.
- travis-walton-abduction-1975-evidence-witnesses | Travis Walton 1975 | — A forestry worker vanished in front of six colleagues in Arizona. The crew chief later confessed it was a hoax — then retracted.
- phoenix-lights-1997 | Phoenix Lights 1997 | — Over one million witnesses across Arizona and Nevada. The governor who mocked it publicly later admitted he saw it too.
## Government Disclosure & Official Investigations
> What the U.S. government has officially acknowledged, investigated, and released — and what remains classified.
- project-blue-book-declassified-ufo-files | Project Blue Book | — 12,618 sightings over 22 years, 701 of them still unexplained, and a program that was quietly told to stop finding answers.
- americas-secret-psychic-spy-program-declassified-documents-reveal-project-stargates-extraordinary-history | Project Stargate | — Twenty million dollars and two decades of CIA and military remote viewing. The declassified files show what it actually produced.
- aaro-ufo-investigation-eight-decades-us-government-research | AARO: Eight Decades of U.S. UFO Research | — The Pentagon's own UAP office reviewed 80 years of classified programs and found no alien technology. Here's what they did find.
- immaculate-constellation-secret-uap-program-congress | Immaculate Constellation | — A 2024 whistleblower document describes a secret Pentagon UAP program. Nineteen months later it has been neither confirmed nor denied.
- david-grusch-capitol-2026-whistleblower-uap-disclosure | David Grusch at the Capitol | — Three years after his sworn testimony: the claims, the congressional obstruction, and the declassification that still hasn't arrived.
## Declassified File Releases
> The Pentagon's rolling UAP declassifications — what each drop actually contains, file by file.
- pentagon-uap-second-release-may-2026 | Pentagon's Second UAP Declassification | — 64 new files, Apollo 12 astronaut testimony, and 209 UAP sightings near nuclear facilities.
- 222-declassified-ufo-files-reveal-hide-unanswered-2026 | 222 Declassified UAP Files 2026 | — Two releases, 222 files from WWII to 2026. Apollo astronaut testimony, submarine transmedium footage, and Cold War green orbs near nuclear facilities.
- pursue-release-3-cia-confession-apollo-uap-files-2026 | PURSUE Release 3 | — A 1992 CIA confession that spy planes caused over half the UFO reports of the 1960s, active analysis of Apollo lunar photographs, and 294 files spanning 82 years.
- pursue-release-4-pantex-los-alamos-apollo-17-uap-files | PURSUE Release 4 | — A 2015 intrusion at a nuclear weapons plant in Texas, a 1949 Los Alamos conference on unexplained "green fireballs", and a fresh look at an Apollo 17 photograph.
## Latin America & Spain
> Cases from the Spanish-speaking world — often overlooked in English-language UAP research, rigorously documented in military and government records.
- canary-islands-1976-ufo-sighting | Canary Islands 1976 | — Over one million witnesses. A Spanish Navy corvette. Declassified files 18 years later and a Cold War secret the U.S. couldn't acknowledge.
- manises-1979-spain-ufo-incident | Manises 1979 | — The only commercial flight ever diverted by a UFO. A Mirage F1 scrambled at Mach 1.4 couldn't catch it.
- varginha-1996-brazil-ufo-incident | Varginha 1996 | — Brazil's Roswell. A creature captured by military in a small city, witness testimonies suppressed for years, and a cover-up that still hasn't fully unraveled.
- chiles-military-declassified-uap-files-cefaas-groundbreaking-investigation-of-unexplained-aerial-phenomena | Chile's CEFAA Files | — A 2014 Navy helicopter encounter on thermal video, analyzed by a military commission that published the entire file instead of burying it.
## Viral Claims, Myths & Forensic Analysis
> Extraordinary claims put through the evidence — frame by frame, source by source. Sometimes they hold up. Often they don't.
- tassili-najjer-cave-paintings-ancient-astronaut-theory | Tassili N'Ajjer | — "Ancient Aliens" calls the Round Head paintings proof of contact. Archaeology points to shamanic trance, psychoactive plants, and some very creative 1950s copies.
- roswell-341-viral-crater-video-forensic-analysis | Roswell's Viral "Crater" Video | — 1,305 frames through a full forensic pipeline. The "crater with an alien body" turns out to be a camera filming a photograph in a book.
- yellow-sea-star-pr104-forensic-analysis-six-pointed-uap | The Yellow Sea "Star" | — PURSUE's six-pointed UAP video: chain of custody verified, then taken apart with a negative control and an optical simulation of the pattern.
- missing-scientists-theory-youtube-white-house-2026 | The "Missing Scientists" Theory | — How a YouTube video became a question at a White House press briefing in fourteen weeks. A study of the amplification chain, not a verdict.
- spielberg-disclosure-day-2026-uap-film | Spielberg's "Disclosure Day" | — Fifty years after Close Encounters, Hollywood's most famous UFO skeptic built a film on declassified files and on-the-record testimony — and says he now believes.
+55
View File
@@ -0,0 +1,55 @@
SLUG: guia-completa-casos-ovni
TITULO: Guía completa de casos OVNI: todos los expedientes, uno a uno
META_TITLE: Guía completa de casos OVNI: todos los expedientes
META_DESC: Todos los casos OVNI investigados en Zona de Exclusión, por categoría: encuentros militares, archivos desclasificados, clásicos y casos españoles.
INTRO: Todo lo que sabemos sobre los fenómenos aéreos no identificados, ordenado por categoría y sostenido en documentos desclasificados, registros militares y testimonios a pie de expediente. Esta guía reúne los {N} casos e investigaciones publicados en Zona de Exclusión.
CIERRE: Zona de Exclusión publica investigación sobre fenómenos aéreos no identificados dos veces por semana. Cada caso de esta guía se apoya en documentos oficiales, registros militares y testimonios verificables. Ni despachamos ni exageramos: investigamos. Los casos nuevos se añaden según se publican.
## Encuentros militares
> Incidentes con personal militar de por medio, investigaciones oficiales y expedientes de defensa desclasificados.
- kecksburg-1965-objeto-ejercito-pennsylvania | Kecksburg 1965 | — Algo cayó en un bosque de Pensilvania, el Ejército acordonó la zona y se llevó un objeto. Los archivos de aquella recuperación no aparecen.
- batalla-los-angeles-1942-ovni-antiaereo | La batalla de Los Ángeles, 1942 | — Cincuenta y ocho minutos y 1.430 proyectiles antiaéreos contra algo que nadie identificó. Tres versiones oficiales distintas y una foto icónica retocada.
- chiles-whitted-1948-piloto-eastern-airlines-ovni | Chiles-Whitted 1948 | — Dos pilotos de línea describieron un objeto con ventanas iluminadas a 5.000 pies. El informe que concluía «vehículo interplanetario» acabó destruido.
- talavera-la-real-1976-humanoide-verde | Talavera la Real, 1976 | — Dos soldados vaciaron medio cargador contra una figura de tres metros dentro de una base aérea española. Los casquillos nunca aparecieron.
- el-incidente-ovni-de-rendlesham-forest-1980-el-caso-mas-documentado-de-la-historia-militar-britanica | Rendlesham Forest 1980 | — Tres noches de encuentros en una base de la OTAN en Suffolk, un memorándum desclasificado y el expediente militar mejor documentado de Europa.
- el-incidente-nimitz-2004-cuando-pilotos-de-caza-f-18-se-toparon-con-lo-inexplicable | Nimitz 2004 | — Pilotos de F-18, radar y cámara FLIR frente a un objeto sin propulsión visible. El caso con más evidencia técnica independiente que existe.
- gimbal-gofast-uap-uss-theodore-roosevelt-2015 | Gimbal y GoFast, 2015 | — El Pentágono confirmó que los dos vídeos son auténticos y sigue sin identificar lo que grabaron. Los datos de radar que lo cerrarían siguen clasificados.
## Casos clásicos
> Los expedientes que formaron la idea que el público tiene del fenómeno: investigados, discutidos y nunca cerrados del todo.
- kenneth-arnold-1947-platillos-volantes | Kenneth Arnold, 1947 | — Nueve objetos a velocidad imposible sobre el monte Rainier y un error de un periodista que bautizó el siglo entero: «platillos volantes».
- roswell-1947-el-misterio-que-cambio-la-historia-de-la-ufologia | Roswell 1947 | — Una nota de prensa del Ejército que hablaba de un platillo, retirada al día siguiente, y ocho décadas de explicaciones oficiales que nunca terminan de encajar.
- roswell-1947-preguntas-sin-respuesta-78-anos | Roswell: lo que sigue sin responder | — Un vídeo aparecido en los Archivos Nacionales, el diario sin publicar de Marcel y una confesión póstuma. Lo que la leyenda repetida mil veces se deja fuera.
- betty-y-barney-hill-1961-el-primer-caso-de-abduccion-extraterrestre-documentado | Betty y Barney Hill, 1961 | — Tres horas perdidas en una carretera de New Hampshire y lo que la pareja recordó bajo hipnosis. La plantilla de la que salen todas las abducciones posteriores.
- pascagoula-1973-abduccion-hickson-parker | Pascagoula 1973 | — Dos pescadores llegaron temblando a la oficina del sheriff intentando describir lo que acababan de ver. Cinco décadas después su relato no ha cambiado.
- travis-walton-1975-abduccion-ovni-arizona | Travis Walton, 1975 | — Un leñador desapareció cinco días delante de seis compañeros que pasaron el polígrafo. Lo que contó al volver sigue sin explicación oficial.
- phoenix-lights-1997-la-noche-cuando-arizona-vio-lo-inexplicable | Phoenix Lights 1997 | — Decenas de miles de testigos en Arizona. El gobernador que ridiculizó el caso en público admitió años después que él también lo vio.
## Divulgación e investigaciones oficiales
> Qué han reconocido, investigado y publicado los gobiernos — y qué sigue clasificado.
- el-testimonio-de-david-grusch-el-denunciante-que-cambio-la-conversacion-sobre-ovnis-en-el-congreso-de-ee-uu | El testimonio de David Grusch | — Julio de 2023: un exoficial de inteligencia declara bajo juramento ante el Congreso. Los detalles que los grandes medios no cubrieron, con las fuentes primarias.
- aaro-vs-grusch-pentagono-testimonio-contradiccion | AARO contra Grusch | — El Pentágono sostiene que no hay evidencia de tecnología no humana; Grusch declaró bajo juramento que sí la hay. Los dos no pueden tener razón.
- grusch-capitolio-9-junio-2026-divulgacion-uap | Grusch en el Capitolio, junio de 2026 | — El día que la divulgación pasó del testimonio a la exigencia: desclasificación total, cuerpos recuperados y los bloqueos que siguen en pie.
- immaculate-constellation-programa-secreto-pentagon-ovni | Immaculate Constellation | — Un documento de doce páginas describe un programa que archiva en secreto cada foto, vídeo y radar de UAP desde 2017. El Pentágono ni lo confirma ni lo desmiente.
- las-revelaciones-del-pentagono-sobre-uap-un-hito-historico-en-la-busqueda-de-la-verdad-sobre-fenomenos-anomalos-no-identificados | Las revelaciones del Pentágono | — Más de mil objetos sobrevolaron bases militares de EE. UU. y Europa en un solo mes de 2024 sin que nadie los interceptara.
- cefaa-chile-unidad-oficial-ovni | El CEFAA chileno | — Chile creó en 1997 el único organismo estatal del mundo dedicado a investigar estos casos, y publicó sus expedientes en vez de enterrarlos.
- trans-en-provence-1981-ovni-gepan | Trans-en-Provence, 1981 | — La agencia estatal francesa GEPAN analizó en laboratorio las marcas dejadas en el suelo de una finca. El expediente con más trabajo científico detrás.
## Desclasificaciones
> Las tandas de archivos UAP que el Pentágono va liberando: qué contiene cada una en realidad, documento a documento.
- eeuu-desclasifica-162-archivos-secretos-sobre-ovnis-y-vida-extraterrestre-el-pentagono-abre-sus-bovedas | Los 162 archivos de mayo de 2026 | — La primera tanda de war.gov/ufo: fotos, vídeos en infrarrojo y transcripciones de las misiones Apolo.
- segunda-desclasificacion-ovni-mayo-2026 | La segunda tanda, mayo de 2026 | — Sesenta y cuatro documentos nuevos: audio inédito del Apolo 12, 209 avistamientos sobre bases nucleares y 51 vídeos militares.
- 222-archivos-ovni-desclasificados-pentagono-2026 | Los 222 archivos, en conjunto | — Dos tandas, 222 documentos y 82 años de registros. Qué revela de verdad la mayor desclasificación de la historia y qué deja intacto.
- archivos-pursue-uap-bases-nucleares-desclasificado | Los Archivos PURSUE y las bases nucleares | — Malmstrom, Minot, Vandenberg, Loring. Décadas de incidentes sobre instalaciones nucleares que el propio Gobierno acaba de confirmar.
## España y Latinoamérica
> Casos del mundo hispanohablante, casi siempre ausentes de la investigación en inglés y documentados a fondo en archivos militares y estatales.
- la-explosion-de-luz-de-canarias-1976-testigos-militares-espanoles-desvelan-un-secreto-de-la-guerra-fria | Canarias 1976 | — Un millón y medio de testigos, una corbeta de la Armada y unos expedientes militares que tardaron dieciocho años en desclasificarse.
- la-noche-que-el-cielo-se-nego-a-revelar-sus-secretos-el-caso-manises-de-1979 | Manises 1979 | — El único vuelo comercial de la historia desviado por un objeto no identificado. El Mirage F-1 que despegó a interceptarlo no lo alcanzó.
- los-villares-1996-el-encuentro-extraterrestre-que-marco-la-ufologia-espanola | Los Villares, 1996 | — Un jubilado de Jaén, humanoides, piedras con símbolos y efectos físicos documentados. El caso que marcó la ufología española de los noventa.
- varginha-1996-el-caso-ovni-mas-intrigante-de-brasil | Varginha 1996 | — El Roswell brasileño: una criatura capturada por militares en una ciudad pequeña, testimonios silenciados durante años y un policía muerto poco después.
## Alrededor del fenómeno
> Lo que rodea al avistamiento: instituciones, obsesiones personales y las consecuencias de buscar donde no toca.
- vaticano-extraterrestres-iglesia-catolica | El Vaticano y los extraterrestres | — Del «hermano extraterrestre» al bautismo de marcianos: un siglo largo de la Iglesia pensando en serio sobre vida fuera de la Tierra sin chocar con la fe.
- gary-mckinnon-el-hacker-que-vio-algo-en-los-servidores-de-la-nasa | Gary McKinnon | — Un escocés con un módem de 56k entró en sistemas clasificados del Pentágono buscando pruebas, y acabó en una batalla legal de diez años.
+334
View File
@@ -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())