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,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