títulos del ES en mayúscula de oración: 91 campos y la regla que lo vigila

Los encabezados llevaban limpios desde el 21 de julio, pero la regla solo
miraba <h2>/<h3>/<h4>: 28 de los 31 títulos publicados del ES seguían en
Title Case inglés, con sus meta_title, og_title y twitter_title detrás. Es
justo lo que el lector español lee en Google.

seo_case.py reescribe, y no reutiliza el detector tal cual porque no vale
para esto: `_title_case_words` decide con una sola prueba —«¿la he visto en
minúscula alguna vez?»— y eso deja fuera los verbos que solo viven en
titulares («Ocultan», «Conmocionó», «Penetró») y las palabras de dos letras,
que ni entran en el vocabulario. La primera versión devolvía títulos a medio
arreglar y rompía «Talavera la Real» y «vuelo Iberia». Ahora se decide con
dos pruebas —nunca en minúscula Y en mayúscula a media frase— más una lista
de palabras gramaticales, y las frases propias se enmascaran conservando las
posiciones para reescribir por desplazamiento sobre el original.

Decisión de Jose: «Ejército», «Gobierno» y demás instituciones conservan la
mayúscula; «Archivos» baja salvo en «Los Archivos PURSUE», que se protege
por frase porque ahí se distingue por contexto, no por palabra suelta.

check_title_case cierra el agujero en seo_watch. Simulado el ciclo entero
antes de tocar nada: 78 hallazgos → 16 tras el pase automático → 0 con la
decisión aplicada. Aplicado y verificado releyendo Ghost campo a campo (91
de 91), con backup pristine por post en ~/link-batch-backup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-07-29 07:42:37 +00:00
co-authored by Claude Opus 5
parent 9823255851
commit 1ac51267fe
3 changed files with 720 additions and 6 deletions
+44 -6
View File
@@ -262,6 +262,9 @@ CASE_PHRASES = [
"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
"Talavera la Real", # el pueblo de Badajoz: «Real» es del topónimo
"Archivos PURSUE", # nombre propio de la desclasificación; el resto
# de «archivos» del corpus son nombre común
]
CASE_KEEP = {
"Pentágono", "Roswell", "Rendlesham", "Varginha", "Manises", "Nimitz",
@@ -277,7 +280,7 @@ CASE_KEEP = {
"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",
"Kenneth", "Ártico", "Julio", "Iberia", # la aerolínea del caso Manises
}
_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*$")
@@ -370,6 +373,34 @@ def check_heading_case(posts, site):
return out
def check_title_case(posts, site):
"""Lo mismo, pero en el TÍTULO y el meta_title — que es lo que se lee en Google.
El agujero se vio el 2026-07-29: los encabezados llevaban limpios desde el
21 de julio y, sin embargo, 28 de los 31 títulos publicados del ES seguían
en Title Case inglés, con 16 meta_title detrás. La regla estaba escrita y
funcionando; simplemente nunca había mirado ahí.
Umbral de 2, igual que en los encabezados y por el mismo motivo. Se miran
también `og_title` y `twitter_title` porque son los que viajan a X y a
Facebook, y suelen ser copia del meta: si se limpia uno y no los otros, el
título viejo sigue circulando por ahí.
"""
if site != "es":
return []
vocab = lowercase_vocab(posts)
out = []
for p in posts:
if p.get("status") != "published":
continue
for campo in ("title", "meta_title", "og_title", "twitter_title"):
texto = (p.get(campo) or "").strip()
malas = _title_case_words(texto, vocab)
if len(malas) >= 2:
out.append((p["slug"], f"{campo}: {texto[:44]}", malas[:4]))
return out
def check_rules(posts, site):
"""Violaciones accionables (excluye waivers e INFO), agrupadas por regla."""
out = {}
@@ -385,7 +416,7 @@ def check_rules(posts, site):
# ---------- salida ----------
def build_report(roto, nocanon, prematuro, envejecido, n_sitemap, sm_malos, reglas, mayus):
def build_report(roto, nocanon, prematuro, envejecido, n_sitemap, sm_malos, reglas, mayus, titulos):
L = []
if roto:
L.append(f"🔴 {len(roto)} enlace(s) interno(s) ROTO(S):")
@@ -408,6 +439,9 @@ def build_report(roto, nocanon, prematuro, envejecido, n_sitemap, sm_malos, regl
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 titulos:
L.append(f"🔠 {len(titulos)} título/meta en Title Case inglés (es lo que se lee en Google):")
L += [f" {s[:20]}: {t}" for s, t, _ in titulos[:6]]
if reglas:
tot = sum(len(v) for v in reglas.values())
L.append(f"📋 {tot} violación(es) accionable(s) del auditor:")
@@ -417,7 +451,7 @@ 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, mayus):
def fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas, mayus, titulos):
return json.dumps({
"roto": sorted(f"{s}|{u}" for s, u in roto),
"nocanon": sorted(f"{s}|{u}" for s, u in nocanon),
@@ -425,6 +459,7 @@ def fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas, mayus):
"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),
"titulos": sorted(f"{s}|{t}" for s, t, _ in titulos),
"reglas": {k: sorted(v) for k, v in reglas.items()},
}, sort_keys=True)
@@ -450,13 +485,16 @@ def run_site(site):
n_sm, sm_malos = check_sitemap()
reglas = check_rules(posts, site)
mayus = check_heading_case(posts, site)
titulos = check_title_case(posts, site)
body = build_report(roto, nocanon, prematuro, envejecido, n_sm, sm_malos, reglas, mayus)
body = build_report(roto, nocanon, prematuro, envejecido, n_sm, sm_malos, reglas, mayus,
titulos)
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, mayus)
hallazgos = bool(roto or nocanon or prematuro or envejecido or sm_malos or mayus)
fp = fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas, mayus, titulos)
hallazgos = bool(roto or nocanon or prematuro or envejecido or sm_malos or mayus
or titulos)
return header + "\n" + body, fp, hallazgos