"""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") # Blogger themes ship no
| ", ' | ', ), (" | ", ' | '), ) 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) html = _MD.convert("\n".join(lines).strip()) for plain, styled in _TABLE_STYLES: html = html.replace(plain, styled) return html def post_title_prefix(result: BriefingResult) -> str: """Stable per-day prefix - resends update the existing post instead of creating a duplicate.""" label = "미국 주간 리뷰" if result.kind == "weekly" else "미국 마감 브리핑" return f"{label} {result.payload.krx_date.isoformat()}" def post_title(result: BriefingResult) -> str: """'{prefix} — 헤드라인' (falls back to the session date without a report).""" base = post_title_prefix(result) headline = (result.report.headline or "").strip() if result.report else "" return f"{base} — {headline}" if headline else f"{base} (미 {result.payload.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 day's post - or update it when one already exists - 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 existing_id = self._find_existing(post_title_prefix(result)) if existing_id: data = self._request("PUT", f"{API_BASE}/blogs/{self.blog_id}/posts/{existing_id}", json_body=body) action = "updated" else: data = self._request("POST", f"{API_BASE}/blogs/{self.blog_id}/posts/", json_body=body) action = "published" post_url = data.get("url") or f"post id {data.get('id', '?')}" log.info("blogger post %s: %s", action, post_url) return post_url def _find_existing(self, title_prefix: str) -> str | None: """Id of the newest post whose title carries this day's prefix; None on miss or lookup failure.""" try: data = self._request( "GET", f"{API_BASE}/blogs/{self.blog_id}/posts", params={"fetchBodies": "false", "maxResults": "25"}, ) except DeliveryError as exc: if "token refresh failed" in str(exc): raise # a broken token will fail the publish too - surface it once, immediately # any other lookup failure must not block a fresh publish log.warning("blogger post lookup failed, publishing as a new post: %s", exc) return None for item in data.get("items", []): if str(item.get("title", "")).startswith(title_prefix): return str(item["id"]) return None def _request( self, method: str, url: str, *, params: dict[str, str] | None = None, json_body: dict[str, Any] | None = None, ) -> dict[str, Any]: """One authorized API call with retries (fresh token on 401, backoff on 429/5xx).""" force_token = False last_error = "unknown error" for attempt in range(1, MAX_ATTEMPTS + 1): try: resp = self._client.request( method, url, params=params, json=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 %s attempt %d/%d failed: %s", method, attempt, MAX_ATTEMPTS, last_error) time.sleep(BACKOFF_BASE_SECONDS * attempt) continue if resp.status_code == 200: return resp.json() 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 %s attempt %d/%d failed: %s", method, 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]) |
|---|