Files
ChemaVXandClaude Opus 5 1ac51267fe 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>
2026-07-29 07:42:37 +00:00

336 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())