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:
co-authored by
Claude Fable 5
parent
68ae6b436c
commit
3d74857d85
+9
-3
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user