seo_watch: check de mayúsculas en el ES + arregla los checks de enlaces del ES

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.
This commit is contained in:
ChemaVX
2026-07-21 07:41:18 +00:00
parent ea6c24927a
commit b0d5e1bd0c
+99 -6
View File
@@ -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"<h([234])[^>]*>(.*?)</h\1>", 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