#!/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 `
//`. 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())