seo_watch/seo_link: dos fallos que se manifestaban callándose

1) El corpus se pedía con --limit 100. Con 40 posts en EN y 29 en ES sobraba,
   pero a 2 por semana habría cruzado los 100 hacia 2027 y se habría truncado
   EN SILENCIO. Y un corpus truncado no da error: da falsos «enlaces rotos»
   contra los posts que faltan, que es peor que no mirar. Pasa a --limit all
   y, sobre todo, se comprueba el número contra meta.pagination.total: eso es
   lo que convierte un fallo mudo en uno que se oye. En seo_link el truncado
   era aún peor, porque haría rechazar como inexistente un destino que sí
   está.

2) check_sitemap daba VERDE cuando curl fallaba: 0 URLs descargadas son 0
   problemas encontrados, y el informe decía « sin hallazgos». O sea que
   justo el rato en que el sitio está caído era cuando este check se callaba.
   Un sitemap vacío pasa a ser hallazgo con mensaje propio; en un blog con
   29-40 artículos nunca lo está legítimamente.

Probado con casos que DEBEN fallar: corpus recortado a 3 de 40 (revienta y lo
dice), corpus completo (pasa limpio), y sitemap vacío (hallazgo, informe claro
y notificaría). Y en real contra los dos blogs: EN 40 posts / 33 URLs, ES 29 /
31, ambos sin hallazgos.

No toca seo_rules.py, así que el vendor-sync con ResearchOwl sigue en verde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-07-21 16:01:28 +00:00
co-authored by Claude Opus 4.8
parent 48fbe4302f
commit d381c0225c
2 changed files with 44 additions and 7 deletions
+14 -3
View File
@@ -62,14 +62,25 @@ def sh(cmd, timeout=240):
def fetch_corpus(cli): def fetch_corpus(cli):
"""Corpus completo (published + scheduled). A fichero: un PIPE se trunca.""" """Corpus completo (published + scheduled). A fichero: un PIPE se trunca.
`--limit all` + comprobación del total (ver seo_watch.fetch_posts). Aquí un
corpus truncado es peor todavía que en el vigilante: haría fallar el
«¿existe el destino?» y rechazaría un enlace perfectamente válido, o peor,
daría por inexistente un post que sí está."""
fd, path = tempfile.mkstemp(suffix=".json") fd, path = tempfile.mkstemp(suffix=".json")
os.close(fd) os.close(fd)
try: try:
sh(f"{cli} post list --limit 100 --formats lexical,mobiledoc,html --json > {path}") sh(f"{cli} post list --limit all --formats lexical,mobiledoc,html --json > {path}")
with open(path) as f: with open(path) as f:
d = json.load(f) d = json.load(f)
return d.get("posts", d) if isinstance(d, dict) else d posts = d.get("posts", d) if isinstance(d, dict) else d
total = ((d.get("meta") or {}).get("pagination") or {}).get("total") \
if isinstance(d, dict) else None
if total is not None and len(posts) != total:
sys.exit(f"✗ corpus truncado: {len(posts)} de {total} posts — abortado "
f"sin escribir (el chequeo de destinos sería mentira)")
return posts
finally: finally:
os.unlink(path) os.unlink(path)
+30 -4
View File
@@ -82,14 +82,28 @@ def sh(cmd, timeout=180):
def fetch_posts(): def fetch_posts():
"""Todos los posts (published + scheduled) con html. Salida a fichero: la """Todos los posts (published + scheduled) con html. Salida a fichero: la
respuesta pesa cientos de KB y un PIPE se trunca a 64 KB.""" respuesta pesa cientos de KB y un PIPE se trunca a 64 KB.
`--limit all` y ADEMÁS se comprueba contra meta.pagination. Antes pedía
`--limit 100`: con 40 posts en EN y 29 en ES sobraba, pero a 2 posts por
semana el corpus habría cruzado los 100 hacia 2027 y se habría truncado EN
SILENCIO — y un corpus truncado no da error, da falsos «enlaces rotos»
contra los posts que faltan. Verificar el total es lo que convierte ese
fallo mudo en uno que se oye."""
fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_watch_") fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_watch_")
os.close(fd) os.close(fd)
try: try:
r = sh(f"{CLI} post list --limit 100 --formats html --json > {path}", timeout=240) r = sh(f"{CLI} post list --limit all --formats html --json > {path}", timeout=240)
with open(path) as f: with open(path) as f:
data = json.load(f) data = json.load(f)
return data.get("posts", data) if isinstance(data, dict) else data posts = data.get("posts", data) if isinstance(data, dict) else data
total = ((data.get("meta") or {}).get("pagination") or {}).get("total") \
if isinstance(data, dict) else None
if total is not None and len(posts) != total:
raise RuntimeError(
f"corpus truncado: {len(posts)} de {total} posts. "
f"Los checks de enlaces darían falsos rotos — abortado.")
return posts
finally: finally:
os.unlink(path) os.unlink(path)
@@ -211,11 +225,20 @@ def check_link_aging(posts):
def check_sitemap(): def check_sitemap():
"""URLs del sitemap que no dan 200.
Un sitemap VACÍO cuenta como hallazgo, no como éxito: si curl falla o el
sitio está caído mientras corre la revisión, se descargan 0 URLs, se
encuentran 0 problemas y el informe decía «✅ sin hallazgos». Justo el rato
en que el sitio está roto es cuando este check se callaba. El sitemap de
posts nunca está legítimamente vacío en un blog con 29-40 artículos."""
malos = [] malos = []
urls = [] urls = []
for part in ("posts", "pages"): for part in ("posts", "pages"):
r = sh(f"curl -s --max-time 25 {HOST}sitemap-{part}.xml") r = sh(f"curl -s --max-time 25 {HOST}sitemap-{part}.xml")
urls += re.findall(r"<loc>(.*?)</loc>", r.stdout) urls += re.findall(r"<loc>(.*?)</loc>", r.stdout)
if not urls:
return 0, [(f"{HOST}sitemap-posts.xml", "vacío/inaccesible")]
with ThreadPoolExecutor(max_workers=8) as ex: with ThreadPoolExecutor(max_workers=8) as ex:
for u, (code, _) in zip(urls, ex.map(lambda x: http_status(x), urls)): for u, (code, _) in zip(urls, ex.map(lambda x: http_status(x), urls)):
if code != 200: if code != 200:
@@ -376,7 +399,10 @@ def build_report(roto, nocanon, prematuro, envejecido, n_sitemap, sm_malos, regl
if envejecido: if envejecido:
L.append(f"🔵 {len(envejecido)} enlace(s) ENVEJECIDO(S) (ya existe mejor destino):") L.append(f"🔵 {len(envejecido)} enlace(s) ENVEJECIDO(S) (ya existe mejor destino):")
L += [f" {s[:26]}: «{a}» → /{t[:24]}/ ⇒ /{m[:26]}/" for s, a, t, m in envejecido[:6]] L += [f" {s[:26]}: «{a}» → /{t[:24]}/ ⇒ /{m[:26]}/" for s, a, t, m in envejecido[:6]]
if sm_malos: if sm_malos and n_sitemap == 0:
L.append("🔴 sitemap VACÍO o inaccesible — no se ha podido comprobar "
"ninguna URL (¿sitio caído?, ¿curl sin salida?)")
elif sm_malos:
L.append(f"🔴 sitemap: {len(sm_malos)} de {n_sitemap} URL(s) no dan 200:") L.append(f"🔴 sitemap: {len(sm_malos)} de {n_sitemap} URL(s) no dan 200:")
L += [f" [{c}] {u.replace(HOST,'/')}" for u, c in sm_malos[:6]] L += [f" [{c}] {u.replace(HOST,'/')}" for u, c in sm_malos[:6]]
if mayus: if mayus: