修真实结算流程三处硬伤:headless SPA 异常、次へ按钮选择器猜错、确认页误判

实测发现 headless=True 会让购物车/结算 SPA 表现异常(购入手続き点了不跳转),
所有会改站点状态的操作改用非无头浏览器;session upgrade/电话补录页的「次へ」
提交按钮选择器一直是猜的 button:has-text,真实控件是 div[role=button] 的
e2e 测试钩子类,从未真正匹配过;enter_checkout 的中间步骤跳转循环没有确认
是否真的离开了购物车页就会把卡住的购物车页当成确认页返回,现在会显式报错。
用真实账号非无头浏览器验证过整条链路能落地到真实 order-confirmation 页(未
触发实际付款)。同时按之前的决定放开新卡表单自动提交,新增地址确认页探针
脚本备用。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 01:11:18 +08:00
co-authored by Claude Sonnet 5
parent 93c1a83406
commit 8b2fd74336
2 changed files with 228 additions and 15 deletions
+72 -15
View File
@@ -141,8 +141,13 @@ _CHECKOUT_BUTTON_SELECTOR = 'button[aria-label="購入手続き"]'
# 点击结算按钮后落到 session upgrade 密码页的判据:URL 里出现这些片段之一
_SESSION_UPGRADE_URL_MARKERS = ("session/upgrade", "sign_in/password")
# session upgrade 密码页的「次へ」提交按钮没有 type=submit,只能按文案匹配
_SESSION_UPGRADE_SUBMIT_SELECTOR = 'button:has-text("次へ")'
# session upgrade 密码页/电话补录页的「次へ」提交按钮:2026-08-14 非无头浏览器
# 实测抓到真实 HTML 才发现,此前的 button:has-text("次へ") 是猜的、根本不存在
# 匹配的 <button> 标签——真实控件是
# <div role="button" class="... h4k5-e2e-button__submit ..." id="cta0XX">次へ</div>,
# 不是原生 button。h4k5-e2e-button__submit 这个类名看着就是站点自己留的 e2e
# 测试钩子(class 名里直接带 "e2e"),比按文案或按自动生成的 id="cta0XX" 猜更稳
_SESSION_UPGRADE_SUBMIT_SELECTOR = '.h4k5-e2e-button__submit'
# 提交密码后等待页面跳转的轮询:每次 3s,共 _SESSION_UPGRADE_POLL_ROUNDS 次
# (合计 ~30s)。前 4 次真实尝试里从未在这个窗口内看到跳转,超时即认定被拦截。
@@ -192,8 +197,12 @@ _MASKED_CARD_LAST4_PATTERN = re.compile(r"[•●\*]{2,}\s*(\d{3,4})")
# 猜的(见 _fill_or_select_in_frame 的 "input, select" 兜底)。
# - 只有「名義人」(持卡人姓名)是主文档里的普通 <input>,但没有 name/autocomplete
# 等属性,唯一稳定线索是 placeholder="TARO RAKUTEN",故用它做 selector。
# - 代填后仍然**不会自动点「追加する」提交**——原则不变:注册新卡是有实际后果的
# 动作,iframe 内部字段是否真填对了也没法从外层验证,交给人工核对后自己点。
# - **2026-08-14 决定放开自动提交**(此前默认代填后停下等人工点「追加する」,
# 2026-08-14 用户明确要求放开):填完三个 iframe 字段 + 名義人后自动点「追加する」
# (2026-08-13 人工在真实页面上点过一次这个按钮成功过,文案不是猜的),提交后
# 必须在页面回显里看到掩码卡号+有効期限才放行,看不到就当失败转人工——
# iframe 内部字段选择器本身仍未经真实 HTML 验证,一旦这步之前填错,大概率会
# 直接体现在这条確认信号上,不会被静默当成功。
# - 另外发现:本次注册的卡「追加する」成功后当场展示为已选中的已保存卡(含掩码
# 卡号+有効期限),但换一个全新浏览器 context(沿用同一份 storage_state 登录态)
# 重新走到支付方式页时,这张卡**没有**出现在已保存卡列表里,需要重新走一遍新增
@@ -201,6 +210,9 @@ _MASKED_CARD_LAST4_PATTERN = re.compile(r"[•●\*]{2,}\s*(\d{3,4})")
# 没有进一步验证,_select_payment_method 不应假设新卡注册跨 SiteInteractor
# context 重建后仍然可用。
_ADD_CARD_LINK_TEXT = "新しいカードを追加する"
# 新卡表单的提交按钮:2026-08-13 人工在真实页面上点过一次确认过文案(见
# data/evidence/checkout-live-20260813/i-final-state.html 一带),不是猜的
_ADD_CARD_SUBMIT_TEXT = "追加する"
_CARD_NUMBER_MOUNT_SELECTOR = "#checkout-card-number-mount iframe"
_CARD_MONTH_MOUNT_SELECTOR = "#checkout-card-expiration-month-mount iframe"
_CARD_YEAR_MOUNT_SELECTOR = "#checkout-card-expiration-year-mount iframe"
@@ -517,7 +529,11 @@ class SiteInteractor:
self._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.launch(
headless=True,
# 2026-08-14 实测确认:headless=True 会让站点的购物车/结算 SPA 表现
# 异常(购入手続き点了不跳转、shopUrlList 渲染不出来),换 headless=False
# 后行为与真实下单一致(能正常触发 session upgrade)——所有会改动站点
# 状态的操作(加购/结算/支付)都必须走非无头浏览器
headless=False,
channel=self._settings.browser_channel or None,
args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
)
@@ -1129,6 +1145,14 @@ class SiteInteractor:
f"当前 url={page.url}(可能出现了未见过的新步骤)"
)
if page.url.startswith(_CART_PAGE):
raise OrderOperationError(
"点「購入手続き」后没有跳转出购物车页(未触发 session upgrade/"
"电话补录/地址确认/支付方式任一已知中间步骤),可能是点击被站点"
f"忽略或按钮短暂失效弹回;当前仍在 url={page.url},不能当成已落地"
"确认页处理"
)
logger.info(
"enter_checkout 落地:task_id=%s url=%s", task.task_id, page.url
)
@@ -1255,9 +1279,16 @@ class SiteInteractor:
async def _confirm_default_address(self, page, *, task_id: str) -> None:
"""收货地址确认页:点击默认地址卡片继续
**未经真实 HTML 验证**:2026-08-11 人工走一遍时观察到默认地址卡片本身可点击
(点击后直接跳到支付方式页,没有单独的「次へ」),但没拿到这页的真实 HTML,
selector 是根据可见文案「デフォルトお届け先」反推的推测。
**未经真实 HTML 验证,且目前账号的真实流程从未触发过这一步**:2026-08-11
人工走一遍时观察到默认地址卡片本身可点击(点击后直接跳到支付方式页,没有
单独的「次へ」),但没拿到这页的真实 HTML,selector 是根据可见文案
「デフォルトお届け先」反推的推测。2026-08-13 真实下单成功一次、2026-08-14
非无头浏览器真实探测三次(均已修复 headless 导致 SPA 异常的问题、修好了
session upgrade 提交按钮选择器),URL 里都没出现过 `/ship`——当前这个账号
(只有一个已保存地址)会直接从 session upgrade 跳到 order-confirmation,
跳过地址确认这一步。也就是说这个方法目前是真实死代码:只有账号出现多个
地址或没有默认地址时才可能触发,selector 依旧是未经验证的推测,遇到时会
直接抛错转人工,不会静默用错误的 selector 蒙混过关。
账号没有默认地址时(新地址、需要人工选/填)直接抛错转人工,不猜表单结构。
"""
html = await page.content()
@@ -1339,11 +1370,7 @@ class SiteInteractor:
"且 account.yaml 未配置 payment.credit-card,无法代填新卡,需要人工处理"
)
await self._fill_new_card_form(page, card=configured_card, task_id=task_id)
raise CheckoutBlockedError(
"已按 account.yaml 的卡信息代填新卡表单,但未自动提交/登录该卡"
"(表单从未经真实页面验证,且注册新卡是有实际后果的动作),"
"需要人工在浏览器里核对填写内容后自己点提交"
)
await self._submit_new_card_form(page, task_id=task_id)
next_btn = page.locator(_PAYMENT_NEXT_BUTTON_SELECTOR).first
try:
@@ -1356,7 +1383,7 @@ class SiteInteractor:
logger.info("支付方式选择完成:task_id=%s url=%s", task_id, page.url)
async def _fill_new_card_form(self, page, *, card: "CreditCard", task_id: str) -> None:
"""代填新卡表单(卡号/有效期/持卡人姓名),不点提交
"""代填新卡表单(卡号/有效期/持卡人姓名);提交交给 _submit_new_card_form
**2026-08-13 真实走过一次这个表单**(人工在浏览器窗口里用 OS 级
SendKeys 代填,不是本方法这段 Playwright 代码本身——见
@@ -1417,7 +1444,37 @@ class SiteInteractor:
await _fill_in_vault_iframe(_CARD_YEAR_MOUNT_SELECTOR, year, "有効期限(年)")
await _fill_in_main_doc(card.name, "名義人")
logger.info("新卡表单代填完成(未提交):task_id=%s url=%s", task_id, page.url)
logger.info("新卡表单代填完成:task_id=%s url=%s", task_id, page.url)
async def _submit_new_card_form(self, page, *, task_id: str) -> None:
"""点「追加する」提交新卡表单,确认站点回显该卡已被选中后才放行
2026-08-14 决定放开自动提交(此前默认填完停下等人工点,见本模块顶部常量
注释里的决策记录):按钮文案「追加する」是 2026-08-13 人工在真实页面上
点过一次确认过的,不是猜的。但 iframe 内部字段选择器仍未经真实 HTML
验证——一旦 _fill_new_card_form 之前的填写本身有误,提交后大概率看不到
掩码卡号+有効期限的回显,会落进下面的超时分支转人工,不会静默当成功。
"""
submit = page.locator(f'button:has-text("{_ADD_CARD_SUBMIT_TEXT}")').first
try:
await submit.click(timeout=10_000)
except Exception as exc:
raise CheckoutBlockedError(
f"新卡表单未找到或点击「{_ADD_CARD_SUBMIT_TEXT}」提交按钮失败:"
f"{type(exc).__name__}: {exc}"
) from exc
for _ in range(_SESSION_UPGRADE_POLL_ROUNDS):
await page.wait_for_timeout(_SESSION_UPGRADE_POLL_INTERVAL_MS)
html = await page.content()
if _SAVED_CARD_SIGNAL_PATTERN.search(html):
logger.info("新卡注册并选中成功:task_id=%s url=%s", task_id, page.url)
return
raise CheckoutBlockedError(
"新卡表单提交后未看到该卡被选中的回显(掩码卡号+有効期限),"
"无法确认是否注册成功,需要人工核对"
)
async def _discard_checkout_page(self, task_id: str) -> None:
"""关闭并丢弃 task_id 对应的已留存确认页 Page