#!/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())