Files
chemavx-seo-tools/seo_exceptions.py
T
ChemaVX cf61b8bda3 seo_exceptions: waivers de enlazado del backlog ES
Barrido del 2026-07-21: se buscaron todas las entidades del catálogo ES en la
prosa de los 26 posts con enlazado flojo y se pusieron los 24 enlaces honestos
que había. Los 19 que siguen bajo el umbral es porque no hay más entidades del
corpus nombradas en su texto — el catálogo español se cruza poco y forzar un
enlace temático es justo lo que estas excepciones existen para evitar.

Cada waiver dice qué enlaza y qué nombra la prosa que NO tiene artículo, para
que se pueda revisar cuando el catálogo crezca.

Un falso positivo descartado a mano: el «Roosevelt» de la batalla de Los
Ángeles es el presidente, no el portaaviones del artículo de Gimbal/GoFast.

Fuera el waiver de roswell-1947-preguntas-sin-respuesta: ya tiene 2 enlaces
(el de mayo y PURSUE), así que decía algo falso.

El ES pasa de 143 violaciones accionables a 12 (11 meta_description y 1
meta_title largos, que ya estaban ahí pero tapados por el corte del top-6).
2026-07-21 08:10:48 +00:00

210 lines
13 KiB
Python

"""
Accepted SEO exceptions — consciously-waived findings for SPECIFIC posts.
This is an A-side (auditor) REPORTING filter, NOT a rule change. The shared rule
engine (seo_rules.py) still CHECKS every rule on every post; the auditor simply
moves a matching violation into a separate "Accepted exceptions (won't fix —
documented)" section so the headline "issues to fix" count reflects only
genuinely-actionable items. Each waiver carries its REASON (no silent skips).
Structure: EXCEPTIONS[site][rule_family][slug] = "documented reason"
El ámbito por SITIO ("en"/"es") es deliberado: los waivers se indexaban solo por
slug, así que un slug repetido entre blogs habría aplicado en silencio la
excepción del sitio equivocado — y ese post habría dejado de auditarse sin que
nadie se enterase. Hoy no hay colisiones (los slugs ES van en español), pero
nada lo impedía. Con el ámbito, los dos sitios son independientes de verdad.
Scope:
- A waiver applies ONLY to the named rule family for the named slug. Any OTHER
rule the same post violates still surfaces as a real issue.
- A waiver is matched against a Violation whose `rule` equals the family key or
starts with "<family>." — so "internal_links" covers "internal_links.too_few".
- If a post later stops violating the rule (e.g. a link is added and it clears
the threshold), there's simply no violation to waive — the waiver is moot.
Tool C (seo_validate.py, pre-publish validator) deliberately does NOT consult
this registry: exceptions are per-EXISTING-post decisions; a brand-new draft is
held to the full standard.
"""
EXCEPTIONS = {
"en": {
# internal_links < 2 — these posts are intentionally left as-is because there
# is no honest 2nd internal-link target in their prose; forcing one would hurt
# quality. (Decided 2026-06-23.)
"internal_links": {
"roswell-1947-ufo-incident":
"1 link (Spielberg); no 2nd entity in prose maps to an article — "
"forcing a link would hurt quality.",
"manises-1979-spain-ufo-incident":
"1 link (Canary Islands); no 2nd entity in prose maps to an article.",
"betty-barney-hill-1961-abduction":
"1 link (alien abduction → travis-walton, thematic); no 2nd honest target.",
"malmstrom-1967-ufo-nuclear-missiles-incident":
"1 link (Grusch); the only entity named in the prose.",
"rendlesham-forest-incident-1980":
"0 links; genuinely isolated — no entity in prose maps to one of our articles.",
"phoenix-lights-1997":
"0 links; only thematic candidates are stretches — left as-is.",
"canary-islands-1976-ufo-sighting":
"1 link (reciprocal → manises); no honest 2nd entity in prose — "
"Spanish case, self-contained.",
"chiles-military-declassified-uap-files-cefaas-groundbreaking-investigation-of-unexplained-aerial-phenomena":
"1 link (→ pursue-release-3, the 294 files); no 2nd verbatim entity "
"in prose — self-contained Chilean case.",
"belgium-1989-1990-nato-fighters-ufo-wave":
"1 link (→ gimbal-gofast, por la referencia a los vídeos de la Navy); "
"la prosa no nombra ningún otro caso del corpus — la oleada belga se "
"narra con sus propias fuentes (SOBEPS, De Brouwer, el memo de la DIA). "
"(Revisado 2026-07-20, en el remate previo a publicar.)",
"levelland-1957-texas-ufo-vehicle-interference":
"1 link (→ project-blue-book, la única entidad del corpus nombrada en "
"la prosa; lo demás es Hynek y 'ball lightning', sin artículo propio). "
"El enlace previo a AARO era un destino de conveniencia: Levelland se "
"creó el 30-jun y el artículo de Blue Book no existía hasta el 1-jul, "
"así que el autofill enlazó a lo más cercano disponible. Repuntado al "
"destino correcto el 2026-07-18.",
"hundreds-watched-in-broad-daylight-the-westall-ufo-mystery-that-australia-still-cant-explain":
"0 links; la prosa no nombra ningún otro caso del corpus — episodio "
"escolar australiano, autocontenido. (Revisado 2026-07-18, antes de "
"su publicación del 6-ago.)",
"colares-1977-brazil-air-force-ufo-investigation":
"1 link (→ varginha, el otro caso brasileño); lo único más que nombra "
"es 'Operation Saucer', que es su propia operación y no tiene artículo. "
"(Revisado 2026-07-18, antes de su publicación del 24-ago.)",
"tassili-najjer-cave-paintings-ancient-astronaut-theory":
"1 link (→ gimbal-gofast); ningún otro caso del corpus aparece en la "
"prosa — arqueología sahariana, autocontenida. (Revisado 2026-07-18.)",
"missing-scientists-theory-youtube-white-house-2026":
"1 link (→ grusch-capitol); trata de la cadena mediática, no de casos "
"UAP — no hay 2ª entidad del corpus en la prosa. (Revisado 2026-07-18.)",
"grusch-capitol-june-9-2026-uap-disclosure":
"1 link (→ la investigación completa, que es lo correcto). Es el ADELANTO "
"del evento, con canonical_url apuntando a esa investigación: sus señales "
"se consolidan allí, así que forzar enlaces aquí no aporta. "
"(Revisado 2026-07-18, ver project-en-seo-recovery.)",
"tehran-1976-iranian-f4-ufo-incident":
"0 links; prose names no case we have an article for (only Klass, "
"Jafari, the DIA report) — self-contained. Thematic candidates "
"(Malmstrom, Levelland, Manises) appear nowhere in the text. "
"(Reviewed 2026-07-18.)",
},
},
# El blog ES se auditó por primera vez el 2026-07-20 (al crear ghst-es) y
# arrastra backlog: 28 posts sin alt-text, 27 con enlazado flojo. Es deuda
# acumulada por no haberse revisado nunca, NO regresión — se irá waiveando o
# arreglando post a post, con la misma vara de medir que el EN.
"es": {
# Barrido del backlog el 2026-07-21: se buscaron TODAS las entidades del
# catálogo ES en la prosa de los 26 posts con enlazado flojo y se añadieron
# los 24 enlaces honestos que había. Los que siguen no llegan a 2 porque no
# hay más entidades del corpus nombradas en su texto — el catálogo español
# se cruza poco, y forzar un enlace temático sería justo lo que no queremos.
# (Un falso positivo descartado a mano: el «Roosevelt» de la batalla de Los
# Ángeles es el presidente, no el portaaviones del artículo de Gimbal.)
"internal_links": {
"archivos-pursue-uap-bases-nucleares-desclasificado":
"1 enlace (→ AARO). Lo demás que nombra son organismos sin artículo "
"propio: ODNI, Departamento de Energía, NASA, FBI, Hegseth.",
"batalla-los-angeles-1942-ovni-antiaereo":
"1 enlace (→ Kenneth Arnold, «cinco años antes de que Arnold acuñara "
"el término platillo volante»). El resto son Knox, Stimson y Marshall, "
"sin artículo propio.",
"betty-y-barney-hill-1961-el-primer-caso-de-abduccion-extraterrestre-documentado":
"0 enlaces; la prosa no nombra ningún otro caso del catálogo ES.",
"cefaa-chile-unidad-oficial-ovni":
"1 enlace (→ la segunda desclasificación). Caso chileno autocontenido.",
"chiles-whitted-1948-piloto-eastern-airlines-ovni":
"0 enlaces; nombra a Chiles, Whitted y el Proyecto Sign, ninguno con "
"artículo propio en el blog ES.",
"eeuu-desclasifica-162-archivos-secretos-sobre-ovnis-y-vida-extraterrestre-el-pentagono-abre-sus-bovedas":
"1 enlace (→ AARO). El resto del texto son agencias y el caso "
"Bariloche, sin artículos propios.",
"el-incidente-nimitz-2004-cuando-pilotos-de-caza-f-18-se-toparon-con-lo-inexplicable":
"1 enlace (→ Grusch, la única entidad del corpus en la prosa).",
"el-incidente-ovni-de-rendlesham-forest-1980-el-caso-mas-documentado-de-la-historia-militar-britanica":
"1 enlace (→ Roswell, sobre «el Roswell británico»). Halt, Penniston y "
"el faro de Orford Ness no tienen artículo propio.",
"gary-mckinnon-el-hacker-que-vio-algo-en-los-servidores-de-la-nasa":
"1 enlace (→ PURSUE). Trata de un caso de hacking y extradición, no de "
"avistamientos: no hay 2ª entidad del catálogo.",
"gimbal-gofast-uap-uss-theodore-roosevelt-2015":
"0 enlaces; los vídeos de la Navy se narran con sus propias fuentes.",
"la-explosion-de-luz-de-canarias-1976-testigos-militares-espanoles-desvelan-un-secreto-de-la-guerra-fria":
"0 enlaces; caso canario autocontenido (Padrón, los Poseidon C-3).",
"la-noche-que-el-cielo-se-nego-a-revelar-sus-secretos-el-caso-manises-de-1979":
"0 enlaces; la prosa no nombra otro caso del corpus.",
"las-revelaciones-del-pentagono-sobre-uap-un-hito-historico-en-la-busqueda-de-la-verdad-sobre-fenomenos-anomalos-no-identificados":
"1 enlace (→ Immaculate Constellation, el programa del que trata).",
"los-villares-1996-el-encuentro-extraterrestre-que-marco-la-ufologia-espanola":
"1 enlace (→ Manises, el otro caso español). Sin 2ª entidad honesta.",
"pascagoula-1973-abduccion-hickson-parker":
"0 enlaces; nombra a Hickson, Parker y Hynek, sin artículo propio.",
"phoenix-lights-1997-la-noche-cuando-arizona-vio-lo-inexplicable":
"0 enlaces; los candidatos temáticos serían forzados.",
"travis-walton-1975-abduccion-ovni-arizona":
"1 enlace (→ Betty y Barney Hill, por la película que disparó los "
"reportes de abducción). Sin 2ª entidad del corpus.",
"varginha-1996-el-caso-ovni-mas-intrigante-de-brasil":
"1 enlace (→ Roswell, por la comparación de los restos). Chereze y "
"Mudinho son de su propio caso.",
"vaticano-extraterrestres-iglesia-catolica":
"0 enlaces; trata de teología y astrobiología, no de casos UAP.",
},
# Los dos Roswell del ES comparten caso y año, así que topic.collision va a
# seguir avisando siempre. Se resolvió por la vía que propone la propia regla
# ("retitle to a distinct angle" + "interlink deliberately"), no fusionando:
# el de mayo es el relato de referencia (2.278 palabras, 3 clics / 152 impr.)
# y el de julio cubre otra cosa — el vídeo de 2025 de los Archivos Nacionales,
# el diario de Marcel y la confesión póstuma de Haut. El 2026-07-21 el de julio
# se retituló a su ángulo real (dejó de presentarse como "Roswell 1947: El
# Accidente OVNI...") y se enlazaron entre sí en ambos sentidos.
"topic": {
"roswell-1947-el-misterio-que-cambio-la-historia-de-la-ufologia":
"Complementario, no duplicado: es el relato de referencia y enlaza al "
"de julio como «las preguntas que 78 años no han resuelto». "
"(Decidido 2026-07-21 con datos de Search Console.)",
"roswell-1947-preguntas-sin-respuesta-78-anos":
"Complementario, no duplicado: retitulado a su ángulo propio (el vídeo "
"y el diario) y enlaza al de mayo sobre «Ese relato ya lo cubrimos». "
"(Decidido 2026-07-21 con datos de Search Console.)",
},
},
}
def accepted_reason(rule, slug, site="en"):
"""Devuelve la razón documentada si (rule, slug) está waiveado EN ESE SITIO.
Empareja la familia de reglas con una Violation cuyo `rule` sea la clave
misma o "<clave>.<detalle>" (la familia "internal_links" cubre
"internal_links.too_few").
`site` por defecto "en" para no romper a seo_audit.py, que es EN-only.
Quien audite el ES DEBE pasar site="es" explícitamente.
"""
for family, slugs in EXCEPTIONS.get(site, {}).items():
if (rule == family or rule.startswith(family + ".")) and slug in slugs:
return slugs[slug]
return None
def collisions():
"""Slugs waiveados en más de un SITIO. Debe estar SIEMPRE vacío: si algo
aparece aquí, hay una excepción ambigua que revisar a mano.
Cuenta una vez por sitio, no por familia de reglas: un mismo post puede
tener waivers en varias familias del mismo blog sin que eso sea ambiguo
(el par Roswell del ES está waiveado en `internal_links` y en `topic`).
"""
from collections import Counter
c = Counter()
for fams in EXCEPTIONS.values():
site_slugs = set()
for slugs in fams.values():
site_slugs.update(slugs.keys())
c.update(site_slugs)
return sorted(s for s, n in c.items() if n > 1)