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