diff --git a/.env.example b/.env.example
index 0424dd8..500632a 100644
--- a/.env.example
+++ b/.env.example
@@ -8,6 +8,15 @@ MINIMAX_FALLBACK_MODELS=MiniMax-M2.7,MiniMax-M2.5
TELEGRAM_BOT_TOKEN=
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_API_KEY=
diff --git a/README.md b/README.md
index 44bf609..6fc8cec 100644
--- a/README.md
+++ b/README.md
@@ -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 --force # 이미 발송된 날이어도 다시(재발송)
.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 # 상주 실행(스케줄러)
```
@@ -77,6 +78,8 @@ LaunchAgent(`container system start` + `container start us2kr`)가 담당합니
| `MINIMAX_MODEL` | `MiniMax-M3` | 기본 모델 |
| `MINIMAX_FALLBACK_MODELS` | `MiniMax-M2.7,MiniMax-M2.5` | 실패 시 순차 폴백 |
| `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` | — | 있으면 경제지표 발표 일정 포함 |
| `US2KR_RUN_OFFSET_MINUTES` | `75` | 미국 마감 후 몇 분 뒤 실행 |
| `US2KR_CATCHUP_WINDOW_HOURS` | `3` | 재시작 시 따라잡기 허용 창 |
diff --git a/pyproject.toml b/pyproject.toml
index f1db74e..79ae995 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,6 +15,7 @@ dependencies = [
"python-dotenv>=1.0",
"httpx>=0.27",
"requests>=2.31",
+ "markdown>=3.6",
]
[project.optional-dependencies]
diff --git a/tests/test_blogger.py b/tests/test_blogger.py
new file mode 100644
index 0000000..41b1370
--- /dev/null
+++ b/tests/test_blogger.py
@@ -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 "
총평
" in html
+ assert "하락 마감" in html
+ assert "" in html and "| 7,686.42 | " 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 "" 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]
diff --git a/us2kr/cli.py b/us2kr/cli.py
index 49d7229..da61c04 100644
--- a/us2kr/cli.py
+++ b/us2kr/cli.py
@@ -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("인증 완료 — 터미널로 돌아가세요.
".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,
}
diff --git a/us2kr/config.py b/us2kr/config.py
index d4a6df5..8561b79 100644
--- a/us2kr/config.py
+++ b/us2kr/config.py
@@ -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)."""
diff --git a/us2kr/deliver/__init__.py b/us2kr/deliver/__init__.py
index c80c2c0..eec89d5 100644
--- a/us2kr/deliver/__init__.py
+++ b/us2kr/deliver/__init__.py
@@ -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
diff --git a/us2kr/deliver/blogger.py b/us2kr/deliver/blogger.py
new file mode 100644
index 0000000..71d6b3b
--- /dev/null
+++ b/us2kr/deliver/blogger.py
@@ -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])