Hasta ahora el hreflang era un estado —puesto una vez a mano— y no una regla. Un slug que cambiara habría dejado el enlace apuntando al artículo viejo sin que nada avisara. El auditor gana dos reglas: hreflang.missing (pareja declarada sin bloque) y hreflang.stale (bloque que ya no coincide con la pareja). Viven en seo_hreflang y no en seo_rules porque necesitan hreflang-parejas.md, que es una decisión editorial curada; el motor sigue sin saber que hay parejas. Probadas en los cinco estados, no solo en el feliz: dispara sin bloque, dispara con el bloque apuntando a un slug viejo, y se calla cuando está correcto, cuando el post no es de ninguna pareja y cuando aún es borrador. Y el remate gana el paso 3c: buscar el espejo en el otro idioma y dejarlo en el informe. Con la advertencia explícita de decidir por TÍTULO y no por vector, y las dos reglas duras — solo espejos publicados, y uno a uno.
256 lines
10 KiB
Python
256 lines
10 KiB
Python
#!/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:
|
|
|
|
<!-- hreflang:inicio -->…<!-- hreflang:fin -->
|
|
|
|
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 = "<!-- hreflang:inicio -->", "<!-- hreflang: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'<link rel="alternate" hreflang="en" href="{en}">',
|
|
f'<link rel="alternate" hreflang="es" href="{es}">',
|
|
f'<link rel="alternate" hreflang="x-default" href="{en}">',
|
|
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'<link[^>]+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())
|