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:
co-authored by
Claude Opus 4.8
parent
34263469e4
commit
f772703051
+53
-13
@@ -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'}]")
|
||||
|
||||
Reference in New Issue
Block a user