From 7d07375dd1a02ae0a62b24f6c92a55c0a5c31360 Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Sat, 4 Jul 2026 20:26:23 +0000 Subject: [PATCH] feat(ghost): guard anti-duplicados en el fallback de publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Si el autofill falla DESPUÉS de que Ghost aceptara el POST (p.ej. error leyendo la respuesta), el draft ya existe: el fallback ahora comprueba por título exacto entre los drafts recientes (find_draft_by_title) y reutiliza el existente en vez de duplicar. Solo aplica al camino de fallback-tras-excepción — el modo OFF y la re-generación deliberada siguen creando draft nuevo como siempre. Best-effort: si el check falla, se publica bare como antes. Co-Authored-By: Claude Fable 5 --- src/generator/generator.py | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/generator/generator.py b/src/generator/generator.py index 364d899..c623753 100644 --- a/src/generator/generator.py +++ b/src/generator/generator.py @@ -419,6 +419,37 @@ class GhostPublisher: ) return f"{signing}.{sig}" + async def find_draft_by_title(self, title: str) -> 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.""" + 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" + ) + async with _aio.ClientSession(timeout=_aio.ClientTimeout(total=15)) as sess: + async with sess.get( + url, + headers={ + "Authorization": f"Ghost {token}", + "Accept-Version": "v5.0", + "Accept-Encoding": "gzip, deflate", # ver publish_draft + }, + ) as resp: + if resp.status != 200: + return None + data = await resp.json() + for p in data.get("posts", []): + if p.get("title") == title: + return p + return None + async def publish_draft(self, title: str, markdown_content: str, tags: list[str] | None = None, seo: dict | None = None, @@ -559,6 +590,20 @@ 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) # fall through to the bare-draft publish below # OFF mode, or autofill failed → today's exact bare-draft publish.