Add a Blogger delivery channel with one-time OAuth setup

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>
This commit is contained in:
jsahn
2026-09-01 12:04:49 +09:00
co-authored by Claude Fable 5
parent fecc767a5c
commit 12d7b47f46
8 changed files with 521 additions and 4 deletions
+120
View File
@@ -85,6 +85,16 @@ def build_parser() -> argparse.ArgumentParser:
notify.add_argument("--message", default="US2KR test notification", help="text to send")
sub.add_parser("show-config", help="print effective settings (secrets masked)")
auth = sub.add_parser(
"blogger-auth",
help="one-time OAuth flow for the Blogger channel (prints BLOGGER_* lines for .env)",
)
auth.add_argument("--client-id", default=None, help="OAuth client id (default: BLOGGER_CLIENT_ID)")
auth.add_argument(
"--client-secret", default=None, help="OAuth client secret (default: BLOGGER_CLIENT_SECRET)"
)
auth.add_argument("--timeout", type=int, default=300, help="seconds to wait for the browser consent")
return parser
@@ -140,16 +150,126 @@ def cmd_show_config(_args: argparse.Namespace, settings: Settings) -> int:
print(f"{name} = {shown}")
print(f"db_path = {settings.db_path}")
print(f"telegram_enabled = {settings.telegram_enabled}")
print(f"blogger_enabled = {settings.blogger_enabled}")
print(f"project_root = {PROJECT_ROOT}")
return EXIT_OK
def cmd_blogger_auth(args: argparse.Namespace, settings: Settings) -> int:
"""Run the loopback OAuth flow once; print the BLOGGER_* env lines and the user's blog ids."""
import threading
import urllib.parse
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from secrets import token_urlsafe
import httpx
client_id = args.client_id or settings.blogger_client_id
client_secret = args.client_secret or settings.blogger_client_secret
if not (client_id and client_secret):
print(
"error: pass --client-id/--client-secret or set BLOGGER_CLIENT_ID / BLOGGER_CLIENT_SECRET",
file=sys.stderr,
)
return EXIT_ERROR
state = token_urlsafe(16)
received: dict[str, str] = {}
done = threading.Event()
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # http.server API naming
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
received.update({k: v[0] for k, v in query.items()})
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write("<h3>인증 완료 — 터미널로 돌아가세요.</h3>".encode())
done.set()
def log_message(self, *_args: object) -> None: # keep the terminal clean
pass
server = HTTPServer(("127.0.0.1", 0), Handler)
port = server.server_address[1]
redirect_uri = f"http://127.0.0.1:{port}"
threading.Thread(target=server.serve_forever, daemon=True).start()
auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode(
{
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "https://www.googleapis.com/auth/blogger",
"access_type": "offline",
"prompt": "consent", # force a refresh_token even if previously consented
"state": state,
}
)
print("브라우저에서 Google 계정으로 승인하세요 (자동으로 열리지 않으면 URL을 직접 여세요):\n")
print(auth_url + "\n")
webbrowser.open(auth_url)
try:
if not done.wait(timeout=args.timeout):
print("error: timed out waiting for the browser consent", file=sys.stderr)
return EXIT_ERROR
finally:
server.shutdown()
if received.get("state") != state:
print("error: OAuth state mismatch - try again", file=sys.stderr)
return EXIT_ERROR
code = received.get("code")
if not code:
print(f"error: consent denied ({received.get('error', 'no code returned')})", file=sys.stderr)
return EXIT_ERROR
with httpx.Client(timeout=30.0) as client:
token_resp = client.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if token_resp.status_code != 200:
print(f"error: token exchange failed: {token_resp.text[:300]}", file=sys.stderr)
return EXIT_ERROR
tokens = token_resp.json()
refresh_token = tokens.get("refresh_token")
if not refresh_token:
print("error: no refresh_token returned - remove the app's prior access and retry", file=sys.stderr)
return EXIT_ERROR
blogs_resp = client.get(
"https://www.googleapis.com/blogger/v3/users/self/blogs",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
blogs = blogs_resp.json().get("items", []) if blogs_resp.status_code == 200 else []
print("\n이 계정의 블로그:")
for blog in blogs:
print(f" - {blog.get('name')} id={blog.get('id')} url={blog.get('url')}")
blog_id = blogs[0]["id"] if len(blogs) == 1 else "<위 목록에서 선택>"
print("\n.env에 추가할 내용:\n")
print(f"BLOGGER_CLIENT_ID={client_id}")
print(f"BLOGGER_CLIENT_SECRET={client_secret}")
print(f"BLOGGER_REFRESH_TOKEN={refresh_token}")
print(f"BLOGGER_BLOG_ID={blog_id}")
return EXIT_OK
COMMANDS = {
"run-once": cmd_run_once,
"daemon": cmd_daemon,
"next-run": cmd_next_run,
"test-notify": cmd_test_notify,
"show-config": cmd_show_config,
"blogger-auth": cmd_blogger_auth,
}
+21 -1
View File
@@ -35,6 +35,15 @@ class Settings(BaseSettings):
telegram_bot_token: str | None = Field(default=None, alias="TELEGRAM_BOT_TOKEN")
telegram_chat_id: str | None = Field(default=None, alias="TELEGRAM_CHAT_ID")
# --- Blogger (optional; issue the refresh token once with `us2kr blogger-auth`) ---
blogger_client_id: str | None = Field(default=None, alias="BLOGGER_CLIENT_ID")
blogger_client_secret: str | None = Field(default=None, alias="BLOGGER_CLIENT_SECRET")
blogger_refresh_token: str | None = Field(default=None, alias="BLOGGER_REFRESH_TOKEN")
blogger_blog_id: str | None = Field(default=None, alias="BLOGGER_BLOG_ID")
blogger_labels: Annotated[list[str], NoDecode] = Field(
default_factory=lambda: ["미국증시", "마감브리핑"], alias="BLOGGER_LABELS"
)
# --- Optional data keys ---
fred_api_key: str | None = Field(default=None, alias="FRED_API_KEY")
@@ -70,7 +79,9 @@ class Settings(BaseSettings):
log_level: str = Field(default="INFO", alias="US2KR_LOG_LEVEL")
@field_validator("minimax_fallback_models", "krx_extra_holidays", "searxng_queries", mode="before")
@field_validator(
"minimax_fallback_models", "krx_extra_holidays", "searxng_queries", "blogger_labels", mode="before"
)
@classmethod
def _split_csv(cls, v):
if isinstance(v, str):
@@ -85,6 +96,15 @@ class Settings(BaseSettings):
def telegram_enabled(self) -> bool:
return bool(self.telegram_bot_token and self.telegram_chat_id)
@property
def blogger_enabled(self) -> bool:
return bool(
self.blogger_client_id
and self.blogger_client_secret
and self.blogger_refresh_token
and self.blogger_blog_id
)
def get_settings() -> Settings:
"""Load settings from environment / .env (called once per process by the CLI)."""
+21 -3
View File
@@ -1,4 +1,4 @@
"""Delivery channels: local file archive (always) + Telegram (when configured)."""
"""Delivery channels: local file archive (always) + Telegram / Blogger (when configured)."""
from __future__ import annotations
@@ -6,19 +6,37 @@ import logging
from us2kr.config import Settings
from us2kr.deliver.base import Deliverer, DeliveryError
from us2kr.deliver.blogger import BloggerDeliverer
from us2kr.deliver.file import FileDeliverer
from us2kr.deliver.telegram import TelegramDeliverer
log = logging.getLogger(__name__)
__all__ = ["Deliverer", "DeliveryError", "FileDeliverer", "TelegramDeliverer", "build_deliverers"]
__all__ = [
"BloggerDeliverer",
"Deliverer",
"DeliveryError",
"FileDeliverer",
"TelegramDeliverer",
"build_deliverers",
]
def build_deliverers(settings: Settings) -> list[Deliverer]:
"""FileDeliverer always; TelegramDeliverer when both token and chat id are configured."""
"""FileDeliverer always; Telegram / Blogger when fully configured."""
deliverers: list[Deliverer] = [FileDeliverer(settings.reports_dir)]
if settings.telegram_enabled:
assert settings.telegram_bot_token and settings.telegram_chat_id # guaranteed by telegram_enabled
deliverers.append(TelegramDeliverer(settings.telegram_bot_token, settings.telegram_chat_id))
if settings.blogger_enabled:
deliverers.append(
BloggerDeliverer(
settings.blogger_client_id or "",
settings.blogger_client_secret or "",
settings.blogger_refresh_token or "",
settings.blogger_blog_id or "",
labels=settings.blogger_labels,
)
)
log.info("deliverers: %s", ", ".join(d.name for d in deliverers))
return deliverers
+144
View File
@@ -0,0 +1,144 @@
"""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])