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:
+46
-3
@@ -39,6 +39,7 @@ import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
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*$")
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
text = re.sub(re.escape(ph), " ", text, flags=re.IGNORECASE)
|
||||
@@ -277,7 +316,10 @@ def _title_case_words(text):
|
||||
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:
|
||||
if not titled or tok in CASE_KEEP:
|
||||
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
|
||||
|
||||
@@ -292,13 +334,14 @@ def check_heading_case(posts, site):
|
||||
"""
|
||||
if site != "es":
|
||||
return []
|
||||
vocab = lowercase_vocab(posts)
|
||||
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)
|
||||
malas = _title_case_words(texto, vocab)
|
||||
if len(malas) >= 2:
|
||||
out.append((p["slug"], texto[:50], malas[:4]))
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user