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'}]")