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