trading 不再管理 ラクマ 的购物车购买、支付与订单监控。删除 auth_site / auth_session / login_runner / models.AuthSite 中的 rakuma 分支与常量、 account.yaml.example 的 rakuma 段,并清理相关测试。scraping 侧的 ラクマ 抓取 API(/api/rakuma/*)保留不动。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
457 lines
16 KiB
Python
457 lines
16 KiB
Python
"""账号密码自动登录的运行时核心,供 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
|
|
|
|
`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
|