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>
This commit is contained in:
ChemaVX
2026-07-30 09:12:28 +00:00
co-authored by Claude Opus 5
parent d2882c0fdd
commit 6403c8e6c9
5 changed files with 408 additions and 14 deletions
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""hreflang-watch — pone el hreflang en cuanto el espejo se publica.
Por qué existe. Las parejas se curan a mano en hreflang-parejas.md, pero solo se
pueden DECLARAR cuando los dos lados están publicados: un hreflang a una URL
programada apunta a un 404. El 2026-07-30 había cinco parejas esperando a que
publicase el espejo español, repartidas entre el 4 y el 27 de agosto:
04-ago oleada belga 11-ago proyecto Libro Azul
24-ago Colares 25-ago JAL 1628 27-ago Teherán
La alternativa a esto era que Jose se acordase de correr `seo_hreflang.py
aplicar` cinco veces en agosto, cada una el día justo. Eso no es una solución,
es una lista de tareas que se cumple a medias: el hreflang se queda a medio
poner y encima nadie se entera, porque el que falta no da error en ningún sitio.
Qué hace. Una vez al día pregunta si alguna pareja ha pasado a publicada por los
dos lados, y si es así la escribe y la verifica en la web pública. Escribe por
`seo_hreflang.aplica()`, el MISMO camino que el comando manual — no una copia.
Disciplina de aviso (la de la casa): Telegram solo si ha PASADO algo. Nada de
recordar a diario las que faltan; el silencio significa «ninguna se ha abierto
hoy», y ese es el estado normal 26 días de cada 31. Si el silencio fuera el
aviso, el aviso de verdad no se distinguiría.
python3 hreflang_watch.py --seco # dice qué haría, no escribe ni avisa
python3 hreflang_watch.py
"""
import base64
import json
import os
import subprocess
import sys
import urllib.parse
import urllib.request
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import seo_hreflang as H # noqa: E402
TG_SECRET = ("monitoring", "grafana-telegram-infisical")
LOG_DIR = Path.home() / ".local/state/hreflang-watch"
def sh(cmd):
return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60)
def telegram(texto):
"""Mismo camino que canibal-watch e indexnow-watch: el token sale del secret
que gestiona Infisical, nunca de disco."""
t = sh(f"kubectl get secret -n {TG_SECRET[0]} {TG_SECRET[1]} "
f"-o jsonpath='{{.data.TELEGRAM_BOT_TOKEN}}'")
c = sh(f"kubectl get secret -n {TG_SECRET[0]} {TG_SECRET[1]} "
f"-o jsonpath='{{.data.TELEGRAM_CHAT_ID}}'")
if not t.stdout or not c.stdout:
print("✗ no se pudo leer el secret de Telegram", file=sys.stderr)
return False
token = base64.b64decode(t.stdout).decode()
chat = base64.b64decode(c.stdout).decode()
data = urllib.parse.urlencode({"chat_id": chat, "text": texto[:3900]}).encode()
req = urllib.request.Request(
f"https://api.telegram.org/bot{token}/sendMessage", data=data)
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read()).get("ok", False)
def verifica(parejas_hechas):
"""Comprueba en la web PÚBLICA solo las parejas que acabamos de escribir.
Que Ghost devuelva 200 al guardar no significa que la página lo emita: el
codeinjection_head podría no llegar a renderizarse. Y se comprueban las URLs
EXACTAS, no «que haya hreflang»: un par cruzado también tendría dos.
"""
malos = []
for slug_en, slug_es in sorted(parejas_hechas):
esperado = {"en": f"{H.SITIOS['en']['base']}/{slug_en}/",
"es": f"{H.SITIOS['es']['base']}/{slug_es}/"}
esperado["x-default"] = esperado["en"]
for site, slug in (("en", slug_en), ("es", slug_es)):
url = f"{H.SITIOS[site]['base']}/{slug}/"
try:
req = urllib.request.Request(
url, headers={"User-Agent": "hreflang-watch/1.0"})
html = urllib.request.urlopen(req, timeout=30).read().decode("utf8", "ignore")
except Exception as exc:
malos.append(f"{slug}: no se pudo leer ({exc})")
continue
enc = dict(H.re.findall(
r'<link[^>]+hreflang="([^"]+)"[^>]+href="([^"]+)"', html))
for k, v in esperado.items():
if enc.get(k) != v:
malos.append(f"{slug}: {k} dice {enc.get(k)}, debería ser {v}")
return malos
def main():
seco = "--seco" in sys.argv
plan, avisos, parejas = H.cambios()
espera = H.pendientes(parejas)
for (a, b), estado in sorted(espera.items()):
print(f" … pendiente ({estado}): {a[:52]}")
if not plan:
print(f"nada que abrir hoy · {len(parejas) - len(espera)} vivas, "
f"{len(espera)} esperando al espejo")
return 0
print(f"\n{len(plan)} posts a escribir:")
for site, p, _ in plan:
print(f" [{site}] {p['slug'][:56]}")
if seco:
print("\n(--seco: no se ha escrito nada)")
return 0
LOG_DIR.mkdir(parents=True, exist_ok=True)
hechos, fallos, copia = H.aplica(plan)
# De los slugs escritos, deducir qué PAREJAS se han abierto, para verificar
# solo esas: el resto ya se verificó el día que se pusieron.
tocados = {s for _, s in hechos}
abiertas = {(a, b) for a, b in parejas if a in tocados or b in tocados}
malos = verifica(abiertas) if hechos else []
lineas = [f"🌐 hreflang: {len(abiertas)} pareja(s) abiertas"]
for a, b in sorted(abiertas):
lineas.append(f"{a[:44]}{b[:44]}")
if fallos:
lineas.append(f"\n🚨 {len(fallos)} no se pudieron escribir:")
lineas += [f"• [{s}] {sl}" for s, sl in fallos]
if malos:
lineas.append("\n🚨 escrito en Ghost pero MAL en la web pública:")
lineas += [f"{m}" for m in malos]
if not fallos and not malos:
lineas.append("\n✅ verificado en la web pública, URLs exactas")
if espera:
lineas.append(f"\n… quedan {len(espera)} esperando a que publique el espejo")
lineas.append(f"\ncopia previa: {copia}")
texto = "\n".join(lineas)
print("\n" + texto)
if not telegram(texto):
print("✗ no se pudo avisar por Telegram", file=sys.stderr)
return 1
return 1 if (fallos or malos) else 0
if __name__ == "__main__":
sys.exit(main())