seo_watch: detecta enlaces envejecidos (check 4)

El corpus crece 2 posts/semana, así que un enlace apunta al mejor destino que
existía el día en que se escribió — y envejece. Caso real que motivó el check
(2026-07-18): levelland enlazaba el anchor 'Project Blue Book' al artículo de
AARO porque cuando se generó (30-jun) el de Blue Book no existía (1-jul).
No fue un fallo del generador: fue el mejor destino disponible, un día antes
de que apareciera el correcto.

Heurística deliberadamente conservadora, por la misma razón que el resto del
job: un vigilante desatendido que grita en falso se acaba ignorando.
  * solo marca si el anchor contiene TODOS los tokens distintivos de otro post
  * no marca si el anchor ya casa con el destino actual (el enlace está bien)
  * ignora posts con <2 tokens distintivos — p.ej. roswell-1947 se reduce a
    {roswell}, que casa con demasiadas cosas

Test de regresión hecho contra los datos reales pre y post arreglo: caza
levelland antes, 0 hallazgos después (incluido el falso positivo de yellow-sea,
cuyo anchor menciona 'roswell' pero apunta correctamente al post del vídeo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-07-20 10:50:06 +00:00
co-authored by Claude Opus 4.8
parent dc0c1cfa73
commit 2833144653
+71 -7
View File
@@ -9,8 +9,11 @@ medida que se publica (2 posts/semana), no el inventario completo de siempre:
2. Enlaces internos no canónicos (apex, http, o sin barra final → salto 301)
3. Enlaces prematuros (a un post que se publica DESPUÉS que el que
lo enlaza → 404 en la ventana intermedia)
4. Sitemap (toda URL listada debe dar 200 directo)
5. Violaciones accionables (delta vs la última ejecución, vía seo_rules)
4. Enlaces envejecidos (el anchor nombra a otro artículo que se
publicó DESPUÉS: el corpus crece y el
mejor destino cambia)
5. Sitemap (toda URL listada debe dar 200 directo)
6. Violaciones accionables (delta vs la última ejecución, vía seo_rules)
Solo avisa por Telegram cuando hay hallazgos o cuando algo CAMBIA respecto a la
ejecución anterior — un informe semanal de "todo bien" es ruido que se ignora.
@@ -122,6 +125,62 @@ def check_links(posts):
return roto, nocanon, prematuro
# Tokens que no distinguen a un post de otro: aparecen en medio corpus.
_GENERIC = {
"the", "a", "of", "and", "in", "on", "to", "that", "its", "an", "at",
"ufo", "ufos", "uap", "incident", "files", "file", "declassified", "secret",
"analysis", "forensic", "video", "case", "cases", "mystery", "history",
"new", "us", "american", "america", "americas", "government", "official",
"report", "reports", "still", "what", "how", "why", "who", "cant", "explain",
}
def _distinctive(slug):
"""Tokens del slug que de verdad identifican a ESE post."""
return {t for t in slug.split("-")
if t not in _GENERIC and not t.isdigit() and len(t) > 2}
def check_link_aging(posts):
"""Enlaces cuyo anchor nombra inequívocamente a OTRO artículo del corpus.
El corpus crece 2 posts/semana, así que un enlace apunta al mejor destino
que existía el día en que se escribió — y envejece. Caso real (2026-07-18):
levelland enlazaba el anchor 'Project Blue Book' al artículo de AARO, porque
cuando se generó (30-jun) el artículo de Blue Book aún no existía (1-jul).
Heurística deliberadamente CONSERVADORA: solo avisa si el anchor contiene
TODOS los tokens distintivos de otro post. Prefiere callarse a dar la lata:
un vigilante desatendido que grita en falso se acaba ignorando.
"""
live = {p["slug"] for p in posts if p.get("status") == "published"}
tokmap = {s: _distinctive(s) for s in live}
out = []
for p in posts:
html = p.get("html") or ""
for m in re.finditer(
r'<a href="' + re.escape(HOST) + r'([^"/]+)/?"[^>]*>(.*?)</a>', html, re.S):
target = m.group(1)
anchor = re.sub(r"<[^>]+>", "", m.group(2)).strip().lower()
if not anchor:
continue
# Si el anchor ya casa con el destino actual, el enlace está bien:
# no hay nada que "envejecer". (Evita marcar p.ej. un anchor sobre
# el vídeo viral de Roswell que apunta —correctamente— a ese post
# solo porque también contiene la palabra "roswell".)
tgt_toks = tokmap.get(target) or set()
if tgt_toks and all(t in anchor for t in tgt_toks):
continue
for other, toks in tokmap.items():
# <2 tokens distintivos = identificador débil (p.ej. {roswell}),
# casa con demasiadas cosas. No es señal suficiente.
if other in (target, p["slug"]) or len(toks) < 2:
continue
if all(t in anchor for t in toks):
out.append((p["slug"], anchor[:40], target, other))
return out
def check_sitemap():
malos = []
urls = []
@@ -150,7 +209,7 @@ def check_rules(posts):
# ---------- salida ----------
def build_report(roto, nocanon, prematuro, n_sitemap, sm_malos, reglas):
def build_report(roto, nocanon, prematuro, envejecido, n_sitemap, sm_malos, reglas):
L = []
if roto:
L.append(f"🔴 {len(roto)} enlace(s) interno(s) ROTO(S):")
@@ -161,6 +220,9 @@ def build_report(roto, nocanon, prematuro, n_sitemap, sm_malos, reglas):
if nocanon:
L.append(f"🟡 {len(nocanon)} enlace(s) NO CANÓNICO(S) (salto 301 evitable):")
L += [f" {s[:34]}{u}" for s, u in nocanon[:6]]
if envejecido:
L.append(f"🔵 {len(envejecido)} enlace(s) ENVEJECIDO(S) (ya existe mejor destino):")
L += [f" {s[:26]}: «{a}» → /{t[:24]}/ ⇒ /{m[:26]}/" for s, a, t, m in envejecido[:6]]
if sm_malos:
L.append(f"🔴 sitemap: {len(sm_malos)} de {n_sitemap} URL(s) no dan 200:")
L += [f" [{c}] {u.replace(HOST,'/')}" for u, c in sm_malos[:6]]
@@ -173,11 +235,12 @@ def build_report(roto, nocanon, prematuro, n_sitemap, sm_malos, reglas):
return "\n".join(L)
def fingerprint(roto, nocanon, prematuro, sm_malos, reglas):
def fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas):
return json.dumps({
"roto": sorted(f"{s}|{u}" for s, u in roto),
"nocanon": sorted(f"{s}|{u}" for s, u in nocanon),
"prematuro": sorted(f"{s}|{t}" for s, t, _, _ in prematuro),
"envejecido": sorted(f"{s}|{t}|{m}" for s, _, t, m in envejecido),
"sitemap": sorted(u for u, _ in sm_malos),
"reglas": {k: sorted(v) for k, v in reglas.items()},
}, sort_keys=True)
@@ -203,15 +266,16 @@ def main():
posts = fetch_posts()
roto, nocanon, prematuro = check_links(posts)
envejecido = check_link_aging(posts)
n_sm, sm_malos = check_sitemap()
reglas = check_rules(posts)
body = build_report(roto, nocanon, prematuro, n_sm, sm_malos, reglas)
body = build_report(roto, nocanon, prematuro, envejecido, n_sm, sm_malos, reglas)
pub = sum(1 for p in posts if p.get("status") == "published")
header = f"🔎 SEO watch — theexclusionzone.com\n({pub} publicados, {len(posts)} totales, sitemap {n_sm} URLs)\n"
report = header + "\n" + body
fp = fingerprint(roto, nocanon, prematuro, sm_malos, reglas)
fp = fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas)
prev = ""
try:
with open(STATE) as f:
@@ -219,7 +283,7 @@ def main():
except FileNotFoundError:
pass
changed = fp != prev
hallazgos = bool(roto or nocanon or prematuro or sm_malos)
hallazgos = bool(roto or nocanon or prematuro or envejecido or sm_malos)
print(report)
print(f"\n[cambios vs última ejecución: {'' if changed else 'no'}]")