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:
@@ -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=
|
||||||
|
|
||||||
|
|||||||
@@ -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` | 재시작 시 따라잡기 허용 창 |
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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])
|
||||||
Reference in New Issue
Block a user