validador: un par consolidado no es una colisión, es su solución

topic_collision marcaba como colisión los dos artículos del acto de Grusch
del 9 de junio, cuando el de mayo lleva canonical_url apuntando al otro
desde hace semanas. Google ya sabe cuál manda y Ghost excluye al secundario
del sitemap: no hay nada que arreglar, y el propio mensaje ofrecía
«interlink deliberately» como salida, que es justo lo que había.

Un aviso que no se puede resolver nunca es peor que no avisar, porque
enseña a ignorar al validador entero — la misma razón por la que esta
mañana retiramos la comprobación de reddit-intel de backup-verify.

El par se salta cuando cualquiera de los dos declara canónico al otro. Si
el canónico apunta a un tercero, la colisión sigue avisando: comprobado con
los cuatro casos (sin canónico dispara, en las dos direcciones se calla,
canónico a un tercero dispara). fetch_corpus pide ya canonical_url, que
antes no traía.
This commit is contained in:
ChemaVX
2026-07-28 07:11:51 +00:00
parent f7be9ccb3e
commit 1febcfe03b
2 changed files with 25 additions and 1 deletions
+22
View File
@@ -270,6 +270,19 @@ def _sig_tokens(title, slug):
if len(t) >= 3 and t not in TOPIC_STOPWORDS and not _YEAR_RE.fullmatch(t)} if len(t) >= 3 and t not in TOPIC_STOPWORDS and not _YEAR_RE.fullmatch(t)}
def _canoniza_a(post):
"""Slug al que este post declara canónico, o None si no declara ninguno.
Ghost guarda una URL completa; aquí solo interesa el último segmento, que es
lo único comparable con el slug de otro post del mismo sitio.
"""
url = _s(post.get("canonical_url"))
if not url:
return None
resto = url.split("?")[0].split("#")[0].rstrip("/")
return resto.rsplit("/", 1)[-1] or None
def _hook(title): def _hook(title):
return _s(title).split(":")[0].strip().lower() return _s(title).split(":")[0].strip().lower()
@@ -290,10 +303,19 @@ def topic_collision(post, corpus):
c_hook = _hook(post.get("title")) c_hook = _hook(post.get("title"))
c_slug_toks = _tokens(_s(post.get("slug")).replace("-", " ")) c_slug_toks = _tokens(_s(post.get("slug")).replace("-", " "))
c_canon = _canoniza_a(post)
for other in corpus: for other in corpus:
if other.get("id") == post.get("id"): if other.get("id") == post.get("id"):
continue continue
o_title, o_slug = _s(other.get("title")), _s(other.get("slug")) o_title, o_slug = _s(other.get("title")), _s(other.get("slug"))
# Un par consolidado NO es una colisión: es la solución a una colisión.
# Cuando uno de los dos declara al otro como canónico, Google ya sabe
# cuál manda y Ghost excluye al secundario del sitemap. Marcarlo sería
# pedir que se arregle algo que está arreglado — y el aviso, al no poder
# resolverse nunca, enseña a ignorar al validador.
if c_canon == o_slug or _canoniza_a(other) == _s(post.get("slug")):
continue
o_years = _case_years(o_title, o_slug) o_years = _case_years(o_title, o_slug)
o_sig = _sig_tokens(o_title, o_slug) o_sig = _sig_tokens(o_title, o_slug)
+3 -1
View File
@@ -50,8 +50,10 @@ def fetch_corpus():
fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_corpus_") fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_corpus_")
os.close(fd) os.close(fd)
try: try:
# canonical_url hace falta para NO marcar como colisión un par que ya
# está consolidado a propósito (ver topic_collision en seo_rules.py).
cmd = (f"ghst-en --json post list --limit all " cmd = (f"ghst-en --json post list --limit all "
f"--fields id,title,slug,status > {path}") f"--fields id,title,slug,status,canonical_url > {path}")
r = subprocess.run(cmd, shell=True, stderr=subprocess.PIPE, text=True) r = subprocess.run(cmd, shell=True, stderr=subprocess.PIPE, text=True)
if r.returncode != 0: if r.returncode != 0:
print(f" [warn] corpus fetch failed — topic collision NOT checked:\n{r.stderr}", print(f" [warn] corpus fetch failed — topic collision NOT checked:\n{r.stderr}",