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:
co-authored by
Claude Opus 4.8
parent
dc0c1cfa73
commit
2833144653
+71
-7
@@ -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)
|
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
|
3. Enlaces prematuros (a un post que se publica DESPUÉS que el que
|
||||||
lo enlaza → 404 en la ventana intermedia)
|
lo enlaza → 404 en la ventana intermedia)
|
||||||
4. Sitemap (toda URL listada debe dar 200 directo)
|
4. Enlaces envejecidos (el anchor nombra a otro artículo que se
|
||||||
5. Violaciones accionables (delta vs la última ejecución, vía seo_rules)
|
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
|
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.
|
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
|
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():
|
def check_sitemap():
|
||||||
malos = []
|
malos = []
|
||||||
urls = []
|
urls = []
|
||||||
@@ -150,7 +209,7 @@ def check_rules(posts):
|
|||||||
|
|
||||||
# ---------- salida ----------
|
# ---------- 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 = []
|
L = []
|
||||||
if roto:
|
if roto:
|
||||||
L.append(f"🔴 {len(roto)} enlace(s) interno(s) ROTO(S):")
|
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:
|
if nocanon:
|
||||||
L.append(f"🟡 {len(nocanon)} enlace(s) NO CANÓNICO(S) (salto 301 evitable):")
|
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]]
|
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:
|
if sm_malos:
|
||||||
L.append(f"🔴 sitemap: {len(sm_malos)} de {n_sitemap} URL(s) no dan 200:")
|
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]]
|
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)
|
return "\n".join(L)
|
||||||
|
|
||||||
|
|
||||||
def fingerprint(roto, nocanon, prematuro, sm_malos, reglas):
|
def fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas):
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"roto": sorted(f"{s}|{u}" for s, u in roto),
|
"roto": sorted(f"{s}|{u}" for s, u in roto),
|
||||||
"nocanon": sorted(f"{s}|{u}" for s, u in nocanon),
|
"nocanon": sorted(f"{s}|{u}" for s, u in nocanon),
|
||||||
"prematuro": sorted(f"{s}|{t}" for s, t, _, _ in prematuro),
|
"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),
|
"sitemap": sorted(u for u, _ in sm_malos),
|
||||||
"reglas": {k: sorted(v) for k, v in reglas.items()},
|
"reglas": {k: sorted(v) for k, v in reglas.items()},
|
||||||
}, sort_keys=True)
|
}, sort_keys=True)
|
||||||
@@ -203,15 +266,16 @@ def main():
|
|||||||
|
|
||||||
posts = fetch_posts()
|
posts = fetch_posts()
|
||||||
roto, nocanon, prematuro = check_links(posts)
|
roto, nocanon, prematuro = check_links(posts)
|
||||||
|
envejecido = check_link_aging(posts)
|
||||||
n_sm, sm_malos = check_sitemap()
|
n_sm, sm_malos = check_sitemap()
|
||||||
reglas = check_rules(posts)
|
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")
|
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"
|
header = f"🔎 SEO watch — theexclusionzone.com\n({pub} publicados, {len(posts)} totales, sitemap {n_sm} URLs)\n"
|
||||||
report = header + "\n" + body
|
report = header + "\n" + body
|
||||||
|
|
||||||
fp = fingerprint(roto, nocanon, prematuro, sm_malos, reglas)
|
fp = fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas)
|
||||||
prev = ""
|
prev = ""
|
||||||
try:
|
try:
|
||||||
with open(STATE) as f:
|
with open(STATE) as f:
|
||||||
@@ -219,7 +283,7 @@ def main():
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
changed = fp != prev
|
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(report)
|
||||||
print(f"\n[cambios vs última ejecución: {'sí' if changed else 'no'}]")
|
print(f"\n[cambios vs última ejecución: {'sí' if changed else 'no'}]")
|
||||||
|
|||||||
Reference in New Issue
Block a user