支付方式选择/新卡代填链路用真实站点探针验证并修复两处真实 bug

真实调用 _select_payment_method 验证「已有匹配卡→点次へ」分支成功;
强制走新卡代填分支验证 _fill_new_card_form/_submit_new_card_form,发现并
修复:1) 选中支付方式后详情面板默认折叠,需再点一次才能展开找到「新增卡」
链接;2)「追加する」提交按钮有重复 DOM 匹配,需逐个尝试真正可点的那个,
不能盲用 .first。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 01:56:41 +08:00
co-authored by Claude Sonnet 5
parent 8b2fd74336
commit e5f7da09a9
3 changed files with 556 additions and 19 deletions
+66 -19
View File
@@ -167,6 +167,12 @@ _ADDRESS_STEP_URL_MARKER = "/ship"
_ADDRESS_DEFAULT_BADGE_TEXT = "デフォルトお届け先"
# 支付方式选择页:URL 里带 /pay;radio 按可见文案匹配,选完点「次へ」
# 2026-08-14 用 scripts/probe_payment_method.py 从 order-confirmation 页的
# 「支払い方法」区块「変更」按钮手动跳回本页(该账号默认流程会跳过 /pay,直落
# 确认页),真实调用 _select_payment_method 验证过:本页的「次へ」确实是原生
# <button aria-label="次へ" type="button">(跟 session upgrade 页那个 div 伪按钮
# 不是同一种实现),选择器命中,「已保存卡尾号匹配 → 点次へ」分支成功把页面从
# /payment 带回 order-confirmation(l2-id 变成 step3_sp_next)。
_PAYMENT_STEP_URL_MARKER = "/pay"
_PAYMENT_NEXT_BUTTON_SELECTOR = 'button:has-text("次へ")'
# 选中「クレジットカード」后,账号已保存卡的真实信号(2026-08-11 人工在真实页面上
@@ -1310,6 +1316,17 @@ class SiteInteractor:
async def _select_payment_method(self, page, *, task_id: str) -> None:
"""支付方式选择页:选配置的支付方式(默认「クレジットカード」)并点「次へ」
**2026-08-14 本方法自身被真实调用验证过两条分支**:
- 「直接点次へ」分支(scripts/probe_payment_method.py,从
order-confirmation 页的「変更」按钮手动跳回 /payment,因为该账号默认
流程会跳过这一页直落确认页):账号已保存卡尾号(8476)与 account.yaml
配置一致,成功把页面带回 order-confirmation——这条分支和
_PAYMENT_NEXT_BUTTON_SELECTOR 不再是纯猜测。
- 「新卡代填/提交」分支(scripts/probe_new_card.py,绕开尾号匹配判断,
强制走 _fill_new_card_form + _submit_new_card_form):真实注册了一张
新卡(account.yaml 里配置的同一张卡号),发现并修了两个真实站点问题——
见 _fill_new_card_form/_submit_new_card_form 各自文档。
**2026-08-11 人工在真实页面上核对过截图**:选中「クレジットカード」后账号
已有一张已保存卡时会展开显示掩码卡号(VISA ●●●● 8476)+「有効期限:12/2029」,
该卡的 radio 默认就是选中态,不需要额外点选。
@@ -1369,6 +1386,17 @@ class SiteInteractor:
f"选择「{label}」后没有找到匹配的已保存卡(掩码卡号 + 有効期限),"
"且 account.yaml 未配置 payment.credit-card,无法代填新卡,需要人工处理"
)
# 2026-08-14 真实站点验证发现:选中「クレジットカード」radio 后其
# 详情面板(含「新しいカードを追加する」链接)默认是折叠的
# (visibility:hidden; height:0),即使 radio 本身已是选中态;
# 对同一个 label 再点一次才会展开,之后该链接才 is_visible()。
try:
await option.click(timeout=10_000)
await page.wait_for_timeout(1_000)
except Exception as exc:
raise CheckoutBlockedError(
f"展开「{label}」详情面板失败:{type(exc).__name__}: {exc}"
) from exc
await self._fill_new_card_form(page, card=configured_card, task_id=task_id)
await self._submit_new_card_form(page, task_id=task_id)
@@ -1385,14 +1413,16 @@ class SiteInteractor:
async def _fill_new_card_form(self, page, *, card: "CreditCard", task_id: str) -> None:
"""代填新卡表单(卡号/有效期/持卡人姓名);提交交给 _submit_new_card_form
**2026-08-13 真实走过一次这个表单**(人工在浏览器窗口里用 OS 级
SendKeys 代填,不是本方法这段 Playwright 代码本身——见
_CARD_NUMBER_MOUNT_SELECTOR 等常量注释):卡号/有效期(月)/有效期(年)
分别托管在三个独立跨域 iframe 里(Rakuten PCI 代付 vault),只有持卡人
姓名是主文档里的普通 input。iframe 挂载点 selector 是真实验证过的,但
iframe **内部**的具体字段 selector("input, select" 兜底)没有拿到真实
HTML,仍是猜的——找不到就直接抛 CheckoutBlockedError 转人工,不做「找不到
**2026-08-14 用 scripts/probe_new_card.py 真实调用这段 Playwright 代码本身
验证成功**(此前 2026-08-13 那次是人工用 OS 级 SendKeys 代填,不是这段代码):
卡号/有效期(月)/有效期(年)分别托管在三个独立跨域 iframe 里(Rakuten PCI
代付 vault),只有持卡人姓名是主文档里的普通 input。iframe 挂载点 selector
iframe 内部字段 selector"input, select" 兜底)在真实站点上都命中成功,
不再是纯猜测。找不到就直接抛 CheckoutBlockedError 转人工,不做「找不到
就跳过」的静默降级,代填不完整的卡信息比不填更危险。
进入本方法前调用方(_select_payment_method)已经对支付方式 label 点了
第二次以展开折叠的详情面板,所以这里能找到「新しいカードを追加する」链接。
"""
try:
link = page.locator(f'text="{_ADD_CARD_LINK_TEXT}"').first
@@ -1449,20 +1479,37 @@ class SiteInteractor:
async def _submit_new_card_form(self, page, *, task_id: str) -> None:
"""点「追加する」提交新卡表单,确认站点回显该卡已被选中后才放行
2026-08-14 决定放开自动提交(此前默认填完停下等人工点,见本模块顶部常量
注释里的决策记录):按钮文案「追加する」是 2026-08-13 人工在真实页面上
点过一次确认过的,不是猜的。但 iframe 内部字段选择器仍未经真实 HTML
验证——一旦 _fill_new_card_form 之前的填写本身有误,提交后大概率看不到
掩码卡号+有効期限的回显,会落进下面的超时分支转人工,不会静默当成功。
2026-08-14 用 scripts/probe_new_card.py 真实调用这段代码验证成功。真实
页面上 `button:has-text("追加する")` 会命中 2 个匹配,且不能默认 `.first`
就是能点的那个——`.first` 那个虽然 is_visible()=True,但点击会被同页面
「名義人」input 所在的 aria-modal="true" 弹层拦截("element intercepts
pointer events" 超时),只有第二个候选真正可点。因此这里遍历所有匹配,
对 is_visible() 为真的逐个尝试点击,命中第一个真正点得中的就停止;全部
失败才转人工。
"""
submit = page.locator(f'button:has-text("{_ADD_CARD_SUBMIT_TEXT}")').first
try:
await submit.click(timeout=10_000)
except Exception as exc:
candidates = page.locator(f'button:has-text("{_ADD_CARD_SUBMIT_TEXT}")')
count = await candidates.count()
clicked = False
last_exc: Exception | None = None
for i in range(count):
candidate = candidates.nth(i)
if not await candidate.is_visible():
continue
try:
await candidate.click(timeout=5_000)
clicked = True
break
except Exception as exc: # noqa: BLE001
last_exc = exc
if not clicked:
if last_exc is not None:
raise CheckoutBlockedError(
f"新卡表单未找到或点击「{_ADD_CARD_SUBMIT_TEXT}」提交按钮失败"
f"(匹配数量={count}):{type(last_exc).__name__}: {last_exc}"
) from last_exc
raise CheckoutBlockedError(
f"新卡表单未找到或点击{_ADD_CARD_SUBMIT_TEXT}」提交按钮失败:"
f"{type(exc).__name__}: {exc}"
) from exc
f"新卡表单未找到「{_ADD_CARD_SUBMIT_TEXT}」提交按钮(匹配数量={count}"
)
for _ in range(_SESSION_UPGRADE_POLL_ROUNDS):
await page.wait_for_timeout(_SESSION_UPGRADE_POLL_INTERVAL_MS)