diff --git a/hub-en.md b/hub-en.md new file mode 100644 index 0000000..434c9fd --- /dev/null +++ b/hub-en.md @@ -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. diff --git a/hub-es.md b/hub-es.md new file mode 100644 index 0000000..9db3dbf --- /dev/null +++ b/hub-es.md @@ -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. diff --git a/seo_hub.py b/seo_hub.py new file mode 100644 index 0000000..b187eb2 --- /dev/null +++ b/seo_hub.py @@ -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"

{e(cab['INTRO'].replace('{N}', str(n)))}

"] + for s in secciones: + out.append(f"

{e(s['titulo'])}

") + if s["intro"]: + out.append(f"

{e(s['intro'])}

") + out.append("") + if cab.get("CIERRE"): + out.append(f"

{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())