diff --git a/src/generator/generator.py b/src/generator/generator.py index a0fab1b..2835ab5 100644 --- a/src/generator/generator.py +++ b/src/generator/generator.py @@ -9,9 +9,10 @@ import json import re import time +import aiohttp import structlog -from src.config import settings +from src.config import settings, SAFE_ACCEPT_ENCODING from src.llm import get_anthropic_client from src.processor.processor import OllamaClient, ContentProcessor from src.db.database import ResearchDB, OutputType @@ -437,6 +438,34 @@ class GhostPublisher: ) return f"{signing}.{sig}" + def _admin_headers(self) -> dict: + """Headers canónicos de la Admin API (token JWT fresco por llamada). + Único sitio donde se construyen: cualquier cambio (Accept-Version, + encoding) se hace aquí y cubre todas las llamadas a Ghost.""" + return { + "Authorization": f"Ghost {self._make_token()}", + "Accept-Version": "v5.0", + # Nunca heredar el default de aiohttp: con un backend brotli + # instalado anunciaría br y el decode roto de aiohttp 3.14 rompe + # la lectura de respuestas (incidente 2026-07-04, KNOWN-ISSUES.md). + "Accept-Encoding": SAFE_ACCEPT_ENCODING, + } + + async def _admin_get(self, query: str, timeout: float = 15) -> dict | None: + """GET a {url}/ghost/api/admin/{query} con auth + headers canónicos. + None si non-200 (logueado); las excepciones de red/parseo suben tal + cual para que cada caller aplique su propia política best-effort.""" + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=timeout)) as sess: + async with sess.get(f"{self.url}/ghost/api/admin/{query}", + headers=self._admin_headers()) as resp: + if resp.status != 200: + body = await resp.text() + logger.warning("Ghost admin GET non-200", query=query[:80], + status=resp.status, body=body[:200]) + return None + return await resp.json() + 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. @@ -449,25 +478,12 @@ class GhostPublisher: como "ya publicado". La comparación usa _norm_title (whitespace colapsado + truncado a 255) para sobrevivir la normalización de Ghost. 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" + data = await self._admin_get( + "posts/?filter=status:draft&order=created_at%20desc&limit=15" "&fields=id,title,slug,created_at" ) - 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() + if data is None: + return None wanted = _norm_title(title) for p in data.get("posts", []): if _norm_title(p.get("title") or "") != wanted: @@ -493,8 +509,6 @@ class GhostPublisher: * seo — a generate_seo_fields dict; when given, its meta/OG/Twitter fields are added to the post and its (allow-list) tags are used. """ - import aiohttp as _aio - # Caller-supplied linked HTML wins; otherwise convert markdown as before. html = body_html if body_html is not None else self._build_html(markdown_content) @@ -515,7 +529,6 @@ class GhostPublisher: "sections": [[10, 0]], }) - token = self._make_token() # Language-aware default tag (fixes the hardcoded "investigacion" for EN). tag_names = tags or [_DEFAULT_TAG.get(self.lang, "investigation")] post_obj = { @@ -536,19 +549,11 @@ class GhostPublisher: }) body = {"posts": [post_obj]} attempt_start = time.time() - async with _aio.ClientSession() as sess: + async with aiohttp.ClientSession() as sess: async with sess.post( f"{self.url}/ghost/api/admin/posts/", json=body, - headers={ - "Authorization": f"Ghost {token}", - "Accept-Version": "v5.0", - # Explícito para no heredar el default de aiohttp: si algún - # día se reinstala un backend brotli, el default anunciaría - # br y el decode roto de aiohttp 3.14 duplicaba drafts - # (fallo al leer la respuesta de un POST ya aceptado). - "Accept-Encoding": "gzip, deflate", - }, + headers=self._admin_headers(), ) as resp: if resp.status not in (200, 201): text = await resp.text() diff --git a/src/seo/autofill.py b/src/seo/autofill.py index a0b6568..ba269f1 100644 --- a/src/seo/autofill.py +++ b/src/seo/autofill.py @@ -75,37 +75,20 @@ async def fetch_published_menu(lang: str) -> list[dict]: try: # Lazy import to avoid a heavy/circular import at module load. from src.generator.generator import GhostPublisher - import aiohttp as _aio pub = GhostPublisher(lang=lang) if not pub.is_configured(): logger.warning("seo.menu: Ghost not configured for lang", lang=lang) return [] - token = pub._make_token() - url = ( - f"{pub.url}/ghost/api/admin/posts/" - "?filter=status:published&fields=id,slug,title&limit=all" + # _admin_get lleva los headers canónicos (auth, Accept-Version y el + # Accept-Encoding sin br — ver KNOWN-ISSUES.md) y loguea los non-200. + data = await pub._admin_get( + "posts/?filter=status:published&fields=id,slug,title&limit=all", + timeout=30, ) - timeout = _aio.ClientTimeout(total=30) - async with _aio.ClientSession(timeout=timeout) as sess: - async with sess.get( - url, - headers={ - "Authorization": f"Ghost {token}", - "Accept-Version": "v5.0", - # Explícito: sin esto, un backend brotli instalado haría - # que aiohttp anuncie br y su decode roto tumbe el menú - # ("using empty menu") — ver publish_draft. - "Accept-Encoding": "gzip, deflate", - }, - ) as resp: - if resp.status != 200: - body = await resp.text() - logger.warning("seo.menu: non-200 from Ghost", - status=resp.status, body=body[:200]) - return [] - data = await resp.json() + if data is None: + return [] menu = [ {"slug": p["slug"], "title": p.get("title", "")}