结算流程用真实下单证据修正:新卡代填 iframe vault、订单号正则、登录态判据

- 新卡代填改用真实 DOM 结构:卡号/有效期分别托管在 Rakuten PCI 代付 vault 的
  跨域 iframe 里,此前按 autocomplete/name 猜的 selector 在主文档里根本找不到
  元素;持卡人姓名字段改用「名義人」标签相对定位,不用 placeholder 示例文案
  (TARO RAKUTEN 只是示例用户名,不是稳定标识)
- 订单号正则修正   实体导致的分隔符匹配失败(2026-08-13 真实下单验证)
- login_runner._is_logged_in 改用与 AuthSession 一致的 __INITIAL_STATE__ 判据,
  修掉此前误判「已登录」导致保存无效 storage_state 的问题
- 新增 CheckoutBlockedError:站点风控拦截(session upgrade/3DS)转 needs_human,
  不当普通失败重试
- account.yaml 支持 phone / payment.credit-card 字段
- data/evidence/(真实 PII)加入 .gitignore

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 22:36:49 +08:00
co-authored by Claude Sonnet 5
parent 55c01ae4f5
commit 5f7921b788
8 changed files with 1049 additions and 70 deletions
+61 -10
View File
@@ -36,6 +36,16 @@ _POLL_INTERVAL_SECONDS = 5
_ACCOUNTS_FILE = BASE_DIR / "account.yaml"
@dataclass(slots=True)
class CreditCard:
"""account.yaml 中 payment.credit-card 的结构化形式"""
number: str
month: str
year: str
name: str
@dataclass(slots=True)
class Account:
"""account.yaml 中的一条账号记录"""
@@ -47,6 +57,13 @@ class Account:
user_data_dir: Path
state_filename: str
default: bool
# 电话号码:可选。仅用于 checkout 流程里「账号缺电话号码」时的补录步骤
# (site_interact.py::_complete_phone_registration),登录本身不需要它。
phone: str | None = None
# 信用卡:可选。仅用于 checkout 流程里支付方式页「账号没有已保存卡」时的
# 新卡代填步骤(site_interact.py::_fill_new_card_form)。该表单选择器从未
# 见过真实 HTML,代填后不会自动提交,须人工核对再点提交,详见该方法文档字符串。
credit_card: CreditCard | None = None
# ---- YAML 读取与账号选择 ----
@@ -103,6 +120,25 @@ def load_accounts() -> dict[str, list[Account]]:
f"account.yaml: {site}[{idx}].{required} 必填"
)
account_id = str(rec["id"])
payment = rec.get("payment") or {}
cc_raw = payment.get("credit-card") if isinstance(payment, dict) else None
credit_card = None
if cc_raw:
if not isinstance(cc_raw, dict):
raise ValueError(
f"account.yaml: {site}[{idx}].payment.credit-card 应为字典"
)
for required in ("card-no", "month", "year", "name"):
if not cc_raw.get(required):
raise ValueError(
f"account.yaml: {site}[{idx}].payment.credit-card.{required} 必填"
)
credit_card = CreditCard(
number=str(cc_raw["card-no"]),
month=str(cc_raw["month"]),
year=str(cc_raw["year"]),
name=str(cc_raw["name"]),
)
accounts.append(
Account(
site=site,
@@ -112,6 +148,8 @@ def load_accounts() -> dict[str, list[Account]]:
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)),
phone=str(rec["phone"]) if rec.get("phone") else None,
credit_card=credit_card,
)
)
result[site] = accounts
@@ -174,18 +212,31 @@ def default_account_for(site: str, accounts_by_site: dict[str, list[Account]]) -
async def _is_logged_in(page, site: str) -> bool:
"""访问 profile.login_url 看落地是否被踢到 SSO
"""访问 profile.probe_url(购物车页)读 `__INITIAL_STATE__.user.isLoggedIn`
`site` 目前恒为 `"rakuten"`:未登录时会被重定向到 login.account.rakuten.com
或 /login 路径;登录后停在 my.rakuten.co.jp。保留 site 参数形态是为了与
SiteAuthProfile 共用签名,方便未来加站点。
`site` 目前恒为 `"rakuten"`。此前用「落地 URL 是否被踢到 SSO」判断
profile.login_url(myrakuten):实测发现未登录时该页会落到 my.rakuten.co.jp
而不重定向到 login.account.rakuten.com,导致误判为「已登录」,
login_one() 因此跳过填表、保存一份只有 Akamai/负载均衡 cookie(无真实会话)
的 storage_state——看似登录成功,实际购物车页仍判未登录。
改用与 AuthSession._check_rakuten / site_interact.py 一致的判据(购物车页,
Playwright 渲染后取 `__INITIAL_STATE__.user.isLoggedIn`),三处判据不再漂移。
"""
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
profile = auth_site.profile(site)
await page.goto(profile.probe_url, wait_until="domcontentloaded", timeout=60_000)
try:
await page.wait_for_function(
"() => window.__INITIAL_STATE__ !== undefined", timeout=8_000
)
except Exception:
pass # 未渲染出来时按 isLoggedIn=false 处理(下面 evaluate 拿到 None)
state = await page.evaluate(
"() => (window.__INITIAL_STATE__ && window.__INITIAL_STATE__.user) "
"? window.__INITIAL_STATE__.user.isLoggedIn : null"
)
return bool(state)
# ---- 自动填表(启发式选择器)----
@@ -319,12 +370,12 @@ async def login_one(
)
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)
# 已经登录则直接保存
# 已经登录则直接保存(_is_logged_in 自己打购物车页判定,不依赖 login_url 落地)
if await _is_logged_in(page, account.site):
log(f"已处于登录态,跳过填表:{account.id}")
else:
await page.goto(profile.login_url, wait_until="domcontentloaded", timeout=60_000)
filled = await _try_autofill(page, account)
if not filled:
log("未找到登录表单且未登录,请在浏览器里手动完成登录")