Files
US2KR/us2kr/cli.py
T
jsahnandClaude Fable 5 f5fa7ec70f Add a Monday-morning weekly recap briefing
No US session closes into Monday KST, so Mondays (default 07:00, cron
in the daemon with restart catch-up) now publish a weekly review built
entirely from stored data: the quotes table collapsed into week-over-
week changes and the week's daily briefing headlines as the narrative.
The recap is a BriefingResult kind="weekly" reusing MarketPayload, so
the store, verification pass, all delivery channels (Blogger title
"미국 주간 리뷰 <monday>") and the dashboard (주간 리뷰 badge) work
unchanged. Manual runs via `run-once --weekly [--date <monday>]`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 23:52:31 +09:00

319 lines
12 KiB
Python

"""Command-line entry point: ``us2kr run-once | daemon | next-run | test-notify | show-config``."""
from __future__ import annotations
import argparse
import logging
import re
import sys
from datetime import date, datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from us2kr import __version__
from us2kr.config import ET, KST, Settings, get_settings
from us2kr.daemon import run_daemon
from us2kr.deliver import build_deliverers
from us2kr.logging_setup import setup_logging
from us2kr.market_time import MarketClock
from us2kr.pipeline import SKIP_ALREADY_SENT, SKIP_KRX_CLOSED, delivery_failed, run_briefing
from us2kr.weekly import run_weekly
log = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
EXIT_OK = 0
EXIT_ERROR = 1
EXIT_SKIPPED = 2
SECRET_FIELD_RE = re.compile(r"(api_key|_key|_token|secret|password)$")
_PATH_FIELDS = ("data_dir", "reports_dir", "log_dir")
def _resolve_paths(settings: Settings, root: Path = PROJECT_ROOT) -> Settings:
"""Make relative path settings absolute against the project root (launchd starts with cwd=/)."""
update = {}
for name in _PATH_FIELDS:
value = Path(getattr(settings, name))
if not value.is_absolute():
update[name] = root / value
return settings.model_copy(update=update) if update else settings
def _load_settings() -> Settings:
"""Load settings; fall back to the project-root .env when none exists in the current directory."""
root_env = PROJECT_ROOT / ".env"
if not Path(".env").is_file() and root_env.is_file():
settings = Settings(_env_file=root_env) # type: ignore[call-arg]
else:
settings = get_settings()
return _resolve_paths(settings, PROJECT_ROOT)
def mask_secret(value: object) -> str:
if value is None or value == "":
return "(unset)"
text = str(value)
return f"{text[:4]}***" if len(text) > 8 else "***"
def _parse_date(text: str) -> date:
try:
return date.fromisoformat(text)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"invalid date {text!r}, expected YYYY-MM-DD") from exc
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="us2kr", description="US market close -> Korea open briefing bot")
parser.add_argument("--version", action="version", version=f"us2kr {__version__}")
sub = parser.add_subparsers(dest="command", required=True)
once = sub.add_parser("run-once", help="build (and deliver) one briefing")
once.add_argument(
"--date",
type=_parse_date,
default=None,
help="US session date YYYY-MM-DD (must already be closed unless --dry-run)",
)
once.add_argument("--dry-run", action="store_true", help="print markdown, do not save or deliver")
once.add_argument("--force", action="store_true", help="re-run (resend) even if already sent")
once.add_argument("--no-llm", action="store_true", help="skip the LLM (tables only)")
once.add_argument(
"--weekly",
action="store_true",
help="build the Monday weekly recap instead (--date must be a Monday KST; default: latest Monday)",
)
sub.add_parser("daemon", help="run the resident scheduler")
sub.add_parser("next-run", help="show the next scheduled briefing")
notify = sub.add_parser("test-notify", help="send a test message through every delivery channel")
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")
auth.add_argument(
"--port",
type=int,
default=0,
help="fixed loopback port (0 = random). Use with a *web application* OAuth client whose "
"authorized redirect URI is registered as http://127.0.0.1:<port>",
)
return parser
# ---------------------------------------------------------------------------- commands
def cmd_run_once(args: argparse.Namespace, settings: Settings) -> int:
run = run_weekly if args.weekly else run_briefing
result = run(args.date, settings, dry_run=args.dry_run, force=args.force, no_llm=args.no_llm)
if result.error in (SKIP_ALREADY_SENT, SKIP_KRX_CLOSED):
print(f"skipped: {result.error} (KRX date {result.payload.krx_date})", file=sys.stderr)
return EXIT_SKIPPED
if result.degraded:
log.warning("briefing produced in degraded mode: %s", result.error or "no LLM")
if delivery_failed(result):
print(f"error: {result.error} (KRX date {result.payload.krx_date})", file=sys.stderr)
return EXIT_ERROR
return EXIT_OK
def cmd_daemon(_args: argparse.Namespace, settings: Settings) -> int:
run_daemon(settings)
return EXIT_OK
def cmd_next_run(_args: argparse.Namespace, settings: Settings) -> int:
clock = MarketClock(settings.run_offset_minutes, extra_krx_holidays=settings.krx_extra_holidays)
now = datetime.now(ZoneInfo(KST))
session, run_at = clock.next_briefing(now)
close_et = clock.us_session_close(session)
krx_date = clock.krx_date_for(session)
krx_open = clock.is_krx_session(krx_date)
print(f"US session : {session}")
print(f"US close (ET) : {close_et.isoformat(timespec='minutes')} [{ET}]")
print(
f"briefing at KST : {run_at.isoformat(timespec='minutes')} (offset {settings.run_offset_minutes} min)"
)
print(f"KRX date : {krx_date} ({'open' if krx_open else 'CLOSED'})")
return EXIT_OK
def cmd_test_notify(args: argparse.Namespace, settings: Settings) -> int:
deliverers = build_deliverers(settings)
if not deliverers:
print("no delivery channels configured", file=sys.stderr)
return EXIT_ERROR
for deliverer in deliverers:
deliverer.notify_error("US2KR 테스트 알림", args.message)
print(f"sent test notification via {deliverer.name}")
return EXIT_OK
def cmd_show_config(_args: argparse.Namespace, settings: Settings) -> int:
for name, value in settings.model_dump().items():
shown = mask_secret(value) if SECRET_FIELD_RE.search(name) else value
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", args.port), 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 []
if blogs_resp.status_code != 200:
print(
f"\n경고: 블로그 목록 조회 실패 (HTTP {blogs_resp.status_code}): {blogs_resp.text[:300]}",
file=sys.stderr,
)
elif not blogs:
print("\n경고: 이 계정에 블로그가 없습니다 - 블로그 소유 계정으로 승인했는지 확인하세요", file=sys.stderr)
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,
}
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
settings = _load_settings()
except Exception as exc: # noqa: BLE001 - bad .env must produce a readable message
print(f"error: could not load settings: {exc}", file=sys.stderr)
return EXIT_ERROR
setup_logging(settings)
try:
return COMMANDS[args.command](args, settings)
except KeyboardInterrupt:
return EXIT_ERROR
except Exception as exc: # top-level guard: log and exit non-zero
log.exception("command %s failed", args.command)
print(f"error: {exc}", file=sys.stderr)
return EXIT_ERROR
if __name__ == "__main__":
raise SystemExit(main())