seo_watch: extiende el vigilante al blog ES (--site en|es|both)

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 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-07-20 16:09:45 +00:00
co-authored by Claude Opus 4.8
parent 34263469e4
commit f772703051
+53 -13
View File
@@ -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: {'' if changed else 'no'}]")