Files
jsahnandClaude Fable 5 f5fa7ec70f Add a Monday-morning weekly recap briefing
No US session closes into Monday KST, so Mondays (default 07:00, cron
in the daemon with restart catch-up) now publish a weekly review built
entirely from stored data: the quotes table collapsed into week-over-
week changes and the week's daily briefing headlines as the narrative.
The recap is a BriefingResult kind="weekly" reusing MarketPayload, so
the store, verification pass, all delivery channels (Blogger title
"미국 주간 리뷰 <monday>") and the dashboard (주간 리뷰 badge) work
unchanged. Manual runs via `run-once --weekly [--date <monday>]`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 23:52:31 +09:00

203 lines
8.4 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")
# Blogger themes ship no <table> styling, so the numbers render as a borderless jumble.
# Inline styles are the only thing every theme (and the mobile view) reliably honors.
_TABLE_STYLES: tuple[tuple[str, str], ...] = (
("<table>", '<table style="border-collapse:collapse;margin:10px 0;">'),
(
"<th>",
'<th style="border:1px solid #ccc;padding:4px 10px;background:#f2f2f2;text-align:left;">',
),
("<td>", '<td style="border:1px solid #ccc;padding:4px 10px;white-space:nowrap;">'),
)
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])