fix(ghost): guard anti-duplicados dentro de publish_draft, solo tras POST aceptado

El guard anterior vivía en el fallback de autofill y casaba con CUALQUIER
draft del mismo título: un fallo pre-POST (Ollama caído) con un draft viejo
del mismo topic descartaba en silencio el contenido recién generado.

Ahora la recuperación vive dentro de publish_draft y solo se activa cuando
el POST fue aceptado (2xx) y falla la lectura de la respuesta — el escenario
exacto del incidente br del 2026-07-04 — con filtro since=attempt_start en
find_draft_by_title. Cubre a todos los callers: autofill, bare publish y
/publish (antes sin proteger: reintento del usuario = draft duplicado).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-07-05 15:54:49 +00:00
co-authored by Claude Fable 5
parent 68ae6b436c
commit 3d74857d85
2 changed files with 66 additions and 26 deletions
+57 -23
View File
@@ -385,6 +385,16 @@ def _seo_dryrun_message(ghost: "GhostPublisher", post: dict, seo: dict, inserted
)
def _parse_ghost_ts(ts: str) -> float | None:
"""created_at de la Admin API (ISO 8601, p.ej. 2026-07-04T20:26:00.000+00:00
o sufijo Z) → epoch. None si no parsea."""
try:
from datetime import datetime
return datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
except Exception:
return None
class GhostPublisher:
def __init__(self, lang: str = "es"):
self.lang = lang
@@ -419,19 +429,22 @@ class GhostPublisher:
)
return f"{signing}.{sig}"
async def find_draft_by_title(self, title: str) -> dict | None:
async def find_draft_by_title(self, title: str,
since: float | None = None) -> dict | None:
"""Busca entre los drafts recientes uno con título exacto.
Guard anti-duplicados del fallback de _publish_blog_to_ghost: si el
POST de publish_draft fue aceptado por Ghost pero la lectura de la
respuesta falló (p.ej. decode roto), el draft existe aunque el caller
viera una excepción. Best-effort: None si no hay match o si falla."""
Mitad de recuperación del guard anti-duplicados de publish_draft: si el
POST fue aceptado por Ghost pero la lectura de la respuesta falló
(p.ej. decode roto), el draft existe aunque el caller viera una
excepción. Con `since` (epoch) solo casan drafts creados en este
intento — un draft viejo del mismo título de una run anterior NO cuenta
como "ya publicado". Best-effort: None si no hay match o si falla."""
import aiohttp as _aio
token = self._make_token()
url = (
f"{self.url}/ghost/api/admin/posts/"
"?filter=status:draft&order=created_at%20desc&limit=15"
"&fields=id,title,slug"
"&fields=id,title,slug,created_at"
)
async with _aio.ClientSession(timeout=_aio.ClientTimeout(total=15)) as sess:
async with sess.get(
@@ -446,8 +459,14 @@ class GhostPublisher:
return None
data = await resp.json()
for p in data.get("posts", []):
if p.get("title") == title:
return p
if p.get("title") != title:
continue
if since is not None:
created = _parse_ghost_ts(p.get("created_at") or "")
# 60s de margen por posible desfase de reloj Ghost/bot.
if created is None or created < since - 60:
continue
return p
return None
async def publish_draft(self, title: str, markdown_content: str,
@@ -505,6 +524,7 @@ class GhostPublisher:
"twitter_description": seo["twitter_description"],
})
body = {"posts": [post_obj]}
attempt_start = time.time()
async with _aio.ClientSession() as sess:
async with sess.post(
f"{self.url}/ghost/api/admin/posts/",
@@ -522,7 +542,30 @@ class GhostPublisher:
if resp.status not in (200, 201):
text = await resp.text()
raise ValueError(f"Ghost API {resp.status}: {text[:300]}")
return await resp.json()
# Guard anti-duplicados (incidente 2026-07-04): el 2xx confirma
# que Ghost YA creó el draft; si la lectura del body falla
# (p.ej. decode br roto), recuperamos el draft recién creado en
# vez de propagar — propagar hacía que el caller (o el usuario
# con /publish) reintentara y duplicara. El guard vive AQUÍ para
# cubrir a todos los callers, y solo se activa tras un POST
# aceptado: un fallo pre-POST jamás lo dispara.
try:
return await resp.json()
except Exception as read_err:
existing = None
try:
existing = await self.find_draft_by_title(
title, since=attempt_start)
except Exception as find_err:
logger.warning("publish_draft: draft-exists check failed",
error=str(find_err))
if existing:
logger.warning(
"publish_draft: POST aceptado pero lectura de la "
"respuesta falló; draft recuperado sin re-publicar",
post_id=existing["id"], error=str(read_err))
return {"posts": [existing]}
raise
# ─── Output generation ────────────────────────────────────────────────────────
@@ -590,20 +633,11 @@ class OutputGenerator:
except Exception as e:
logger.warning("SEO autofill failed; falling back to bare draft",
error=str(e))
# Guard anti-duplicados: si la excepción llegó DESPUÉS de que
# Ghost aceptara el POST (p.ej. fallo leyendo la respuesta),
# el draft ya existe — re-publicar lo duplicaría (visto
# 2026-07-04 con el decode br roto de aiohttp).
try:
existing = await ghost.find_draft_by_title(title)
except Exception as find_err:
logger.warning("Draft-exists check failed; continuing to bare publish",
error=str(find_err))
existing = None
if existing:
logger.info("Draft ya existente en Ghost tras fallo de autofill; no se re-publica",
post_id=existing["id"])
return _bare_ghost_notice(ghost, existing)
# Sin guard anti-duplicados aquí: vive DENTRO de publish_draft
# (solo se activa tras un POST aceptado). Un fallo pre-POST
# (Ollama caído, menú, links) cae aquí y DEBE publicar el
# contenido nuevo — un guard por título casaba con drafts
# viejos del mismo topic y lo descartaba en silencio.
# fall through to the bare-draft publish below
# OFF mode, or autofill failed → today's exact bare-draft publish.