From b0d5e1bd0c14882c02e887c0c25d2fe49f718fb0 Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Tue, 21 Jul 2026 07:41:18 +0000 Subject: [PATCH] =?UTF-8?q?seo=5Fwatch:=20check=20de=20may=C3=BAsculas=20e?= =?UTF-8?q?n=20el=20ES=20+=20arregla=20los=20checks=20de=20enlaces=20del?= =?UTF-8?q?=20ES?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El check nuevo detecta encabezados en Title Case inglés, que en español no se usa. Solo corre sobre el ES (en el EN es lo correcto). Umbral de 2 palabras sospechosas por encabezado: con 1 sola, un nombre propio que falte en las listas daría un falso positivo. Verificado en los dos sentidos — 0 avisos sobre los 271 encabezados del ES ya limpios, 208 sobre el corpus de ayer, 0 sobre el corpus EN. Hace falta porque la fuga es intermitente: el 2026-07-21 había 219 encabezados sucios en 23 de 29 posts, pero otros del mismo mes salieron limpios, así que no basta con limpiar una vez. Y de paso, un agujero que apareció leyendo el código para meter el check: internal_urls() tenía theexclusionzone.com escrito a fuego. Desde que el vigilante se extendió al ES (ayer), los checks de enlaces rotos, no canónicos y prematuros buscaban en el corpus español un dominio que no aparece en él — o sea, veían 0 enlaces y no podían fallar nunca. Ahora el dominio sale de R.SITE_HOST, como el resto. Nada más arreglarlo encontró su primer hallazgo real: los-villares enlazaba a www.zonadeexclusion.com, y en el ES el canónico es el apex (al revés que en el EN). Salto 301 evitable, ya corregido en Ghost. --- seo_watch.py | 105 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 6 deletions(-) diff --git a/seo_watch.py b/seo_watch.py index a09d1e8..27a3920 100644 --- a/seo_watch.py +++ b/seo_watch.py @@ -110,7 +110,13 @@ def http_status(url): def internal_urls(html): - return {u.split("#")[0] for u in re.findall(r'href="(https?://[^"]*theexclusionzone\.com[^"]*)"', html or "")} + """Enlaces al sitio ACTIVO. El dominio sale de R.SITE_HOST, que fija + use_site(): estuvo clavado a theexclusionzone.com y, al extender el + vigilante al ES (2026-07-20), dejó los checks de enlaces rotos, no + canónicos y prematuros mirando a un dominio que no existe en ese corpus — + o sea, sin mirar nada. Corregido el 2026-07-21.""" + pat = r'href="(https?://[^"]*' + re.escape(R.SITE_HOST) + r'[^"]*)"' + return {u.split("#")[0] for u in re.findall(pat, html or "")} def slug_of(url): @@ -216,6 +222,88 @@ def check_sitemap(): return len(urls), malos +# Encabezados en Title Case inglés: el generador del blog ES lo cuela a ratos +# (el 2026-07-21 había 219 en 23 de 29 posts, pero otros del mismo mes salieron +# limpios — es una fuga intermitente, no deuda cerrada). Estas dos listas son +# las mismas que se usaron en aquella limpieza: sin ellas, un titular legítimo +# lleno de nombres propios se marcaría como error. +CASE_PHRASES = [ + "Guerra Fría", "Segunda Guerra Mundial", "Archivos Nacionales", + "Fuerza Aérea", "Proyecto Mogul", "Proyecto Sign", "Proyecto Blue Book", + "Blue Book", "Casa Blanca", "Área 51", "Immaculate Constellation", + "Zeta Reticuli", "Indian Head", "Turkey Springs", "Phoenix Lights", + "Lake Huron", "Estados Unidos", "Reino Unido", "Nuevo México", + "Vía Láctea", "Marco Eli Chereze", "Travis Walton", "Kurt Russell", + "Donald Keyhoe", "Theresa May", "Jesse Marcel", "Walter Haut", + "Maria Blair", "Kenneth Arnold", "Memorándum Halt", "Moment of Contact", + "Los Ángeles", "Los Villares", "Puerto Rico", "El Yunque", + "Francisco Julio Padrón", # «Julio» aquí es nombre de pila, no el mes +] +CASE_KEEP = { + "Pentágono", "Roswell", "Rendlesham", "Varginha", "Manises", "Nimitz", + "Grusch", "Arnold", "Elizondo", "Penniston", "Halt", "Marcel", "Haut", + "Keyhoe", "Walton", "Chereze", "Mudinho", "Padrón", "Blair", "Russell", + "Trump", "Obama", "Obama-Trump", "Hynek", "Klass", "Jafari", "Galileo", + "Apolo", "ExoMars", "GoFast", "Gimbal", "Vaticano", "Antártida", "Chile", + "Argentina", "Bariloche", "Japón", "Brasil", "España", "Canarias", + "Mediterráneo", "Hollywood", "Congreso", "Capitolio", "Marte", "Bolivia", + "Perú", "México", "Colombia", "Venezuela", "Escocia", "Bretaña", + "Francia", "Rusia", "Sign", "Immaculate", "Constellation", "Phoenix", + "Lights", "Zeta", "Reticuli", "Indian", "Head", "Turkey", "Springs", + "Lake", "Huron", "Villares", "Yunque", "Ángeles", "Memorándum", "Moment", + "Contact", "Dr.", "Sr.", "San", "Santa", "Francisco", "David", "Kurt", + "Travis", "Donald", "Theresa", "Jesse", "Walter", "Maria", "Marco", "Eli", + "Kenneth", "Ártico", "Julio", +} +_CASE_WORD = re.compile(r"[0-9A-Za-zÁÉÍÓÚÜÑáéíóúüñ][0-9A-Za-zÁÉÍÓÚÜÑáéíóúüñ'’\-\.]*") +_CASE_ABBR = re.compile(r"(?:^|\s)(?:[A-ZÁÉÍÓÚÑ]{1,3}|Dr|Sr|Sra|vs|etc)\.\s*$") + + +def _title_case_words(text): + """Palabras capitalizadas al estilo inglés que NO son nombre propio. + + Ignora la primera palabra y la que abre frase tras . ? ! (pero no tras dos + puntos ni raya, que en español no llevan mayúscula), los acrónimos (OVNI, + AARO) y todo lo que esté en las listas de arriba. + """ + for ph in CASE_PHRASES: + text = re.sub(re.escape(ph), " ", text, flags=re.IGNORECASE) + malas, first = [], True + for m in _CASE_WORD.finditer(text): + tok = m.group(0) + starts = first or (re.search(r"[.?!]\s*$", text[:m.start()]) + and not _CASE_ABBR.search(text[:m.start()])) + if starts: + first = False + continue + titled = tok.isupper() if len(tok) == 1 else (tok[:1].isupper() and tok[1:].islower()) + if titled and tok not in CASE_KEEP: + malas.append(tok) + return malas + + +def check_heading_case(posts, site): + """Encabezados con mayúsculas al estilo inglés. Solo en el blog ES: en el + EN el Title Case es lo correcto. + + Umbral de 2 palabras: con 1 sola, un nombre propio que falte en las listas + daría un falso positivo; un titular de verdad en Title Case las tiene a + pares. Verificado contra los 271 encabezados del ES ya limpios: 0 avisos. + """ + if site != "es": + return [] + out = [] + for p in posts: + if p.get("status") != "published": + continue + for lvl, inner in re.findall(r"]*>(.*?)", p.get("html") or "", re.S): + texto = re.sub(r"<[^>]+>", "", inner).strip() + malas = _title_case_words(texto) + if len(malas) >= 2: + out.append((p["slug"], texto[:50], malas[:4])) + return out + + def check_rules(posts, site): """Violaciones accionables (excluye waivers e INFO), agrupadas por regla.""" out = {} @@ -231,7 +319,7 @@ def check_rules(posts, site): # ---------- salida ---------- -def build_report(roto, nocanon, prematuro, envejecido, n_sitemap, sm_malos, reglas): +def build_report(roto, nocanon, prematuro, envejecido, n_sitemap, sm_malos, reglas, mayus): L = [] if roto: L.append(f"🔴 {len(roto)} enlace(s) interno(s) ROTO(S):") @@ -248,6 +336,9 @@ def build_report(roto, nocanon, prematuro, envejecido, n_sitemap, sm_malos, regl if sm_malos: 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]] + if mayus: + L.append(f"🔤 {len(mayus)} encabezado(s) en Title Case inglés (el ES va en mayúscula de oración):") + L += [f" {s[:24]}: «{t[:34]}» ({', '.join(w)})" for s, t, w in mayus[:6]] if reglas: tot = sum(len(v) for v in reglas.values()) L.append(f"📋 {tot} violación(es) accionable(s) del auditor:") @@ -257,13 +348,14 @@ def build_report(roto, nocanon, prematuro, envejecido, n_sitemap, sm_malos, regl return "\n".join(L) -def fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas): +def fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas, mayus): return json.dumps({ "roto": sorted(f"{s}|{u}" for s, u in roto), "nocanon": sorted(f"{s}|{u}" for s, u in nocanon), "prematuro": sorted(f"{s}|{t}" for s, t, _, _ in prematuro), "envejecido": sorted(f"{s}|{t}|{m}" for s, _, t, m in envejecido), "sitemap": sorted(u for u, _ in sm_malos), + "mayus": sorted(f"{s}|{t}" for s, t, _ in mayus), "reglas": {k: sorted(v) for k, v in reglas.items()}, }, sort_keys=True) @@ -288,13 +380,14 @@ def run_site(site): envejecido = check_link_aging(posts) n_sm, sm_malos = check_sitemap() reglas = check_rules(posts, site) + mayus = check_heading_case(posts, site) - body = build_report(roto, nocanon, prematuro, envejecido, n_sm, sm_malos, reglas) + body = build_report(roto, nocanon, prematuro, envejecido, n_sm, sm_malos, reglas, mayus) pub = sum(1 for p in posts if p.get("status") == "published") header = (f"── [{site.upper()}] {cfg['host']}\n" f" {pub} publicados, {len(posts)} totales, sitemap {n_sm} URLs") - fp = fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas) - hallazgos = bool(roto or nocanon or prematuro or envejecido or sm_malos) + fp = fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas, mayus) + hallazgos = bool(roto or nocanon or prematuro or envejecido or sm_malos or mayus) return header + "\n" + body, fp, hallazgos