"""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 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)") 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:", ) return parser # ---------------------------------------------------------------------------- commands def cmd_run_once(args: argparse.Namespace, settings: Settings) -> int: result = run_briefing(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("

인증 완료 — 터미널로 돌아가세요.

".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())