La pareja decía que «David Grusch at the Capitol» (el acto del 9 de junio de 2026) y «El testimonio de David Grusch» (la comparecencia jurada de julio de 2023) son el mismo artículo en otro idioma. Son dos hechos con tres años de diferencia. El espejo real es grusch-capitolio-9-junio-2026, publicado un día después del acto y con extensión pareja. Se coló porque las 16 parejas se validaron por firma de título, y con casos históricos el año identifica el hecho, pero con NOTICIAS el mismo protagonista vuelve. Revisadas a fondo las otras 15 con ese criterio —abriendo los dos artículos, no comparando títulos—: correctas. Las dos de noticia que quedaban (222 archivos y la segunda desclasificación) se publicaron el MISMO DÍA en los dos sitios y con extensión casi idéntica, que es la firma de una pareja real. Y el arreglo destapó un agujero: `cambios()` solo escribía las parejas del fichero, así que repuntar una dejaba la anterior declarada por su lado. Se habría quedado el ES de 2023 señalando a un EN que ya no le señala a él. `huerfanos()` recorre ahora todo el corpus y retira nuestro bloque de quien ha dejado de estar en la lista; `desmarca()` quita solo lo nuestro. Aplicado y verificado en la web pública: 3 posts, las dos páginas de la pareja nueva declaran las dos versiones y el ES de 2023 se queda sin hreflang, que es lo correcto porque no hay artículo inglés sobre esa comparecencia. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
288 lines
12 KiB
Python
288 lines
12 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 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'<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())
|