账号
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
"""账号密码自动登录的运行时核心,供 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 解析:显式 > 首项默认 <site>_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
|
||||
|
||||
- rakuten:未登录会被重定向到 login.account.rakuten.com 或 /login 路径
|
||||
- rakuma:未登录会被重定向到 /users/sign_in
|
||||
"""
|
||||
profile = auth_site.profile(site)
|
||||
await page.goto(profile.login_url, wait_until="domcontentloaded", timeout=60_000)
|
||||
final_url = page.url
|
||||
if site == "rakuten":
|
||||
return "login.account.rakuten.com" not in final_url and "/login" not in final_url
|
||||
return auth_site.RAKUMA_SIGN_IN_PATH 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
|
||||
Reference in New Issue
Block a user