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>
349 lines
14 KiB
Python
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())
|