Files
chemavx-seo-tools/seo_hreflang.py
T
ChemaVXandClaude Opus 5 6403c8e6c9 hreflang: aplicarlo solo cuando el espejo publica, sin que nadie se acuerde
Las parejas se curan a mano, pero solo se pueden declarar cuando los dos
lados están publicados: un hreflang a una URL programada apunta a un 404.
Había cinco esperando, repartidas entre el 4 y el 27 de agosto, y la
alternativa era acordarse de correr `aplicar` cinco veces en las fechas
justas. Eso no es una solución: es una lista que se cumple a medias, y el
hreflang que falta no da error en ninguna parte, así que nadie se entera.

- hreflang_watch.py + timer diario a las 06:40 UTC (40 min tras la cola,
  antes de canibal-watch). Telegram SOLO si algo se ha abierto o ha
  fallado: 26 días de cada 31 no habrá nada, y si esos días avisara, el
  día que importa se leería igual que los otros.
- `aplica()` extraída de cmd_aplicar: el automático escribe por el MISMO
  camino que el manual, no por una copia suya.
- `comprueba` separa PENDIENTE de ROTO. Antes daba diez ✗ de 404 fijos por
  los espejos programados; diez rojos permanentes que no se pueden
  arreglar son un informe que se deja de leer.

Verificado por el camino real, no en seco: se le quitó el bloque al lado ES
de la pareja de Varginha (avería visible en la web pública, 0 hreflang), el
vigilante la detectó, la escribió, la verificó contra las URLs exactas y
avisó por Telegram. Restaurada y comprobada después. Y `verifica()` se ve
fallar con una pareja cruzada a propósito, así que el  no es vacuo.

Nueve tests. Ensayado además arrancando el unit por systemd, que es donde
los CLIs de node mueren con 127 si falta el bin de nvm en el PATH.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 09:12:28 +00:00

349 lines
14 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
_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'<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}")
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())