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
+53 -14
View File
@@ -231,13 +231,14 @@ def cmd_plan(a):
return 0
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
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")
@@ -245,8 +246,8 @@ def cmd_aplicar(a):
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)
print(f"copia previa: {bpath}\n")
fallos = 0
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:
@@ -254,20 +255,56 @@ def cmd_aplicar(a):
r = sh(f'{SITIOS[site]["cli"]} post update {p["id"]} --from-json "{ppath}"')
os.unlink(ppath)
ok = "Slug:" in r.stdout
fallos += 0 if ok else 1
print(f" {'' if ok else ''} [{site}] {p['slug'][:56]}")
(hechos if ok else fallos).append((site, p["slug"]))
log(f" {'' if ok else ''} [{site}] {p['slug'][:56]}")
if not ok:
print(f" {(r.stdout + r.stderr)[:200]}")
print(f"\n{len(plan) - fallos}/{len(plan)} aplicados")
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:
@@ -290,7 +327,9 @@ def cmd_comprueba(a):
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}")
print(f"\n{'✓ todas las páginas declaran las dos versiones' if not malos else f'{malos} con problema'}")
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