refactor(ghost): _admin_get/_admin_headers compartidos en GhostPublisher
Build & Deploy ResearchOwl / build-and-push (push) Successful in 8s

find_draft_by_title y fetch_published_menu duplicaban línea a línea el GET
admin de Ghost (token, URL, ClientSession, dict de headers, status check,
resp.json), y el dict Authorization/Accept-Version/Accept-Encoding estaba
copiado en 3 sitios — el incidente brotli ya demostró que el fix por copia
se deja sitios. Ahora los headers se construyen en un único punto
(_admin_headers, con SAFE_ACCEPT_ENCODING de config) y el GET en _admin_get;
publish_draft usa los mismos headers. aiohttp pasa a import de módulo en
generator.py (era 'import aiohttp as _aio' repetido dentro de dos métodos).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-07-05 16:00:31 +00:00
co-authored by Claude Fable 5
parent fbe8ae1885
commit d31badeb5b
2 changed files with 43 additions and 55 deletions
+35 -30
View File
@@ -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:
if data is None:
return None
data = await resp.json()
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()
+6 -23
View File
@@ -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])
if data is None:
return []
data = await resp.json()
menu = [
{"slug": p["slug"], "title": p.get("title", "")}