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_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=
+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 --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` | 재시작 시 따라잡기 허용 창 |
+1
View File
@@ -15,6 +15,7 @@ dependencies = [
"python-dotenv>=1.0",
"httpx>=0.27",
"requests>=2.31",
"markdown>=3.6",
]
[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")
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])