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
|
#!/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
|
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:
|
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_rules as R
|
||||||
import seo_exceptions as X
|
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")
|
STATE = os.path.expanduser("~/.local/state/seo-watch/last.json")
|
||||||
TG_SECRET = ("monitoring", "grafana-telegram-infisical")
|
TG_SECRET = ("monitoring", "grafana-telegram-infisical")
|
||||||
TIMEOUT = 20
|
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 ----------
|
# ---------- utilidades ----------
|
||||||
|
|
||||||
def sh(cmd, timeout=180):
|
def sh(cmd, timeout=180):
|
||||||
@@ -63,7 +85,7 @@ def fetch_posts():
|
|||||||
fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_watch_")
|
fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_watch_")
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
try:
|
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:
|
with open(path) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return data.get("posts", data) if isinstance(data, dict) else data
|
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)
|
return json.loads(resp.read()).get("ok", False)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def run_site(site):
|
||||||
ap = argparse.ArgumentParser()
|
"""Corre todos los checks sobre un sitio. Devuelve (informe, huella, hay_hallazgos)."""
|
||||||
ap.add_argument("--dry-run", action="store_true", help="imprime, no notifica ni guarda estado")
|
cfg = use_site(site)
|
||||||
ap.add_argument("--force", action="store_true", help="notifica aunque no haya cambios")
|
|
||||||
a = ap.parse_args()
|
|
||||||
|
|
||||||
posts = fetch_posts()
|
posts = fetch_posts()
|
||||||
roto, nocanon, prematuro = check_links(posts)
|
roto, nocanon, prematuro = check_links(posts)
|
||||||
envejecido = check_link_aging(posts)
|
envejecido = check_link_aging(posts)
|
||||||
@@ -272,10 +291,32 @@ def main():
|
|||||||
|
|
||||||
body = build_report(roto, nocanon, prematuro, envejecido, n_sm, sm_malos, reglas)
|
body = build_report(roto, nocanon, prematuro, envejecido, n_sm, sm_malos, reglas)
|
||||||
pub = sum(1 for p in posts if p.get("status") == "published")
|
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"
|
header = (f"── [{site.upper()}] {cfg['host']}\n"
|
||||||
report = header + "\n" + body
|
f" {pub} publicados, {len(posts)} totales, sitemap {n_sm} URLs")
|
||||||
|
|
||||||
fp = fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas)
|
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 = ""
|
prev = ""
|
||||||
try:
|
try:
|
||||||
with open(STATE) as f:
|
with open(STATE) as f:
|
||||||
@@ -283,7 +324,6 @@ def main():
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
changed = fp != prev
|
changed = fp != prev
|
||||||
hallazgos = bool(roto or nocanon or prematuro or envejecido or sm_malos)
|
|
||||||
|
|
||||||
print(report)
|
print(report)
|
||||||
print(f"\n[cambios vs última ejecución: {'sí' if changed else 'no'}]")
|
print(f"\n[cambios vs última ejecución: {'sí' if changed else 'no'}]")
|
||||||
|
|||||||
Reference in New Issue
Block a user