#!/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 slugs is None or p["slug"] in slugs} def desmarca(actual): """Quita NUESTRO bloque y deja intacto lo demás. Devuelve None si no había.""" actual = actual or "" if INICIO not in actual or FIN not in actual: return None limpio = re.sub(re.escape(INICIO) + r".*?" + re.escape(FIN), "", actual, flags=re.S) return re.sub(r"\n{3,}", "\n\n", limpio).strip() def huerfanos(parejas): """Posts que llevan nuestro bloque y YA NO están en ninguna pareja SI. Sin esto, repuntar una pareja deja media declaración viva. Pasó con Grusch el 2026-07-29: el ES de la comparecencia de 2023 estaba emparejado por error con el EN del acto de 2026, y al corregirlo el ES viejo se habría quedado señalando a un artículo que ya no le señala a él. Un hreflang que no es recíproco Google lo ignora, pero el que sobra sigue afirmando algo falso. Se busca en TODO el corpus, no solo en los slugs de las parejas: el que hay que limpiar es justamente el que ha dejado de estar en la lista. """ fuera = [] for site, dentro in (("en", {a for a, _ in parejas}), ("es", {b for _, b in parejas})): for slug, p in trae(site, None).items(): if slug in dentro: continue limpio = desmarca(p.get("codeinjection_head")) if limpio is not None: fuera.append((site, p, limpio)) return fuera 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 + huerfanos(parejas), avisos, parejas _PUB_OTRO = {} def publicados(site): """Slugs publicados del OTRO sitio, cacheado. Hace falta para no cantar una violación que no se puede arreglar (ver `violaciones`).""" if site not in _PUB_OTRO: _PUB_OTRO[site] = {s for s, p in trae(site, None).items() if p.get("status") == "published"} return _PUB_OTRO[site] def violaciones(post, site, parejas=None): """Regla para el auditor: una pareja declarada que NO lo tiene puesto. Vive aquí y no en seo_rules porque necesita conocer hreflang-parejas.md, que es una decisión editorial curada a mano y no una regla del motor. El auditor la inyecta; el motor sigue sin saber nada de parejas. No basta con que exista el bloque: se comprueba que las URLs sean las de ESTA pareja. Si un slug cambia, el hreflang se queda apuntando al viejo y esto es lo único que lo cazaría. Hacen falta los DOS lados publicados, no solo este. Al curar las parejas de agosto (2026-07-29) quedaron tres EN ya publicados emparejados con un ES aún programado, y el auditor cantó tres violaciones con una receta que no las arreglaba: `aplicar` se niega, y con razón, porque el hreflang apuntaría a una URL que todavía no existe. Un aviso que no se puede atender es ruido, y el ruido acaba con que nadie mire el informe. """ import seo_rules as R if parejas is None: parejas = lee_parejas() idx = {(a if site == "en" else b): (a, b) for a, b in parejas} par = idx.get(post.get("slug")) if not par: return [] if post.get("status") != "published": return [] otro_site = "es" if site == "en" else "en" if (par[1] if site == "en" else par[0]) not in publicados(otro_site): return [] cabeza = post.get("codeinjection_head") or "" esperado = bloque(*par) if esperado in cabeza: return [] falta = INICIO not in cabeza return [R.Violation( "hreflang.missing" if falta else "hreflang.stale", R.MED, ("sin hreflang y tiene espejo declarado" if falta else "el hreflang no coincide con la pareja declarada (¿cambió un slug?)") + f" — pareja: {par[1] if site == 'en' else par[0]}", "python3 ~/seo-tools/seo_hreflang.py aplicar")] 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 aplica(plan, log=print): """Escribe el plan en Ghost. Devuelve (hechos, fallos, ruta_de_la_copia). Vive fuera de cmd_aplicar para que el vigilante automático (hreflang_watch) escriba por ESTE camino y no por una copia suya: si el manual y el automático no comparten la escritura, el ensayo de uno no dice nada del otro. """ 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) log(f"copia previa: {bpath}\n") hechos, fallos = [], [] 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 (hechos if ok else fallos).append((site, p["slug"])) log(f" {'✓' if ok else '✗'} [{site}] {p['slug'][:56]}") if not ok: log(f" {(r.stdout + r.stderr)[:200]}") return hechos, fallos, bpath 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 hechos, fallos, _ = aplica(plan) print(f"\n{len(hechos)}/{len(plan)} aplicados") return 1 if fallos else 0 def pendientes(parejas): """Parejas en las que algún lado no está publicado todavía. Se separan de los fallos porque NO son un fallo: la cola de agosto tiene cinco espejos programados, y comprobarlos daba diez ✗ de 404 en cada ejecución. Un informe con diez rojos permanentes que no se pueden arreglar es un informe que se deja de leer, y entonces el rojo de verdad tampoco se ve. Mismo criterio que `cambios()`: aquí solo se juzga lo que ya vive. """ en = trae("en", {a for a, _ in parejas}) es = trae("es", {b for _, b in parejas}) out = {} for a, b in parejas: pa, pb = en.get(a), es.get(b) if not pa or not pb: out[(a, b)] = "no encontrado en Ghost" elif pa["status"] != "published" or pb["status"] != "published": out[(a, b)] = f"{pa['status']}/{pb['status']}" return out 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() espera = pendientes(parejas) malos = 0 for slug_en, slug_es in parejas: if (slug_en, slug_es) in espera: print(f" … [pendiente {espera[(slug_en, slug_es)]}] {slug_en[:46]}") continue 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}") vivas = len(parejas) - len(espera) print(f"\n{f'✓ las {vivas} parejas vivas declaran las dos versiones' if not malos else f'✗ {malos} con problema'}" + (f" · {len(espera)} pendientes de que publique el espejo" if espera else "")) 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())