"""账号密码自动登录的运行时核心,供 scripts/login.py(CLI)与 AuthSession(运行时重登)共用 设计要点: - **凭据安全**:Account 在内存中只存到 login_one() 返回;日志只打 account.id,绝不打 username/password。account.yaml 由调用方负责(默认项目根,已在 .gitignore 排除)。 - **持久化浏览器**:用 launch_persistent_context,user_data_dir 来自 account.yaml, cookie/localStorage/缓存跨重启保留。 - **人工 fallback**:自动填账号密码后撞 reCAPTCHA / OTP 时,浏览器保持有头模式, 由人完成验证,脚本继续轮询登录态;超时则失败返回。 - **登录态判据**:访问 profile.login_url 看落地 URL 是否被踢到 SSO。旧 marker 「現在ログインしていません」在新版 SP cart SPA 上不可靠(详见 project://jp-rakuten/checkout-flow-probe-findings §2),本模块不用。 """ from __future__ import annotations import asyncio import logging import sys from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from typing import Any import yaml from app.shared.config import BASE_DIR, Settings from app.trading.core import auth_site logger = logging.getLogger(__name__) # 自动填表后通常秒级;fallback 人工要给足过验证码、收短信的时间 _DEFAULT_TIMEOUT_SECONDS = 600 _POLL_INTERVAL_SECONDS = 5 _ACCOUNTS_FILE = BASE_DIR / "account.yaml" @dataclass(slots=True) class Account: """account.yaml 中的一条账号记录""" site: str id: str username: str password: str user_data_dir: Path state_filename: str default: bool # ---- YAML 读取与账号选择 ---- def accounts_file_path() -> Path: """account.yaml 默认在项目根""" return _ACCOUNTS_FILE def _resolve_user_data_dir(site: str, raw: dict[str, Any], account_id: str) -> Path: """从 YAML 取 user_data_dir,未配置则回退默认""" raw_dir = raw.get("user_data_dir") or f".browser-data/{site}-{account_id}" p = Path(raw_dir) if not p.is_absolute(): p = BASE_DIR / p return p def _resolve_state_filename(site: str, raw: dict[str, Any], is_first: bool) -> str: """state_filename 解析:显式 > 首项默认 _state.json(兼容 AuthSession)> 按 id 派生""" explicit = raw.get("state_filename") if explicit: return str(explicit) if is_first: return f"{site}_state.json" return f"{site}_state_{raw.get('id', 'account')}.json" def load_accounts() -> dict[str, list[Account]]: """读 account.yaml,按站点分组返回所有账号 文件不存在时抛 FileNotFoundError,调用方决定如何降级。 """ path = accounts_file_path() if not path.exists(): raise FileNotFoundError( f"未找到 {path}。请复制 account.yaml.example 为 account.yaml 并填入凭据。" ) raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} result: dict[str, list[Account]] = {} for site in auth_site.SITES: records = raw.get(site) or [] if not isinstance(records, list): raise ValueError(f"account.yaml: {site} 应为列表,收到 {type(records).__name__}") accounts: list[Account] = [] for idx, rec in enumerate(records): if not isinstance(rec, dict): raise ValueError(f"account.yaml: {site}[{idx}] 应为字典") for required in ("id", "username", "password"): if not rec.get(required): raise ValueError( f"account.yaml: {site}[{idx}].{required} 必填" ) account_id = str(rec["id"]) accounts.append( Account( site=site, id=account_id, username=str(rec["username"]), password=str(rec["password"]), user_data_dir=_resolve_user_data_dir(site, rec, account_id), state_filename=_resolve_state_filename(site, rec, is_first=(idx == 0)), default=bool(rec.get("default", False)), ) ) result[site] = accounts return result def select_accounts( site: str, accounts_by_site: dict[str, list[Account]], *, account_id: str | None, all_accounts: bool, ) -> list[Account]: """按 --account / --all-accounts 选择要登录的账号列表 `site="all"` 时跳过 account.yaml 未配置的站点;若所有站点都没配则报错。 """ if site == "all": targets: list[Account] = [] for s in auth_site.SITES: pool = accounts_by_site.get(s, []) if not pool: continue targets.extend( select_accounts(s, accounts_by_site, account_id=account_id, all_accounts=all_accounts) ) if not targets: raise ValueError("account.yaml 没有任何站点的账号记录") return targets pool = accounts_by_site.get(site, []) if not pool: raise ValueError(f"account.yaml 没有 {site} 的账号记录") if all_accounts: return list(pool) if account_id: for acc in pool: if acc.id == account_id: return [acc] raise ValueError(f"account.yaml 中 {site} 没有 id={account_id} 的账号") # 默认:标 default 的;无标记用首项 for acc in pool: if acc.default: return [acc] return [pool[0]] def default_account_for(site: str, accounts_by_site: dict[str, list[Account]]) -> Account | None: """取某站的默认账号(标 default 或首项),用于自动重登。无配置返回 None""" pool = accounts_by_site.get(site, []) if not pool: return None for acc in pool: if acc.default: return acc return pool[0] # ---- 登录态判定(URL 判据,不依赖旧 marker)---- async def _is_logged_in(page, site: str) -> bool: """访问 profile.login_url 看落地是否被踢到 SSO `site` 目前恒为 `"rakuten"`:未登录时会被重定向到 login.account.rakuten.com 或 /login 路径;登录后停在 my.rakuten.co.jp。保留 site 参数形态是为了与 SiteAuthProfile 共用签名,方便未来加站点。 """ profile = auth_site.profile(site) await page.goto(profile.login_url, wait_until="domcontentloaded", timeout=60_000) final_url = page.url if site != "rakuten": raise ValueError(f"未知站点:{site}") return "login.account.rakuten.com" not in final_url and "/login" not in final_url # ---- 自动填表(启发式选择器)---- async def _try_autofill(page, account: Account) -> bool: """启发式填账号密码并提交 Returns: True 表示已尝试自动填表(无论是否撞验证码); False 表示页面没有密码框(已经登录或落地异常),交给调用方判定。 """ try: await page.wait_for_selector( "input[type='password']", state="visible", timeout=10_000 ) except Exception: return False # 用户名框:按常见命名启发式找 username_selectors = [ "input[type='email']", "input[name='username']", "input[name='user_id']", "input[name*='email' i]", "input[name*='userid' i]", "input[name*='login' i]", "input[autocomplete='username']", "input[type='text']:visible", "input[type='tel']:visible", ] username_input = None for sel in username_selectors: username_input = await page.query_selector(sel) if username_input and await username_input.is_visible(): break username_input = None if username_input: await username_input.fill(account.username) pwd = await page.query_selector("input[type='password']:visible") if pwd: await pwd.fill(account.password) # 提交按钮:按文案与类型启发式 submit_selectors = [ "button[type='submit']:visible", "input[type='submit']:visible", "button:has-text('ログイン')", "button:has-text('Sign in')", "button:has-text('Login')", "[data-role='submit']:visible", ] for sel in submit_selectors: btn = await page.query_selector(sel) if btn and await btn.is_visible(): try: await btn.click() except Exception: continue break return True async def _detect_human_challenge(page) -> str | None: """检测页面是否出现需要人工处理的验证(reCAPTCHA / OTP / 设备验证)""" if await page.query_selector("iframe[title*='reCAPTCHA' i], iframe[src*='recaptcha']"): return "reCAPTCHA" if await page.query_selector("iframe[src*='hcaptcha']"): return "hCaptcha" if await page.query_selector( "input[name*='otp' i], input[name*='code' i], input[autocomplete='one-time-code']" ): return "短信/邮箱 OTP" if await page.query_selector( "input[name*='verification' i], input[name*='captcha' i]" ): return "设备验证/图形验证码" return None # ---- 单账号登录主流程 ---- async def login_one( account: Account, settings: Settings, *, timeout_seconds: int = _DEFAULT_TIMEOUT_SECONDS, progress: Callable[[str], None] | None = None, ) -> bool: """对一个账号执行自动登录,成功则保存 storage_state Args: account: 账号记录(含明文凭据,调用方负责保密) settings: 全局配置(取 auth_state_path / browser_channel / playwright_proxy) timeout_seconds: 整个登录流程的最长等待(含人工 fallback) progress: 可选的进度回调,默认用 logger.info;CLI 传 print Returns: True 表示登录成功且 storage_state 已落盘;False 表示超时或失败 """ from playwright.async_api import async_playwright log = progress or (lambda msg: logger.info(msg)) profile = auth_site.profile(account.site) state_path = settings.auth_state_path / account.state_filename account.user_data_dir.mkdir(parents=True, exist_ok=True) log(f"=== {profile.label}({account.site}/{account.id})登录 ===") log(f"持久化目录:{account.user_data_dir}") log(f"登录入口:{profile.login_url}") async with async_playwright() as playwright: context = await playwright.chromium.launch_persistent_context( user_data_dir=str(account.user_data_dir), headless=False, # 自动登录仍需有头:撞验证码要人工接管 channel=settings.browser_channel or None, proxy=settings.playwright_proxy, 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, args=["--no-first-run", "--disable-blink-features=AutomationControlled"], ) try: page = context.pages[0] if context.pages else await context.new_page() await page.goto(profile.login_url, wait_until="domcontentloaded", timeout=60_000) # 已经登录则直接保存 if await _is_logged_in(page, account.site): log(f"已处于登录态,跳过填表:{account.id}") else: filled = await _try_autofill(page, account) if not filled: log("未找到登录表单且未登录,请在浏览器里手动完成登录") else: # 不打账号/密码值,只标识已提交 log(f"已自动填写凭据并提交:account_id={account.id}") await page.wait_for_load_state("domcontentloaded", timeout=30_000) challenge = await _detect_human_challenge(page) if challenge: log( f"检测到 {challenge}:请在浏览器窗口里人工完成验证。" f"最长等待 {timeout_seconds // 60} 分钟。" ) # 轮询登录态 waited = 0 ok = False while waited < timeout_seconds: try: if await _is_logged_in(page, account.site): ok = True break except Exception as exc: log(f"探测中({waited}s):{type(exc).__name__}") await asyncio.sleep(_POLL_INTERVAL_SECONDS) waited += _POLL_INTERVAL_SECONDS if waited % 30 == 0: log(f"等待登录完成…({waited}s)") if not ok: log(f"等待超时,未检测到登录态:{account.id}") return False import json state = await context.storage_state() state_path.write_text( json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8" ) log(f"登录成功,已保存 {len(state.get('cookies', []))} 条 cookie → {state_path}") return True finally: await context.close() # ---- CLI 入口(供 scripts/login.py 调用)---- async def cli_main(argv: list[str] | None = None) -> int: """命令行入口:argparse + 调用 login_one 返回进程退出码。stdout 用 print(给人看),不打账号密码值。 """ import argparse parser = argparse.ArgumentParser(description="按 account.yaml 自动登录乐天市场") parser.add_argument( "--site", choices=[*auth_site.SITES, "all"], default="all", help="要登录的站点,默认 all", ) parser.add_argument( "--account", help="account.yaml 中的账号 id;不传则用 default=true 或首项", ) parser.add_argument( "--all-accounts", action="store_true", help="登录该站点 account.yaml 里的所有账号(按顺序串行)", ) parser.add_argument( "--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"], ) args = parser.parse_args(argv) logging.basicConfig( level=args.log_level, format="{asctime} {levelname} {message}", style="{", ) try: accounts_by_site = load_accounts() except (FileNotFoundError, ValueError) as exc: print(f"配置错误:{exc}", file=sys.stderr) return 2 try: targets = select_accounts( args.site, accounts_by_site, account_id=args.account, all_accounts=args.all_accounts, ) except ValueError as exc: print(f"账号选择失败:{exc}", file=sys.stderr) return 2 if not targets: print("没有匹配的账号可登录", file=sys.stderr) return 2 print( f"将登录 {len(targets)} 个账号:" + ", ".join(f"{a.site}/{a.id}" for a in targets) ) settings = Settings() results: dict[str, bool] = {} for acc in targets: key = f"{acc.site}/{acc.id}" try: results[key] = await login_one(acc, settings, progress=print) except Exception as exc: print(f"✗ {key} 登录异常:{type(exc).__name__}: {exc}", file=sys.stderr) results[key] = False print("\n=== 结果 ===") for key, ok in results.items(): print(f" {key}: {'已登录' if ok else '失败'}") if all(results.values()): print("\n登录态已就绪,可启动服务并用 POST /api/auth/status 复核。") return 0 return 1