diff --git a/seo_case.py b/seo_case.py new file mode 100644 index 0000000..70c4c46 --- /dev/null +++ b/seo_case.py @@ -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 `

/

/

`. 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()) diff --git a/seo_watch.py b/seo_watch.py index 603166b..b4ef72a 100644 --- a/seo_watch.py +++ b/seo_watch.py @@ -262,6 +262,9 @@ CASE_PHRASES = [ "Maria Blair", "Kenneth Arnold", "Memorándum Halt", "Moment of Contact", "Los Ángeles", "Los Villares", "Puerto Rico", "El Yunque", "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 = { "Pentágono", "Roswell", "Rendlesham", "Varginha", "Manises", "Nimitz", @@ -277,7 +280,7 @@ CASE_KEEP = { "Lake", "Huron", "Villares", "Yunque", "Ángeles", "Memorándum", "Moment", "Contact", "Dr.", "Sr.", "San", "Santa", "Francisco", "David", "Kurt", "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_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 +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): """Violaciones accionables (excluye waivers e INFO), agrupadas por regla.""" out = {} @@ -385,7 +416,7 @@ def check_rules(posts, site): # ---------- 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 = [] if roto: 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: 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]] + 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: tot = sum(len(v) for v in reglas.values()) 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) -def fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas, mayus): +def fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas, mayus, titulos): return json.dumps({ "roto": sorted(f"{s}|{u}" for s, u in roto), "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), "sitemap": sorted(u for u, _ in sm_malos), "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()}, }, sort_keys=True) @@ -450,13 +485,16 @@ def run_site(site): n_sm, sm_malos = check_sitemap() reglas = check_rules(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") header = (f"── [{site.upper()}] {cfg['host']}\n" f" {pub} publicados, {len(posts)} totales, sitemap {n_sm} URLs") - fp = fingerprint(roto, nocanon, prematuro, envejecido, sm_malos, reglas, mayus) - hallazgos = bool(roto or nocanon or prematuro or envejecido or sm_malos or 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 + or titulos) return header + "\n" + body, fp, hallazgos diff --git a/titulos-es-propuestas.md b/titulos-es-propuestas.md new file mode 100644 index 0000000..0f562fb --- /dev/null +++ b/titulos-es-propuestas.md @@ -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 + +```