remate: modo corto para el post que solo ha perdido el alt

Sustituir la imagen destacada en Ghost BORRA feature_image_alt: el campo va
atado a la imagen, no al post. El vigilante volvía a considerar candidato al
post y, como no distinguía casos, iba a relanzar el remate COMPLETO para
rellenar 191 caracteres: revisión editorial otra vez, el enlazador sobre un
artículo que ya tenía sus cuatro 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 en que la cuota ya se había agotado una vez.

solo_falta_alt() distingue el caso y build_prompt() sirve un encargo de 2.248
caracteres en vez de 7.237, que además PROHÍBE tocar texto, slug, metas,
excerpt, tags y enlaces: ahí cualquier cambio es una regresión, no una
mejora. El modo va en el nombre del log y en la cabecera de Telegram, porque
al revisar por qué cambió un post lo primero que quieres saber es cuál corrió.

Escribe el alt por el MISMO camino que el modo largo (seo_finish prep → Read
de la imagen → seo_finish apply), así que no hay una segunda forma de
escribirlo que pueda derivar de la primera.

Once tests, y los que importan son los del falso positivo: un post recién
creado NO debe entrar por el corto (se quedaría sin remate y parecería
hecho), ni uno con el remate interrumpido a medias (meta_title sin
meta_description).

Verificado por el camino real sobre un borrador canario en el estado exacto
—imagen y metas puestas, sin alt—: eligió el modo alt, escribió 189/191
caracteres describiendo la imagen, y de doce campos comparados NO tocó
ninguno; el cuerpo quedó literalmente idéntico. Canario borrado después.

Y hub-en/hub-es con los dos artículos publicados hoy (Socorro 1964 y
Cash-Landrum ES), que seo_estado cazó fuera del índice a los minutos de
publicarse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-07-30 13:58:55 +00:00
co-authored by Claude Opus 5
parent 1f07e88c12
commit d1d8549089
5 changed files with 207 additions and 4 deletions
+1
View File
@@ -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.
+1
View File
@@ -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.
+49
View File
@@ -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 <patch.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.
+32 -4
View File
@@ -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"
+124
View File
@@ -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)