From 28331446538f2487a964f884e9af59d3eb740c98 Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Mon, 20 Jul 2026 10:50:06 +0000 Subject: [PATCH] seo_watch: detecta enlaces envejecidos (check 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- seo_watch.py | 78 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/seo_watch.py b/seo_watch.py index a8cdb39..de1b382 100644 --- a/seo_watch.py +++ b/seo_watch.py @@ -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']*>(.*?)', 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: {'sí' if changed else 'no'}]")