seo_watch: el corpus hace de diccionario en el check de mayúsculas

La lista blanca CASE_KEEP no escala y se rompió en el primer artículo real: el
Canarias de prueba del 2026-07-21 traía «El testigo del carguero Osaka Bay» y
«Ricardo Campo Pérez y Vicente-Juan Ballester Olmos», los dos encabezados
correctos, marcados como Title Case porque un barco y dos investigadores no
estaban en la lista. El umbral de 2 palabras protege de un nombre propio
suelto, no de uno de varias palabras — y cada caso nuevo trae nombres nuevos.

Ahora el propio blog resuelve la duda: si el corpus escribe esa palabra en
minúscula en algún otro sitio, capitalizarla a mitad de frase es estilo inglés;
si no aparece nunca en minúscula, es un nombre propio. «ciudad», «borde» y
«pánico» salen por todas partes; «Osaka» y «Ballester» jamás.

minimo=1 elegido barriendo el umbral contra 10 encabezados reales, el corpus
limpio y el sucio de la víspera — la tabla queda en el docstring. Una sola
aparición en minúscula ya prueba lo que hace falta: que la palabra PUEDE ir en
minúscula. Subir el umbral solo pierde detección (10/10 → 7/10 con mínimo 5).

CASE_PHRASES y CASE_KEEP siguen como red: «Guerra Fría» necesita protección
explícita porque «guerra» sí aparece en minúscula. Sin vocabulario, la función
se comporta como antes.
This commit is contained in:
ChemaVX
2026-07-21 08:46:04 +00:00
parent 0844b7596e
commit 48fbe4302f
+47 -4
View File
@@ -39,6 +39,7 @@ import sys
import tempfile import tempfile
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from collections import Counter
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -259,12 +260,50 @@ _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*$") _CASE_ABBR = re.compile(r"(?:^|\s)(?:[A-ZÁÉÍÓÚÑ]{1,3}|Dr|Sr|Sra|vs|etc)\.\s*$")
def _title_case_words(text): def lowercase_vocab(posts, minimo=1):
"""Palabras que el propio corpus escribe EN MINÚSCULA al menos `minimo`
veces. Es el diccionario que decide si una mayúscula sobra.
Nació de un falso positivo real (2026-07-21): «El testigo del carguero
Osaka Bay» y «Ricardo Campo Pérez y Vicente-Juan Ballester Olmos» se
marcaron como Title Case porque esos nombres no estaban en CASE_KEEP. Una
lista blanca a mano no escala — cada caso nuevo trae nombres nuevos — pero
el blog sí sabe la respuesta: «ciudad», «borde» o «pánico» aparecen en
minúscula por todo el corpus, y «Osaka» o «Ballester» no aparecen nunca.
Así que capitalizar las primeras es estilo inglés y las segundas son
nombres propios, sin que nadie tenga que mantener una lista.
minimo=1 elegido barriendo el umbral (2026-07-21) contra una batería de 10
encabezados de verdad, el corpus limpio y el sucio de la víspera:
mínimo vocab aciertos limpio sucio
1 6795 10/10 0 195
2 3184 9/10 0 184
3 2039 8/10 0 167
5 1198 7/10 0 155
Una sola aparición en minúscula ya prueba lo que hace falta probar: que esa
palabra PUEDE ir en minúscula, o sea que no es exclusivamente un nombre
propio. Subir el umbral solo pierde detección.
"""
c = Counter()
for p in posts:
txt = re.sub(r"<[^>]+>", " ", p.get("html") or "")
for w in re.findall(r"(?<![\w-])[a-záéíóúüñ][a-záéíóúüñ]{2,}(?![\w-])", txt):
c[w] += 1
return {w for w, n in c.items() if n >= minimo}
def _title_case_words(text, vocab=None):
"""Palabras capitalizadas al estilo inglés que NO son nombre propio. """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 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, 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. AARO) y todo lo que esté en las listas de arriba.
Con `vocab` (ver lowercase_vocab) solo marca palabras que el corpus escribe
en minúscula en otros sitios: eso descarta los nombres propios sin tener
que enumerarlos. Sin `vocab` se comporta como antes, a base de listas.
""" """
for ph in CASE_PHRASES: for ph in CASE_PHRASES:
text = re.sub(re.escape(ph), " ", text, flags=re.IGNORECASE) text = re.sub(re.escape(ph), " ", text, flags=re.IGNORECASE)
@@ -277,8 +316,11 @@ def _title_case_words(text):
first = False first = False
continue continue
titled = tok.isupper() if len(tok) == 1 else (tok[:1].isupper() and tok[1:].islower()) titled = tok.isupper() if len(tok) == 1 else (tok[:1].isupper() and tok[1:].islower())
if titled and tok not in CASE_KEEP: if not titled or tok in CASE_KEEP:
malas.append(tok) continue
if vocab is not None and tok.lower() not in vocab:
continue # el corpus nunca lo escribe en minúscula → nombre propio
malas.append(tok)
return malas return malas
@@ -292,13 +334,14 @@ def check_heading_case(posts, site):
""" """
if site != "es": if site != "es":
return [] return []
vocab = lowercase_vocab(posts)
out = [] out = []
for p in posts: for p in posts:
if p.get("status") != "published": if p.get("status") != "published":
continue continue
for lvl, inner in re.findall(r"<h([234])[^>]*>(.*?)</h\1>", p.get("html") or "", re.S): for lvl, inner in re.findall(r"<h([234])[^>]*>(.*?)</h\1>", p.get("html") or "", re.S):
texto = re.sub(r"<[^>]+>", "", inner).strip() texto = re.sub(r"<[^>]+>", "", inner).strip()
malas = _title_case_words(texto) malas = _title_case_words(texto, vocab)
if len(malas) >= 2: if len(malas) >= 2:
out.append((p["slug"], texto[:50], malas[:4])) out.append((p["slug"], texto[:50], malas[:4]))
return out return out