diff --git a/seo_exceptions.py b/seo_exceptions.py index d18b1dd..2ff468d 100644 --- a/seo_exceptions.py +++ b/seo_exceptions.py @@ -7,7 +7,13 @@ 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[rule_family][slug] = "documented reason" +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 @@ -23,6 +29,7 @@ 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.) @@ -83,17 +90,38 @@ EXCEPTIONS = { "(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": {}, } -def accepted_reason(rule, slug): - """Return the documented waiver reason if (rule, slug) is an accepted - exception, else None. +def accepted_reason(rule, slug, site="en"): + """Devuelve la razón documentada si (rule, slug) está waiveado EN ESE SITIO. - Matches a rule-family key to a Violation whose `rule` is the key itself or - "." (e.g. family "internal_links" matches "internal_links.too_few"). + Empareja la familia de reglas con una Violation cuyo `rule` sea la clave + misma o "." (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.items(): + 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.""" + from collections import Counter + c = Counter() + for fams in EXCEPTIONS.values(): + for slugs in fams.values(): + c.update(slugs.keys()) + return sorted(s for s, n in c.items() if n > 1) diff --git a/seo_finish.py b/seo_finish.py index a274570..3d0236c 100755 --- a/seo_finish.py +++ b/seo_finish.py @@ -89,10 +89,10 @@ def fetch(slug, cli): sys.exit(f"ERROR: no encuentro el post '{slug}' en {cli}") -def violations_of(post): +def violations_of(post, site): out = [] for v in R.check_post(post): - if v.severity == R.INFO or X.accepted_reason(v.rule, post.get("slug")): + if v.severity == R.INFO or X.accepted_reason(v.rule, post.get("slug"), site): continue out.append(v) return out @@ -142,7 +142,7 @@ def cmd_prep(a): if v: print(f" {v[:150]}") - viol = violations_of(p) + viol = violations_of(p, a.site) print(f"\n─── violaciones vigentes: {len(viol)} ───") for v in viol: print(f" [{v.severity}] {v.rule}: {v.message}") @@ -199,7 +199,7 @@ def cmd_apply(a): print("✓ aplicado") after = fetch(a.slug, cfg["cli"]) - viol = violations_of(after) + viol = violations_of(after, a.site) if viol: print(f"\n⚠ el post SIGUE con {len(viol)} violación(es):") for v in viol: diff --git a/seo_watch.py b/seo_watch.py index a78e100..a09d1e8 100644 --- a/seo_watch.py +++ b/seo_watch.py @@ -216,14 +216,14 @@ def check_sitemap(): return len(urls), malos -def check_rules(posts): +def check_rules(posts, site): """Violaciones accionables (excluye waivers e INFO), agrupadas por regla.""" out = {} for p in posts: if p.get("status") != "published": continue for v in R.check_post(p): - if v.severity == R.INFO or X.accepted_reason(v.rule, p["slug"]): + if v.severity == R.INFO or X.accepted_reason(v.rule, p["slug"], site): continue out.setdefault(v.rule, []).append(p["slug"]) return out @@ -287,7 +287,7 @@ def run_site(site): roto, nocanon, prematuro = check_links(posts) envejecido = check_link_aging(posts) n_sm, sm_malos = check_sitemap() - reglas = check_rules(posts) + reglas = check_rules(posts, site) body = build_report(roto, nocanon, prematuro, envejecido, n_sm, sm_malos, reglas) pub = sum(1 for p in posts if p.get("status") == "published")