"""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])