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 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(
|
return None
|
||||||
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()
|
|
||||||
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()
|
||||||
|
|||||||
+7
-24
@@ -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:
|
return []
|
||||||
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()
|
|
||||||
|
|
||||||
menu = [
|
menu = [
|
||||||
{"slug": p["slug"], "title": p.get("title", "")}
|
{"slug": p["slug"], "title": p.get("title", "")}
|
||||||
|
|||||||
Reference in New Issue
Block a user