Los waivers se indexaban SOLO por slug. Hoy no hay colisiones (los slugs ES van en español), pero nada lo impedía: un slug repetido entre blogs habría aplicado en silencio la excepción del sitio equivocado, y ese post habría dejado de auditarse sin que nadie se enterara. Riesgo latente, no activo — pero 'no pasa hoy' no es lo mismo que 'no puede pasar'. EXCEPTIONS[site][familia][slug] = razón accepted_reason(rule, slug, site='en') El default 'en' mantiene funcionando a seo_audit.py sin tocarlo (es EN-only). seo_watch y seo_finish pasan el sitio activo explícitamente. Añadido collisions(): lista los slugs waiveados en más de un sitio. Debe estar siempre vacío; si algo aparece ahí, hay una excepción ambigua que revisar. Verificado: mismo slug waiveado en EN se audita con normalidad en ES; los 16 waivers EN intactos; seo_audit, seo_watch (ambos sitios) y seo_finish (ambos sitios) siguen dando los mismos resultados que antes del cambio. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
348 lines
14 KiB
Python
348 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Tool D — vigilante SEO periódico de los dos blogs (EN + ES, Ghost).
|
|
|
|
READ-ONLY sobre Ghost. Corre los chequeos que detectan REGRESIONES nuevas a
|
|
medida que se publica (2 posts/semana), no el inventario completo de siempre:
|
|
|
|
1. Enlaces internos rotos (destino 404 / slug inexistente)
|
|
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. 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.
|
|
|
|
Uso:
|
|
python3 seo_watch.py # chequea y notifica si procede
|
|
python3 seo_watch.py --dry-run # imprime, no notifica ni guarda estado
|
|
python3 seo_watch.py --force # notifica aunque no haya cambios
|
|
|
|
Diseño (lecciones del 2026-07-18, ver memoria project-en-seo-recovery):
|
|
- Lo determinista va primero: fue lo que encontró los problemas reales.
|
|
- Gemini NO sugiere enlaces temáticos (0/6 acertó). Si se le llama, es para
|
|
EXTRAER entidades del texto que casen con slugs del corpus, pasándole
|
|
siempre los waivers de seo_exceptions para que no reabra decisiones cerradas.
|
|
- Nada de esto modifica Ghost. Las correcciones las decide un humano.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import urllib.parse
|
|
import urllib.request
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import seo_rules as R
|
|
import seo_exceptions as X
|
|
|
|
# Los dos sitios. Ojo al canónico: EN es www, ES es el APEX — al revés.
|
|
SITES = {
|
|
"en": {"cli": "ghst-en", "host": "theexclusionzone.com",
|
|
"url": "https://www.theexclusionzone.com/"},
|
|
"es": {"cli": "ghst-es", "host": "zonadeexclusion.com",
|
|
"url": "https://zonadeexclusion.com/"},
|
|
}
|
|
HOST = SITES["en"]["url"] # el sitio activo; lo fija use_site()
|
|
CLI = SITES["en"]["cli"]
|
|
STATE = os.path.expanduser("~/.local/state/seo-watch/last.json")
|
|
TG_SECRET = ("monitoring", "grafana-telegram-infisical")
|
|
TIMEOUT = 20
|
|
|
|
|
|
def use_site(site):
|
|
"""Fija el sitio activo para todos los checks.
|
|
|
|
seo_rules.SITE_HOST está clavado a EN y NO se parametriza ahí a propósito:
|
|
ese fichero se vendoriza byte a byte en ResearchOwl y la CI lo verifica.
|
|
Se reasigna en runtime, que deja intacto el contrato compartido.
|
|
"""
|
|
global HOST, CLI
|
|
cfg = SITES[site]
|
|
HOST, CLI = cfg["url"], cfg["cli"]
|
|
R.SITE_HOST = cfg["host"]
|
|
return cfg
|
|
|
|
|
|
# ---------- utilidades ----------
|
|
|
|
def sh(cmd, timeout=180):
|
|
return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
|
|
|
|
|
def fetch_posts():
|
|
"""Todos los posts (published + scheduled) con html. Salida a fichero: la
|
|
respuesta pesa cientos de KB y un PIPE se trunca a 64 KB."""
|
|
fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_watch_")
|
|
os.close(fd)
|
|
try:
|
|
r = sh(f"{CLI} post list --limit 100 --formats html --json > {path}", timeout=240)
|
|
with open(path) as f:
|
|
data = json.load(f)
|
|
return data.get("posts", data) if isinstance(data, dict) else data
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def http_status(url):
|
|
"""(código, destino_redirección). No sigue la redirección."""
|
|
req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "seo-watch/1.0"})
|
|
try:
|
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
raise urllib.error.HTTPError(req.full_url, code, msg, headers, None)
|
|
op = urllib.request.build_opener(NoRedirect)
|
|
with op.open(req, timeout=TIMEOUT) as r:
|
|
return r.status, None
|
|
except urllib.error.HTTPError as e:
|
|
return e.code, e.headers.get("Location")
|
|
except Exception as e:
|
|
return 0, str(e)[:60]
|
|
|
|
|
|
def internal_urls(html):
|
|
return {u.split("#")[0] for u in re.findall(r'href="(https?://[^"]*theexclusionzone\.com[^"]*)"', html or "")}
|
|
|
|
|
|
def slug_of(url):
|
|
p = urllib.parse.urlparse(url).path.strip("/")
|
|
return p.split("/")[0] if p else ""
|
|
|
|
|
|
# ---------- chequeos ----------
|
|
|
|
def check_links(posts):
|
|
"""Rotos, no canónicos y prematuros, en una pasada."""
|
|
live = {p["slug"] for p in posts if p.get("status") == "published"}
|
|
known = {p["slug"]: p for p in posts}
|
|
roto, nocanon, prematuro = [], [], []
|
|
|
|
for p in posts:
|
|
src, src_pub = p["slug"], p.get("published_at") or ""
|
|
for u in internal_urls(p.get("html")):
|
|
# no canónico: debe ser https + www + barra final
|
|
if not u.startswith(HOST) or not u.endswith("/"):
|
|
nocanon.append((src, u))
|
|
continue
|
|
s = slug_of(u)
|
|
if not s or s in ("tag", "author", "about", "complete-guide-uap-cases"):
|
|
continue
|
|
tgt = known.get(s)
|
|
if tgt is None:
|
|
roto.append((src, u))
|
|
elif s not in live:
|
|
# el destino existe pero aún no está publicado
|
|
tgt_pub = tgt.get("published_at") or ""
|
|
if tgt_pub > src_pub:
|
|
prematuro.append((src, s, str(src_pub)[:10], str(tgt_pub)[:10]))
|
|
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 = []
|
|
for part in ("posts", "pages"):
|
|
r = sh(f"curl -s --max-time 25 {HOST}sitemap-{part}.xml")
|
|
urls += re.findall(r"<loc>(.*?)</loc>", r.stdout)
|
|
with ThreadPoolExecutor(max_workers=8) as ex:
|
|
for u, (code, _) in zip(urls, ex.map(lambda x: http_status(x), urls)):
|
|
if code != 200:
|
|
malos.append((u, code))
|
|
return len(urls), malos
|
|
|
|
|
|
def check_rules(posts, site):
|
|
"""Violaciones accionables (excluye waivers e INFO), agrupadas por regla."""
|
|
out = {}
|
|
for p in posts:
|
|
if p.get("status") != "published":
|
|
continue
|
|
for v in R.check_post(p):
|
|
if v.severity == R.INFO or X.accepted_reason(v.rule, p["slug"], site):
|
|
continue
|
|
out.setdefault(v.rule, []).append(p["slug"])
|
|
return out
|
|
|
|
|
|
# ---------- salida ----------
|
|
|
|
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):")
|
|
L += [f" {s[:34]} → {u.replace(HOST,'/')}" for s, u in roto[:6]]
|
|
if prematuro:
|
|
L.append(f"🟠 {len(prematuro)} enlace(s) PREMATURO(S) (destino se publica después → 404 temporal):")
|
|
L += [f" {s[:28]} ({sp}) → {t[:28]} ({tp})" for s, t, sp, tp in prematuro[:6]]
|
|
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]]
|
|
if reglas:
|
|
tot = sum(len(v) for v in reglas.values())
|
|
L.append(f"📋 {tot} violación(es) accionable(s) del auditor:")
|
|
L += [f" {k}: {len(v)}" for k, v in sorted(reglas.items(), key=lambda x: -len(x[1]))[:6]]
|
|
if not L:
|
|
L.append("✅ Sin hallazgos: enlaces, sitemap y reglas OK.")
|
|
return "\n".join(L)
|
|
|
|
|
|
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)
|
|
|
|
|
|
def telegram(text):
|
|
r = sh(f"kubectl get secret -n {TG_SECRET[0]} {TG_SECRET[1]} -o jsonpath='{{.data.TELEGRAM_BOT_TOKEN}}'")
|
|
import base64
|
|
token = base64.b64decode(r.stdout).decode()
|
|
r2 = sh(f"kubectl get secret -n {TG_SECRET[0]} {TG_SECRET[1]} -o jsonpath='{{.data.TELEGRAM_CHAT_ID}}'")
|
|
chat = base64.b64decode(r2.stdout).decode()
|
|
data = urllib.parse.urlencode({"chat_id": chat, "text": text[:3900]}).encode()
|
|
req = urllib.request.Request(f"https://api.telegram.org/bot{token}/sendMessage", data=data)
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
return json.loads(resp.read()).get("ok", False)
|
|
|
|
|
|
def run_site(site):
|
|
"""Corre todos los checks sobre un sitio. Devuelve (informe, huella, hay_hallazgos)."""
|
|
cfg = use_site(site)
|
|
posts = fetch_posts()
|
|
roto, nocanon, prematuro = check_links(posts)
|
|
envejecido = check_link_aging(posts)
|
|
n_sm, sm_malos = check_sitemap()
|
|
reglas = check_rules(posts, site)
|
|
|
|
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"── [{site.upper()}] {cfg['host']}\n"
|
|
f" {pub} publicados, {len(posts)} totales, sitemap {n_sm} URLs")
|
|
fp = fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas)
|
|
hallazgos = bool(roto or nocanon or prematuro or envejecido or sm_malos)
|
|
return header + "\n" + body, fp, hallazgos
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--dry-run", action="store_true", help="imprime, no notifica ni guarda estado")
|
|
ap.add_argument("--force", action="store_true", help="notifica aunque no haya cambios")
|
|
ap.add_argument("--site", choices=("en", "es", "both"), default="both")
|
|
a = ap.parse_args()
|
|
|
|
sitios = ["en", "es"] if a.site == "both" else [a.site]
|
|
partes, huellas, hallazgos = [], {}, False
|
|
for s in sitios:
|
|
try:
|
|
txt, fp, hall = run_site(s)
|
|
except Exception as e: # un sitio caído no debe tumbar el informe del otro
|
|
txt, fp, hall = f"── [{s.upper()}] ⚠ ERROR al revisar: {e}", f"error:{e}", True
|
|
partes.append(txt); huellas[s] = fp; hallazgos |= hall
|
|
|
|
report = "🔎 SEO watch\n\n" + "\n\n".join(partes)
|
|
fp = json.dumps(huellas, sort_keys=True)
|
|
|
|
prev = ""
|
|
try:
|
|
with open(STATE) as f:
|
|
prev = json.load(f).get("fingerprint", "")
|
|
except FileNotFoundError:
|
|
pass
|
|
changed = fp != prev
|
|
|
|
print(report)
|
|
print(f"\n[cambios vs última ejecución: {'sí' if changed else 'no'}]")
|
|
|
|
if a.dry_run:
|
|
return 0
|
|
|
|
# Notifica si hay hallazgos y algo cambió (o si se fuerza)
|
|
if a.force or (hallazgos and changed):
|
|
sufijo = "" if changed else "\n\n(sin cambios desde la última revisión)"
|
|
ok = telegram(report + sufijo)
|
|
print(f"[telegram: {'enviado' if ok else 'FALLO'}]")
|
|
|
|
os.makedirs(os.path.dirname(STATE), exist_ok=True)
|
|
with open(STATE, "w") as f:
|
|
json.dump({"fingerprint": fp}, f)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|