"""人工登录:起一个有头浏览器,由人完成登录,落盘 cookie 供服务复用 为什么必须人工:乐天与 ラクマ 的登录都带 reCAPTCHA 与设备验证(短信/邮箱 OTP), 自动填表的成功率既低又不稳定,还要在本地存明文密码。这里的取舍是——**密码只经过 你和站点,不经过本服务**:脚本只负责把浏览器打开、等你登录完,然后把 cookie 存下来。 用法: .venv/Scripts/python.exe scripts/login.py --site rakuten .venv/Scripts/python.exe scripts/login.py --site rakuma .venv/Scripts/python.exe scripts/login.py --site all 浏览器窗口打开后手动完成登录,脚本会自动轮询登录态;检测到已登录即保存并退出。 也可以登录完成后回到终端按回车立即保存。 产物落在 settings.auth_state_dir(默认 .auth/),内含可直接冒充账号的 cookie, 已在 .gitignore 里排除,不要提交、不要外传。 """ from __future__ import annotations import argparse import asyncio import json import sys from pathlib import Path # 允许以 `python scripts/login.py` 直接运行 sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from app.shared.config import get_settings # noqa: E402 from app.trading.core import auth_site # noqa: E402 # 轮询间隔与总时长:给足人工过验证码、收短信的时间 _POLL_INTERVAL_SECONDS = 5 _POLL_TIMEOUT_SECONDS = 600 async def _is_logged_in(page, site: str) -> bool: """在浏览器里探测登录态 判据与 AuthSession 共用 auth_site.is_logged_in,只是这里喂浏览器页面内容、 那里喂 httpx 响应,避免两条链路对「算不算登录」各判各的。 """ await page.goto( auth_site.profile(site).probe_url, wait_until="domcontentloaded", timeout=60_000 ) return auth_site.is_logged_in(site, final_url=page.url, body=await page.content()) async def login(site: str) -> bool: """打开浏览器让用户登录指定站点,成功则保存 storage_state""" from playwright.async_api import async_playwright settings = get_settings() profile = auth_site.profile(site) state_path = settings.auth_state_path / profile.state_filename print(f"\n=== {profile.label}({site})登录 ===") print(f"即将打开浏览器:{profile.login_url}") print("请在浏览器窗口里完成登录(账号密码只在浏览器与站点之间传递,本脚本不读取)。") print(f"登录完成后脚本会自动检测,最长等待 {_POLL_TIMEOUT_SECONDS // 60} 分钟。\n") async with async_playwright() as playwright: browser = await playwright.chromium.launch( headless=False, # 人工登录必须有头 channel=settings.browser_channel or None, proxy=settings.playwright_proxy, args=["--no-first-run", "--disable-blink-features=AutomationControlled"], ) try: context = await browser.new_context( # UA 取自 auth_site:服务端后续用同一个 UA 发请求,换了可能触发 # 站点的设备校验,让这次辛苦登来的 cookie 提前失效。 user_agent=profile.user_agent, locale="ja-JP", timezone_id="Asia/Tokyo", viewport=( {"width": 390, "height": 844} if profile.mobile else {"width": 1440, "height": 900} ), is_mobile=profile.mobile, has_touch=profile.mobile, ) page = await context.new_page() await page.goto(profile.login_url, wait_until="domcontentloaded", timeout=60_000) waited = 0 while waited < _POLL_TIMEOUT_SECONDS: await asyncio.sleep(_POLL_INTERVAL_SECONDS) waited += _POLL_INTERVAL_SECONDS try: if await _is_logged_in(page, site): break except Exception as exc: # 页面正在跳转时探测可能失败,继续等 print(f" 探测中({waited}s):{type(exc).__name__}") continue print(f" 等待登录中…({waited}s)") else: print(f"✗ {profile.label} 等待超时,未检测到登录态。未保存。") return False state = await context.storage_state() state_path.write_text( json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8" ) print(f"✓ {profile.label} 登录成功,已保存 {len(state.get('cookies', []))} 条 cookie") print(f" → {state_path}") return True finally: await browser.close() async def main() -> int: parser = argparse.ArgumentParser(description="人工登录并保存乐天/ラクマ 登录态") parser.add_argument( "--site", choices=[*auth_site.SITES, "all"], default="all", help="要登录的站点,默认两站都登录", ) args = parser.parse_args() targets = list(auth_site.SITES) if args.site == "all" else [args.site] results = {site: await login(site) for site in targets} print("\n=== 结果 ===") for site, ok in results.items(): print(f" {site}: {'已登录' if ok else '失败'}") print("\n登录态已就绪,可启动服务并用 POST /api/auth/status 复核。") return 0 if all(results.values()) else 1 if __name__ == "__main__": raise SystemExit(asyncio.run(main()))