hub-es.md: las 5 entradas que faltaban (37/37, el índice cuadra). seo_watch.py: «Proyecto Libro Azul» / «Libro Azul» a CASE_PHRASES, junto a sus hermanas «Proyecto Blue Book» / «Blue Book». Es el nombre propio del proyecto —24 menciones en el artículo, ninguna en minúscula— pero «libro» y «azul» son comunes en el corpus, así que el vocabulario no las salvaba. El artículo estaba bien escrito; el detector no. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
581 lines
26 KiB
Python
581 lines
26 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 collections import Counter
|
||
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
|
||
import seo_estado as E
|
||
|
||
# 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.
|
||
|
||
`--limit all` y ADEMÁS se comprueba contra meta.pagination. Antes pedía
|
||
`--limit 100`: con 40 posts en EN y 29 en ES sobraba, pero a 2 posts por
|
||
semana el corpus habría cruzado los 100 hacia 2027 y se habría truncado EN
|
||
SILENCIO — y un corpus truncado no da error, da falsos «enlaces rotos»
|
||
contra los posts que faltan. Verificar el total es lo que convierte ese
|
||
fallo mudo en uno que se oye."""
|
||
fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_watch_")
|
||
os.close(fd)
|
||
try:
|
||
r = sh(f"{CLI} post list --limit all --formats html --json > {path}", timeout=240)
|
||
with open(path) as f:
|
||
data = json.load(f)
|
||
posts = data.get("posts", data) if isinstance(data, dict) else data
|
||
total = ((data.get("meta") or {}).get("pagination") or {}).get("total") \
|
||
if isinstance(data, dict) else None
|
||
if total is not None and len(posts) != total:
|
||
raise RuntimeError(
|
||
f"corpus truncado: {len(posts)} de {total} posts. "
|
||
f"Los checks de enlaces darían falsos rotos — abortado.")
|
||
return posts
|
||
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):
|
||
"""Enlaces al sitio ACTIVO. El dominio sale de R.SITE_HOST, que fija
|
||
use_site(): estuvo clavado a theexclusionzone.com y, al extender el
|
||
vigilante al ES (2026-07-20), dejó los checks de enlaces rotos, no
|
||
canónicos y prematuros mirando a un dominio que no existe en ese corpus —
|
||
o sea, sin mirar nada. Corregido el 2026-07-21."""
|
||
pat = r'href="(https?://[^"]*' + re.escape(R.SITE_HOST) + r'[^"]*)"'
|
||
return {u.split("#")[0] for u in re.findall(pat, 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():
|
||
"""URLs del sitemap que no dan 200.
|
||
|
||
Un sitemap VACÍO cuenta como hallazgo, no como éxito: si curl falla o el
|
||
sitio está caído mientras corre la revisión, se descargan 0 URLs, se
|
||
encuentran 0 problemas y el informe decía «✅ sin hallazgos». Justo el rato
|
||
en que el sitio está roto es cuando este check se callaba. El sitemap de
|
||
posts nunca está legítimamente vacío en un blog con 29-40 artículos."""
|
||
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)
|
||
if not urls:
|
||
return 0, [(f"{HOST}sitemap-posts.xml", "vacío/inaccesible")]
|
||
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
|
||
|
||
|
||
# Encabezados en Title Case inglés: el generador del blog ES lo cuela a ratos
|
||
# (el 2026-07-21 había 219 en 23 de 29 posts, pero otros del mismo mes salieron
|
||
# limpios — es una fuga intermitente, no deuda cerrada). Estas dos listas son
|
||
# las mismas que se usaron en aquella limpieza: sin ellas, un titular legítimo
|
||
# lleno de nombres propios se marcaría como error.
|
||
CASE_PHRASES = [
|
||
"Guerra Fría", "Segunda Guerra Mundial", "Archivos Nacionales",
|
||
"Fuerza Aérea", "Proyecto Mogul", "Proyecto Sign", "Proyecto Blue Book",
|
||
"Ejército del Aire", # el español, hermano de «Fuerza Aérea»: las dos
|
||
# palabras existen en minúscula por el corpus
|
||
# («al aire libre», «el ejército»), así que el
|
||
# vocabulario NO las salva y hacía falta la frase
|
||
"Blue Book",
|
||
"Proyecto Libro Azul", "Libro Azul", # la traducción española de «Blue
|
||
# Book», hermana de las dos de arriba: el ES lo
|
||
# llama así de principio a fin (24 menciones en
|
||
# su artículo, ni una en minúscula), pero
|
||
# «libro» y «azul» son palabras comunes que el
|
||
# corpus escribe en minúscula, así que el
|
||
# vocabulario no las salva. Falso positivo desde
|
||
# el 2026-08-11, cazado el 2026-08-17.
|
||
"Casa Blanca", "Área 51", "Immaculate Constellation",
|
||
"Zeta Reticuli", "Indian Head", "Turkey Springs", "Phoenix Lights",
|
||
"Lake Huron", "Estados Unidos", "Reino Unido", "Nuevo México",
|
||
"Vía Láctea", "Marco Eli Chereze", "Travis Walton", "Kurt Russell",
|
||
"Donald Keyhoe", "Theresa May", "Jesse Marcel", "Walter Haut",
|
||
"Maria Blair", "Kenneth Arnold", "Memorándum Halt", "Moment of Contact",
|
||
"Los Ángeles", "Los Villares", "Puerto Rico", "El Yunque",
|
||
"Francisco Julio Padrón", # «Julio» aquí es nombre de pila, no el mes
|
||
"Talavera la Real", # el pueblo de Badajoz: «Real» es del topónimo
|
||
"Archivos PURSUE", # nombre propio de la desclasificación; el resto
|
||
# de «archivos» del corpus son nombre común
|
||
]
|
||
CASE_KEEP = {
|
||
"Pentágono", "Roswell", "Rendlesham", "Varginha", "Manises", "Nimitz",
|
||
"Grusch", "Arnold", "Elizondo", "Penniston", "Halt", "Marcel", "Haut",
|
||
"Keyhoe", "Walton", "Chereze", "Mudinho", "Padrón", "Blair", "Russell",
|
||
"Trump", "Obama", "Obama-Trump", "Hynek", "Klass", "Jafari", "Galileo",
|
||
"Apolo", "ExoMars", "GoFast", "Gimbal", "Vaticano", "Antártida", "Chile",
|
||
"Argentina", "Bariloche", "Japón", "Brasil", "España", "Canarias",
|
||
"Mediterráneo", "Hollywood", "Congreso", "Capitolio", "Marte", "Bolivia",
|
||
"Perú", "México", "Colombia", "Venezuela", "Escocia", "Bretaña",
|
||
"Francia", "Rusia", "Sign", "Immaculate", "Constellation", "Phoenix",
|
||
"Lights", "Zeta", "Reticuli", "Indian", "Head", "Turkey", "Springs",
|
||
"Lake", "Huron", "Villares", "Yunque", "Ángeles", "Memorándum", "Moment",
|
||
"Contact", "Dr.", "Sr.", "San", "Santa", "Francisco", "David", "Kurt",
|
||
"Travis", "Donald", "Theresa", "Jesse", "Walter", "Maria", "Marco", "Eli",
|
||
"Kenneth", "Ártico", "Julio", "Iberia", # la aerolínea del caso Manises
|
||
}
|
||
_CASE_WORD = re.compile(r"[0-9A-Za-zÁÉÍÓÚÜÑáéíóúüñ][0-9A-Za-zÁÉÍÓÚÜÑáéíóúüñ'’\-\.]*")
|
||
_CASE_ABBR = re.compile(r"(?:^|\s)(?:[A-ZÁÉÍÓÚÑ]{1,3}|Dr|Sr|Sra|vs|etc)\.\s*$")
|
||
|
||
|
||
def lowercase_vocab(posts, minimo=1):
|
||
"""Palabras que el propio corpus escribe EN MINÚSCULA al menos `minimo`
|
||
veces. Es el diccionario que decide si una mayúscula sobra.
|
||
|
||
Nació de un falso positivo real (2026-07-21): «El testigo del carguero
|
||
Osaka Bay» y «Ricardo Campo Pérez y Vicente-Juan Ballester Olmos» se
|
||
marcaron como Title Case porque esos nombres no estaban en CASE_KEEP. Una
|
||
lista blanca a mano no escala — cada caso nuevo trae nombres nuevos — pero
|
||
el blog sí sabe la respuesta: «ciudad», «borde» o «pánico» aparecen en
|
||
minúscula por todo el corpus, y «Osaka» o «Ballester» no aparecen nunca.
|
||
Así que capitalizar las primeras es estilo inglés y las segundas son
|
||
nombres propios, sin que nadie tenga que mantener una lista.
|
||
|
||
minimo=1 elegido barriendo el umbral (2026-07-21) contra una batería de 10
|
||
encabezados de verdad, el corpus limpio y el sucio de la víspera:
|
||
|
||
mínimo vocab aciertos limpio sucio
|
||
1 6795 10/10 0 195
|
||
2 3184 9/10 0 184
|
||
3 2039 8/10 0 167
|
||
5 1198 7/10 0 155
|
||
|
||
Una sola aparición en minúscula ya prueba lo que hace falta probar: que esa
|
||
palabra PUEDE ir en minúscula, o sea que no es exclusivamente un nombre
|
||
propio. Subir el umbral solo pierde detección.
|
||
"""
|
||
c = Counter()
|
||
for p in posts:
|
||
txt = re.sub(r"<[^>]+>", " ", p.get("html") or "")
|
||
for w in re.findall(r"(?<![\w-])[a-záéíóúüñ][a-záéíóúüñ]{2,}(?![\w-])", txt):
|
||
c[w] += 1
|
||
return {w for w, n in c.items() if n >= minimo}
|
||
|
||
|
||
def _title_case_words(text, vocab=None):
|
||
"""Palabras capitalizadas al estilo inglés que NO son nombre propio.
|
||
|
||
Ignora la primera palabra y la que abre frase tras . ? ! (pero no tras dos
|
||
puntos ni raya, que en español no llevan mayúscula), los acrónimos (OVNI,
|
||
AARO) y todo lo que esté en las listas de arriba.
|
||
|
||
Con `vocab` (ver lowercase_vocab) solo marca palabras que el corpus escribe
|
||
en minúscula en otros sitios: eso descarta los nombres propios sin tener
|
||
que enumerarlos. Sin `vocab` se comporta como antes, a base de listas.
|
||
"""
|
||
for ph in CASE_PHRASES:
|
||
text = re.sub(re.escape(ph), " ", text, flags=re.IGNORECASE)
|
||
malas, first = [], True
|
||
for m in _CASE_WORD.finditer(text):
|
||
tok = m.group(0)
|
||
starts = first or (re.search(r"[.?!]\s*$", text[:m.start()])
|
||
and not _CASE_ABBR.search(text[:m.start()]))
|
||
if starts:
|
||
first = False
|
||
continue
|
||
titled = tok.isupper() if len(tok) == 1 else (tok[:1].isupper() and tok[1:].islower())
|
||
if not titled or tok in CASE_KEEP:
|
||
continue
|
||
if vocab is not None and tok.lower() not in vocab:
|
||
continue # el corpus nunca lo escribe en minúscula → nombre propio
|
||
malas.append(tok)
|
||
return malas
|
||
|
||
|
||
def check_heading_case(posts, site):
|
||
"""Encabezados con mayúsculas al estilo inglés. Solo en el blog ES: en el
|
||
EN el Title Case es lo correcto.
|
||
|
||
Umbral de 2 palabras: con 1 sola, un nombre propio que falte en las listas
|
||
daría un falso positivo; un titular de verdad en Title Case las tiene a
|
||
pares. Verificado contra los 271 encabezados del ES ya limpios: 0 avisos.
|
||
"""
|
||
if site != "es":
|
||
return []
|
||
vocab = lowercase_vocab(posts)
|
||
out = []
|
||
for p in posts:
|
||
if p.get("status") != "published":
|
||
continue
|
||
for lvl, inner in re.findall(r"<h([234])[^>]*>(.*?)</h\1>", p.get("html") or "", re.S):
|
||
texto = re.sub(r"<[^>]+>", "", inner).strip()
|
||
malas = _title_case_words(texto, vocab)
|
||
if len(malas) >= 2:
|
||
out.append((p["slug"], texto[:50], malas[:4]))
|
||
return out
|
||
|
||
|
||
def check_title_case(posts, site):
|
||
"""Lo mismo, pero en el TÍTULO y el meta_title — que es lo que se lee en Google.
|
||
|
||
El agujero se vio el 2026-07-29: los encabezados llevaban limpios desde el
|
||
21 de julio y, sin embargo, 28 de los 31 títulos publicados del ES seguían
|
||
en Title Case inglés, con 16 meta_title detrás. La regla estaba escrita y
|
||
funcionando; simplemente nunca había mirado ahí.
|
||
|
||
Umbral de 2, igual que en los encabezados y por el mismo motivo. Se miran
|
||
también `og_title` y `twitter_title` porque son los que viajan a X y a
|
||
Facebook, y suelen ser copia del meta: si se limpia uno y no los otros, el
|
||
título viejo sigue circulando por ahí.
|
||
"""
|
||
if site != "es":
|
||
return []
|
||
vocab = lowercase_vocab(posts)
|
||
out = []
|
||
for p in posts:
|
||
if p.get("status") != "published":
|
||
continue
|
||
for campo in ("title", "meta_title", "og_title", "twitter_title"):
|
||
texto = (p.get(campo) or "").strip()
|
||
malas = _title_case_words(texto, vocab)
|
||
if len(malas) >= 2:
|
||
out.append((p["slug"], f"{campo}: {texto[:44]}", malas[:4]))
|
||
return out
|
||
|
||
|
||
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, mayus,
|
||
titulos, estado):
|
||
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 and n_sitemap == 0:
|
||
L.append("🔴 sitemap VACÍO o inaccesible — no se ha podido comprobar "
|
||
"ninguna URL (¿sitio caído?, ¿curl sin salida?)")
|
||
elif 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 mayus:
|
||
L.append(f"🔤 {len(mayus)} encabezado(s) en Title Case inglés (el ES va en mayúscula de oración):")
|
||
L += [f" {s[:24]}: «{t[:34]}» ({', '.join(w)})" for s, t, w in mayus[:6]]
|
||
if titulos:
|
||
L.append(f"🔠 {len(titulos)} título/meta en Title Case inglés (es lo que se lee en Google):")
|
||
L += [f" {s[:20]}: {t}" for s, t, _ in titulos[: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]]
|
||
L += E.informe(estado)
|
||
if not L:
|
||
L.append("✅ Sin hallazgos: enlaces, sitemap, reglas y estado del sitio OK.")
|
||
return "\n".join(L)
|
||
|
||
|
||
def fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas, mayus, titulos,
|
||
estado):
|
||
return json.dumps({
|
||
"estado": {k: sorted(v) for k, v in estado.items()},
|
||
"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),
|
||
"mayus": sorted(f"{s}|{t}" for s, t, _ in mayus),
|
||
"titulos": sorted(f"{s}|{t}" for s, t, _ in titulos),
|
||
"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)
|
||
mayus = check_heading_case(posts, site)
|
||
titulos = check_title_case(posts, site)
|
||
# Los chequeos de SITIO (tags, páginas, head, robots, hreflang, hub,
|
||
# programados). Van aquí y no en un timer aparte porque comparten lo que
|
||
# hace útil a este vigilante: una sola huella por sitio y un solo aviso.
|
||
estado = E.revisa(site, posts)
|
||
|
||
body = build_report(roto, nocanon, prematuro, envejecido, n_sm, sm_malos, reglas, mayus,
|
||
titulos, estado)
|
||
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, mayus, titulos,
|
||
estado)
|
||
hallazgos = bool(roto or nocanon or prematuro or envejecido or sm_malos or mayus
|
||
or titulos or estado)
|
||
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)
|
||
|
||
# Estado POR SITIO, y solo se toca el de los sitios revisados. Antes se
|
||
# guardaba una única huella de los dos juntos, así que una ejecución manual
|
||
# con --site en la machacaba con media huella y el siguiente disparo del
|
||
# timer creía que TODO había cambiado: te reenviaba hallazgos viejos como si
|
||
# fueran nuevos. Un vigilante que cría falsas alarmas se acaba ignorando,
|
||
# que es exactamente lo que este no puede permitirse.
|
||
previo = {}
|
||
try:
|
||
with open(STATE) as f:
|
||
guardado = json.load(f)
|
||
previo = guardado.get("sites") or {}
|
||
except (FileNotFoundError, ValueError):
|
||
pass
|
||
|
||
cambiados = [s for s in sitios if huellas[s] != previo.get(s)]
|
||
changed = bool(cambiados)
|
||
|
||
print(report)
|
||
print(f"\n[cambios vs última ejecución: "
|
||
f"{', '.join(cambiados) if cambiados else 'ninguno'}]")
|
||
|
||
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)
|
||
previo.update(huellas) # fusiona: no pisa el sitio que no se revisó
|
||
with open(STATE, "w") as f:
|
||
json.dump({"sites": previo}, f)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|