diff --git a/hub-en.md b/hub-en.md index 434c9fd..49d0578 100644 --- a/hub-en.md +++ b/hub-en.md @@ -25,6 +25,7 @@ EXCLUIDOS: grusch-capitol-june-9-2026-uap-disclosure - vilas-boas-1957-abduction-before-the-hills | Vilas-Boas 1957 | — A Brazilian farmer reported the template abduction four years before Betty and Barney Hill. The debunking everyone cites rests on an anachronism. - travis-walton-abduction-1975-evidence-witnesses | Travis Walton 1975 | — A forestry worker vanished in front of six colleagues in Arizona. The crew chief later confessed it was a hoax — then retracted. - phoenix-lights-1997 | Phoenix Lights 1997 | — Over one million witnesses across Arizona and Nevada. The governor who mocked it publicly later admitted he saw it too. +- socorro-1964-zamora-ufo-landing | Socorro 1964 | — A police officer broke off a traffic pursuit and found an egg-shaped craft on legs with two small figures beside it. Four depressions in caliche-hard soil, vegetation still smoldering, and a Blue Book verdict of "Unknown" that has never been revised. ## Government Disclosure & Official Investigations > What the U.S. government has officially acknowledged, investigated, and released — and what remains classified. diff --git a/hub-es.md b/hub-es.md index 49666a8..115cb16 100644 --- a/hub-es.md +++ b/hub-es.md @@ -24,6 +24,7 @@ CIERRE: Zona de Exclusión publica investigación sobre fenómenos aéreos no id - pascagoula-1973-abduccion-hickson-parker | Pascagoula 1973 | — Dos pescadores llegaron temblando a la oficina del sheriff intentando describir lo que acababan de ver. Cinco décadas después su relato no ha cambiado. - travis-walton-1975-abduccion-ovni-arizona | Travis Walton, 1975 | — Un leñador desapareció cinco días delante de seis compañeros que pasaron el polígrafo. Lo que contó al volver sigue sin explicación oficial. - phoenix-lights-1997-la-noche-cuando-arizona-vio-lo-inexplicable | Phoenix Lights 1997 | — Decenas de miles de testigos en Arizona. El gobernador que ridiculizó el caso en público admitió años después que él también lo vio. +- cash-landrum-1980-demanda-ovni-tribunal-federal | Cash-Landrum 1980 | — Dos mujeres y un niño se cruzaron con un objeto en forma de diamante en una carretera de Texas y enfermaron esa misma noche. La única demanda OVNI que llegó a un tribunal federal, desestimada sin entrar en los hechos. ## Divulgación e investigaciones oficiales > Qué han reconocido, investigado y publicado los gobiernos — y qué sigue clasificado. diff --git a/remate_alt_prompt.md b/remate_alt_prompt.md new file mode 100644 index 0000000..5611202 --- /dev/null +++ b/remate_alt_prompt.md @@ -0,0 +1,49 @@ +Eres el rematador automático de la publicación. Te ha lanzado el vigilante +remate-watch en modo ALT, que es un encargo MUCHO más pequeño que el remate +completo: este post YA está rematado. Solo le falta el texto alternativo de la +imagen destacada. + +Por qué pasa esto: al sustituir la imagen destacada en Ghost, el campo +`feature_image_alt` se BORRA — va atado a la imagen, no al post. El artículo, el +slug, las metas y los enlaces internos ya están bien y NO hay que revisarlos. + +DATOS + sitio : $site ($site_name) + slug : $slug + id : $id + título : $title + CLI : $cli + +QUÉ TIENES QUE HACER, y nada más: + +1) `python3 ~/seo-tools/seo_finish.py prep $slug --site $site` + descarga la imagen destacada a ~/.cache/seo-finish/. + +2) LEE esa imagen con la herramienta Read y escribe `feature_image_alt` + describiendo lo que SE VE de verdad en ella. No lo deduzcas del título ni + del artículo: míralo. Si es una infografía, di que lo es y resume qué + bloques la componen. + - Máximo 191 caracteres. Ghost corta ahí en silencio, con un 422 mudo. + - En el idioma del sitio ($site). + +3) Aplica SOLO ese campo con + `python3 ~/seo-tools/seo_finish.py apply $slug --site $site --from-json ` + donde el JSON lleve **únicamente** `feature_image_alt`. + +PROHIBIDO — sin excepciones: +- Tocar el texto del artículo, el título, el slug, las metas, el excerpt, los + tags o los enlaces internos. Ya están rematados: cualquier cambio aquí es una + regresión, no una mejora. +- Publicar o programar el post: queda en DRAFT (publicar es decisión de Jose). +- Tocar otros posts o pages. +- Tocar n8n, X/Twitter, credenciales, secretos o infraestructura. +- Acciones destructivas de cualquier tipo. + +Si al mirar la imagen ves que CONTRADICE al artículo (un cargo, una fecha o una +cifra que no cuadran), NO corrijas nada: escribe el alt igualmente describiendo +lo que hay, y dilo en el informe para que lo decida Jose. + +INFORME FINAL (tu último mensaje; texto plano apto para móvil, <1000 caracteres): +- El alt que has escrito, con su longitud. +- Qué se ve en la imagen, en una línea. +- Cualquier contradicción entre imagen y artículo, si la hay. diff --git a/remate_watch.py b/remate_watch.py index 6bd2abb..7b098d5 100644 --- a/remate_watch.py +++ b/remate_watch.py @@ -60,6 +60,8 @@ STATE = os.path.join(STATE_DIR, "state.json") LOCK = os.path.join(STATE_DIR, "lock") LOGS = os.path.join(STATE_DIR, "logs") PROMPT_TMPL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "remate_prompt.md") +# Encargo corto para el post que YA está rematado y solo ha perdido el alt. +PROMPT_ALT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "remate_alt_prompt.md") # Sobreescribible por env para poder ensayar el camino de fallo con un binario # que casca, sin tocar código (feedback_canario_camino_real: hay que VER fallar # la alerta antes de fiarse de ella). @@ -159,9 +161,27 @@ def release_lock(): pass +def solo_falta_alt(p): + """El post ya está rematado y solo ha perdido el texto alternativo. + + Pasa cada vez que se SUSTITUYE la imagen destacada en Ghost: el campo + `feature_image_alt` va atado a la imagen, no al post, y se borra. Sin + distinguir este caso, el vigilante relanzaba el remate COMPLETO para + rellenar un campo de 191 caracteres: rehacía la revisión editorial, volvía a + pasar el enlazador sobre un artículo que ya tenía sus enlaces, podía + recambiar el slug —rompiendo la pareja de hreflang recién declarada— y se + comía otra sesión entera del plan. Visto el 2026-07-30 al regenerar la + infografía de Socorro. + """ + return bool(p.get("feature_image") + and p.get("meta_title") and p.get("meta_description") + and not p.get("feature_image_alt")) + + def build_prompt(site, post): cfg = SITES[site] - with open(PROMPT_TMPL) as f: + plantilla = PROMPT_ALT if solo_falta_alt(post) else PROMPT_TMPL + with open(plantilla) as f: tmpl = Template(f.read()) return tmpl.substitute( site=site, site_name=cfg["site_name"], canonical=cfg["canonical"], @@ -189,7 +209,10 @@ def run_remate(site, post): """Lanza claude con el checklist y devuelve (ok, texto_para_telegram, log).""" os.makedirs(LOGS, exist_ok=True) ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S") - log = os.path.join(LOGS, f"{ts}-{site}-{post['slug'][:40]}.log") + # El modo va en el NOMBRE del log: al revisar por qué un post cambió, lo + # primero que quieres saber es si corrió el checklist entero o solo el alt. + modo = "alt" if solo_falta_alt(post) else "full" + log = os.path.join(LOGS, f"{ts}-{site}-{modo}-{post['slug'][:40]}.log") prompt = build_prompt(site, post) try: @@ -294,7 +317,8 @@ def main(): "ts": datetime.datetime.utcnow().isoformat()} save_state(state) - print(f"[{site}] rematando {p['slug']} …") + modo = "alt" if solo_falta_alt(p) else "completo" + print(f"[{site}] rematando ({modo}) {p['slug']} …") ok, texto, log = run_remate(site, p) if ok: @@ -306,7 +330,11 @@ def main(): save_state(state) if ok: - msg = f"🪄 Remate automático [{site.upper()}] {p['slug']}\n\n{texto}" + cabecera = ("🖼 Alt rehecho [%s] %s\n(la imagen se sustituyó y Ghost borró " + "el alt; el resto del remate NO se ha tocado)" + % (site.upper(), p["slug"]) if modo == "alt" + else "🪄 Remate automático [%s] %s" % (site.upper(), p["slug"])) + msg = f"{cabecera}\n\n{texto}" else: msg = (f"🚨 remate-watch: el remate de [{site.upper()}] «{p['slug']}» " f"FALLÓ: {texto}\n\nLog: {log}\n" diff --git a/test_remate_watch.py b/test_remate_watch.py new file mode 100644 index 0000000..5c85bce --- /dev/null +++ b/test_remate_watch.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Tests de remate_watch.py — modo completo frente a modo alt. + +Cicatriz del 2026-07-30: al SUSTITUIR la imagen destacada de un post ya +rematado, Ghost borra `feature_image_alt` (va atado a la imagen, no al post) y +el vigilante volvía a considerarlo candidato. Como no distinguía casos, iba a +relanzar el remate COMPLETO para rellenar un campo de 191 caracteres: revisión +editorial otra vez, el enlazador sobre un artículo que ya tenía sus 4 enlaces, +posible cambio de slug —que rompe la pareja de hreflang recién declarada— y otra +sesión entera del plan, el mismo día que la cuota ya se había agotado. + +Lo peligroso de los dos lados: + · si el modo corto NO salta, se rehace todo y se pisa trabajo bueno + · si el modo corto salta de más, un post nuevo se queda SIN remate + + python3 -m pytest test_remate_watch.py -q + python3 test_remate_watch.py # sin pytest +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import remate_watch as R + + +def post(**kw): + base = {"id": "id-1", "slug": "un-caso-1964", "title": "Un caso", "status": "draft", + "feature_image": "https://x/img.webp", "meta_title": None, + "meta_description": None, "feature_image_alt": None} + base.update(kw) + return base + + +RECIEN = post() # sin nada +REMATADO = post(meta_title="mt", meta_description="md", feature_image_alt="alt") +SIN_ALT = post(meta_title="mt", meta_description="md") # le cambiaron la imagen + + +# ─────────────────── a quién considera candidato ─────────────────── + +def test_el_post_que_perdio_el_alt_sigue_siendo_candidato(): + """No basta con dejar de rehacerlo todo: el alt hay que reponerlo, o el post + se queda sin texto alternativo para siempre y nadie se entera.""" + assert R.is_candidate(SIN_ALT) + + +def test_un_post_completo_no_es_candidato(): + assert not R.is_candidate(REMATADO) + + +def test_un_borrador_sin_imagen_no_es_candidato(): + """El disparo es la imagen: sin ella no hay nada que describir ni el + artículo está listo.""" + assert not R.is_candidate(post(feature_image=None)) + + +# ─────────────────── qué modo elige ─────────────────── + +def test_solo_falta_alt_cuando_las_metas_ya_estan(): + assert R.solo_falta_alt(SIN_ALT) + + +def test_un_post_recien_creado_NO_va_por_el_modo_corto(): + """El falso positivo caro: el modo alt tiene PROHIBIDO tocar metas, slug y + enlaces. Si saltara aquí, el post se quedaría sin remate de verdad y + parecería hecho.""" + assert not R.solo_falta_alt(RECIEN) + + +def test_con_media_meta_tampoco_va_por_el_corto(): + """Un remate interrumpido a medias tiene meta_title y no meta_description. + Eso NO es «solo falta el alt»: hay que rematarlo entero.""" + assert not R.solo_falta_alt(post(meta_title="mt")) + assert not R.solo_falta_alt(post(meta_description="md")) + + +def test_sin_imagen_no_hay_modo_alt(): + assert not R.solo_falta_alt(post(meta_title="mt", meta_description="md", + feature_image=None)) + + +# ─────────────────── que el prompt sea el que toca ─────────────────── + +def test_el_modo_alt_usa_la_plantilla_corta_y_prohibe_lo_demas(): + p = R.build_prompt("en", SIN_ALT) + assert "modo ALT" in p + assert "191" in p # el límite de Ghost sigue dicho + for prohibido in ("el slug", "las metas", "los enlaces internos"): + assert prohibido in p, prohibido + assert "seo_link.py" not in p, "el modo corto no debe invitar a enlazar" + + +def test_el_modo_completo_sigue_llevando_el_checklist_entero(): + p = R.build_prompt("en", RECIEN) + assert "METAS + ALT" in p + assert "seo_link.py" in p or "enlaces internos" in p + assert "modo ALT" not in p + + +def test_los_dos_prompts_prohiben_publicar(): + """La regla que no se negocia: publicar es decisión de Jose.""" + for p in (R.build_prompt("en", SIN_ALT), R.build_prompt("en", RECIEN)): + assert "DRAFT" in p and "Publicar o programar" in p + + +def test_el_prompt_corto_no_deja_variables_sin_sustituir(): + p = R.build_prompt("es", SIN_ALT) + assert "$" not in p, [l for l in p.splitlines() if "$" in l] + assert "un-caso-1964" in p + + +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)