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 re
import time import time
import aiohttp
import structlog import structlog
from src.config import settings from src.config import settings, SAFE_ACCEPT_ENCODING
from src.llm import get_anthropic_client from src.llm import get_anthropic_client
from src.processor.processor import OllamaClient, ContentProcessor from src.processor.processor import OllamaClient, ContentProcessor
from src.db.database import ResearchDB, OutputType from src.db.database import ResearchDB, OutputType
@@ -437,6 +438,34 @@ class GhostPublisher:
) )
return f"{signing}.{sig}" 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, async def find_draft_by_title(self, title: str,
since: float | None = None) -> dict | None: since: float | None = None) -> dict | None:
"""Busca entre los drafts recientes uno con título exacto. """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 como "ya publicado". La comparación usa _norm_title (whitespace
colapsado + truncado a 255) para sobrevivir la normalización de Ghost. colapsado + truncado a 255) para sobrevivir la normalización de Ghost.
Best-effort: None si no hay match o si falla.""" Best-effort: None si no hay match o si falla."""
import aiohttp as _aio data = await self._admin_get(
token = self._make_token() "posts/?filter=status:draft&order=created_at%20desc&limit=15"
url = (
f"{self.url}/ghost/api/admin/posts/"
"?filter=status:draft&order=created_at%20desc&limit=15"
"&fields=id,title,slug,created_at" "&fields=id,title,slug,created_at"
) )
async with _aio.ClientSession(timeout=_aio.ClientTimeout(total=15)) as sess: if data is None:
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 return None
data = await resp.json()
wanted = _norm_title(title) wanted = _norm_title(title)
for p in data.get("posts", []): for p in data.get("posts", []):
if _norm_title(p.get("title") or "") != wanted: 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 * 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. 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. # 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) html = body_html if body_html is not None else self._build_html(markdown_content)
@@ -515,7 +529,6 @@ class GhostPublisher:
"sections": [[10, 0]], "sections": [[10, 0]],
}) })
token = self._make_token()
# Language-aware default tag (fixes the hardcoded "investigacion" for EN). # Language-aware default tag (fixes the hardcoded "investigacion" for EN).
tag_names = tags or [_DEFAULT_TAG.get(self.lang, "investigation")] tag_names = tags or [_DEFAULT_TAG.get(self.lang, "investigation")]
post_obj = { post_obj = {
@@ -536,19 +549,11 @@ class GhostPublisher:
}) })
body = {"posts": [post_obj]} body = {"posts": [post_obj]}
attempt_start = time.time() attempt_start = time.time()
async with _aio.ClientSession() as sess: async with aiohttp.ClientSession() as sess:
async with sess.post( async with sess.post(
f"{self.url}/ghost/api/admin/posts/", f"{self.url}/ghost/api/admin/posts/",
json=body, json=body,
headers={ headers=self._admin_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",
},
) as resp: ) as resp:
if resp.status not in (200, 201): if resp.status not in (200, 201):
text = await resp.text() text = await resp.text()
+6 -23
View File
@@ -75,37 +75,20 @@ async def fetch_published_menu(lang: str) -> list[dict]:
try: try:
# Lazy import to avoid a heavy/circular import at module load. # Lazy import to avoid a heavy/circular import at module load.
from src.generator.generator import GhostPublisher from src.generator.generator import GhostPublisher
import aiohttp as _aio
pub = GhostPublisher(lang=lang) pub = GhostPublisher(lang=lang)
if not pub.is_configured(): if not pub.is_configured():
logger.warning("seo.menu: Ghost not configured for lang", lang=lang) logger.warning("seo.menu: Ghost not configured for lang", lang=lang)
return [] return []
token = pub._make_token() # _admin_get lleva los headers canónicos (auth, Accept-Version y el
url = ( # Accept-Encoding sin br — ver KNOWN-ISSUES.md) y loguea los non-200.
f"{pub.url}/ghost/api/admin/posts/" data = await pub._admin_get(
"?filter=status:published&fields=id,slug,title&limit=all" "posts/?filter=status:published&fields=id,slug,title&limit=all",
timeout=30,
) )
timeout = _aio.ClientTimeout(total=30) if data is None:
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 [] return []
data = await resp.json()
menu = [ menu = [
{"slug": p["slug"], "title": p.get("title", "")} {"slug": p["slug"], "title": p.get("title", "")}