修真实结算流程三处硬伤: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 里出现这些片段之一
_SESSION_UPGRADE_URL_MARKERS = ("session/upgrade", "sign_in/password") _SESSION_UPGRADE_URL_MARKERS = ("session/upgrade", "sign_in/password")
# session upgrade 密码页的「次へ」提交按钮没有 type=submit,只能按文案匹配 # session upgrade 密码页/电话补录页的「次へ」提交按钮:2026-08-14 非无头浏览器
_SESSION_UPGRADE_SUBMIT_SELECTOR = 'button:has-text("次へ")' # 实测抓到真实 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 次 # 提交密码后等待页面跳转的轮询:每次 3s,共 _SESSION_UPGRADE_POLL_ROUNDS 次
# (合计 ~30s)。前 4 次真实尝试里从未在这个窗口内看到跳转,超时即认定被拦截。 # (合计 ~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" 兜底)。 # 猜的(见 _fill_or_select_in_frame 的 "input, select" 兜底)。
# - 只有「名義人」(持卡人姓名)是主文档里的普通 <input>,但没有 name/autocomplete # - 只有「名義人」(持卡人姓名)是主文档里的普通 <input>,但没有 name/autocomplete
# 等属性,唯一稳定线索是 placeholder="TARO RAKUTEN",故用它做 selector。 # 等属性,唯一稳定线索是 placeholder="TARO RAKUTEN",故用它做 selector。
# - 代填后仍然**不会自动点「追加する」提交**——原则不变:注册新卡是有实际后果的 # - **2026-08-14 决定放开自动提交**(此前默认代填后停下等人工点「追加する」,
# 动作,iframe 内部字段是否真填对了也没法从外层验证,交给人工核对后自己点。 # 2026-08-14 用户明确要求放开):填完三个 iframe 字段 + 名義人后自动点「追加する」
# (2026-08-13 人工在真实页面上点过一次这个按钮成功过,文案不是猜的),提交后
# 必须在页面回显里看到掩码卡号+有効期限才放行,看不到就当失败转人工——
# iframe 内部字段选择器本身仍未经真实 HTML 验证,一旦这步之前填错,大概率会
# 直接体现在这条確认信号上,不会被静默当成功。
# - 另外发现:本次注册的卡「追加する」成功后当场展示为已选中的已保存卡(含掩码 # - 另外发现:本次注册的卡「追加する」成功后当场展示为已选中的已保存卡(含掩码
# 卡号+有効期限),但换一个全新浏览器 context(沿用同一份 storage_state 登录态) # 卡号+有効期限),但换一个全新浏览器 context(沿用同一份 storage_state 登录态)
# 重新走到支付方式页时,这张卡**没有**出现在已保存卡列表里,需要重新走一遍新增 # 重新走到支付方式页时,这张卡**没有**出现在已保存卡列表里,需要重新走一遍新增
@@ -201,6 +210,9 @@ _MASKED_CARD_LAST4_PATTERN = re.compile(r"[•●\*]{2,}\s*(\d{3,4})")
# 没有进一步验证,_select_payment_method 不应假设新卡注册跨 SiteInteractor # 没有进一步验证,_select_payment_method 不应假设新卡注册跨 SiteInteractor
# context 重建后仍然可用。 # context 重建后仍然可用。
_ADD_CARD_LINK_TEXT = "新しいカードを追加する" _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_NUMBER_MOUNT_SELECTOR = "#checkout-card-number-mount iframe"
_CARD_MONTH_MOUNT_SELECTOR = "#checkout-card-expiration-month-mount iframe" _CARD_MONTH_MOUNT_SELECTOR = "#checkout-card-expiration-month-mount iframe"
_CARD_YEAR_MOUNT_SELECTOR = "#checkout-card-expiration-year-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._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.launch( 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, channel=self._settings.browser_channel or None,
args=["--no-first-run", "--disable-blink-features=AutomationControlled"], args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
) )
@@ -1129,6 +1145,14 @@ class SiteInteractor:
f"当前 url={page.url}(可能出现了未见过的新步骤)" f"当前 url={page.url}(可能出现了未见过的新步骤)"
) )
if page.url.startswith(_CART_PAGE):
raise OrderOperationError(
"点「購入手続き」后没有跳转出购物车页(未触发 session upgrade/"
"电话补录/地址确认/支付方式任一已知中间步骤),可能是点击被站点"
f"忽略或按钮短暂失效弹回;当前仍在 url={page.url},不能当成已落地"
"确认页处理"
)
logger.info( logger.info(
"enter_checkout 落地:task_id=%s url=%s", task.task_id, page.url "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: async def _confirm_default_address(self, page, *, task_id: str) -> None:
"""收货地址确认页:点击默认地址卡片继续 """收货地址确认页:点击默认地址卡片继续
**未经真实 HTML 验证**:2026-08-11 人工走一遍时观察到默认地址卡片本身可点击 **未经真实 HTML 验证,且目前账号的真实流程从未触发过这一步**:2026-08-11
(点击后直接跳到支付方式页,没有单独的「次へ」),但没拿到这页的真实 HTML, 人工走一遍时观察到默认地址卡片本身可点击(点击后直接跳到支付方式页,没有
selector 是根据可见文案「デフォルトお届け先」反推的推测。 单独的「次へ」),但没拿到这页的真实 HTML,selector 是根据可见文案
「デフォルトお届け先」反推的推测。2026-08-13 真实下单成功一次、2026-08-14
非无头浏览器真实探测三次(均已修复 headless 导致 SPA 异常的问题、修好了
session upgrade 提交按钮选择器),URL 里都没出现过 `/ship`——当前这个账号
(只有一个已保存地址)会直接从 session upgrade 跳到 order-confirmation,
跳过地址确认这一步。也就是说这个方法目前是真实死代码:只有账号出现多个
地址或没有默认地址时才可能触发,selector 依旧是未经验证的推测,遇到时会
直接抛错转人工,不会静默用错误的 selector 蒙混过关。
账号没有默认地址时(新地址、需要人工选/填)直接抛错转人工,不猜表单结构。 账号没有默认地址时(新地址、需要人工选/填)直接抛错转人工,不猜表单结构。
""" """
html = await page.content() html = await page.content()
@@ -1339,11 +1370,7 @@ class SiteInteractor:
"且 account.yaml 未配置 payment.credit-card,无法代填新卡,需要人工处理" "且 account.yaml 未配置 payment.credit-card,无法代填新卡,需要人工处理"
) )
await self._fill_new_card_form(page, card=configured_card, task_id=task_id) await self._fill_new_card_form(page, card=configured_card, task_id=task_id)
raise CheckoutBlockedError( await self._submit_new_card_form(page, task_id=task_id)
"已按 account.yaml 的卡信息代填新卡表单,但未自动提交/登录该卡"
"(表单从未经真实页面验证,且注册新卡是有实际后果的动作),"
"需要人工在浏览器里核对填写内容后自己点提交"
)
next_btn = page.locator(_PAYMENT_NEXT_BUTTON_SELECTOR).first next_btn = page.locator(_PAYMENT_NEXT_BUTTON_SELECTOR).first
try: try:
@@ -1356,7 +1383,7 @@ class SiteInteractor:
logger.info("支付方式选择完成:task_id=%s url=%s", task_id, page.url) 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: async def _fill_new_card_form(self, page, *, card: "CreditCard", task_id: str) -> None:
"""代填新卡表单(卡号/有效期/持卡人姓名),不点提交 """代填新卡表单(卡号/有效期/持卡人姓名);提交交给 _submit_new_card_form
**2026-08-13 真实走过一次这个表单**(人工在浏览器窗口里用 OS 级 **2026-08-13 真实走过一次这个表单**(人工在浏览器窗口里用 OS 级
SendKeys 代填,不是本方法这段 Playwright 代码本身——见 SendKeys 代填,不是本方法这段 Playwright 代码本身——见
@@ -1417,7 +1444,37 @@ class SiteInteractor:
await _fill_in_vault_iframe(_CARD_YEAR_MOUNT_SELECTOR, year, "有効期限(年)") await _fill_in_vault_iframe(_CARD_YEAR_MOUNT_SELECTOR, year, "有効期限(年)")
await _fill_in_main_doc(card.name, "名義人") 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: async def _discard_checkout_page(self, task_id: str) -> None:
"""关闭并丢弃 task_id 对应的已留存确认页 Page """关闭并丢弃 task_id 对应的已留存确认页 Page
+156
View File
@@ -0,0 +1,156 @@
"""收货地址确认页真实 HTML 探针
走 SiteInteractor 的真实生产代码路径(add_to_cart → verify_cart → enter_checkout),
在 _confirm_default_address 被调用前把该页 HTML 落盘,验证/纠正选择器是否与真实页面
一致。**不调用 submit_order / pay**——enter_checkout 落地到确认页就返回,这里直接
丢弃该页,不产生下单动作。
用法:
.venv/Scripts/python.exe scripts/probe_address_confirm.py
.venv/Scripts/python.exe scripts/probe_address_confirm.py --item-url https://item.rakuten.co.jp/...
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.shared.config import get_settings # noqa: E402
from app.trading.services.auth_session import AuthSession # noqa: E402
from app.trading.worker.models import LeaseTask # noqa: E402
from app.trading.worker.site_interact import SiteInteractor # noqa: E402
PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "address_confirm"
PROBE_DIR.mkdir(parents=True, exist_ok=True)
# fafachai/10000033 已下架/不可购买;改用 2026-08-13 真实下单时用过的商品
# (data/evidence/checkout-live-20260813/i00-cart-initial.html 里的 itemUrl)
DEFAULT_ITEM_URL = "https://item.rakuten.co.jp/moccasin/ds001iwrgesaaa2/"
async def _start_visible(site: SiteInteractor, settings) -> None:
"""复制 SiteInteractor.start() 但 headless=False,供本地诊断肉眼看浏览器
不改动生产代码(生产 start() 硬编码 headless=True),直接绕过它、把结果
塞进 site 的私有属性——只在这个一次性诊断脚本里这么干。
"""
from playwright.async_api import async_playwright
from app.trading.core import auth_site
state_path = settings.auth_state_path / auth_site.profile("rakuten").state_filename
storage_state = str(state_path) if state_path.exists() else None
site._playwright = await async_playwright().start()
site._browser = await site._playwright.chromium.launch(
headless=False,
channel=settings.browser_channel or None,
args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
)
site._context = await site._browser.new_context(
storage_state=storage_state,
user_agent=auth_site.RAKUTEN_USER_AGENT,
locale="ja-JP",
timezone_id="Asia/Tokyo",
viewport={"width": 390, "height": 844},
is_mobile=True,
has_touch=True,
)
print(f"[visible] 浏览器已启动(headless=False),storage_state={storage_state or '(none)'}")
async def run(item_url: str) -> int:
settings = get_settings()
auth = AuthSession(settings)
await auth.start()
site = SiteInteractor(auth_session=auth, settings=settings)
await _start_visible(site, settings)
captured: dict[str, str] = {}
orig_confirm = site._confirm_default_address
async def spy_confirm(page, *, task_id: str) -> None:
html = await page.content()
captured["html"] = html
captured["url"] = page.url
path = PROBE_DIR / "address-confirm-page.html"
path.write_text(html, encoding="utf-8")
print(f"[捕获] 收货地址确认页 -> {path} ({len(html)} bytes), url={page.url}")
await orig_confirm(page, task_id=task_id)
site._confirm_default_address = spy_confirm
orig_session_upgrade = site._complete_session_upgrade
async def spy_session_upgrade(page, *, task_id: str) -> None:
html = await page.content()
path = PROBE_DIR / "session-upgrade-before.html"
path.write_text(html, encoding="utf-8")
print(f"[捕获] session upgrade 页(点提交前)-> {path} ({len(html)} bytes), url={page.url}")
try:
await orig_session_upgrade(page, task_id=task_id)
finally:
html_after = await page.content()
(PROBE_DIR / "session-upgrade-after.html").write_text(html_after, encoding="utf-8")
site._complete_session_upgrade = spy_session_upgrade
task = LeaseTask(
task_id="probe-addr-1",
site="rakuten",
intent={"item_url": item_url, "quantity": 1},
)
try:
print("=== add_to_cart ===")
cart_add_html = await site.add_to_cart(task)
if cart_add_html:
(PROBE_DIR / "01-cart-add.html").write_text(cart_add_html, encoding="utf-8")
print(f" cart-add 页已存盘 ({len(cart_add_html)} bytes)")
print("=== verify_cart ===")
verify_html = await site.verify_cart(task)
if verify_html:
(PROBE_DIR / "02-cart-verify.html").write_text(verify_html, encoding="utf-8")
print(f" cart-verify 页已存盘 ({len(verify_html)} bytes)")
print("=== enter_checkout(落地到确认页或撞上未捕获的中间步骤为止)===")
try:
html = await site.enter_checkout(task)
print(f"enter_checkout 返回,长度={len(html)}")
(PROBE_DIR / "03-enter-checkout-final.html").write_text(html, encoding="utf-8")
except Exception as exc:
print(f"enter_checkout 抛出:{type(exc).__name__}: {exc}")
finally:
# 不调用 submit_order/pay;enter_checkout 若成功会把 page 留存在
# _checkout_pages,这里直接丢弃,不产生下单动作
page = site._checkout_pages.pop(task.task_id, None)
if page is not None:
print(f" 最终落地 url={page.url}")
await page.close()
if "html" not in captured:
print("\n没有触发 _confirm_default_address——账号可能没走到这一步"
"(比如没有电话补录/地址确认步骤,或选择器判断条件本身有问题)")
finally:
try:
await site.clear_cart()
except Exception as exc: # noqa: BLE001
print(f"清理购物车失败(忽略):{type(exc).__name__}: {exc}")
await site.close()
await auth.close()
print(f"\n探针输出目录:{PROBE_DIR}")
return 0
async def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--item-url", default=DEFAULT_ITEM_URL)
args = parser.parse_args()
return await run(args.item_url)
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))