From 5b91895ef144ed81bd813b0680e79490ca44d377 Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Wed, 29 Jul 2026 17:21:46 +0000 Subject: [PATCH] estado: cerrar los siete puntos ciegos del vigilante, y pasarlo a diario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recuento de los fallos de esta semana: diecisiete. Ni uno solo fue un caso que el vigilante juzgara mal — los diecisiete estaban en sitios donde el vigilante no mira. Eso es una buena noticia: los puntos ciegos son enumerables y el juicio equivocado no. Lo que no miraba nadie, con el fallo real que lo motiva: tags 5 duplicados vivieron TRES SEMANAS repartiendo 7 posts entre dos archivos flacos, los dos ofrecidos a Google en sitemap-tags.xml páginas todas las reglas eran de posts; /about no tiene metas en ninguno de los dos sitios el EN emite DOS Article con titulares distintos, y sigue con twitter:site=@ghost y article:publisher=facebook.com/ghost robots.txt el bloque «Cloudflare Managed» apareció solo en el EN y nadie lo decidió; cambia sin pasar por git hreflang lo comprobaba SOLO seo_audit.py, que NO TIENE TIMER hub se publicó una vez y se pudrió: «31 casos» con 33 programados check_rules hace `if status != "published": continue`, así que un programado con la meta mal es invisible hasta el día DESPUÉS de publicarse — cuando ya lo ha visto Google Decisiones: - módulo aparte y no dentro de seo_watch.py (ya pasa de 500 líneas), pero colgando del MISMO vigilante: una huella por sitio y un solo aviso. Dos notificadores compitiendo es como se deja de leer un informe - robots.txt se compara contra una INSTANTÁNEA versionada. Sin una foto contra la que comparar, un cambio que nadie hizo es invisible para siempre - un chequeo que revienta se convierte en HALLAZGO, no tumba a los demás. Lo peligroso de un vigilante no es que falle: es que falle y parezca limpio - og_*/twitter_* vacíos en una PÁGINA quedan fuera por COMPROBACIÓN, no por comodidad: Ghost los deriva de las metas al renderizar, verificado sobre el HTML público del hub ES. Avisar de eso sería mandar a arreglar algo que ya está bien Timer semanal → DIARIO. Con 2 posts/semana más programados y deriva de tema y robots, el lunes dejaba hasta seis días de exposición. No añade ruido: solo avisa si hay hallazgos Y han cambiado, así que un día limpio no manda nada. 8 tests, cada uno la cicatriz de un fallo real, incluidos los dos que hacen creíble a un vigilante: que DETECTA la avería (probado con deriva simulada de robots) y que un chequeo roto se oye. Primera ejecución real: 8 hallazgos, todos preexistentes, 32 s. Co-Authored-By: Claude Opus 5 --- robots-esperado.json | 21 +++ seo-watch.timer | 9 +- seo_estado.py | 402 +++++++++++++++++++++++++++++++++++++++++++ seo_hub.py | 22 +++ seo_watch.py | 22 ++- test_seo_estado.py | 155 +++++++++++++++++ 6 files changed, 623 insertions(+), 8 deletions(-) create mode 100644 robots-esperado.json create mode 100644 seo_estado.py create mode 100644 test_seo_estado.py diff --git a/robots-esperado.json b/robots-esperado.json new file mode 100644 index 0000000..03874ea --- /dev/null +++ b/robots-esperado.json @@ -0,0 +1,21 @@ +{ + "_nota": "Foto de robots.txt para detectar deriva. El bloque «Cloudflare Managed content» del EN aparece y cambia SOLO, sin pasar por git. OJO: hoy 2026-07-29 el EN bloquea 9 rastreadores de IA y el ES no bloquea NINGUNO. Esa asimetría NO la decidió nadie: es el default de Cloudflare en una zona y no en la otra. Está congelada aquí para que al menos no cambie en silencio, pero sigue pendiente de decisión. Cuando se decida, actualizar este fichero.", + "en": { + "bloqueados": [ + "Amazonbot", + "Applebot-Extended", + "Bytespider", + "CCBot", + "ClaudeBot", + "CloudflareBrowserRenderingCrawler", + "GPTBot", + "Google-Extended", + "meta-externalagent" + ], + "content_signal": "search=yes,ai-train=no,use=reference" + }, + "es": { + "bloqueados": [], + "content_signal": null + } +} diff --git a/seo-watch.timer b/seo-watch.timer index cb50333..2829f2c 100644 --- a/seo-watch.timer +++ b/seo-watch.timer @@ -1,8 +1,13 @@ [Unit] -Description=SEO watch semanal (lunes 05:30 UTC, antes de la publicación de las 06:00) +Description=SEO watch DIARIO (05:30 UTC, antes de la publicación de las 06:00) [Timer] -OnCalendar=Mon *-*-* 05:30:00 UTC +# Era semanal (lunes). Se pasó a diario el 2026-07-29: con 2 posts por semana +# más los programados y la deriva de robots/tema, una cadencia semanal dejaba +# hasta SEIS días de exposición — el bug de los tags duplicados vivió tres +# semanas. Diario no añade ruido: solo avisa si hay hallazgos Y han cambiado +# respecto a la ejecución anterior, así que un día limpio no manda nada. +OnCalendar=*-*-* 05:30:00 UTC Persistent=true RandomizedDelaySec=300 diff --git a/seo_estado.py b/seo_estado.py new file mode 100644 index 0000000..d41e8f9 --- /dev/null +++ b/seo_estado.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +"""Tool J — el estado del SITIO: los puntos ciegos del vigilante. + +Por qué existe. El 2026-07-29 se hizo el recuento de los fallos de esa semana: +diecisiete. **Ni uno solo fue un caso que el vigilante juzgara mal.** Los +diecisiete estaban en sitios donde el vigilante no mira. `seo_watch.py` audita +POSTS PUBLICADOS y sus enlaces, y hace muy bien lo que hace; el problema es todo +lo demás, que se pone una vez y no lo vuelve a mirar nadie: + + 1. tags — 5 duplicados vivieron TRES SEMANAS repartiendo 7 posts + entre dos archivos flacos, los dos en el sitemap + 2. páginas — todas las reglas eran de posts. /about no tiene metas + 3. — el EN emite DOS Article con titulares distintos, y sigue + con twitter:site=@ghost y article:publisher=.../ghost, + defaults que nadie tocó desde que se instaló el tema + 4. robots.txt — el bloque «Cloudflare Managed» apareció SOLO en el EN y + nadie lo decidió. Cambia bajo tus pies + 5. hreflang — solo lo comprobaba seo_audit.py, que NO TIENE TIMER + 6. hub — se publicó una vez y se pudrió: «31 casos» con 33 + 7. programados — check_rules hace `if status != "published": continue`, así + que un programado con la meta mal es invisible hasta el día + DESPUÉS de publicarse + +La regla de diseño que sale de ahí: **un fallo de sitio no se arregla mirando +más veces los posts.** Estos chequeos son baratos, de sitio, y su valor está en +que existan, no en que sean listos. + +READ-ONLY de punta a punta. No escribe en Ghost ni en ningún sitio. + +Uso: + python3 seo_estado.py --site en|es|both # a pantalla + (y lo llama seo_watch.py en cada ejecución) +""" +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +import unicodedata +import urllib.request + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import seo_rules as R +import seo_exceptions as X + +SITES = { + "en": {"cli": "ghst-en", "host": "theexclusionzone.com", + "url": "https://www.theexclusionzone.com/", "lang": "en"}, + "es": {"cli": "ghst-es", "host": "zonadeexclusion.com", + "url": "https://zonadeexclusion.com/", "lang": "es"}, +} +AQUI = os.path.dirname(os.path.abspath(__file__)) +ROBOTS_SNAP = os.path.join(AQUI, "robots-esperado.json") +UA = "seo-estado/1.0 (+https://chemavx.xyz)" +TIMEOUT = 25 + +# Reglas de seo_rules que NO tienen sentido en una página. Se comparan por el +# prefijo antes del punto (`feature_image.missing` → `feature_image`). +# +# Los og_*/twitter_* están aquí por COMPROBACIÓN, no por comodidad: en los posts +# se rellenan a mano para tener control, pero en una página vacío NO significa +# ausente — Ghost los deriva de meta_title/meta_description al renderizar. +# Verificado el 2026-07-29 sobre el HTML público del hub ES: los cuatro tags +# salen correctos con los cuatro campos vacíos en la BD. Avisar de eso sería +# mandarte a arreglar algo que ya está bien, que es como se empieza a ignorar +# un informe. +REGLAS_FUERA_DE_PAGINAS = {"feature_image", "feature_image_alt", "internal_links", + "jsonld", "custom_excerpt", + "og_title", "og_description", + "twitter_title", "twitter_description"} + + +def _fuera_de_paginas(rule): + return rule.split(".", 1)[0] in REGLAS_FUERA_DE_PAGINAS + + +def sh(cmd, timeout=180): + return subprocess.run(cmd, shell=True, capture_output=True, text=True, + timeout=timeout) + + +def trae(url): + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + return r.read().decode("utf-8", "replace") + + +def _norm(s): + """Minúsculas, sin acentos y sin separadores: 'Casos Militares' y + 'casos-militares' colapsan al mismo sitio, que es exactamente el par que + Ghost creó por duplicado.""" + s = unicodedata.normalize("NFKD", s or "") + s = "".join(c for c in s if not unicodedata.combining(c)) + return re.sub(r"[^a-z0-9]", "", s.lower()) + + +# ─────────────────────────────────── tags ─────────────────────────────────── + +def check_tags(site, cfg, posts): + out = [] + r = sh(f'{cfg["cli"]} tag list --limit all --json') + if r.returncode != 0: + return [f"no he podido listar los tags: {r.stderr.strip()[:120]}"] + d = json.loads(r.stdout) + tags = d["tags"] if isinstance(d, dict) and "tags" in d else d + publicos = [t for t in tags if t.get("visibility") != "internal"] + + # 1. Duplicados: dos tags que colapsan al mismo nombre normalizado. Es la + # firma exacta del bug de researchowl (slug mandado en el campo `name`). + porn = {} + for t in publicos: + porn.setdefault(_norm(t.get("name")), []).append(t["slug"]) + for k, v in sorted(porn.items()): + if len(v) > 1: + out.append(f"tags DUPLICADOS (mismo nombre normalizado): {sorted(v)}") + + # 2. Tags sin ningún post: páginas de archivo vacías, y Ghost las mete en + # sitemap-tags.xml. + usados = {t.get("slug") for p in posts for t in (p.get("tags") or [])} + vacios = sorted(t["slug"] for t in publicos if t["slug"] not in usados) + if vacios: + out.append(f"tags SIN NINGÚN POST (archivo vacío en el sitemap): {vacios}") + + # 3. Posts sin categoría. Incluye los PROGRAMADOS a propósito: los 9 de + # agosto salían sin ninguna y se habrían publicado así. + for estado in ("published", "scheduled"): + sin = sorted(p["slug"] for p in posts if p.get("status") == estado + and not [t for t in (p.get("tags") or []) + if t.get("visibility") != "internal"]) + if sin: + out.append(f"{len(sin)} post(s) {estado} SIN CATEGORÍA pública: " + + ", ".join(s[:38] for s in sin[:6])) + return out + + +# ────────────────────────────────── páginas ────────────────────────────────── + +def check_paginas(site, cfg): + out = [] + fd, path = tempfile.mkstemp(suffix=".json") + os.close(fd) + try: + r = sh(f'{cfg["cli"]} page list --limit all --formats html --json > {path}', + timeout=180) + if r.returncode != 0: + return [f"no he podido listar las páginas: {r.stderr.strip()[:120]}"] + d = json.load(open(path)) + finally: + os.unlink(path) + pages = d["pages"] if isinstance(d, dict) and "pages" in d else d + for p in pages: + if p.get("status") != "published": + continue + malas = [v for v in R.check_post(p) + if v.severity != R.INFO + and not _fuera_de_paginas(v.rule) + and not X.accepted_reason(v.rule, p["slug"], site)] + for v in malas: + out.append(f"página /{p['slug']}/ — {v.rule}: {v.message[:70]}") + return out + + +# ──────────────────────────────── head público ──────────────────────────────── + +def check_head(site, cfg, posts): + """Lo que emite el TEMA, no el artículo. Se mira un post cualquiera porque + estos fallos son de plantilla: si están en uno, están en los 33. + + El post se elige por orden alfabético de slug, no «el más reciente»: la + huella del vigilante tiene que ser estable entre ejecuciones o el informe + cambiaría solo al publicar, y avisaría de un cambio que no lo es. + """ + out = [] + pub = sorted(p["slug"] for p in posts if p.get("status") == "published") + if not pub: + return ["sin posts publicados que mirar"] + url = cfg["url"].rstrip("/") + "/" + pub[0] + "/" + try: + h = trae(url) + except Exception as exc: + return [f"no he podido leer {url}: {exc}"] + + bloques = re.findall(r'', + h, re.S) + arts = [] + for b in bloques: + try: + d = json.loads(b) + except ValueError: + out.append("ld+json que no parsea") + continue + for o in (d.get("@graph") if isinstance(d, dict) and "@graph" in d + else [d] if isinstance(d, dict) else d): + if isinstance(o, dict) and o.get("@type") in ( + "Article", "BlogPosting", "NewsArticle"): + arts.append((o.get("@type"), (o.get("headline") or "")[:40])) + if len(arts) > 1: + out.append("SCHEMA DUPLICADO: " + " + ".join( + f"{t}«{hd}»" for t, hd in arts) + " — Google elige cuál usa") + + tw = re.findall(r'name="twitter:site" content="([^"]*)"', h) + if tw and tw[0].lower() in ("@ghost", "ghost"): + out.append(f"twitter:site sigue en el default de Ghost ({tw[0]}): " + "atribuye las tarjetas a la cuenta de Ghost") + pub_fb = re.findall(r'property="article:publisher" content="([^"]*)"', h) + if pub_fb and "facebook.com/ghost" in pub_fb[0]: + out.append("article:publisher sigue en el default de Ghost " + f"({pub_fb[0]})") + + canon = re.findall(r']*\blang="([^"]*)"', h) + if lang and not lang[0].lower().startswith(cfg["lang"]): + out.append(f" y el sitio es {cfg['lang']}") + return out + + +# ─────────────────────────────────── robots ─────────────────────────────────── + +def politica_robots(txt): + """{agente: True} para los agentes con un `Disallow: /` completo, más la + línea Content-Signal si la hay. Normalizado para poder compararlo.""" + bloqueados, señal, agentes, en_directivas = set(), None, [], False + for linea in txt.splitlines(): + l = linea.split("#")[0].strip() + if not l: + continue + k, _, v = l.partition(":") + k, v = k.strip().lower(), v.strip() + if k == "user-agent": + # Varios User-agent seguidos comparten bloque; uno después de una + # directiva abre bloque nuevo. + if en_directivas: + agentes, en_directivas = [], False + agentes.append(v) + else: + en_directivas = True + if k == "disallow" and v == "/": + bloqueados.update(agentes) + elif k == "content-signal": + señal = v + return {"bloqueados": sorted(bloqueados), "content_signal": señal} + + +def check_robots(site, cfg): + out = [] + try: + txt = trae(cfg["url"].rstrip("/") + "/robots.txt") + except Exception as exc: + return [f"no he podido leer robots.txt: {exc}"] + if "sitemap:" not in txt.lower(): + out.append("robots.txt NO declara Sitemap:") + + ahora = politica_robots(txt) + # Instantánea versionada: el bloque «Cloudflare Managed» aparece y cambia + # SOLO, sin que nadie lo toque en git. Sin una foto contra la que comparar, + # ese cambio es invisible para siempre. + try: + esperado = json.load(open(ROBOTS_SNAP)).get(site) + except (FileNotFoundError, ValueError): + esperado = None + if esperado is None: + out.append(f"sin instantánea de robots.txt para [{site}]: " + f"crea {os.path.basename(ROBOTS_SNAP)} con {json.dumps(ahora)}") + elif esperado != ahora: + out.append("robots.txt CAMBIÓ respecto a la instantánea:") + if esperado.get("bloqueados") != ahora["bloqueados"]: + antes, hoy = set(esperado.get("bloqueados") or []), set(ahora["bloqueados"]) + if hoy - antes: + out.append(f" ahora BLOQUEA además: {sorted(hoy - antes)}") + if antes - hoy: + out.append(f" ya NO bloquea: {sorted(antes - hoy)}") + if esperado.get("content_signal") != ahora["content_signal"]: + out.append(f" Content-Signal: {esperado.get('content_signal')} " + f"→ {ahora['content_signal']}") + return out + + +# ────────────────────────────────── hreflang ────────────────────────────────── + +def check_hreflang(site, posts): + """La misma regla que usa el auditor. Vive aquí porque seo_audit.py no + tiene timer: hasta hoy, lo único que comprobaba hreflang solo corría cuando + alguien lo lanzaba a mano.""" + try: + import seo_hreflang as HL + parejas = HL.lee_parejas() + except Exception as exc: + return [f"no he podido leer las parejas: {exc}"] + out = [] + for p in posts: + if p.get("status") != "published": + continue + try: + for v in HL.violaciones(p, site, parejas): + out.append(f"{p['slug'][:40]} — {v.message[:80]}") + except Exception as exc: + out.append(f"{p['slug'][:40]} — error al comprobar: {exc}") + break + return out + + +# ──────────────────────────────────── hub ──────────────────────────────────── + +def check_hub(site): + try: + import seo_hub as HUB + return HUB.problemas_de(site) + except Exception as exc: + return [f"no he podido revisar el hub: {exc}"] + + +# ───────────────────────────────── programados ───────────────────────────────── + +def check_programados(site, posts): + """Las reglas, sobre los PROGRAMADOS. `check_rules` de seo_watch los salta + (`if status != "published": continue`), así que un programado con la meta + mal, sin alt o con el título largo no se ve hasta el día DESPUÉS de + publicarse — cuando ya lo ha visto Google. Aquí se ven antes.""" + out = [] + for p in posts: + if p.get("status") != "scheduled": + continue + malas = [v for v in R.check_post(p) + if v.severity != R.INFO + and not X.accepted_reason(v.rule, p["slug"], site)] + for v in malas: + out.append(f"{(p.get('published_at') or '')[:10]} {p['slug'][:34]} " + f"— {v.rule}: {v.message[:60]}") + return out + + +# ─────────────────────────────────── fachada ─────────────────────────────────── + +CHEQUEOS = ( + ("tags", lambda s, c, p: check_tags(s, c, p)), + ("páginas", lambda s, c, p: check_paginas(s, c)), + ("head", lambda s, c, p: check_head(s, c, p)), + ("robots", lambda s, c, p: check_robots(s, c)), + ("hreflang", lambda s, c, p: check_hreflang(s, p)), + ("hub", lambda s, c, p: check_hub(s)), + ("programados", lambda s, c, p: check_programados(s, p)), +) + + +def revisa(site, posts): + """{clave: [líneas]} — solo las claves con hallazgos. + + Un chequeo que revienta NO puede tumbar a los demás: se convierte en un + hallazgo que dice que ese chequeo está roto, que es información útil y no + un informe a medias que parece limpio. + """ + cfg = SITES[site] + out = {} + for nombre, fn in CHEQUEOS: + try: + res = fn(site, cfg, posts) + except Exception as exc: + res = [f"⚠ el chequeo '{nombre}' ha fallado: {type(exc).__name__}: {exc}"] + if res: + out[nombre] = res + return out + + +def informe(estado): + if not estado: + return [] + L = [] + for clave, lineas in estado.items(): + L.append(f"🧱 {clave}: {len(lineas)}") + L += [f" {x}" for x in lineas[:6]] + if len(lineas) > 6: + L.append(f" … y {len(lineas) - 6} más") + return L + + +def main(): + ap = argparse.ArgumentParser(description="Estado del sitio (solo lectura)") + ap.add_argument("--site", choices=("en", "es", "both"), default="both") + a = ap.parse_args() + malo = False + for site in (["en", "es"] if a.site == "both" else [a.site]): + print(f"═══ [{site.upper()}] {SITES[site]['host']}") + import seo_watch as W + W.use_site(site) + posts = W.fetch_posts() + est = revisa(site, posts) + if est: + malo = True + print("\n".join(informe(est))) + else: + print(" ✓ sin hallazgos de sitio") + print() + return 1 if malo else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/seo_hub.py b/seo_hub.py index b187eb2..23d2057 100644 --- a/seo_hub.py +++ b/seo_hub.py @@ -128,6 +128,28 @@ def slugs_del_curado(secciones): # ─────────────────────────────── la revisión ─────────────────────────────── +def problemas_de(site): + """[líneas] con lo que no cuadra. Sin imprimir: lo consume seo_estado.py, + que es quien lo mete en el vigilante semanal.""" + cab, secciones = lee_curado(site) + posts = fetch_posts(SITES[site]["cli"]) + pub = {p["slug"] for p in posts if p.get("status") == "published"} + prog = {p["slug"] for p in posts if p.get("status") == "scheduled"} + en_indice = slugs_del_curado(secciones) + out = [] + for s in sorted({s for s in en_indice if en_indice.count(s) > 1}): + out.append(f"repetido en el índice: {s}") + for s in sorted(s for s in en_indice if s in prog): + out.append(f"PREMATURO (aún no publicado, daría 404): {s}") + for s in sorted(s for s in en_indice if s not in pub and s not in prog): + out.append(f"apunta a un post que no existe: {s}") + for s in sorted(pub - set(en_indice) - set(cab["EXCLUIDOS"])): + out.append(f"publicado FUERA del índice: {s}") + for s in sorted(x for x in cab["EXCLUIDOS"] if x not in pub and x not in prog): + out.append(f"EXCLUIDO que ya no existe, quita la línea: {s}") + return out + + def revisa_site(site): cab, secciones = lee_curado(site) posts = fetch_posts(SITES[site]["cli"]) diff --git a/seo_watch.py b/seo_watch.py index b4ef72a..fd10425 100644 --- a/seo_watch.py +++ b/seo_watch.py @@ -45,6 +45,7 @@ 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 = { @@ -416,7 +417,8 @@ def check_rules(posts, site): # ---------- salida ---------- -def build_report(roto, nocanon, prematuro, envejecido, n_sitemap, sm_malos, reglas, mayus, titulos): +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):") @@ -446,13 +448,16 @@ def build_report(roto, nocanon, prematuro, envejecido, n_sitemap, sm_malos, regl 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 y reglas OK.") + 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): +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), @@ -486,15 +491,20 @@ def run_site(site): 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) + 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) + 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 titulos or estado) return header + "\n" + body, fp, hallazgos diff --git a/test_seo_estado.py b/test_seo_estado.py new file mode 100644 index 0000000..298b53a --- /dev/null +++ b/test_seo_estado.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Tests de seo_estado.py — los puntos ciegos del vigilante. + +Cada test de aquí es la cicatriz de un fallo REAL de la semana del 2026-07-29. +Se ejercitan las dos cosas que hacen creíble a un vigilante: + + 1. que DETECTA la avería (no basta con que no dé error) + 2. que un chequeo roto no deja el informe limpio por accidente + + python3 -m pytest test_seo_estado.py -q + python3 test_seo_estado.py # sin pytest +""" +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import seo_estado as E + + +def _tags_falsos(tags): + """Sustituye la llamada al CLI por una lista de tags de mentira.""" + payload = json.dumps({"tags": tags}) + return lambda cmd, timeout=180: type( + "R", (), {"returncode": 0, "stdout": payload, "stderr": ""})() + + +# ─────────────────────── tags: el bug que vivió 3 semanas ─────────────────────── + +def test_detecta_el_duplicado_de_ghost(): + """`Casos Militares` + `casos-militares` → Ghost crea `casos-militares-2`. + Es la firma exacta de researchowl mandando el slug en el campo `name`.""" + orig = E.sh + E.sh = _tags_falsos([ + {"slug": "casos-militares", "name": "Casos Militares", "visibility": "public"}, + {"slug": "casos-militares-2", "name": "casos-militares", "visibility": "public"}, + ]) + try: + salida = E.check_tags("es", E.SITES["es"], [ + {"slug": "p", "status": "published", + "tags": [{"slug": "casos-militares-2", "visibility": "public"}]}]) + finally: + E.sh = orig + assert any("DUPLICADOS" in l for l in salida), salida + + +def test_no_inventa_duplicados_donde_no_los_hay(): + orig = E.sh + E.sh = _tags_falsos([ + {"slug": "casos-militares", "name": "Casos Militares", "visibility": "public"}, + {"slug": "casos-espana", "name": "Casos España", "visibility": "public"}, + ]) + try: + salida = E.check_tags("es", E.SITES["es"], [ + {"slug": "p", "status": "published", "tags": [ + {"slug": "casos-militares", "visibility": "public"}, + {"slug": "casos-espana", "visibility": "public"}]}]) + finally: + E.sh = orig + assert not any("DUPLICADOS" in l for l in salida), salida + + +def test_ve_los_programados_sin_categoria(): + """Los 9 de agosto salían con solo el tag interno #agosto-2026 y se habrían + publicado sin ninguna categoría. `check_rules` de seo_watch los salta.""" + orig = E.sh + E.sh = _tags_falsos([{"slug": "uap", "name": "UAP", "visibility": "public"}]) + try: + salida = E.check_tags("es", E.SITES["es"], [ + {"slug": "agosto-1", "status": "scheduled", + "tags": [{"slug": "hash-agosto-2026", "visibility": "internal"}]}, + {"slug": "vivo", "status": "published", + "tags": [{"slug": "uap", "visibility": "public"}]}]) + finally: + E.sh = orig + assert any("scheduled SIN CATEGORÍA" in l for l in salida), salida + + +# ────────────────────────────── robots: la deriva ────────────────────────────── + +ROBOTS_EN = """ +User-agent: * +Content-Signal: search=yes,ai-train=no,use=reference +Allow: / + +User-agent: GPTBot +User-agent: CCBot +Disallow: / + +User-agent: * +Sitemap: https://x/sitemap.xml +Disallow: /ghost/ +""" + + +def test_parser_de_robots_agrupa_agentes_consecutivos(): + p = E.politica_robots(ROBOTS_EN) + assert p["bloqueados"] == ["CCBot", "GPTBot"], p + assert p["content_signal"] == "search=yes,ai-train=no,use=reference", p + + +def test_disallow_parcial_no_cuenta_como_bloqueo(): + """`Disallow: /ghost/` NO es bloquear el sitio. Confundirlos daría por + bloqueado a todo bot con una regla de directorio.""" + assert E.politica_robots(ROBOTS_EN)["bloqueados"] == ["CCBot", "GPTBot"] + + +def test_comentarios_no_confunden_al_parser(): + txt = "User-agent: GPTBot # el de OpenAI\nDisallow: / # todo\n" + assert E.politica_robots(txt)["bloqueados"] == ["GPTBot"] + + +# ───────────────────── resiliencia: un chequeo roto se OYE ───────────────────── + +def test_un_chequeo_que_revienta_no_deja_el_informe_limpio(): + """Lo peligroso de un vigilante no es que falle: es que falle y parezca que + todo está bien.""" + orig = E.CHEQUEOS + + def explota(s, c, p): + raise RuntimeError("boom") + + E.CHEQUEOS = (("explosivo", explota),) + try: + r = E.revisa("en", []) + finally: + E.CHEQUEOS = orig + assert "explosivo" in r and "ha fallado" in r["explosivo"][0], r + + +# ───────────────── páginas: no fabricar hallazgos inatendibles ───────────────── + +def test_las_reglas_de_post_que_no_aplican_a_paginas_quedan_fuera(): + """og_*/twitter_* vacíos en una PÁGINA no son un fallo: Ghost los deriva de + las metas al renderizar (verificado sobre el HTML público del hub ES).""" + for regla in ("og_title.empty", "twitter_description.empty", + "feature_image.missing", "internal_links.too_few"): + assert E._fuera_de_paginas(regla), regla + for regla in ("meta_title.missing", "meta_description.too_long"): + assert not E._fuera_de_paginas(regla), regla + + +if __name__ == "__main__": + fallos = 0 + for nombre, fn in sorted(globals().items()): + if not nombre.startswith("test_"): + continue + try: + fn() + print(f" ✓ {nombre}") + except AssertionError as exc: + fallos += 1 + print(f" ✗ {nombre}: {exc}") + print(f"\n{'✓ todo pasa' if not fallos else f'✗ {fallos} fallo(s)'}") + sys.exit(1 if fallos else 0)