BloggerDeliverer posts each briefing (markdown -> HTML, H1 becomes the post title with the report headline) via the Blogger API v3 using a long-lived refresh token; `us2kr blogger-auth` runs the loopback OAuth flow once and prints the BLOGGER_* lines for .env. Enabled only when all four BLOGGER_* settings are present. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
145 lines
5.9 KiB
Python
145 lines
5.9 KiB
Python
"""Blogger deliverer: publish each briefing as a post via the Blogger API v3.
|
|
|
|
Auth: a long-lived OAuth refresh token (issued once with ``us2kr blogger-auth``) is exchanged for a
|
|
short-lived access token, cached until shortly before expiry. Secrets travel only in POST bodies and
|
|
the Authorization header - never in URLs, so nothing needs log masking.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import markdown as md
|
|
|
|
from us2kr.deliver.base import DeliveryError
|
|
from us2kr.models import BriefingResult
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
API_BASE = "https://www.googleapis.com/blogger/v3"
|
|
TIMEOUT_SECONDS = 30.0
|
|
MAX_ATTEMPTS = 3
|
|
BACKOFF_BASE_SECONDS = 1.0
|
|
TOKEN_SAFETY_MARGIN_SECONDS = 60.0
|
|
|
|
_MD = md.Markdown(extensions=["tables"], output_format="html")
|
|
|
|
|
|
def briefing_html(markdown_text: str) -> str:
|
|
"""Briefing markdown -> HTML body. The leading H1 is dropped (the post title carries it)."""
|
|
lines = markdown_text.splitlines()
|
|
if lines and lines[0].startswith("# "):
|
|
lines = lines[1:]
|
|
_MD.reset() # the converter keeps per-document state (e.g. reference links)
|
|
return _MD.convert("\n".join(lines).strip())
|
|
|
|
|
|
def post_title(result: BriefingResult) -> str:
|
|
"""'미국 마감 브리핑 YYYY-MM-DD — 헤드라인' (falls back to the session date without a report)."""
|
|
p = result.payload
|
|
base = f"미국 마감 브리핑 {p.krx_date.isoformat()}"
|
|
headline = (result.report.headline or "").strip() if result.report else ""
|
|
return f"{base} — {headline}" if headline else f"{base} (미 {p.session_date.isoformat()} 세션)"
|
|
|
|
|
|
class BloggerDeliverer:
|
|
"""Publish the briefing to one Blogger blog."""
|
|
|
|
name = "blogger"
|
|
|
|
def __init__(
|
|
self,
|
|
client_id: str,
|
|
client_secret: str,
|
|
refresh_token: str,
|
|
blog_id: str,
|
|
labels: list[str] | None = None,
|
|
client: httpx.Client | None = None,
|
|
) -> None:
|
|
if not (client_id and client_secret and refresh_token and blog_id):
|
|
raise ValueError("BloggerDeliverer requires client_id, client_secret, refresh_token, blog_id")
|
|
self._client_id = client_id
|
|
self._client_secret = client_secret
|
|
self._refresh_token = refresh_token
|
|
self.blog_id = blog_id
|
|
self.labels = list(labels or [])
|
|
self._client = client or httpx.Client(timeout=httpx.Timeout(TIMEOUT_SECONDS))
|
|
self._access_token: str | None = None
|
|
self._token_deadline = 0.0
|
|
|
|
# ------------------------------------------------------------------ auth
|
|
def _token(self, force: bool = False) -> str:
|
|
if not force and self._access_token and time.monotonic() < self._token_deadline:
|
|
return self._access_token
|
|
try:
|
|
resp = self._client.post(
|
|
TOKEN_URL,
|
|
data={
|
|
"client_id": self._client_id,
|
|
"client_secret": self._client_secret,
|
|
"refresh_token": self._refresh_token,
|
|
"grant_type": "refresh_token",
|
|
},
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
raise DeliveryError(f"blogger token refresh failed: {type(exc).__name__}: {exc}") from exc
|
|
if resp.status_code != 200:
|
|
raise DeliveryError(f"blogger token refresh failed: HTTP {resp.status_code} {resp.text[:200]}")
|
|
data = resp.json()
|
|
token = data.get("access_token")
|
|
if not token:
|
|
raise DeliveryError("blogger token refresh failed: no access_token in response")
|
|
self._access_token = token
|
|
self._token_deadline = (
|
|
time.monotonic() + float(data.get("expires_in", 3600)) - TOKEN_SAFETY_MARGIN_SECONDS
|
|
)
|
|
return token
|
|
|
|
# ------------------------------------------------------------------ delivery
|
|
def deliver(self, result: BriefingResult) -> str:
|
|
"""Create the post and return its public URL."""
|
|
body: dict[str, Any] = {
|
|
"kind": "blogger#post",
|
|
"title": post_title(result),
|
|
"content": briefing_html(result.markdown),
|
|
}
|
|
if self.labels:
|
|
body["labels"] = self.labels
|
|
url = f"{API_BASE}/blogs/{self.blog_id}/posts/"
|
|
|
|
force_token = False
|
|
last_error = "unknown error"
|
|
for attempt in range(1, MAX_ATTEMPTS + 1):
|
|
try:
|
|
resp = self._client.post(
|
|
url, json=body, headers={"Authorization": f"Bearer {self._token(force=force_token)}"}
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
last_error = f"{type(exc).__name__}: {exc}"
|
|
log.warning("blogger post attempt %d/%d failed: %s", attempt, MAX_ATTEMPTS, last_error)
|
|
time.sleep(BACKOFF_BASE_SECONDS * attempt)
|
|
continue
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
post_url = data.get("url") or f"post id {data.get('id', '?')}"
|
|
log.info("blogger post published: %s", post_url)
|
|
return post_url
|
|
last_error = f"HTTP {resp.status_code} {resp.text[:200]}"
|
|
if resp.status_code == 401 and not force_token:
|
|
force_token = True # cached token stale/revoked - mint a fresh one and retry
|
|
continue
|
|
if resp.status_code in (429, 500, 502, 503, 504) and attempt < MAX_ATTEMPTS:
|
|
log.warning("blogger post attempt %d/%d failed: %s", attempt, MAX_ATTEMPTS, last_error)
|
|
time.sleep(BACKOFF_BASE_SECONDS * attempt)
|
|
continue
|
|
break # non-retryable (4xx)
|
|
raise DeliveryError(f"blogger delivery failed: {last_error}")
|
|
|
|
def notify_error(self, subject: str, detail: str) -> None:
|
|
"""A public blog is not an operations channel - log only. Must never raise."""
|
|
log.warning("blogger notify_error (not published): %s: %s", subject, detail[:200])
|