From 12d7b47f4603c10b5e7ac801052c2e331ff343b9 Mon Sep 17 00:00:00 2001 From: jsahn Date: Tue, 1 Sep 2026 12:04:49 +0900 Subject: [PATCH] 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 --- .env.example | 9 ++ README.md | 3 + pyproject.toml | 1 + tests/test_blogger.py | 202 ++++++++++++++++++++++++++++++++++++++ us2kr/cli.py | 120 ++++++++++++++++++++++ us2kr/config.py | 22 ++++- us2kr/deliver/__init__.py | 24 ++++- us2kr/deliver/blogger.py | 144 +++++++++++++++++++++++++++ 8 files changed, 521 insertions(+), 4 deletions(-) create mode 100644 tests/test_blogger.py create mode 100644 us2kr/deliver/blogger.py 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 "" 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 "
7,686.42
" 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])