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:
@@ -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 1989–1990 | — 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.
|
||||||
@@ -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
@@ -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