#!/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 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. """ 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 [] 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 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())