diff --git a/hreflang-parejas.md b/hreflang-parejas.md index 38e2239..da22849 100644 --- a/hreflang-parejas.md +++ b/hreflang-parejas.md @@ -208,7 +208,7 @@ sim 0.824 sim 0.746 EN cash-landrum-1980-ufo-federal-lawsuit Cash-Landrum 1980: The Only UFO Case That Went to Federal Court — and Lost - ES el-incidente-cash-landrum-la-unica-demanda-ovni-contra-el-gobierno-estadounidense-que-llego-a-un-tribunal-federal + ES cash-landrum-1980-demanda-ovni-tribunal-federal El incidente Cash-Landrum: la única demanda OVNI contra el gobierno estadounidense que NOTA (2026-07-30): antes decía NO porque el espejo ES no existía al escanear — se generó hoy. Mismo caso, mismo año, mismo nombre propio en los dos títulos, y las dos @@ -368,10 +368,10 @@ sim n/a bases nucleares decision: NO sim n/a año 1964 socorro zamora - EN when-a-police-officer-encountered-an-egg-shaped-craft-in-socorros-desert + EN socorro-1964-zamora-ufo-landing When a Police Officer Encountered an Egg-Shaped Craft in Socorro's Desert ES socorro-1964-ovni-lonnie-zamora - Socorro 1964: el OVNI que dejó marcas y un policía que no se retractó + Socorro 1964: el policía, el objeto posado y las marcas que quedaron en el suelo NOTA (2026-07-30): pareja nueva, curada a mano el mismo día que se generó el EN. Sin «sim» porque no pasó por el escaneo de vectores — y da igual: mismo caso, 24-abr-1964, el mismo Lonnie Zamora y las mismas marcas en el suelo. El TÍTULO del EN no lleva la @@ -380,4 +380,8 @@ sim n/a año 1964 socorro zamora BORRADOR y sin programar, así que la pareja no se puede declarar hasta que se publiquen los dos. ⚠️ Si al rematar el EN se le cambia el slug para meterle la firma («socorro-1964-…», que sería lo coherente), hay que actualizarlo AQUÍ. + ACTUALIZADO (2026-07-30, remate del EN): el slug EN pasó a «socorro-1964-zamora-ufo-landing» + y aquí queda ya corregido; el título ES también, que había cambiado al rematarlo. El EN + sigue en borrador: cuando se publique (el ES sale el 11-ago), hreflang-watch declara la + pareja solo. decision: SI diff --git a/infografia_brief.py b/infografia_brief.py index ab1e21f..58ee8ff 100755 --- a/infografia_brief.py +++ b/infografia_brief.py @@ -204,6 +204,27 @@ RE_URL = re.compile(r"(?:https?://|www\.)\S+|\b[\w\-]+\.(?:com|es|org|net|gov|br # Un token en mayúscula que va detrás de uno de estos signos lo está por # posición, no por ser nombre propio. RE_ARRANQUE = re.compile(r"[.:;!?¡¿—–\-(\[«\"]\s*$") +# …salvo que el punto sea el de una ABREVIATURA. «Lt. Colonel» no es una frase +# nueva que empiece en «Colonel»: es un cargo. Sin esta excepción, el punto de +# la abreviatura eximía de comprobación a la palabra siguiente, y por ahí entró +# «Lt. Colonel Quintanilla» en la infografía de Socorro el 2026-07-30, cuando el +# artículo dice «Major» las cinco veces que lo nombra. La palabra que habría +# cazado el error («Colonel») era justo la que se saltaba. +RE_ABREV = re.compile( + r"\b(?:Lt|Col|Gen|Sgt|Capt|Cmdr|Maj|Dr|Mr|Mrs|Ms|St|Jr|Sr|Prof|Rev|Hon" + r"|EE|UU|EEUU|Ud|Uds|Sta|Sto|Ing|Lic)\.\s*$", re.I) + +# Cargos y rangos. Se comprueban en FRASE, no palabra a palabra: «Colonel» puede +# estar en el artículo (otro oficial) y «Quintanilla» también, y aun así +# «Colonel Quintanilla» ser falso. Un token a token no puede ver eso nunca. +RE_CARGO = re.compile( + r"\b(?:Lt\.?\s*Col(?:onel)?|Colonel|Lieutenant|Major|Captain|Cmdr\.?|Commander" + r"|Sergeant|Sgt\.?|General|Admiral|Corporal|Airman|Special Agent|Agent" + r"|Officer|Chief|Detective|Judge|Dr\.?|Professor|Prof\.?|Senator|Congressman" + r"|Coronel|Teniente|Comandante|Capitán|Capitan|Sargento|Cabo|Almirante" + r"|Juez|Jueza|Fiscal|Doctor|Doctora|Profesor|Profesora|Agente|Inspector" + r"|Senador|Diputado|Ministro|Presidente|Alcalde)\.?" + r"(?:\s+[A-ZÁÉÍÓÚÑ][\wÁÉÍÓÚÑáéíóúñ'’\-]*\.?){1,3}") def pliega(s): @@ -218,7 +239,39 @@ def pliega(s): def _en_articulo(token, articulo): - return pliega(token).strip(string.punctuation + "’'") in articulo + """¿Aparece el token en el artículo, como PALABRA? + + Era una comparación por subcadena y eso regalaba pases absurdos: «Lt» daba + positivo por estar dentro de *built*, *adults* y *consultant*. Cuanto más + corto el token, más fácil esconderse dentro de otra palabra — justo al revés + de lo que interesa, porque los tokens cortos son las abreviaturas de cargo. + + Se conserva la comparación laxa por los extremos (plurales, genitivos: el + artículo dice «Zamora's» y el guion «Zamora»), exigiendo solo que el token + empiece en frontera de palabra. + """ + t = pliega(token).strip(string.punctuation + "’'") + if not t: + return True + return re.search(r"(? 1 and t.isupper() propio = t[:1].isupper() and not t.isupper() - if propio and RE_ARRANQUE.search(texto[:m.start()] or " "): + previo = texto[:m.start()] or " " + if propio and RE_ARRANQUE.search(previo) and not RE_ABREV.search(previo): continue # va en mayúscula por ir detrás de punto if propio and m.start() == 0: continue # ídem, principio de la cadena @@ -266,6 +320,14 @@ def filtra(guion, articulo_low, informe): if nombres: informe["caidas"].append(f"{donde}: nombre(s) {nombres} fuera del artículo → «{texto[:70]}»") return False + # Un cargo falso se trata como un nombre forastero y tira la viñeta: no + # se puede degradar «Lt. Colonel Quintanilla» a «Quintanilla» sin + # reescribir la frase, y reescribir es inventar. + cargos = cargos_fuera(texto, articulo_low) + if cargos: + informe["caidas"].append( + f"{donde}: cargo/rango {cargos} no aparece así en el artículo → «{texto[:70]}»") + return False return True for campo in ("titular", "subtitulo", "entradilla"): diff --git a/test_infografia_brief.py b/test_infografia_brief.py new file mode 100644 index 0000000..9db4e7d --- /dev/null +++ b/test_infografia_brief.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Tests de infografia_brief.py — que el guion no contradiga al artículo. + +Todo lo de aquí es la cicatriz de un fallo REAL del 2026-07-30: la infografía +de Socorro salió publicando «Lt. Colonel Hector Quintanilla» cuando el artículo +dice «Major» las cinco veces que lo nombra. El rango no lo inventó el modelo que +maqueta la imagen: venía en el guion, y el verificador lo dejó pasar por DOS +motivos independientes, así que hay dos redes que probar. + + python3 -m pytest test_infografia_brief.py -q + python3 test_infografia_brief.py # sin pytest +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import infografia_brief as I + +# Artículo de mentira con la forma del real: nombra a Quintanilla como Major, y +# contiene «consultant» y «built», que es donde se escondía «Lt» por subcadena. +ART = I.pliega( + "The Socorro UFO Landing of 1964 left four depressions and no explanation. " + "Project Blue Book chief, Major Hector Quintanilla, dispatched his " + "scientific consultant, Dr. J. Allen Hynek, to the site. Sergeant Sam " + "Chavez reached the arroyo first. The shack had been built years earlier. " + "Zamora's patrol car was parked on the ridge. Captain Richard T. Holder " + "of White Sands Missile Range arrived that evening." +) + + +# ────────────── red 1: la abreviatura ya no regala el pase ────────────── + +def test_el_punto_de_una_abreviatura_no_exime_a_la_palabra_siguiente(): + """«Lt. Colonel»: RE_ARRANQUE veía el punto de «Lt.» y daba por hecho que + «Colonel» iba en mayúscula por empezar frase, así que NO lo comprobaba. La + única palabra capaz de cazar el error era justo la que se saltaba.""" + nombres, _, _ = I.revisa("Blue Book chief Lt. Colonel Hector Quintanilla spoke", ART) + assert "Colonel" in nombres, nombres + + +def test_un_punto_de_verdad_sigue_eximiendo(): + """La excepción no puede cargarse la regla: detrás de un punto de final de + frase, una mayúscula sí es posicional y no debe marcarse.""" + nombres, _, _ = I.revisa("The object left. Nothing else was found.", ART) + assert nombres == [], nombres + + +# ────────────── red 2: comparar por palabra, no por subcadena ────────────── + +def test_un_token_corto_no_se_esconde_dentro_de_otra_palabra(): + """«Lt» daba positivo por estar dentro de *built*, *adults* y *consultant*. + Cuanto más corto el token más fácil colarse — y los tokens cortos son + justamente las abreviaturas de cargo.""" + assert not I._en_articulo("Lt", ART) + assert "built" in ART and "consultant" in ART # la trampa sigue ahí + + +def test_los_plurales_y_genitivos_siguen_valiendo(): + """La comparación se ata solo por la IZQUIERDA a propósito: el artículo dice + «Zamora's» y el guion «Zamora», y eso no es contaminación.""" + assert I._en_articulo("Zamora", ART) + assert I._en_articulo("Quintanilla", ART) + + +# ────────────── red 3: el cargo se comprueba en FRASE ────────────── + +def test_un_cargo_falso_se_caza_aunque_sus_palabras_esten_sueltas(): + """El fallo real no fue una palabra inventada sino una COMBINACIÓN falsa. + Token a token no se puede ver nunca: hay que comparar la frase entera.""" + assert I.cargos_fuera("Lt. Colonel Hector Quintanilla said so", ART) == \ + ["Lt. Colonel Hector Quintanilla"] + + +def test_el_cargo_correcto_no_salta(): + assert I.cargos_fuera("Major Hector Quintanilla said so", ART) == [] + + +def test_otros_cargos_reales_del_articulo_no_saltan(): + """Si marcara cargos legítimos, el informe se volvería inservible y se + dejaría de leer — que es como se pierde el aviso de verdad.""" + for frase in ("Sergeant Sam Chavez reached the site", + "Captain Richard T. Holder of White Sands", + "his scientific consultant, Dr. J. Allen Hynek"): + assert I.cargos_fuera(frase, ART) == [], frase + + +def test_la_abreviatura_no_cuenta_como_cargo_distinto(): + """«Lt. Col.» y «Lt Col» son el mismo cargo: si el punto los separase, + cualquier artículo que abrevie distinto daría falso positivo.""" + art = I.pliega("Lt. Col. Hector Quintanilla ran the program.") + assert I.cargos_fuera("Lt Col Hector Quintanilla ran it", art) == [] + + +# ────────────── camino de producción: filtra() poda lo justo ────────────── + +def _guion(rango): + return { + "titular": "Socorro UFO Landing, 1964", + "subtitulo": "Four depressions and no explanation", + "entradilla": "Sergeant Sam Chavez reached the arroyo first.", + "cronologia": [ + {"marca": "within 24 hours", + "texto": f"Blue Book chief {rango} Hector Quintanilla dispatched Hynek."}, + {"marca": "within minutes", + "texto": "Sergeant Sam Chavez reached the arroyo first."}], + "paneles": [{"titulo": "The witness", "vinetas": [ + "Zamora's patrol car was parked on the ridge", + f"{rango} Hector Quintanilla classified it"]}], + "franja": [], + } + + +def test_filtra_tira_solo_las_frases_con_el_cargo_falso(): + inf = {"avisos": [], "caidas": []} + out = I.filtra(_guion("Lt. Colonel"), ART, inf) + assert len(out["cronologia"]) == 1, out["cronologia"] + assert sum(len(p["vinetas"]) for p in out["paneles"]) == 1 + assert out["titular"], "no debía tocar el titular" + assert any("Colonel" in c for c in inf["caidas"]), inf["caidas"] + + +def test_filtra_no_toca_nada_si_el_cargo_es_el_bueno(): + """La otra mitad: un verificador que poda de más es tan inútil como uno que + no poda — deja el guion cojo y obliga a rehacerlo a mano.""" + inf = {"avisos": [], "caidas": []} + out = I.filtra(_guion("Major"), ART, inf) + assert len(out["cronologia"]) == 2, inf["caidas"] + assert sum(len(p["vinetas"]) for p in out["paneles"]) == 2, inf["caidas"] + assert inf["caidas"] == [], inf["caidas"] + + +if __name__ == "__main__": + fallos = 0 + for nombre, fn in sorted(globals().items()): + if not nombre.startswith("test_"): + continue + try: + fn() + print(f" ✓ {nombre}") + except AssertionError as exc: + fallos += 1 + print(f" ✗ {nombre}: {exc}") + print(f"\n{'✓ todo pasa' if not fallos else f'✗ {fallos} fallo(s)'}") + sys.exit(1 if fallos else 0)