From f772703051dd698bc5c06dd9f3c39c8cdd696a26 Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Mon, 20 Jul 2026 16:09:45 +0000 Subject: [PATCH] seo_watch: extiende el vigilante al blog ES (--site en|es|both) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El ES nunca se había auditado: no había wrapper hasta hoy (ghst-es). Corre ambos por defecto y emite un informe con una sección por sitio. Detalles del diseño: * canónico invertido entre sitios (EN www, ES apex) → SITES parametriza url, cli y host. * seo_rules.SITE_HOST se reasigna en runtime desde use_site(): ese fichero se vendoriza byte a byte en ResearchOwl y la CI lo verifica, así que no se puede parametrizar en origen. * huella de estado por sitio (dict), para que un cambio en uno no reabra la notificación del otro por accidente. * un sitio caído no tumba el informe del otro: run_site va en try/except y el error se reporta como hallazgo de ese sitio. Primera pasada real: EN 0 hallazgos; ES 152 violaciones del auditor (28 sin alt-text — todos —, 27 con enlazado flojo, 21 sin OG/Twitter). Ninguna dispara Telegram: las violaciones de reglas son inventario, no regresión — solo avisan los enlaces rotos/no canónicos/prematuros/envejecidos y el sitemap. Co-Authored-By: Claude Opus 4.8 --- seo_watch.py | 66 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/seo_watch.py b/seo_watch.py index de1b382..a78e100 100644 --- a/seo_watch.py +++ b/seo_watch.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Tool D — vigilante SEO periódico de theexclusionzone.com (EN, Ghost). +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: @@ -45,12 +45,34 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import seo_rules as R import seo_exceptions as X -HOST = "https://www.theexclusionzone.com/" +# 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): @@ -63,7 +85,7 @@ def fetch_posts(): fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_watch_") os.close(fd) try: - r = sh(f"ghst-en post list --limit 100 --formats html --json > {path}", timeout=240) + 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 @@ -258,12 +280,9 @@ def telegram(text): return json.loads(resp.read()).get("ok", False) -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") - a = ap.parse_args() - +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) @@ -272,10 +291,32 @@ def main(): 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 - + 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: @@ -283,7 +324,6 @@ def main(): except FileNotFoundError: pass changed = fp != prev - 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'}]")