diff --git a/hreflang-parejas.md b/hreflang-parejas.md
index f49a7c5..0bdc393 100644
--- a/hreflang-parejas.md
+++ b/hreflang-parejas.md
@@ -127,16 +127,18 @@ sim 0.749 roswell
Roswell's Viral "Crater" Video: A Frame-by-Frame Forensic Analysis
ES roswell-1947-preguntas-sin-respuesta-78-anos
Un vídeo, un diario y una confesión: lo que Roswell sigue sin responder
- NOTA: DUDA TUYA. El título ES empieza por «Un vídeo…», que encaja con el análisis forense del EN, pero el resto («un diario y una confesión») suena a pieza más amplia. Tú los escribiste: ¿es el espejo o no?
- decision: ?
+ RESUELTO 2026-07-28 leyendo los dos: NO son el mismo artículo. El EN es el forense de la cinta 341.ROSWELL.41; el ES es una pieza de tres hilos (vídeo, diario de Marcel, confesión de Haut) que además enlaza al Roswell principal como relato base. Comparten un hecho, no un artículo.
+ NOTA: el título ES empieza por «Un vídeo…», que encaja con el análisis forense del EN, pero el resto («un diario y una confesión») suena a pieza más amplia. Tú los escribiste: ¿es el espejo o no?
+ decision: NO
sim 0.726 pursue
EN pursue-release-3-cia-confession-apollo-uap-files-2026
PURSUE Release 3: The CIA Confession, Apollo Lunar Anomalies, and 294 Files That Raise
ES archivos-pursue-uap-bases-nucleares-desclasificado
Los Archivos PURSUE y las Bases Nucleares: Lo que el Gobierno Acaba de Confirmar
- NOTA: DUDA TUYA. Tres artículos EN de PURSUE apuntan al MISMO ES, y hreflang es 1 a 1: solo uno puede declararlo. Por vector gana éste (0,726); por tema encaja mejor el Release 4, que va de Pantex y Los Álamos, o sea bases nucleares. Elige uno y deja los otros dos en NO.
- decision: ?
+RESUELTO 2026-07-28: el espejo real del artículo ES es «Nuclear Bases and UAP: Six Decades of Documented Incursions», que está PROGRAMADO. Ninguno de los tres PURSUE es su traducción; el par entra cuando se publique.
+ NOTA: Tres artículos EN de PURSUE apuntan al MISMO ES, y hreflang es 1 a 1: solo uno puede declararlo. Por vector gana éste (0,726); por tema encaja mejor el Release 4, que va de Pantex y Los Álamos, o sea bases nucleares. Elige uno y deja los otros dos en NO.
+ decision: NO
sim 0.722 pursue
EN pursue-release-4-pantex-los-alamos-apollo-17-uap-files
diff --git a/seo_hreflang.py b/seo_hreflang.py
new file mode 100644
index 0000000..c906016
--- /dev/null
+++ b/seo_hreflang.py
@@ -0,0 +1,222 @@
+#!/usr/bin/env python3
+"""
+Tool I — hreflang recíproco entre los artículos espejo ES ↔ EN.
+
+Qué problema resuelve. La casa publica el mismo caso en dos idiomas y en dos
+dominios, y hasta el 2026-07-28 no había NI UN hreflang en ninguno de los dos.
+Google no tenía forma de saber que «Varginha 1996: El Caso OVNI Más Intrigante
+de Brasil» y «Brazil's 1996 Varginha Incident» son el mismo artículo en otro
+idioma, así que servía el que le parecía y las señales de cada uno se quedaban
+sueltas en su lado.
+
+⚠️ POR QUÉ LAS PAREJAS SE CURAN A MANO Y NO SE INFIEREN. Se intentó con bge-m3
+y no vale: los DOS artículos de Immaculate Constellation puntúan 0,774 entre sí,
+y uno que no tiene nada que ver puntúa 0,778. Y el espejo real del artículo ES
+de bases nucleares puntúa 0,719, por debajo de tres artículos de PURSUE que no
+lo son. Un hreflang equivocado le dice a Google que dos artículos DISTINTOS son
+el mismo, que es peor que no ponerlo. Por eso la fuente de verdad es
+hreflang-parejas.md, revisado por una persona, y esta herramienta solo lo
+ejecuta.
+
+Cómo escribe. En `codeinjection_head` de cada post, entre marcas, para poder
+reescribirlo o retirarlo sin tocar lo que hubiera ya:
+
+ …
+
+Cada página declara las DOS versiones, incluida ella misma: el autorreferente no
+es opcional, Google descarta el grupo si falta. x-default apunta al EN por ser
+la audiencia más amplia.
+
+⚠️ Solo se emparejan PUBLICADOS. Varios espejos existen pero están PROGRAMADOS
+para agosto: un hreflang a una URL que aún no existe apunta a un 404.
+
+Uso:
+ python3 seo_hreflang.py plan # qué haría (no escribe)
+ python3 seo_hreflang.py aplicar # escribe, con copia previa
+ python3 seo_hreflang.py comprueba # lo verifica en la web pública
+"""
+import argparse
+import datetime
+import json
+import os
+import re
+import subprocess
+import sys
+import tempfile
+
+PAREJAS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hreflang-parejas.md")
+BDIR = os.path.expanduser("~/link-batch-backup")
+SITIOS = {
+ "en": {"cli": "ghst-en", "base": "https://www.theexclusionzone.com"},
+ "es": {"cli": "ghst-es", "base": "https://zonadeexclusion.com"},
+}
+INICIO, FIN = "", ""
+
+
+def sh(cmd, timeout=300):
+ return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
+
+
+def lee_parejas():
+ """Bloques con `decision: SI` del fichero revisado a mano."""
+ texto = open(PAREJAS).read()
+ out = []
+ for bloque in re.split(r"\nsim ", texto)[1:]:
+ m_en = re.search(r"^ EN (\S+)", bloque, re.M)
+ m_es = re.search(r"^ ES (\S+)", bloque, re.M)
+ m_d = re.search(r"^ decision:\s*(\S+)", bloque, re.M)
+ if not (m_en and m_es and m_d):
+ continue
+ if m_d.group(1).upper() != "SI":
+ continue
+ out.append((m_en.group(1), m_es.group(1)))
+ return out
+
+
+def bloque(slug_en, slug_es):
+ en = f"{SITIOS['en']['base']}/{slug_en}/"
+ es = f"{SITIOS['es']['base']}/{slug_es}/"
+ return "\n".join([
+ INICIO,
+ f'',
+ f'',
+ f'',
+ FIN,
+ ])
+
+
+def fusiona(actual, nuevo):
+ """Sustituye SOLO nuestro bloque; lo que hubiera fuera se queda tal cual."""
+ actual = actual or ""
+ if INICIO in actual and FIN in actual:
+ return re.sub(re.escape(INICIO) + r".*?" + re.escape(FIN), nuevo, actual, flags=re.S)
+ return (actual.rstrip() + "\n" + nuevo).strip() if actual.strip() else nuevo
+
+
+def trae(site, slugs):
+ """{slug: post} con id, estado y codeinjection_head, por fichero (el pipe
+ trunca a 64 KB, cicatriz compartida con seo_audit)."""
+ fd, path = tempfile.mkstemp(suffix=".json")
+ os.close(fd)
+ try:
+ sh(f'{SITIOS[site]["cli"]} post list --limit all '
+ f'--fields id,slug,status,codeinjection_head --json > {path}')
+ posts = json.load(open(path))["posts"]
+ finally:
+ os.unlink(path)
+ return {p["slug"]: p for p in posts if p["slug"] in slugs}
+
+
+def cambios():
+ parejas = lee_parejas()
+ en = trae("en", {a for a, _ in parejas})
+ es = trae("es", {b for _, b in parejas})
+ plan, avisos = [], []
+ for a, b in parejas:
+ pa, pb = en.get(a), es.get(b)
+ if not pa or not pb:
+ avisos.append(f"no encontrado: {a if not pa else b}")
+ continue
+ if pa["status"] != "published" or pb["status"] != "published":
+ avisos.append(f"{a} / {b}: alguno no está publicado ({pa['status']}/{pb['status']}) "
+ "— un hreflang a una URL futura es un 404")
+ continue
+ blq = bloque(a, b)
+ for site, p in (("en", pa), ("es", pb)):
+ nuevo = fusiona(p.get("codeinjection_head"), blq)
+ if nuevo != (p.get("codeinjection_head") or ""):
+ plan.append((site, p, nuevo))
+ return plan, avisos, parejas
+
+
+def cmd_plan(a):
+ plan, avisos, parejas = cambios()
+ print(f"{len(parejas)} parejas marcadas SI en {os.path.basename(PAREJAS)}")
+ for w in avisos:
+ print(f" ⚠ {w}")
+ print(f"\n{len(plan)} posts a modificar:")
+ for site, p, _ in plan:
+ ya = "reescribe" if INICIO in (p.get("codeinjection_head") or "") else "añade"
+ otro = "con contenido previo" if (p.get("codeinjection_head") or "").strip() else "vacío"
+ print(f" [{site}] {ya:9} ({otro:19}) {p['slug'][:52]}")
+ if plan:
+ print("\n(plan: no se ha escrito nada — repite con `aplicar`)")
+ return 0
+
+
+def cmd_aplicar(a):
+ plan, avisos, _ = cambios()
+ for w in avisos:
+ print(f" ⚠ {w}")
+ if not plan:
+ print("nada que hacer: ya está todo puesto")
+ return 0
+ os.makedirs(BDIR, exist_ok=True)
+ ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
+ bpath = os.path.join(BDIR, f"hreflang-pristine-{ts}.json")
+ with open(bpath, "w") as f:
+ json.dump([{"site": s, "id": p["id"], "slug": p["slug"],
+ "codeinjection_head": p.get("codeinjection_head")}
+ for s, p, _ in plan], f, ensure_ascii=False, indent=1)
+ print(f"copia previa: {bpath}\n")
+ fallos = 0
+ for site, p, nuevo in plan:
+ fd, ppath = tempfile.mkstemp(suffix=".json")
+ with os.fdopen(fd, "w") as f:
+ json.dump({"codeinjection_head": nuevo}, f, ensure_ascii=False)
+ r = sh(f'{SITIOS[site]["cli"]} post update {p["id"]} --from-json "{ppath}"')
+ os.unlink(ppath)
+ ok = "Slug:" in r.stdout
+ fallos += 0 if ok else 1
+ print(f" {'✓' if ok else '✗'} [{site}] {p['slug'][:56]}")
+ if not ok:
+ print(f" {(r.stdout + r.stderr)[:200]}")
+ print(f"\n{len(plan) - fallos}/{len(plan)} aplicados")
+ return 1 if fallos else 0
+
+
+def cmd_comprueba(a):
+ """La verdad está en la página pública: que Ghost lo guarde no basta."""
+ import urllib.request
+ parejas = lee_parejas()
+ malos = 0
+ for slug_en, slug_es in parejas:
+ for site, slug in (("en", slug_en), ("es", slug_es)):
+ url = f"{SITIOS[site]['base']}/{slug}/"
+ try:
+ req = urllib.request.Request(url, headers={"User-Agent": "seo-hreflang/1.0"})
+ html = urllib.request.urlopen(req, timeout=30).read().decode("utf8", "ignore")
+ except Exception as e:
+ print(f" ✗ [{site}] {slug[:48]}: {e}")
+ malos += 1
+ continue
+ enc = dict(re.findall(
+ r']+hreflang="([^"]+)"[^>]+href="([^"]+)"', html))
+ # No basta con que HAYA hreflang: un par cruzado (el ES apuntando al
+ # EN equivocado) declararía las dos versiones y pasaría igual. Se
+ # comprueba que las URLs sean EXACTAMENTE las de esta pareja.
+ esperado = {"en": f"{SITIOS['en']['base']}/{slug_en}/",
+ "es": f"{SITIOS['es']['base']}/{slug_es}/"}
+ esperado["x-default"] = esperado["en"]
+ mal = {k: (enc.get(k), v) for k, v in esperado.items() if enc.get(k) != v}
+ malos += 0 if not mal else 1
+ print(f" {'✓' if not mal else '✗'} [{site}] {slug[:52]}")
+ for k, (tiene, debe) in mal.items():
+ print(f" {k}: dice {tiene} — debería ser {debe}")
+ print(f"\n{'✓ todas las páginas declaran las dos versiones' if not malos else f'✗ {malos} con problema'}")
+ return 1 if malos else 0
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ sub = ap.add_subparsers(dest="cmd", required=True)
+ for nombre, fn in (("plan", cmd_plan), ("aplicar", cmd_aplicar),
+ ("comprueba", cmd_comprueba)):
+ sub.add_parser(nombre).set_defaults(func=fn)
+ a = ap.parse_args()
+ return a.func(a) or 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())