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>
151 lines
6.0 KiB
Python
151 lines
6.0 KiB
Python
#!/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())
|