#!/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']*>(.*?)', 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"(.*?)", 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"(?= 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"]*>(.*?)", 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())