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
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Tests de hreflang_watch.py y de la separación pendiente/roto.
Lo que se protege aquí no es «que no dé error»: es la disciplina de aviso. Un
vigilante que canta a diario cinco rojos que nadie puede arreglar se convierte
en ruido, y entonces el rojo de verdad tampoco se ve. Eso ya pasó con los
og_*/twitter_* de las páginas.
python3 -m pytest test_hreflang_watch.py -q
python3 test_hreflang_watch.py # sin pytest
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import seo_hreflang as H
import hreflang_watch as W
def _con_posts(en, es):
"""Sustituye la lectura de Ghost por corpus de mentira, por sitio."""
orig = H.trae
H.trae = lambda site, slugs: (en if site == "en" else es)
return orig
P = [("caso-en", "caso-es")]
def _post(slug, status, head=""):
return {"id": "i-" + slug, "slug": slug, "status": status,
"codeinjection_head": head}
# ─────────────── pendiente NO es roto (la cola de agosto) ───────────────
def test_un_espejo_programado_sale_como_pendiente_no_como_fallo():
orig = _con_posts({"caso-en": _post("caso-en", "published")},
{"caso-es": _post("caso-es", "scheduled")})
try:
esp = H.pendientes(P)
finally:
H.trae = orig
assert esp == {("caso-en", "caso-es"): "published/scheduled"}, esp
def test_los_dos_publicados_no_estan_pendientes():
orig = _con_posts({"caso-en": _post("caso-en", "published")},
{"caso-es": _post("caso-es", "published")})
try:
assert H.pendientes(P) == {}
finally:
H.trae = orig
def test_un_slug_que_no_existe_en_ghost_tambien_es_pendiente():
"""Si se renombra un slug en hreflang-parejas.md y no en Ghost, no hay que
tratarlo como una página rota: no hay nada que escribir todavía."""
orig = _con_posts({}, {"caso-es": _post("caso-es", "published")})
try:
assert H.pendientes(P) == {("caso-en", "caso-es"): "no encontrado en Ghost"}
finally:
H.trae = orig
# ─────────────── que un programado NUNCA se escriba ───────────────
def test_no_se_planifica_nada_mientras_el_espejo_este_programado():
"""Es la salvaguarda de fondo: un hreflang a una URL futura es un 404, y un
404 declarado como versión alternativa es peor que no declarar nada."""
orig_t = _con_posts({"caso-en": _post("caso-en", "published")},
{"caso-es": _post("caso-es", "scheduled")})
orig_p = H.lee_parejas
H.lee_parejas = lambda: P
try:
plan, avisos, _ = H.cambios()
finally:
H.trae, H.lee_parejas = orig_t, orig_p
assert plan == [], plan
assert any("404" in w for w in avisos), avisos
def test_en_cuanto_se_publica_el_espejo_aparece_el_plan():
"""La otra mitad: el vigilante no sirve si es tan prudente que nunca actúa."""
orig_t = _con_posts({"caso-en": _post("caso-en", "published")},
{"caso-es": _post("caso-es", "published")})
orig_p = H.lee_parejas
H.lee_parejas = lambda: P
try:
plan, _, _ = H.cambios()
finally:
H.trae, H.lee_parejas = orig_t, orig_p
assert len(plan) == 2, plan # escribe en los DOS lados
assert {s for s, _, _ in plan} == {"en", "es"}, plan
for _, _, nuevo in plan:
assert H.INICIO in nuevo and H.FIN in nuevo
assert 'hreflang="x-default"' in nuevo # sin él Google descarta el grupo
# ─────────────── silencio cuando no hay nada que contar ───────────────
def test_sin_nada_que_abrir_no_avisa_por_telegram():
"""26 días de cada 31 no habrá nada. Si esos días mandara mensaje, el día que
importa se leería igual que los otros."""
orig_t = _con_posts({"caso-en": _post("caso-en", "published")},
{"caso-es": _post("caso-es", "scheduled")})
orig_p, orig_tg = H.lee_parejas, W.telegram
H.lee_parejas = lambda: P
avisado = []
W.telegram = lambda t: avisado.append(t) or True
try:
rc = W.main()
finally:
H.trae, H.lee_parejas, W.telegram = orig_t, orig_p, orig_tg
assert rc == 0, rc
assert avisado == [], avisado
def test_el_aviso_dice_que_pareja_y_que_queda_pendiente():
orig_t = _con_posts({"caso-en": _post("caso-en", "published")},
{"caso-es": _post("caso-es", "published")})
orig_p, orig_tg, orig_a, orig_v = (H.lee_parejas, W.telegram,
H.aplica, W.verifica)
H.lee_parejas = lambda: P
avisado = []
W.telegram = lambda t: avisado.append(t) or True
H.aplica = lambda plan, log=print: ([(s, p["slug"]) for s, p, _ in plan],
[], "/tmp/copia.json")
W.verifica = lambda abiertas: []
try:
rc = W.main()
finally:
(H.trae, H.lee_parejas, W.telegram, H.aplica, W.verifica) = (
orig_t, orig_p, orig_tg, orig_a, orig_v)
assert rc == 0, rc
assert len(avisado) == 1, avisado
assert "caso-en" in avisado[0] and "caso-es" in avisado[0]
assert "verificado en la web pública" in avisado[0]
def test_si_ghost_guarda_pero_la_web_no_lo_emite_grita_y_sale_con_error():
"""El caso peligroso: Ghost dice OK y la página no lo tiene. Sin esto, el
vigilante daría por hecho algo que no está."""
orig_t = _con_posts({"caso-en": _post("caso-en", "published")},
{"caso-es": _post("caso-es", "published")})
orig_p, orig_tg, orig_a, orig_v = (H.lee_parejas, W.telegram,
H.aplica, W.verifica)
H.lee_parejas = lambda: P
avisado = []
W.telegram = lambda t: avisado.append(t) or True
H.aplica = lambda plan, log=print: ([(s, p["slug"]) for s, p, _ in plan],
[], "/tmp/copia.json")
W.verifica = lambda abiertas: ["caso-es: en dice None, debería ser X"]
try:
rc = W.main()
finally:
(H.trae, H.lee_parejas, W.telegram, H.aplica, W.verifica) = (
orig_t, orig_p, orig_tg, orig_a, orig_v)
assert rc == 1, "un fallo silencioso deja exit 0 y systemd lo da por bueno"
assert "🚨" in avisado[0] and "MAL en la web pública" in avisado[0], avisado
if __name__ == "__main__":
fallos = 0
for nombre, fn in sorted(globals().items()):
if not nombre.startswith("test_"):
continue
try:
fn()
print(f"{nombre}")
except AssertionError as exc:
fallos += 1
print(f"{nombre}: {exc}")
print(f"\n{'✓ todo pasa' if not fallos else f'{fallos} fallo(s)'}")
sys.exit(1 if fallos else 0)