#!/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
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())