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:
ChemaVX
2026-07-29 07:42:37 +00:00
co-authored by Claude Opus 5
parent 9823255851
commit 1ac51267fe
3 changed files with 720 additions and 6 deletions
+335
View File
@@ -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())
+44 -6
View File
@@ -262,6 +262,9 @@ CASE_PHRASES = [
"Maria Blair", "Kenneth Arnold", "Memorándum Halt", "Moment of Contact", "Maria Blair", "Kenneth Arnold", "Memorándum Halt", "Moment of Contact",
"Los Ángeles", "Los Villares", "Puerto Rico", "El Yunque", "Los Ángeles", "Los Villares", "Puerto Rico", "El Yunque",
"Francisco Julio Padrón", # «Julio» aquí es nombre de pila, no el mes "Francisco Julio Padrón", # «Julio» aquí es nombre de pila, no el mes
"Talavera la Real", # el pueblo de Badajoz: «Real» es del topónimo
"Archivos PURSUE", # nombre propio de la desclasificación; el resto
# de «archivos» del corpus son nombre común
] ]
CASE_KEEP = { CASE_KEEP = {
"Pentágono", "Roswell", "Rendlesham", "Varginha", "Manises", "Nimitz", "Pentágono", "Roswell", "Rendlesham", "Varginha", "Manises", "Nimitz",
@@ -277,7 +280,7 @@ CASE_KEEP = {
"Lake", "Huron", "Villares", "Yunque", "Ángeles", "Memorándum", "Moment", "Lake", "Huron", "Villares", "Yunque", "Ángeles", "Memorándum", "Moment",
"Contact", "Dr.", "Sr.", "San", "Santa", "Francisco", "David", "Kurt", "Contact", "Dr.", "Sr.", "San", "Santa", "Francisco", "David", "Kurt",
"Travis", "Donald", "Theresa", "Jesse", "Walter", "Maria", "Marco", "Eli", "Travis", "Donald", "Theresa", "Jesse", "Walter", "Maria", "Marco", "Eli",
"Kenneth", "Ártico", "Julio", "Kenneth", "Ártico", "Julio", "Iberia", # la aerolínea del caso Manises
} }
_CASE_WORD = re.compile(r"[0-9A-Za-zÁÉÍÓÚÜÑáéíóúüñ][0-9A-Za-zÁÉÍÓÚÜÑáéíóúüñ'\-\.]*") _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*$") _CASE_ABBR = re.compile(r"(?:^|\s)(?:[A-ZÁÉÍÓÚÑ]{1,3}|Dr|Sr|Sra|vs|etc)\.\s*$")
@@ -370,6 +373,34 @@ def check_heading_case(posts, site):
return out return out
def check_title_case(posts, site):
"""Lo mismo, pero en el TÍTULO y el meta_title — que es lo que se lee en Google.
El agujero se vio el 2026-07-29: los encabezados llevaban limpios desde el
21 de julio y, sin embargo, 28 de los 31 títulos publicados del ES seguían
en Title Case inglés, con 16 meta_title detrás. La regla estaba escrita y
funcionando; simplemente nunca había mirado ahí.
Umbral de 2, igual que en los encabezados y por el mismo motivo. Se miran
también `og_title` y `twitter_title` porque son los que viajan a X y a
Facebook, y suelen ser copia del meta: si se limpia uno y no los otros, el
título viejo sigue circulando por ahí.
"""
if site != "es":
return []
vocab = lowercase_vocab(posts)
out = []
for p in posts:
if p.get("status") != "published":
continue
for campo in ("title", "meta_title", "og_title", "twitter_title"):
texto = (p.get(campo) or "").strip()
malas = _title_case_words(texto, vocab)
if len(malas) >= 2:
out.append((p["slug"], f"{campo}: {texto[:44]}", malas[:4]))
return out
def check_rules(posts, site): def check_rules(posts, site):
"""Violaciones accionables (excluye waivers e INFO), agrupadas por regla.""" """Violaciones accionables (excluye waivers e INFO), agrupadas por regla."""
out = {} out = {}
@@ -385,7 +416,7 @@ def check_rules(posts, site):
# ---------- salida ---------- # ---------- salida ----------
def build_report(roto, nocanon, prematuro, envejecido, n_sitemap, sm_malos, reglas, mayus): def build_report(roto, nocanon, prematuro, envejecido, n_sitemap, sm_malos, reglas, mayus, titulos):
L = [] L = []
if roto: if roto:
L.append(f"🔴 {len(roto)} enlace(s) interno(s) ROTO(S):") L.append(f"🔴 {len(roto)} enlace(s) interno(s) ROTO(S):")
@@ -408,6 +439,9 @@ def build_report(roto, nocanon, prematuro, envejecido, n_sitemap, sm_malos, regl
if mayus: if mayus:
L.append(f"🔤 {len(mayus)} encabezado(s) en Title Case inglés (el ES va en mayúscula de oración):") L.append(f"🔤 {len(mayus)} encabezado(s) en Title Case inglés (el ES va en mayúscula de oración):")
L += [f" {s[:24]}: «{t[:34]}» ({', '.join(w)})" for s, t, w in mayus[:6]] L += [f" {s[:24]}: «{t[:34]}» ({', '.join(w)})" for s, t, w in mayus[:6]]
if titulos:
L.append(f"🔠 {len(titulos)} título/meta en Title Case inglés (es lo que se lee en Google):")
L += [f" {s[:20]}: {t}" for s, t, _ in titulos[:6]]
if reglas: if reglas:
tot = sum(len(v) for v in reglas.values()) tot = sum(len(v) for v in reglas.values())
L.append(f"📋 {tot} violación(es) accionable(s) del auditor:") L.append(f"📋 {tot} violación(es) accionable(s) del auditor:")
@@ -417,7 +451,7 @@ def build_report(roto, nocanon, prematuro, envejecido, n_sitemap, sm_malos, regl
return "\n".join(L) return "\n".join(L)
def fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas, mayus): def fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas, mayus, titulos):
return json.dumps({ return json.dumps({
"roto": sorted(f"{s}|{u}" for s, u in roto), "roto": sorted(f"{s}|{u}" for s, u in roto),
"nocanon": sorted(f"{s}|{u}" for s, u in nocanon), "nocanon": sorted(f"{s}|{u}" for s, u in nocanon),
@@ -425,6 +459,7 @@ def fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas, mayus):
"envejecido": sorted(f"{s}|{t}|{m}" for s, _, t, m in envejecido), "envejecido": sorted(f"{s}|{t}|{m}" for s, _, t, m in envejecido),
"sitemap": sorted(u for u, _ in sm_malos), "sitemap": sorted(u for u, _ in sm_malos),
"mayus": sorted(f"{s}|{t}" for s, t, _ in mayus), "mayus": sorted(f"{s}|{t}" for s, t, _ in mayus),
"titulos": sorted(f"{s}|{t}" for s, t, _ in titulos),
"reglas": {k: sorted(v) for k, v in reglas.items()}, "reglas": {k: sorted(v) for k, v in reglas.items()},
}, sort_keys=True) }, sort_keys=True)
@@ -450,13 +485,16 @@ def run_site(site):
n_sm, sm_malos = check_sitemap() n_sm, sm_malos = check_sitemap()
reglas = check_rules(posts, site) reglas = check_rules(posts, site)
mayus = check_heading_case(posts, site) mayus = check_heading_case(posts, site)
titulos = check_title_case(posts, site)
body = build_report(roto, nocanon, prematuro, envejecido, n_sm, sm_malos, reglas, mayus) body = build_report(roto, nocanon, prematuro, envejecido, n_sm, sm_malos, reglas, mayus,
titulos)
pub = sum(1 for p in posts if p.get("status") == "published") pub = sum(1 for p in posts if p.get("status") == "published")
header = (f"── [{site.upper()}] {cfg['host']}\n" header = (f"── [{site.upper()}] {cfg['host']}\n"
f" {pub} publicados, {len(posts)} totales, sitemap {n_sm} URLs") f" {pub} publicados, {len(posts)} totales, sitemap {n_sm} URLs")
fp = fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas, mayus) fp = fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas, mayus, titulos)
hallazgos = bool(roto or nocanon or prematuro or envejecido or sm_malos or mayus) hallazgos = bool(roto or nocanon or prematuro or envejecido or sm_malos or mayus
or titulos)
return header + "\n" + body, fp, hallazgos return header + "\n" + body, fp, hallazgos
+341
View File
@@ -0,0 +1,341 @@
# Títulos y metas del ES en mayúscula de oración
Propuesta de `seo_case.py`, generada EN SECO el 2026-07-29.
Solo se minusculizan las palabras que el propio corpus escribe en
minúscula en otros sitios (mismo vocabulario que usa la regla de
encabezados). El resto del texto y el slug no se tocan.
```
25 post(s) con títulos/metas en Title Case inglés → 72 campo(s) automáticos, 19 a decisión humana
── trans-en-provence-1981-ovni-gepan
title
antes: La Noche del Misterio en Trans-en-Provence: El Caso OVNI Mejor Documentado de la Historia
ahora: La noche del misterio en Trans-en-Provence: el caso OVNI mejor documentado de la historia
── chiles-whitted-1948-piloto-eastern-airlines-ovni
title
antes: El Encuentro de Chiles-Whitted: Cuando Dos Pilotos Presenciaron lo Inexplicable a 5.000 Pies de Altura
ahora: El encuentro de Chiles-Whitted: cuando dos pilotos presenciaron lo inexplicable a 5.000 pies de altura
og_title
antes: Chiles-Whitted 1948: Pilotos ante lo Inexplicable
ahora: Chiles-Whitted 1948: pilotos ante lo inexplicable
── batalla-los-angeles-1942-ovni-antiaereo
title
antes: La Batalla de Los Ángeles 1942: Pánico Antiaéreo Bajo Reflectores y Misterio Sin Resolver
ahora: La batalla de Los Ángeles 1942: pánico antiaéreo bajo reflectores y misterio sin resolver
og_title
antes: La Batalla de Los Ángeles 1942: Pánico y Misterio
ahora: La batalla de Los Ángeles 1942: pánico y misterio
── vaticano-extraterrestres-iglesia-catolica
title
antes: El Vaticano y los Extraterrestres: Una Iglesia Abierta al Misterio del Cosmos
ahora: El Vaticano y los extraterrestres: una Iglesia abierta al misterio del cosmos
og_title
antes: El Vaticano y los Extraterrestres
ahora: El Vaticano y los extraterrestres
── pascagoula-1973-abduccion-hickson-parker
title
antes: Pascagoula 1973: La Noche que Cambió a Dos Pescadores para Siempre
ahora: Pascagoula 1973: la noche que cambió a dos pescadores para siempre
meta_title
antes: Pascagoula 1973: La Abducción Alien Mejor Documentada
ahora: Pascagoula 1973: la abducción alien mejor documentada
og_title
antes: Pascagoula 1973: La Abducción Alien Mejor Documentada
ahora: Pascagoula 1973: la abducción alien mejor documentada
twitter_title
antes: Pascagoula 1973: La Abducción Alien Mejor Documentada
ahora: Pascagoula 1973: la abducción alien mejor documentada
── los-villares-1996-el-encuentro-extraterrestre-que-marco-la-ufologia-espanola
title
antes: Los Villares 1996: El Encuentro Extraterrestre que Marcó la Ufología Española
ahora: Los Villares 1996: el encuentro extraterrestre que marcó la ufología española
meta_title
antes: Los Villares 1996: El Encuentro OVNI que Marcó España
ahora: Los Villares 1996: el encuentro OVNI que marcó España
og_title
antes: Los Villares 1996: El Encuentro OVNI que Marcó España
ahora: Los Villares 1996: el encuentro OVNI que marcó España
twitter_title
antes: Los Villares 1996: El Encuentro OVNI que Marcó España
ahora: Los Villares 1996: el encuentro OVNI que marcó España
── gary-mckinnon-el-hacker-que-vio-algo-en-los-servidores-de-la-nasa
title
antes: Gary McKinnon: El Hacker que Vio Algo en los Servidores de la NASA
ahora: Gary McKinnon: el hacker que vio algo en los servidores de la NASA
meta_title
antes: Gary McKinnon: El Hacker que Penetró el Pentágono
ahora: Gary McKinnon: el hacker que penetró el Pentágono
og_title
antes: Gary McKinnon: El Hacker que Penetró el Pentágono
ahora: Gary McKinnon: el hacker que penetró el Pentágono
twitter_title
antes: Gary McKinnon: El Hacker que Penetró el Pentágono
ahora: Gary McKinnon: el hacker que penetró el Pentágono
── gimbal-gofast-uap-uss-theodore-roosevelt-2015
title
antes: Gimbal y GoFast: Lo que los Vídeos del USS Theodore Roosevelt Siguen sin Explicar
ahora: Gimbal y GoFast: lo que los vídeos del USS Theodore Roosevelt siguen sin explicar
── grusch-capitolio-9-junio-2026-divulgacion-uap
title
antes: Grusch en el Capitolio: El Día que la Divulgación UAP Pasó del Testimonio a la Exigencia
ahora: Grusch en el Capitolio: el día que la divulgación UAP pasó del testimonio a la exigencia
── aaro-vs-grusch-pentagono-testimonio-contradiccion
title
antes: AARO vs. Grusch: Cuando el Pentágono y un Testigo bajo Juramento se Contradicen
ahora: AARO vs. Grusch: cuando el Pentágono y un testigo bajo juramento se contradicen
── archivos-pursue-uap-bases-nucleares-desclasificado
title ⚠ DECIDE UNA PERSONA (Archivos, Gobierno: ¿institución o nombre común?)
antes: Los Archivos PURSUE y las Bases Nucleares: Lo que el Gobierno Acaba de Confirmar
borr.: Los Archivos PURSUE y las bases nucleares: lo que el Gobierno acaba de confirmar
── 222-archivos-ovni-desclasificados-pentagono-2026
title ⚠ DECIDE UNA PERSONA (Archivos: ¿institución o nombre común?)
antes: 222 Archivos OVNI Desclasificados: Lo que Revelan, lo que Ocultan y lo que Sigue sin Respuesta
borr.: 222 Archivos OVNI desclasificados: lo que revelan, lo que ocultan y lo que sigue sin respuesta
meta_title ⚠ DECIDE UNA PERSONA (Archivos: ¿institución o nombre común?)
antes: 222 Archivos OVNI: Lo que Revelan y lo que Ocultan
borr.: 222 Archivos OVNI: lo que revelan y lo que ocultan
og_title ⚠ DECIDE UNA PERSONA (Archivos: ¿institución o nombre común?)
antes: 222 Archivos OVNI: Lo que Revelan y lo que Ocultan
borr.: 222 Archivos OVNI: lo que revelan y lo que ocultan
twitter_title ⚠ DECIDE UNA PERSONA (Archivos: ¿institución o nombre común?)
antes: 222 Archivos OVNI: Lo que Revelan y lo que Ocultan
borr.: 222 Archivos OVNI: lo que revelan y lo que ocultan
── kecksburg-1965-objeto-ejercito-pennsylvania
meta_title
antes: Kecksburg 1965: El Roswell de Pennsylvania
ahora: Kecksburg 1965: el Roswell de Pennsylvania
og_title
antes: Kecksburg 1965: El Roswell de Pennsylvania
ahora: Kecksburg 1965: el Roswell de Pennsylvania
twitter_title
antes: Kecksburg 1965: El Roswell de Pennsylvania
ahora: Kecksburg 1965: el Roswell de Pennsylvania
title ⚠ DECIDE UNA PERSONA (Ejército: ¿institución o nombre común?)
antes: Kecksburg 1965: El Objeto que el Ejército Recuperó y Nadie Explicó
borr.: Kecksburg 1965: el objeto que el Ejército recuperó y nadie explicó
── immaculate-constellation-programa-secreto-pentagon-ovni
title
antes: Immaculate Constellation: El Programa Secreto del Pentágono que Archiva Todos los OVNIs
ahora: Immaculate Constellation: el programa secreto del Pentágono que archiva todos los OVNIs
meta_title
antes: Immaculate Constellation: Archivo Secreto OVNI del Pentágono
ahora: Immaculate Constellation: archivo secreto OVNI del Pentágono
og_title
antes: Immaculate Constellation: Archivo Secreto OVNI del Pentágono
ahora: Immaculate Constellation: archivo secreto OVNI del Pentágono
twitter_title
antes: Immaculate Constellation: Archivo Secreto OVNI del Pentágono
ahora: Immaculate Constellation: archivo secreto OVNI del Pentágono
── cefaa-chile-unidad-oficial-ovni
title
antes: CEFAA: Chile, el Único País del Mundo con una Unidad Oficial Investigando OVNIs
ahora: CEFAA: Chile, el único país del mundo con una unidad oficial investigando OVNIs
meta_title
antes: CEFAA: Chile y su Unidad Oficial de Investigación OVNI
ahora: CEFAA: Chile y su unidad oficial de investigación OVNI
og_title
antes: CEFAA: Chile y su Unidad Oficial de Investigación OVNI
ahora: CEFAA: Chile y su unidad oficial de investigación OVNI
twitter_title
antes: CEFAA: Chile y su Unidad Oficial de Investigación OVNI
ahora: CEFAA: Chile y su unidad oficial de investigación OVNI
── segunda-desclasificacion-ovni-mayo-2026
title
antes: Segunda Desclasificación OVNI Mayo 2026: Apolo 12, Bases Nucleares y 51 Vídeos Inéditos
ahora: Segunda desclasificación OVNI mayo 2026: Apolo 12, bases nucleares y 51 vídeos inéditos
meta_title
antes: Segunda Desclasificación OVNI: Apolo 12 y Bases Nucleares
ahora: Segunda desclasificación OVNI: Apolo 12 y bases nucleares
og_title
antes: Segunda Desclasificación OVNI: Apolo 12 y Bases Nucleares
ahora: Segunda desclasificación OVNI: Apolo 12 y bases nucleares
twitter_title
antes: Segunda Desclasificación OVNI: Apolo 12 y Bases Nucleares
ahora: Segunda desclasificación OVNI: Apolo 12 y bases nucleares
── travis-walton-1975-abduccion-ovni-arizona
title
antes: Fuego en el Cielo: La Abducción de Travis Walton que Conmocionó a Arizona en 1975
ahora: Fuego en el cielo: la abducción de Travis Walton que conmocionó a Arizona en 1975
meta_title
antes: Travis Walton 1975: La Abducción con Seis Testigos
ahora: Travis Walton 1975: la abducción con seis testigos
og_title
antes: Travis Walton 1975: La Abducción con Seis Testigos
ahora: Travis Walton 1975: la abducción con seis testigos
twitter_title
antes: Travis Walton 1975: La Abducción con Seis Testigos
ahora: Travis Walton 1975: la abducción con seis testigos
── betty-y-barney-hill-1961-el-primer-caso-de-abduccion-extraterrestre-documentado
title
antes: Betty y Barney Hill 1961: El Primer Caso de Abducción Extraterrestre Documentado
ahora: Betty y Barney Hill 1961: el primer caso de abducción extraterrestre documentado
meta_title
antes: Betty y Barney Hill 1961: La Primera Abducción Documentada
ahora: Betty y Barney Hill 1961: la primera abducción documentada
og_title
antes: Betty y Barney Hill 1961: La Primera Abducción Documentada
ahora: Betty y Barney Hill 1961: la primera abducción documentada
twitter_title
antes: Betty y Barney Hill 1961: La Primera Abducción Documentada
ahora: Betty y Barney Hill 1961: la primera abducción documentada
── las-revelaciones-del-pentagono-sobre-uap-un-hito-historico-en-la-busqueda-de-la-verdad-sobre-fenomenos-anomalos-no-identificados
title
antes: Las Revelaciones del Pentágono sobre UAP: Un Hito Histórico en la Búsqueda de la Verdad sobre Fenómenos Anómalos No Identificados
ahora: Las revelaciones del Pentágono sobre UAP: un hito histórico en la búsqueda de la verdad sobre fenómenos anómalos no identificados
meta_title
antes: Immaculate Constellation: El Programa UAP Secreto
ahora: Immaculate Constellation: el programa UAP secreto
og_title
antes: Immaculate Constellation: El Programa UAP Secreto
ahora: Immaculate Constellation: el programa UAP secreto
twitter_title
antes: Immaculate Constellation: El Programa UAP Secreto
ahora: Immaculate Constellation: el programa UAP secreto
── phoenix-lights-1997-la-noche-cuando-arizona-vio-lo-inexplicable
title
antes: Phoenix Lights 1997: La Noche Cuando Arizona Vio lo Inexplicable
ahora: Phoenix Lights 1997: la noche cuando Arizona vio lo inexplicable
meta_title
antes: Phoenix Lights 1997: Dos Eventos que Arizona No Olvidó
ahora: Phoenix Lights 1997: dos eventos que Arizona no olvidó
og_title
antes: Phoenix Lights 1997: Dos Eventos que Arizona No Olvidó
ahora: Phoenix Lights 1997: dos eventos que Arizona no olvidó
twitter_title
antes: Phoenix Lights 1997: Dos Eventos que Arizona No Olvidó
ahora: Phoenix Lights 1997: dos eventos que Arizona no olvidó
── varginha-1996-el-caso-ovni-mas-intrigante-de-brasil
title
antes: Varginha 1996: El Caso OVNI Más Intrigante de Brasil
ahora: Varginha 1996: el caso OVNI más intrigante de Brasil
meta_title ⚠ DECIDE UNA PERSONA (Ejército: ¿institución o nombre común?)
antes: Caso Varginha 1996: El OVNI que el Ejército Silenció
borr.: Caso Varginha 1996: el OVNI que el Ejército silenció
og_title ⚠ DECIDE UNA PERSONA (Ejército: ¿institución o nombre común?)
antes: Caso Varginha 1996: El OVNI que el Ejército Silenció
borr.: Caso Varginha 1996: el OVNI que el Ejército silenció
twitter_title ⚠ DECIDE UNA PERSONA (Ejército: ¿institución o nombre común?)
antes: Caso Varginha 1996: El OVNI que el Ejército Silenció
borr.: Caso Varginha 1996: el OVNI que el Ejército silenció
── el-incidente-nimitz-2004-cuando-pilotos-de-caza-f-18-se-toparon-con-lo-inexplicable
title
antes: El Incidente Nimitz 2004: Cuando Pilotos de Caza F-18 Se Toparon con lo Inexplicable
ahora: El incidente Nimitz 2004: cuando pilotos de caza F-18 se toparon con lo inexplicable
meta_title
antes: Nimitz 2004: El Vídeo UAP que el Pentágono Confirmó
ahora: Nimitz 2004: el vídeo UAP que el Pentágono confirmó
og_title
antes: Nimitz 2004: El Vídeo UAP que el Pentágono Confirmó
ahora: Nimitz 2004: el vídeo UAP que el Pentágono confirmó
twitter_title
antes: Nimitz 2004: El Vídeo UAP que el Pentágono Confirmó
ahora: Nimitz 2004: el vídeo UAP que el Pentágono confirmó
── eeuu-desclasifica-162-archivos-secretos-sobre-ovnis-y-vida-extraterrestre-el-pentagono-abre-sus-bovedas
title ⚠ DECIDE UNA PERSONA (Archivos: ¿institución o nombre común?)
antes: EEUU Desclasifica 162 Archivos Secretos sobre Ovnis y Vida Extraterrestre: El Pentágono Abre sus Bóvedas
borr.: EEUU desclasifica 162 Archivos secretos sobre ovnis y vida extraterrestre: el Pentágono abre sus bóvedas
meta_title ⚠ DECIDE UNA PERSONA (Archivos: ¿institución o nombre común?)
antes: 162 Archivos OVNI Desclasificados: Lo Que Dice el Pentágono
borr.: 162 Archivos OVNI desclasificados: lo que dice el Pentágono
og_title ⚠ DECIDE UNA PERSONA (Archivos: ¿institución o nombre común?)
antes: 162 Archivos OVNI Desclasificados: Lo Que Dice el Pentágono
borr.: 162 Archivos OVNI desclasificados: lo que dice el Pentágono
twitter_title ⚠ DECIDE UNA PERSONA (Archivos: ¿institución o nombre común?)
antes: 162 Archivos OVNI Desclasificados: Lo Que Dice el Pentágono
borr.: 162 Archivos OVNI desclasificados: lo que dice el Pentágono
── roswell-1947-el-misterio-que-cambio-la-historia-de-la-ufologia
title
antes: Roswell 1947: El Misterio Que Cambió la Historia de la Ufología
ahora: Roswell 1947: el misterio que cambió la historia de la ufología
meta_title ⚠ DECIDE UNA PERSONA (Ejército: ¿institución o nombre común?)
antes: Roswell 1947: El Misterio que el Ejército Nunca Explicó
borr.: Roswell 1947: el misterio que el Ejército nunca explicó
og_title ⚠ DECIDE UNA PERSONA (Ejército: ¿institución o nombre común?)
antes: Roswell 1947: El Misterio que el Ejército Nunca Explicó
borr.: Roswell 1947: el misterio que el Ejército nunca explicó
twitter_title ⚠ DECIDE UNA PERSONA (Ejército: ¿institución o nombre común?)
antes: Roswell 1947: El Misterio que el Ejército Nunca Explicó
borr.: Roswell 1947: el misterio que el Ejército nunca explicó
── la-explosion-de-luz-de-canarias-1976-testigos-militares-espanoles-desvelan-un-secreto-de-la-guerra-fria
title
antes: La Explosión de Luz de Canarias 1976: Testigos Militares Españoles Desvelan un Secreto de la Guerra Fría
ahora: La explosión de luz de Canarias 1976: testigos militares españoles desvelan un secreto de la Guerra Fría
meta_title ⚠ DECIDE UNA PERSONA (Ejército: ¿institución o nombre común?)
antes: Canarias 1976: El OVNI que Vio el Ejército Español
borr.: Canarias 1976: el OVNI que vio el Ejército español
og_title ⚠ DECIDE UNA PERSONA (Ejército: ¿institución o nombre común?)
antes: Canarias 1976: El OVNI que Vio el Ejército Español
borr.: Canarias 1976: el OVNI que vio el Ejército español
twitter_title ⚠ DECIDE UNA PERSONA (Ejército: ¿institución o nombre común?)
antes: Canarias 1976: El OVNI que Vio el Ejército Español
borr.: Canarias 1976: el OVNI que vio el Ejército español
── el-testimonio-de-david-grusch-el-denunciante-que-cambio-la-conversacion-sobre-ovnis-en-el-congreso-de-ee-uu
title
antes: El Testimonio de David Grusch: El Denunciante que Cambió la Conversación sobre OVNIs en el Congreso de EE.UU.
ahora: El testimonio de David Grusch: el denunciante que cambió la conversación sobre OVNIs en el Congreso de EE.UU.
meta_title
antes: David Grusch en el Congreso: Lo Que los Medios No Contaron
ahora: David Grusch en el Congreso: lo que los medios no contaron
og_title
antes: David Grusch en el Congreso: Lo Que los Medios No Contaron
ahora: David Grusch en el Congreso: lo que los medios no contaron
twitter_title
antes: David Grusch en el Congreso: Lo Que los Medios No Contaron
ahora: David Grusch en el Congreso: lo que los medios no contaron
── la-noche-que-el-cielo-se-nego-a-revelar-sus-secretos-el-caso-manises-de-1979
title
antes: La Noche que el Cielo se Negó a Revelar sus Secretos: El Caso Manises de 1979
ahora: La noche que el cielo se negó a revelar sus secretos: el caso Manises de 1979
meta_title
antes: Manises 1979: El OVNI que Obligó a Aterrizar un Vuelo Iberia
ahora: Manises 1979: el OVNI que obligó a aterrizar un vuelo Iberia
og_title
antes: Manises 1979: El OVNI que Obligó a Aterrizar un Vuelo Iberia
ahora: Manises 1979: el OVNI que obligó a aterrizar un vuelo Iberia
twitter_title
antes: Manises 1979: El OVNI que Obligó a Aterrizar un Vuelo Iberia
ahora: Manises 1979: el OVNI que obligó a aterrizar un vuelo Iberia
── el-incidente-ovni-de-rendlesham-forest-1980-el-caso-mas-documentado-de-la-historia-militar-britanica
title
antes: El Incidente OVNI de Rendlesham Forest 1980: El Caso Más Documentado de la Historia Militar Británica
ahora: El incidente OVNI de Rendlesham Forest 1980: el caso más documentado de la historia militar británica
meta_title
antes: Rendlesham Forest 1980: El OVNI que Vieron los Militares
ahora: Rendlesham Forest 1980: el OVNI que vieron los militares
og_title
antes: Rendlesham Forest 1980: El OVNI que Vieron los Militares
ahora: Rendlesham Forest 1980: el OVNI que vieron los militares
twitter_title
antes: Rendlesham Forest 1980: El OVNI que Vieron los Militares
ahora: Rendlesham Forest 1980: el OVNI que vieron los militares
```