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:
+44
-6
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user