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
+9 -3
View File
@@ -14,12 +14,18 @@ project-wide: Ghost/Cloudflare responded in brotli, the decode blew up AFTER
Ghost had already accepted the POST, and the publish fallback re-published → Ghost had already accepted the POST, and the publish fallback re-published →
duplicate drafts + silently lost SEO notices. duplicate drafts + silently lost SEO notices.
Fix applied (commits `397546a` + `76c927f` + `7d07375`): Fix applied (commits `397546a` + `76c927f` + `7d07375`, guard relocated
2026-07-05):
- no brotli backend in requirements.txt (comment there explains why), - no brotli backend in requirements.txt (comment there explains why),
- explicit `Accept-Encoding: gzip, deflate` on all Ghost/autofill aiohttp - explicit `Accept-Encoding: gzip, deflate` on all Ghost/autofill aiohttp
sessions and on the scraper HEADERS (`57f341f`), sessions and on the scraper HEADERS (`57f341f`),
- duplicate guard in `_publish_blog_to_ghost`: on fallback-after-exception it - duplicate guard INSIDE `GhostPublisher.publish_draft` (covers every caller,
checks `find_draft_by_title()` before re-publishing. including `/publish`): if the POST is accepted (2xx) but reading the response
fails, it recovers the just-created draft via `find_draft_by_title(title,
since=attempt_start)` instead of raising. The guard only fires after an
accepted POST — a pre-POST failure (Ollama down, menu fetch, links) never
triggers it, so a stale same-title draft from a previous run can no longer
swallow freshly generated content.
Re-test with disclosure.org before ever re-enabling br (e.g. after an aiohttp Re-test with disclosure.org before ever re-enabling br (e.g. after an aiohttp
upgrade). upgrade).
+55 -21
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: class GhostPublisher:
def __init__(self, lang: str = "es"): def __init__(self, lang: str = "es"):
self.lang = lang self.lang = lang
@@ -419,19 +429,22 @@ class GhostPublisher:
) )
return f"{signing}.{sig}" 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. """Busca entre los drafts recientes uno con título exacto.
Guard anti-duplicados del fallback de _publish_blog_to_ghost: si el Mitad de recuperación del guard anti-duplicados de publish_draft: si el
POST de publish_draft fue aceptado por Ghost pero la lectura de la POST fue aceptado por Ghost pero la lectura de la respuesta falló
respuesta falló (p.ej. decode roto), el draft existe aunque el caller (p.ej. decode roto), el draft existe aunque el caller viera una
viera una excepción. Best-effort: None si no hay match o si falla.""" 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 import aiohttp as _aio
token = self._make_token() token = self._make_token()
url = ( url = (
f"{self.url}/ghost/api/admin/posts/" f"{self.url}/ghost/api/admin/posts/"
"?filter=status:draft&order=created_at%20desc&limit=15" "?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 _aio.ClientSession(timeout=_aio.ClientTimeout(total=15)) as sess:
async with sess.get( async with sess.get(
@@ -446,7 +459,13 @@ class GhostPublisher:
return None return None
data = await resp.json() data = await resp.json()
for p in data.get("posts", []): for p in data.get("posts", []):
if p.get("title") == title: 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 p
return None return None
@@ -505,6 +524,7 @@ class GhostPublisher:
"twitter_description": seo["twitter_description"], "twitter_description": seo["twitter_description"],
}) })
body = {"posts": [post_obj]} body = {"posts": [post_obj]}
attempt_start = time.time()
async with _aio.ClientSession() as sess: async with _aio.ClientSession() as sess:
async with sess.post( async with sess.post(
f"{self.url}/ghost/api/admin/posts/", f"{self.url}/ghost/api/admin/posts/",
@@ -522,7 +542,30 @@ class GhostPublisher:
if resp.status not in (200, 201): if resp.status not in (200, 201):
text = await resp.text() text = await resp.text()
raise ValueError(f"Ghost API {resp.status}: {text[:300]}") raise ValueError(f"Ghost API {resp.status}: {text[:300]}")
# 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() 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 ──────────────────────────────────────────────────────── # ─── Output generation ────────────────────────────────────────────────────────
@@ -590,20 +633,11 @@ class OutputGenerator:
except Exception as e: except Exception as e:
logger.warning("SEO autofill failed; falling back to bare draft", logger.warning("SEO autofill failed; falling back to bare draft",
error=str(e)) error=str(e))
# Guard anti-duplicados: si la excepción llegó DESPUÉS de que # Sin guard anti-duplicados aquí: vive DENTRO de publish_draft
# Ghost aceptara el POST (p.ej. fallo leyendo la respuesta), # (solo se activa tras un POST aceptado). Un fallo pre-POST
# el draft ya existe — re-publicar lo duplicaría (visto # (Ollama caído, menú, links) cae aquí y DEBE publicar el
# 2026-07-04 con el decode br roto de aiohttp). # contenido nuevo — un guard por título casaba con drafts
try: # viejos del mismo topic y lo descartaba en silencio.
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)
# fall through to the bare-draft publish below # fall through to the bare-draft publish below
# OFF mode, or autofill failed → today's exact bare-draft publish. # OFF mode, or autofill failed → today's exact bare-draft publish.