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 →
|
||||
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),
|
||||
- explicit `Accept-Encoding: gzip, deflate` on all Ghost/autofill aiohttp
|
||||
sessions and on the scraper HEADERS (`57f341f`),
|
||||
- duplicate guard in `_publish_blog_to_ghost`: on fallback-after-exception it
|
||||
checks `find_draft_by_title()` before re-publishing.
|
||||
- duplicate guard INSIDE `GhostPublisher.publish_draft` (covers every caller,
|
||||
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
|
||||
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:
|
||||
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,7 +459,13 @@ class GhostPublisher:
|
||||
return None
|
||||
data = await resp.json()
|
||||
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 None
|
||||
|
||||
@@ -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]}")
|
||||
# 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.
|
||||
|
||||
Reference in New Issue
Block a user