""" Reusable SEO rule engine for The Exclusion Zone (EN) — theexclusionzone.com (Ghost). Pure functions, NO I/O. Feed it a Ghost Admin API post dict (with `html` format included) and it returns a list of Violation(rule, severity, message, fix). Shared by: - seo_audit.py — Tool A, site-wide auditor (this engine, run over all posts) - (future) seo_validate.py — Tool C, pre-publish validator (same engine, one draft) Design note: every check is an independent function registered in RULES. To add a rule, write a function (post) -> list[Violation] and append it to RULES. The auditor and the validator both just call check_post(); they never re-implement a check. """ import re import unicodedata from collections import namedtuple # Host del sitio que se está auditando. Decide qué href cuenta como enlace # INTERNO, así que auditar el ES con el host del EN daría cero enlaces internos # en los 31 posts: un informe entero de hallazgos falsos. Sigue siendo el EN por # defecto para no cambiarle el comportamiento a nadie que ya lo use. SITE_HOSTS = {"en": "theexclusionzone.com", "es": "zonadeexclusion.com"} SITE_HOST = SITE_HOSTS["en"] def usar_sitio(site): """Apunta el motor de reglas a uno de los dos blogs. Devuelve el host.""" global SITE_HOST if site not in SITE_HOSTS: raise ValueError(f"sitio desconocido: {site!r}") SITE_HOST = SITE_HOSTS[site] return SITE_HOST # ---- thresholds (single source of truth, reused by validator) ------------- META_TITLE_MAX = 60 META_DESC_MAX = 145 CUSTOM_EXCERPT_MAX = 300 MIN_INTERNAL_LINKS = 2 # ---- severity weights (used to rank "worst first") ------------------------ HIGH, MED, LOW, INFO = 3, 2, 1, 0 SEV_NAME = {HIGH: "HIGH", MED: "MED", LOW: "LOW", INFO: "INFO"} # The Edition-main theme injects BlogPosting JSON-LD globally in default.hbs # ({{#is "post"}} ...