Salieron al ordenar la taxonomía del EN, y son los dos visibles:
1. el H1 del archivo de tag sale del campo `name`, y en el EN los siete tags
se llamaban COMO SU SLUG: /tag/military-cases/ le enseñaba
«military-cases» al lector, con el <title> correcto justo encima. El ES
enseñaba «Casos Militares». Siete páginas indexadas con un slug de titular.
2. `uap` e `investigation` tenían el MISMO meta_title — dos archivos
compitiendo en el SERP con el mismo titular.
Los nombres no se inventaron: salían del meta_title que cada tag ya tenía, o
sea de una decisión editorial que estaba en el campo equivocado. Slugs
intactos: `--name` y `--new-slug` son opciones distintas, así que las siete
URLs siguen dando 200 y no hace falta ninguna redirección.
Renombrar era IMPOSIBLE hasta esta mañana: researchowl casaba los tags por
NOMBRE, así que esto habría creado siete duplicados `-2`. Se pudo porque el
commit 3d8cba6 los pasó a resolver por slug→id.
El detector de «nombre == slug» exige un guion a propósito: `uap` llamándose
«uap» no se lee como slug crudo y marcarlo sería un falso positivo. Hay test.
Y un fallo del propio fichero de tests, encontrado al añadir estos: el bloque
`if __name__ == "__main__"` estaba en MEDIO, así que los tests definidos
después no entraban en globals() y no corrían nunca — pasaban en verde sin
ejecutarse. Movido al final.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
7.9 KiB
Python
209 lines
7.9 KiB
Python
#!/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
|
|
|
|
|
|
|
|
# ───────── archivos de tag: lo que el lector ve y lo que ve Google ─────────
|
|
|
|
def test_detecta_el_nombre_de_tag_que_es_el_slug():
|
|
"""El H1 del archivo sale del `name`. En el EN los siete se llamaban como
|
|
su slug: /tag/military-cases/ enseñaba «military-cases» al lector."""
|
|
orig = E.sh
|
|
E.sh = _tags_falsos([
|
|
{"slug": "military-cases", "name": "military-cases", "visibility": "public"},
|
|
])
|
|
try:
|
|
salida = E.check_tags("en", E.SITES["en"], [
|
|
{"slug": "p", "status": "published",
|
|
"tags": [{"slug": "military-cases", "visibility": "public"}]}])
|
|
finally:
|
|
E.sh = orig
|
|
assert any("NOMBRE es el slug" in l for l in salida), salida
|
|
|
|
|
|
def test_un_acronimo_de_una_palabra_no_cuenta_como_slug_crudo():
|
|
"""`uap` llamándose «uap» no es el mismo problema que «military-cases»:
|
|
sin guion no se lee como slug. Exigir el guion evita el falso positivo."""
|
|
orig = E.sh
|
|
E.sh = _tags_falsos([{"slug": "uap", "name": "uap", "visibility": "public"}])
|
|
try:
|
|
salida = E.check_tags("en", E.SITES["en"], [
|
|
{"slug": "p", "status": "published",
|
|
"tags": [{"slug": "uap", "visibility": "public"}]}])
|
|
finally:
|
|
E.sh = orig
|
|
assert not any("NOMBRE es el slug" in l for l in salida), salida
|
|
|
|
|
|
def test_detecta_dos_archivos_de_tag_con_el_mismo_title():
|
|
"""Salió al renombrar: `uap` e `investigation` compartían meta_title, o sea
|
|
dos páginas indexadas compitiendo con el mismo titular."""
|
|
orig = E.sh
|
|
E.sh = _tags_falsos([
|
|
{"slug": "uap", "name": "UAP", "visibility": "public",
|
|
"meta_title": "UAP Investigation — The Exclusion Zone"},
|
|
{"slug": "investigation", "name": "Investigation", "visibility": "public",
|
|
"meta_title": "UAP Investigation — The Exclusion Zone"},
|
|
])
|
|
try:
|
|
salida = E.check_tags("en", E.SITES["en"], [
|
|
{"slug": "p", "status": "published", "tags": [
|
|
{"slug": "uap", "visibility": "public"},
|
|
{"slug": "investigation", "visibility": "public"}]}])
|
|
finally:
|
|
E.sh = orig
|
|
assert any("mismo <title>" in l for l in salida), salida
|
|
|
|
|
|
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)
|