estado: cerrar los siete puntos ciegos del vigilante, y pasarlo a diario
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
<head> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -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
|
||||
|
||||
|
||||
+402
@@ -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. <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")
|
||||
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'<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")
|
||||
|
||||
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'<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())
|
||||
+22
@@ -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"])
|
||||
|
||||
+16
-6
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user