diff --git a/seo_watch.py b/seo_watch.py index 27a3920..c93fba6 100644 --- a/seo_watch.py +++ b/seo_watch.py @@ -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"(?= 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,8 +316,11 @@ 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: - malas.append(tok) + 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"]*>(.*?)", 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