diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md index 85544a1..36f6697 100644 --- a/KNOWN-ISSUES.md +++ b/KNOWN-ISSUES.md @@ -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). diff --git a/src/generator/generator.py b/src/generator/generator.py index c623753..301b815 100644 --- a/src/generator/generator.py +++ b/src/generator/generator.py @@ -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.