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
+9
View File
@@ -8,6 +8,15 @@ MINIMAX_FALLBACK_MODELS=MiniMax-M2.7,MiniMax-M2.5
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID= TELEGRAM_CHAT_ID=
# Blogger 자동 발행 (선택) — 4개 모두 채우면 브리핑을 블로그에 게시.
# 발급: Google Cloud에서 Blogger API 활성화 + OAuth 데스크톱 클라이언트 생성 후 `us2kr blogger-auth`
BLOGGER_CLIENT_ID=
BLOGGER_CLIENT_SECRET=
BLOGGER_REFRESH_TOKEN=
BLOGGER_BLOG_ID=
# 게시물 라벨 CSV (기본: 미국증시,마감브리핑)
BLOGGER_LABELS=
# FRED (선택) — 경제지표 실제 발표값 # FRED (선택) — 경제지표 실제 발표값
FRED_API_KEY= FRED_API_KEY=
+3
View File
@@ -42,6 +42,7 @@ cp .env.example .env # MINIMAX_API_KEY 필수, TELEGRAM_* 선택
.venv/bin/python -m us2kr.cli run-once --date 2026-08-24 # 특정 미국 세션 기준 생성·발송(마감된 세션만) .venv/bin/python -m us2kr.cli run-once --date 2026-08-24 # 특정 미국 세션 기준 생성·발송(마감된 세션만)
.venv/bin/python -m us2kr.cli run-once --force # 이미 발송된 날이어도 다시(재발송) .venv/bin/python -m us2kr.cli run-once --force # 이미 발송된 날이어도 다시(재발송)
.venv/bin/python -m us2kr.cli test-notify # 발송 채널 점검 .venv/bin/python -m us2kr.cli test-notify # 발송 채널 점검
.venv/bin/python -m us2kr.cli blogger-auth # Blogger OAuth 1회 발급(.env에 넣을 값 출력)
.venv/bin/python -m us2kr.cli daemon # 상주 실행(스케줄러) .venv/bin/python -m us2kr.cli daemon # 상주 실행(스케줄러)
``` ```
@@ -77,6 +78,8 @@ LaunchAgent(`container system start` + `container start us2kr`)가 담당합니
| `MINIMAX_MODEL` | `MiniMax-M3` | 기본 모델 | | `MINIMAX_MODEL` | `MiniMax-M3` | 기본 모델 |
| `MINIMAX_FALLBACK_MODELS` | `MiniMax-M2.7,MiniMax-M2.5` | 실패 시 순차 폴백 | | `MINIMAX_FALLBACK_MODELS` | `MiniMax-M2.7,MiniMax-M2.5` | 실패 시 순차 폴백 |
| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | — | 둘 다 있으면 Telegram 발송 | | `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | — | 둘 다 있으면 Telegram 발송 |
| `BLOGGER_CLIENT_ID` `BLOGGER_CLIENT_SECRET` `BLOGGER_REFRESH_TOKEN` `BLOGGER_BLOG_ID` | — | 4개 모두 있으면 Blogger에 자동 발행. Google Cloud에서 Blogger API v3 활성화 + OAuth 데스크톱 클라이언트 생성 후 `us2kr blogger-auth`로 발급 |
| `BLOGGER_LABELS` | `미국증시,마감브리핑` | Blogger 게시물 라벨 CSV |
| `FRED_API_KEY` | — | 있으면 경제지표 발표 일정 포함 | | `FRED_API_KEY` | — | 있으면 경제지표 발표 일정 포함 |
| `US2KR_RUN_OFFSET_MINUTES` | `75` | 미국 마감 후 몇 분 뒤 실행 | | `US2KR_RUN_OFFSET_MINUTES` | `75` | 미국 마감 후 몇 분 뒤 실행 |
| `US2KR_CATCHUP_WINDOW_HOURS` | `3` | 재시작 시 따라잡기 허용 창 | | `US2KR_CATCHUP_WINDOW_HOURS` | `3` | 재시작 시 따라잡기 허용 창 |
+1
View File
@@ -15,6 +15,7 @@ dependencies = [
"python-dotenv>=1.0", "python-dotenv>=1.0",
"httpx>=0.27", "httpx>=0.27",
"requests>=2.31", "requests>=2.31",
"markdown>=3.6",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+202
View File
@@ -0,0 +1,202 @@
"""Tests for us2kr.deliver.blogger (no network: the httpx client is a stub)."""
from __future__ import annotations
import json
from datetime import UTC, date, datetime
from typing import Any
import httpx
import pytest
from us2kr.config import Settings
from us2kr.deliver import build_deliverers
from us2kr.deliver.base import DeliveryError
from us2kr.deliver.blogger import BloggerDeliverer, briefing_html, post_title
from us2kr.models import BriefingResult, MarketPayload, Report
TOKEN_OK = {"access_token": "at-1", "expires_in": 3600}
POST_OK = {"kind": "blogger#post", "id": "42", "url": "https://nybrief.blogspot.com/2026/09/post.html"}
def make_result(headline: str | None = "9월 첫 거래일 하락") -> BriefingResult:
payload = MarketPayload(
session_date=date(2026, 8, 31),
us_close_time=datetime(2026, 8, 31, 20, 0, tzinfo=UTC),
generated_at=datetime(2026, 9, 1, 6, 15, tzinfo=UTC),
krx_date=date(2026, 9, 1),
krx_open_today=True,
)
report = None
if headline is not None:
report = Report(
headline=headline,
market_overview="지수가 내렸습니다.",
key_drivers=["금리 상승"],
rates_fx_commodities="금리 상승.",
korea_implications=["반도체 부담"],
watchlist_today=["환율"],
risk_flags=["변동성"],
)
markdown = (
"# 🇺🇸→🇰🇷 미국 마감 브리핑 — 2026-09-01\n"
"미국 마감: 2026-09-01 05:00 KST\n\n"
"## 총평\n**하락 마감**\n\n"
"| 종목 | 종가 |\n|---|---|\n| S&P 500 | 7,686.42 |\n"
)
return BriefingResult(payload=payload, report=report, markdown=markdown, degraded=report is None)
class _Resp:
def __init__(self, status: int, body: dict[str, Any]):
self.status_code = status
self._body = body
self.text = json.dumps(body)
def json(self) -> dict[str, Any]:
return self._body
class FakeHttp:
"""Minimal stand-in for httpx.Client: scripted responses, recorded calls."""
def __init__(self, responses: list[Any]):
self._responses = list(responses)
self.calls: list[dict[str, Any]] = []
def post(self, url: str, **kwargs: Any) -> Any:
self.calls.append({"url": url, **kwargs})
if not self._responses:
raise AssertionError("unexpected extra HTTP call")
item = self._responses.pop(0)
if isinstance(item, Exception):
raise item
return item
def make_deliverer(responses: list[Any]) -> tuple[BloggerDeliverer, FakeHttp]:
client = FakeHttp(responses)
deliverer = BloggerDeliverer(
"cid", "csecret", "rtoken", "12345", labels=["미국증시"], client=client # type: ignore[arg-type]
)
return deliverer, client
# ------------------------------------------------------------------ html & title
def test_briefing_html_drops_h1_and_renders_tables():
html = briefing_html(make_result().markdown)
assert "미국 마감 브리핑 — 2026-09-01" not in html # H1 is the post title, not body
assert "<h2>총평</h2>" in html
assert "<strong>하락 마감</strong>" in html
assert "<table>" in html and "<td>7,686.42</td>" in html
def test_post_title_with_and_without_headline():
assert post_title(make_result()) == "미국 마감 브리핑 2026-09-01 — 9월 첫 거래일 하락"
assert post_title(make_result(headline=None)) == "미국 마감 브리핑 2026-09-01 (미 2026-08-31 세션)"
# ------------------------------------------------------------------ delivery
def test_deliver_refreshes_token_posts_and_returns_url():
deliverer, client = make_deliverer([_Resp(200, TOKEN_OK), _Resp(200, POST_OK)])
receipt = deliverer.deliver(make_result())
assert receipt == POST_OK["url"]
token_call, post_call = client.calls
assert token_call["data"]["grant_type"] == "refresh_token"
assert "refresh_token" not in post_call["url"] # secrets never in URLs
assert post_call["url"].endswith("/blogs/12345/posts/")
assert post_call["headers"]["Authorization"] == "Bearer at-1"
body = post_call["json"]
assert body["title"].startswith("미국 마감 브리핑 2026-09-01")
assert body["labels"] == ["미국증시"] and "<table>" in body["content"]
def test_deliver_reuses_cached_token():
deliverer, client = make_deliverer([_Resp(200, TOKEN_OK), _Resp(200, POST_OK), _Resp(200, POST_OK)])
deliverer.deliver(make_result())
deliverer.deliver(make_result())
token_calls = [c for c in client.calls if "oauth2" in c["url"]]
assert len(token_calls) == 1
def test_deliver_retries_once_on_401_with_fresh_token():
deliverer, client = make_deliverer(
[
_Resp(200, TOKEN_OK),
_Resp(401, {"error": "invalid token"}),
_Resp(200, {"access_token": "at-2", "expires_in": 3600}),
_Resp(200, POST_OK),
]
)
receipt = deliverer.deliver(make_result())
assert receipt == POST_OK["url"]
assert client.calls[-1]["headers"]["Authorization"] == "Bearer at-2"
def test_deliver_retries_5xx_then_raises(monkeypatch):
monkeypatch.setattr("us2kr.deliver.blogger.time.sleep", lambda _s: None)
deliverer, _client = make_deliverer(
[_Resp(200, TOKEN_OK)] + [_Resp(503, {"error": "backend"})] * 3
)
with pytest.raises(DeliveryError, match="HTTP 503"):
deliverer.deliver(make_result())
def test_deliver_does_not_retry_4xx():
deliverer, client = make_deliverer([_Resp(200, TOKEN_OK), _Resp(403, {"error": "forbidden"})])
with pytest.raises(DeliveryError, match="HTTP 403"):
deliverer.deliver(make_result())
assert len(client.calls) == 2 # no extra attempts
def test_token_failure_raises_delivery_error():
deliverer, _ = make_deliverer([_Resp(400, {"error": "invalid_grant"})])
with pytest.raises(DeliveryError, match="token refresh failed"):
deliverer.deliver(make_result())
def test_network_error_wrapped(monkeypatch):
monkeypatch.setattr("us2kr.deliver.blogger.time.sleep", lambda _s: None)
deliverer, _ = make_deliverer(
[_Resp(200, TOKEN_OK)] + [httpx.ConnectError("boom")] * 3
)
with pytest.raises(DeliveryError, match="ConnectError"):
deliverer.deliver(make_result())
def test_notify_error_never_raises():
deliverer, client = make_deliverer([])
deliverer.notify_error("subject", "detail") # logs only, no HTTP call
assert client.calls == []
def test_requires_all_credentials():
with pytest.raises(ValueError):
BloggerDeliverer("cid", "csecret", "", "12345")
# ------------------------------------------------------------------ wiring
def test_build_deliverers_includes_blogger_when_configured(tmp_path):
base = {
"US2KR_DATA_DIR": str(tmp_path),
"US2KR_REPORTS_DIR": str(tmp_path),
"US2KR_LOG_DIR": str(tmp_path),
}
plain = Settings(_env_file=None, **base)
assert [d.name for d in build_deliverers(plain)] == ["file"]
full = Settings(
_env_file=None,
**base,
BLOGGER_CLIENT_ID="cid",
BLOGGER_CLIENT_SECRET="cs",
BLOGGER_REFRESH_TOKEN="rt",
BLOGGER_BLOG_ID="1",
BLOGGER_LABELS="미국증시, 시황",
)
deliverers = build_deliverers(full)
assert [d.name for d in deliverers] == ["file", "blogger"]
blogger = deliverers[-1]
assert blogger.labels == ["미국증시", "시황"] # type: ignore[attr-defined]
assert blogger.blog_id == "1" # type: ignore[attr-defined]
+120
View File
@@ -85,6 +85,16 @@ def build_parser() -> argparse.ArgumentParser:
notify.add_argument("--message", default="US2KR test notification", help="text to send") notify.add_argument("--message", default="US2KR test notification", help="text to send")
sub.add_parser("show-config", help="print effective settings (secrets masked)") 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 return parser
@@ -140,16 +150,126 @@ def cmd_show_config(_args: argparse.Namespace, settings: Settings) -> int:
print(f"{name} = {shown}") print(f"{name} = {shown}")
print(f"db_path = {settings.db_path}") print(f"db_path = {settings.db_path}")
print(f"telegram_enabled = {settings.telegram_enabled}") print(f"telegram_enabled = {settings.telegram_enabled}")
print(f"blogger_enabled = {settings.blogger_enabled}")
print(f"project_root = {PROJECT_ROOT}") print(f"project_root = {PROJECT_ROOT}")
return EXIT_OK 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 = { COMMANDS = {
"run-once": cmd_run_once, "run-once": cmd_run_once,
"daemon": cmd_daemon, "daemon": cmd_daemon,
"next-run": cmd_next_run, "next-run": cmd_next_run,
"test-notify": cmd_test_notify, "test-notify": cmd_test_notify,
"show-config": cmd_show_config, "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_bot_token: str | None = Field(default=None, alias="TELEGRAM_BOT_TOKEN")
telegram_chat_id: str | None = Field(default=None, alias="TELEGRAM_CHAT_ID") 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 --- # --- Optional data keys ---
fred_api_key: str | None = Field(default=None, alias="FRED_API_KEY") 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") 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 @classmethod
def _split_csv(cls, v): def _split_csv(cls, v):
if isinstance(v, str): if isinstance(v, str):
@@ -85,6 +96,15 @@ class Settings(BaseSettings):
def telegram_enabled(self) -> bool: def telegram_enabled(self) -> bool:
return bool(self.telegram_bot_token and self.telegram_chat_id) 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: def get_settings() -> Settings:
"""Load settings from environment / .env (called once per process by the CLI).""" """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 from __future__ import annotations
@@ -6,19 +6,37 @@ import logging
from us2kr.config import Settings from us2kr.config import Settings
from us2kr.deliver.base import Deliverer, DeliveryError from us2kr.deliver.base import Deliverer, DeliveryError
from us2kr.deliver.blogger import BloggerDeliverer
from us2kr.deliver.file import FileDeliverer from us2kr.deliver.file import FileDeliverer
from us2kr.deliver.telegram import TelegramDeliverer from us2kr.deliver.telegram import TelegramDeliverer
log = logging.getLogger(__name__) 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]: 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)] deliverers: list[Deliverer] = [FileDeliverer(settings.reports_dir)]
if settings.telegram_enabled: if settings.telegram_enabled:
assert settings.telegram_bot_token and settings.telegram_chat_id # guaranteed by 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)) 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)) log.info("deliverers: %s", ", ".join(d.name for d in deliverers))
return 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])