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:
+335
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mayúscula de oración en TÍTULOS y METAS del blog ES.
|
||||
|
||||
Por qué existe. La regla de Title Case inglés ya estaba escrita y funcionando
|
||||
desde el 2026-07-21 —aquella limpieza quitó 219 encabezados— pero
|
||||
`check_heading_case` solo mira `<h2>/<h3>/<h4>`. El título del post y el
|
||||
`meta_title` nunca entraron en su ámbito, y son justo lo que el lector español
|
||||
lee en Google. Barrido del 2026-07-29 sobre los 31 publicados del ES:
|
||||
|
||||
títulos en Title Case inglés 28 de 31
|
||||
meta_title 16 de 31
|
||||
og_title desincronizados de su meta_title 3
|
||||
|
||||
No se reescribe con un modelo ni con reglas de mayúsculas propias: se
|
||||
MINUSCULIZAN EXACTAMENTE las palabras que marca `_title_case_words`, que es la
|
||||
misma función, el mismo vocabulario del corpus y el mismo umbral que ya decide
|
||||
esto en los encabezados. El resto del texto no se toca ni un byte, y el slug
|
||||
NUNCA se toca (cambiarlo obligaría a un redirect 301 y no hay motivo).
|
||||
|
||||
Lo que la máquina NO decide. Hay palabras que llevan mayúscula cuando nombran a
|
||||
la institución («el Ejército del Aire») y minúscula cuando son comunes («el
|
||||
ejército recuperó los restos»). El corpus las escribe de las dos formas, así
|
||||
que el vocabulario las marca y la propuesta las bajaría sin saber cuál es cuál.
|
||||
Esas se apartan en DUDOSAS para que las mire una persona.
|
||||
|
||||
Uso:
|
||||
python3 seo_case.py # en seco: informe de propuestas
|
||||
python3 seo_case.py --md propuestas.md # además, deja el diff en fichero
|
||||
python3 seo_case.py --apply --si-de-verdad
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import seo_rules as R
|
||||
from seo_audit import fetch_posts
|
||||
from seo_watch import CASE_KEEP, CASE_PHRASES, lowercase_vocab
|
||||
|
||||
CLI = "ghst-es"
|
||||
BACKUP_DIR = os.path.expanduser("~/link-batch-backup")
|
||||
|
||||
# Nombres de institución: conservan la mayúscula («el Ejército recuperó los
|
||||
# restos», «lo que el Gobierno acaba de confirmar»). Salieron del barrido como
|
||||
# dudosas —el corpus las escribe de las dos formas— y las resolvió Jose el
|
||||
# 2026-07-29: institución, mayúscula.
|
||||
#
|
||||
# «Archivos» se cayó de esta lista en esa misma decisión: en «222 archivos
|
||||
# OVNI» o «162 archivos secretos» es un nombre común y va en minúscula. El
|
||||
# único que es nombre propio, «Los Archivos PURSUE», se protege por frase en
|
||||
# CASE_PHRASES, que es donde se distingue por contexto y no por palabra suelta.
|
||||
INSTITUCIONES = {"Ejército", "Gobierno", "Estado", "Fuerzas", "Armada",
|
||||
"Iglesia", "Congreso", "Capitolio", "Pentágono"}
|
||||
|
||||
# Sin resolver: palabras que el barrido marque como ambiguas en el futuro. Un
|
||||
# campo que contenga una de estas NO se aplica a medias, va a revisión humana.
|
||||
DUDOSAS = set()
|
||||
|
||||
CAMPOS = ("title", "meta_title", "og_title", "twitter_title")
|
||||
|
||||
# Palabras gramaticales: van SIEMPRE en minúscula a media frase. Hacen falta
|
||||
# explícitamente porque las dos pruebas del corpus no las alcanzan —
|
||||
# `lowercase_vocab` solo recoge palabras de 3 letras o más, así que «el», «la»,
|
||||
# «lo», «un» o «se» jamás entran, y en el cuerpo del blog aparecen capitalizadas
|
||||
# a media frase lo bastante como para pasar por nombre propio. El resultado era
|
||||
# «Betty y Barney Hill 1961: El primer caso…», mitad arreglado.
|
||||
# Los artículos que SON parte de un topónimo («Los Ángeles», «Los Villares»,
|
||||
# «El Yunque») no se ven afectados: van en CASE_PHRASES y se enmascaran antes.
|
||||
FUNCION = {
|
||||
"el", "la", "lo", "los", "las", "un", "una", "unos", "unas", "al", "del",
|
||||
"de", "en", "y", "e", "o", "u", "a", "se", "no", "ni", "si", "su", "sus",
|
||||
"que", "con", "por", "para", "sin", "sobre", "tras", "ante", "entre",
|
||||
"es", "son", "fue", "ya", "más", "muy", "como", "cuando", "donde",
|
||||
"dos", "tres", "sus", "le", "les", "me", "te", "nos",
|
||||
}
|
||||
|
||||
_PAL = re.compile(r"[0-9A-Za-zÁÉÍÓÚÜÑáéíóúüñ][0-9A-Za-zÁÉÍÓÚÜÑáéíóúüñ'’\-\.]*")
|
||||
|
||||
|
||||
def nombres_propios(posts, minimo=1):
|
||||
"""Palabras que el corpus escribe EN MAYÚSCULA A MEDIA FRASE.
|
||||
|
||||
Es la prueba que le faltaba a `lowercase_vocab`, y la razón está medida.
|
||||
`lowercase_vocab` responde «¿la he visto alguna vez en minúscula?», y para
|
||||
DETECTAR sobra: si nunca la has visto en minúscula, callas y no molestas.
|
||||
Para REESCRIBIR no sirve, porque hay verbos vivos que solo existen en los
|
||||
titulares —«Ocultan», «Conmocionó», «Penetró», «Desvelan», «Se Toparon»—
|
||||
y el cuerpo del blog no los usa nunca en minúscula. La primera versión de
|
||||
esta herramienta los dejaba en mayúscula y devolvía títulos a medio
|
||||
arreglar, que es peor que no tocarlos.
|
||||
|
||||
Así que la decisión se toma con las DOS pruebas: una palabra conserva la
|
||||
mayúscula solo si el corpus nunca la escribe en minúscula Y además la
|
||||
escribe en mayúscula a media frase. «Roswell» pasa las dos; «Ocultan» no
|
||||
pasa la segunda y baja.
|
||||
"""
|
||||
c = {}
|
||||
for p in posts:
|
||||
txt = re.sub(r"<[^>]+>", " ", p.get("html") or "")
|
||||
txt = re.sub(r"\s+", " ", txt)
|
||||
primera = True
|
||||
for m in _PAL.finditer(txt):
|
||||
tok = m.group(0)
|
||||
antes = txt[:m.start()]
|
||||
abre = primera or bool(re.search(r"[.?!¿¡:;»\"]\s*$", antes))
|
||||
primera = False
|
||||
if not abre and tok[:1].isupper() and tok[1:].islower():
|
||||
c[tok] = c.get(tok, 0) + 1
|
||||
return {w for w, n in c.items() if n >= minimo}
|
||||
|
||||
|
||||
def _enmascara(texto):
|
||||
"""Sustituye las frases propias por relleno de la MISMA longitud.
|
||||
|
||||
Tiene que conservar las posiciones: la reescritura se hace por desplazamiento
|
||||
sobre el texto original, y `re.sub(frase, ' ')` —lo que hace el detector—
|
||||
acorta la cadena y desalinea todo. Sin esto, «La Batalla de Los Ángeles»
|
||||
perdería la mayúscula de «Los», que es parte del topónimo.
|
||||
"""
|
||||
for ph in CASE_PHRASES:
|
||||
texto = re.sub(re.escape(ph), lambda m: "\0" * len(m.group(0)),
|
||||
texto, flags=re.IGNORECASE)
|
||||
return texto
|
||||
|
||||
|
||||
def propone(texto, vocab, propios):
|
||||
"""(nuevo, bajadas, dudosas). Solo minusculiza; nunca reordena ni recorta."""
|
||||
if not texto:
|
||||
return texto, [], []
|
||||
mascara = _enmascara(texto)
|
||||
chars = list(texto)
|
||||
bajadas, dudosas = [], []
|
||||
for m in _PAL.finditer(mascara):
|
||||
tok = m.group(0)
|
||||
# «Primera palabra» se mide sobre el ORIGINAL, no sobre la máscara. Si
|
||||
# el título abre con una frase propia («Immaculate Constellation: El
|
||||
# programa…»), la máscara la deja en ceros y la palabra siguiente
|
||||
# parecía la primera del texto, así que se salvaba: quedaban «El
|
||||
# programa UAP secreto» y «Archivo secreto OVNI» sin tocar.
|
||||
antes = texto[:m.start()]
|
||||
# En español, tras dos puntos NO va mayúscula: a diferencia del
|
||||
# detector, aquí los dos puntos sí abren minúscula.
|
||||
abre = (not re.search(r"[0-9A-Za-zÁÉÍÓÚÜÑáéíóúüñ]", antes)
|
||||
or bool(re.search(r"[.?!]\s*$", antes)))
|
||||
if abre or "\0" in tok:
|
||||
continue
|
||||
if not (tok[:1].isupper() and tok[1:].islower()):
|
||||
continue # acrónimos (OVNI, EE.UU.) y ya-minúsculas
|
||||
if tok in CASE_KEEP or tok in INSTITUCIONES:
|
||||
continue
|
||||
if tok.lower() not in FUNCION and tok.lower() not in vocab and tok in propios:
|
||||
continue # nombre propio probado por las dos pruebas
|
||||
if tok in DUDOSAS:
|
||||
dudosas.append(tok)
|
||||
continue
|
||||
chars[m.start()] = tok[:1].lower()
|
||||
bajadas.append(tok)
|
||||
return "".join(chars), bajadas, dudosas
|
||||
|
||||
|
||||
def revisa(posts):
|
||||
"""Propuestas por post publicado.
|
||||
|
||||
Lo PROGRAMADO se deja fuera a propósito: sale del remate con las metas ya
|
||||
puestas y aún se puede editar sin prisa; meterlo aquí mezcla una limpieza
|
||||
de lo publicado con la cola de agosto.
|
||||
"""
|
||||
vocab = lowercase_vocab(posts)
|
||||
propios = nombres_propios(posts)
|
||||
fuera = []
|
||||
for p in posts:
|
||||
if p.get("status") != "published":
|
||||
continue
|
||||
cambios, revisar = {}, {}
|
||||
for campo in CAMPOS:
|
||||
actual = p.get(campo) or ""
|
||||
nuevo, _, dud = propone(actual, vocab, propios)
|
||||
if nuevo == actual:
|
||||
continue
|
||||
if dud:
|
||||
# Un campo con una palabra dudosa NO se aplica a medias: dejaría
|
||||
# «162 Archivos secretos», que está peor que como estaba. Va
|
||||
# entero a revisión humana, con la propuesta parcial a la vista.
|
||||
revisar[campo] = (nuevo, sorted(set(dud)))
|
||||
else:
|
||||
cambios[campo] = nuevo
|
||||
# El og/twitter que hoy espeja al meta tiene que seguir espejándolo:
|
||||
# si no, la limpieza deja el título social viejo en Title Case.
|
||||
meta_nuevo = cambios.get("meta_title")
|
||||
if meta_nuevo:
|
||||
for campo in ("og_title", "twitter_title"):
|
||||
if (p.get(campo) or "").strip() == (p.get("meta_title") or "").strip():
|
||||
cambios[campo] = meta_nuevo
|
||||
revisar.pop(campo, None)
|
||||
if cambios or revisar:
|
||||
fuera.append((p, cambios, revisar))
|
||||
return fuera
|
||||
|
||||
|
||||
def comprueba_limites(cambios):
|
||||
"""Los límites duros de Ghost/SERP. Minusculizar no cambia la longitud,
|
||||
así que esto solo puede saltar si el campo YA venía largo: se avisa y no
|
||||
se cuenta como culpa de esta herramienta."""
|
||||
avisos = []
|
||||
mt = cambios.get("meta_title")
|
||||
if mt and len(mt) > R.META_TITLE_MAX:
|
||||
avisos.append(f"meta_title {len(mt)} car. > {R.META_TITLE_MAX} (ya venía largo)")
|
||||
return avisos
|
||||
|
||||
|
||||
def informe(fuera, md=None):
|
||||
L = []
|
||||
tocados = [f for f in fuera if f[1]]
|
||||
n_campos = sum(len(c) for _, c, _ in fuera)
|
||||
n_rev = sum(len(r) for _, _, r in fuera)
|
||||
L.append(f"{len(tocados)} post(s) con títulos/metas en Title Case inglés "
|
||||
f"→ {n_campos} campo(s) automáticos, {n_rev} a decisión humana\n")
|
||||
for p, cambios, revisar in fuera:
|
||||
L.append(f"── {p['slug']}")
|
||||
for campo, nuevo in cambios.items():
|
||||
L.append(f" {campo}")
|
||||
L.append(f" antes: {p.get(campo)}")
|
||||
L.append(f" ahora: {nuevo}")
|
||||
for a in comprueba_limites(cambios):
|
||||
L.append(f" ⚠ {a}")
|
||||
for campo, (nuevo, dud) in revisar.items():
|
||||
L.append(f" {campo} ⚠ DECIDE UNA PERSONA ({', '.join(dud)}: "
|
||||
f"¿institución o nombre común?)")
|
||||
L.append(f" antes: {p.get(campo)}")
|
||||
L.append(f" borr.: {nuevo}")
|
||||
L.append("")
|
||||
texto = "\n".join(L)
|
||||
print(texto)
|
||||
if md:
|
||||
hoy = datetime.date.today().isoformat()
|
||||
with open(md, "w") as fh:
|
||||
fh.write(
|
||||
"# Títulos y metas del ES en mayúscula de oración\n\n"
|
||||
f"Propuesta de `seo_case.py`, generada EN SECO el {hoy}.\n\n"
|
||||
"Solo se minusculizan las palabras que el propio corpus escribe en\n"
|
||||
"minúscula en otros sitios (mismo vocabulario que usa la regla de\n"
|
||||
"encabezados). El resto del texto y el slug no se tocan.\n\n"
|
||||
"```\n" + texto + "\n```\n")
|
||||
print(f"[propuestas escritas en {md}]")
|
||||
return n_campos
|
||||
|
||||
|
||||
def aplica(p, cambios):
|
||||
"""Escribe en Ghost por el mismo camino que seo_finish: backup pristine,
|
||||
`ghst-es post update --from-json` y re-lectura para verificar."""
|
||||
os.makedirs(BACKUP_DIR, exist_ok=True)
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
bpath = os.path.join(BACKUP_DIR, f"es-{p['slug'][:40]}-seocase-pristine-{ts}.json")
|
||||
with open(bpath, "w") as fh:
|
||||
json.dump({"id": p["id"], "slug": p["slug"],
|
||||
**{k: p.get(k) for k in cambios}}, fh, ensure_ascii=False, indent=1)
|
||||
|
||||
fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_case_")
|
||||
os.close(fd)
|
||||
try:
|
||||
with open(path, "w") as fh:
|
||||
json.dump(cambios, fh, ensure_ascii=False)
|
||||
r = subprocess.run(f'{CLI} post update {p["id"]} --from-json "{path}"',
|
||||
shell=True, capture_output=True, text=True, timeout=240)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
if "Slug:" not in r.stdout:
|
||||
print(r.stdout, r.stderr)
|
||||
return False, os.path.basename(bpath)
|
||||
return True, os.path.basename(bpath)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--json", help="posts guardados, en vez de pedirlos a Ghost")
|
||||
ap.add_argument("--md", help="deja el diff completo en este fichero")
|
||||
ap.add_argument("--apply", action="store_true")
|
||||
ap.add_argument("--si-de-verdad", action="store_true",
|
||||
help="obligatorio junto a --apply: son títulos publicados")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.json:
|
||||
data = json.load(open(args.json))
|
||||
posts = data["posts"] if isinstance(data, dict) and "posts" in data else data
|
||||
else:
|
||||
posts = fetch_posts(CLI)
|
||||
|
||||
fuera = revisa(posts)
|
||||
informe(fuera, args.md)
|
||||
|
||||
if not args.apply:
|
||||
print("(en seco: no se ha tocado nada — repite con --apply --si-de-verdad)")
|
||||
return 0
|
||||
if not args.si_de_verdad:
|
||||
print("✗ --apply necesita --si-de-verdad.")
|
||||
return 1
|
||||
|
||||
ok = fallos = 0
|
||||
esperado = {}
|
||||
for p, cambios, _ in fuera:
|
||||
if not cambios:
|
||||
continue
|
||||
bien, backup = aplica(p, cambios)
|
||||
print(f" {'✓' if bien else '✗'} {p['slug']} (backup {backup})")
|
||||
ok += bien
|
||||
fallos += not bien
|
||||
if bien:
|
||||
esperado[p["slug"]] = cambios
|
||||
print(f"\n{ok} post(s) actualizados, {fallos} fallo(s)")
|
||||
|
||||
# Verificación contra la realidad, no contra el código de salida: se
|
||||
# re-lee Ghost y se compara campo a campo. Un «Slug:» en la salida del CLI
|
||||
# solo prueba que la llamada volvió, no que el texto esté guardado.
|
||||
print("\nreleyendo de Ghost para verificar…")
|
||||
ahora = {q["slug"]: q for q in fetch_posts(CLI)}
|
||||
malos = [(s, c) for s, camb in esperado.items() for c, v in camb.items()
|
||||
if (ahora.get(s, {}).get(c) or "") != v]
|
||||
if malos:
|
||||
print(f"⚠ {len(malos)} campo(s) NO quedaron como se pidió:")
|
||||
for s, c in malos[:8]:
|
||||
print(f" {s[:36]} · {c}")
|
||||
return 1
|
||||
print(f"✓ verificado: {sum(len(c) for c in esperado.values())} campos "
|
||||
f"guardados tal cual")
|
||||
return 1 if fallos else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user