refactor(ghost): _admin_get/_admin_headers compartidos en GhostPublisher
Build & Deploy ResearchOwl / build-and-push (push) Successful in 8s
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:
co-authored by
Claude Fable 5
parent
fbe8ae1885
commit
d31badeb5b
+36
-31
@@ -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()
|
||||
|
||||
+7
-24
@@ -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", "")}
|
||||
|
||||
Reference in New Issue
Block a user