El chequeo tenía una lista negra de un solo elemento (el default de Ghost) y por eso se le colaba el caso peor: el sitio ES apuntaba a @ZonaDeExclusion, un handle que no existe. Un handle ajeno o abandonado es más grave que el default, porque cualquiera puede reclamarlo y quedarse con la atribución de las tarjetas de X. Hay UNA sola cuenta para los dos blogs (@ExclusionZoneEN), así que se declara como constante y se compara contra ella. También avisa si el meta desaparece del todo: vaciar el ajuste en Ghost lo quita, y el silencio no es lo mismo que estar bien. Cuatro tests, y verificado que el chequeo se ve fallar con el handle esperado falseado — no es una comparación vacua. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
437 lines
19 KiB
Python
437 lines
19 KiB
Python
#!/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. <head> — 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")
|
|
|
|
# Hay UNA sola cuenta de X para los dos blogs, y es la inglesa (confirmado por
|
|
# Jose el 2026-07-30). El autopost del RSS inglés y el CLI `ze` tiran de ella.
|
|
HANDLE_X = "@ExclusionZoneEN"
|
|
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. El nombre del tag es lo que el TEMA pinta como H1 del archivo. En el EN
|
|
# los siete se llamaban como su slug, así que /tag/military-cases/ le
|
|
# enseñaba «military-cases» al lector (el ES enseñaba «Casos Militares»).
|
|
# Un slug crudo de H1 en una página indexada es un fallo visible.
|
|
feos = sorted(t["slug"] for t in publicos
|
|
if (t.get("name") or "") == t["slug"] and "-" in t["slug"])
|
|
if feos:
|
|
out.append(f"tags cuyo NOMBRE es el slug (sale de H1 en el archivo): {feos}")
|
|
|
|
# 4. Dos archivos de tag con el MISMO <title> compiten entre ellos en el
|
|
# SERP. Salió al renombrar: `uap` e `investigation` tenían los dos
|
|
# «UAP Investigation — The Exclusion Zone».
|
|
por_meta = {}
|
|
for t in publicos:
|
|
mt = (t.get("meta_title") or "").strip()
|
|
if mt:
|
|
por_meta.setdefault(mt, []).append(t["slug"])
|
|
for mt, v in sorted(por_meta.items()):
|
|
if len(v) > 1:
|
|
out.append(f"mismo <title> en {sorted(v)}: «{mt[:52]}»")
|
|
|
|
# 5. 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'<script type="application/ld\+json">(.*?)</script>',
|
|
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")
|
|
|
|
# Comparar contra el handle REAL, no contra la lista de valores malos que se
|
|
# nos ocurran. Este chequeo solo miraba el default de Ghost (@ghost), y por
|
|
# eso se le pasó lo peor de los dos: el ES apuntaba a @ZonaDeExclusion, un
|
|
# handle que no existe. Un handle ajeno o abandonado es más grave que el
|
|
# default, porque cualquiera puede reclamarlo y quedarse con la atribución
|
|
# de las tarjetas. Solo hay UNA cuenta de X para los dos blogs.
|
|
tw = re.findall(r'name="twitter:site" content="([^"]*)"', h)
|
|
if not tw:
|
|
out.append("no hay twitter:site: las tarjetas de X salen sin atribuir")
|
|
elif tw[0].lower() != HANDLE_X.lower():
|
|
que = (" (el default de Ghost, atribuye las tarjetas a la cuenta de Ghost)"
|
|
if tw[0].lower() in ("@ghost", "ghost") else "")
|
|
out.append(f"twitter:site es {tw[0]}{que} y debería ser {HANDLE_X}")
|
|
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'<link rel="canonical" href="([^"]*)"', h)
|
|
if canon and canon[0].rstrip("/") != url.rstrip("/"):
|
|
out.append(f"canonical no apunta a sí mismo: {canon[0]}")
|
|
|
|
lang = re.findall(r'<html[^>]*\blang="([^"]*)"', h)
|
|
if lang and not lang[0].lower().startswith(cfg["lang"]):
|
|
out.append(f"<html lang=\"{lang[0]}\"> 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())
|