/
里,中间隔着上百字符的 class 属性,不是「标签+数字+円」紧邻
+# 的简单文案——之前那版按纯文本猜的 pattern 在真实页面上完全匹配不到,已改成
+# 按「标签 → 数字 → 円」三段分别定位、允许中间跨大段 HTML 标签的写法
+# - 「注文を確定する」是唯一真实存在的确认按钮(,页面上出现两次,内容一致),
+# 在 _ORDER_CONFIRM_BUTTON_SELECTORS 里已经是第一个候选,无需改动
+#
+# 2026-08-13 随后**真实点击了一次「注文を確定する」**(297円小额验证购买,账号
+# trdian022,人工在浏览器窗口里核对过金额/地址/卡号后现场决定执行,不是本模块
+# 自动触发的):点击后 URL 跳到 sp.cart.step.rakuten.co.jp/complete?l2-id=
+# step4_sp_purchase_top(真实的下单完成页,「ご注文がショップに送信されました」),
+# 未检测到 3DS/OTP,证实了 pay() 文档里「信用卡下单即扣款、没有独立付款步骤」
+# 的假设——至少这一次真实下单是这样。完成页给出了真订单号「注文番号
+# 306087-20260813-0863947697」,格式是三段数字用「-」连接,标签和号码之间是
+# HTML 实体 ` ` 而不是普通空白字符——旧版 _ORDER_ID_PATTERN 用
+# `[^\w]{0,10}` 卡分隔符,但 ` ` 里的 n/b/s/p 都是 \w,导致分隔符匹配在
+# `&` 处就断了、根本连不到号码,完全没命中;已改成显式允许 ` ` 序列,
+# 且要求捕获的号码至少包含一段「数字-数字」的真实格式。付款期限这份证据里没有
+# 出现(大概率因为已经当场付款完成,不需要「期限」),_PAY_DEADLINE_PATTERN
+# 仍未经验证,维持原猜测。
+#
+# 金额匹配拆成两条 pattern 分别试(先 tag-bounded 再 inline,谁有匹配用谁):
+# - _AMOUNT_TAG_BOUNDED_PATTERN:真实页面的样子,数字单独在一个标签里
+# (`>297<`),跟「円」中间还隔着上百字符的 class 属性,两段各给足够宽的窗口;
+# 窗口宽是必要的,但如果不要求数字被 `>...<` 包住、只按「离数字最近的 900
+# 开头字符找」,会把 class hash 里散落的数字(如 `16uEu` `28Y_E`)当成命中,
+# 在真实页面上跑出一堆垃圾匹配——已经踩过这个坑,所以数字必须被标签边界卡住。
+# - _AMOUNT_INLINE_PATTERN:标签和数字中间没有任何标签、紧挨着的简单文案
+# (如「お支払い金額 1,980円」),单测用这种简化 fixture,真实页面目前
+# 没见过这种形态,两条互不影响。
+_AMOUNT_LABEL_TEXT = r"(?:支払い金額|お支払い(?:金額|合計|総額)|支払合計|ご請求金額|合計金額)"
+_AMOUNT_TAG_BOUNDED_PATTERN = re.compile(
+ _AMOUNT_LABEL_TEXT + r".{0,400}?>([\d,]+)<.{0,300}?円", re.DOTALL,
+)
+_AMOUNT_INLINE_PATTERN = re.compile(_AMOUNT_LABEL_TEXT + r"[^0-9]{0,20}([\d,]+)\s*円")
+_ORDER_ID_PATTERN = re.compile(
+ r"(?:ご注文番号|注文番号|受付番号)(?: |[^0-9A-Za-z])*([0-9]{4,}(?:-[0-9]{4,}){1,})"
+)
+_PAY_DEADLINE_PATTERN = re.compile(
+ r"(?:お支払い期限|支払期限)[^\d]{0,10}(\d{4}[/年]\d{1,2}[/月]\d{1,2}日?)"
+)
+
+# 确认下单按钮候选文案(未经真实页面验证)
+_ORDER_CONFIRM_BUTTON_SELECTORS = (
+ 'button:has-text("注文を確定する")',
+ 'button:has-text("この内容で注文する")',
+ 'button:has-text("購入を確定する")',
+ 'button:has-text("注文する")',
+)
+
+# 提交下单后检测到这些迹象,视为触发了需要人工处理的付款验证环节(3DS / 短信 OTP)
+_PAYMENT_BLOCK_INDICATORS = (
+ 'iframe[src*="3dsecure" i]',
+ 'iframe[title*="認証" i]',
+ 'input[name*="otp" i]',
+ 'input[autocomplete="one-time-code"]',
+)
+
_NOT_IMPLEMENTED_MSG = (
"站点交互未实现:见 docs/order-gateway.md §10 与 "
"project://jp-rakuten/checkout-flow-probe-findings。"
- "session upgrade 是 checkout 阶段的硬墙,未实测前不写猜测的提交逻辑。"
)
@@ -107,6 +298,11 @@ class SiteInteractor:
# 用于 add_to_cart 把抓出来的 item_id / shop_bid 喂给 verify_cart
_per_task_state: dict[str, dict[str, str]]
+ # 每任务保留的下单确认页 Page:enter_checkout 落地后不关闭页面,存在这里,
+ # submit_order / pay 复用同一个页面继续操作——下单确认页是服务端会话态,
+ # 关掉页面重开一次大概率拿不回同一个订单草稿。pay() 成功/失败后负责关闭。
+ _checkout_pages: dict[str, "Page"]
+
# 所有公开方法共用一把锁:HTTP 接口与 worker 主循环都走它,串行化所有 Playwright 操作。
# 同一账号被并发操作 = 风控触发风险 + cart 状态错乱。
_lock: asyncio.Lock
@@ -118,6 +314,7 @@ class SiteInteractor:
self._browser = None
self._context = None # playwright BrowserContext
self._per_task_state = {}
+ self._checkout_pages = {}
self._lock = asyncio.Lock()
# 上次 build context 时读到的 storage_state 文件 mtime。
# 用于在任务间检测 AuthSession 重登后产生的新 storage_state,触发 context 重建。
@@ -197,6 +394,8 @@ class SiteInteractor:
async def close(self) -> None:
"""关闭 context、browser、playwright,吞掉单个 close 异常"""
+ for task_id in list(self._checkout_pages):
+ await self._discard_checkout_page(task_id)
for resource, name in (
(self._context, "context"),
(self._browser, "browser"),
@@ -215,7 +414,7 @@ class SiteInteractor:
# ---- 已实现:add_to_cart / verify_cart / cart_status ----
- async def add_to_cart(self, task: LeaseTask) -> None:
+ async def add_to_cart(self, task: LeaseTask) -> str | None:
"""加购(worker 入口):从 task.intent 取字段,调 _add_to_cart_with_fields
调用方需在 task.intent 提供:
@@ -224,6 +423,9 @@ class SiteInteractor:
- variant_id: 多规格商品的 variant_id;不传则从 sku.variants[] 自动选第一个非售罄
- choice: 必填选项的取值列表;不传则每个必填选项用第一个候选值(站点不严格校验)
+ Returns:
+ 加购成功后落地页(cart 页)的 HTML,供 runner 落证据;查不到时为 None。
+
Raises:
InvalidRequestError: intent.item_url 缺失
NotLoggedInError: 登录态失效
@@ -249,6 +451,7 @@ class SiteInteractor:
"shop_bid": result["shop_bid"],
"basket_domain": result["basket_domain"],
}
+ return result.get("response_html")
async def add_to_cart_payload(
self,
@@ -359,8 +562,8 @@ class SiteInteractor:
raise CartOperationError(f"加购失败:{msg}")
# 成功标志:URL 跳到 cart 且带 added_item 参数(探针实测的落地)
+ body = await resp.text()
if "cart" not in final_url:
- body = await resp.text()
raise CartOperationError(
f"加购响应异常:final_url={final_url} body_head={body[:200]!r}"
)
@@ -384,15 +587,19 @@ class SiteInteractor:
"shop_bid": fields["form_fields"].get("shop_bid", ""),
"basket_domain": fields["basket_domain"],
"cart_count": cart_count,
+ "response_html": body,
}
- async def verify_cart(self, task: LeaseTask) -> None:
+ async def verify_cart(self, task: LeaseTask) -> str | None:
"""校验购物车里有没有刚加的商品
策略(探针实测最稳的两步):
1. 打 cart count JSONP API:status=100 且 count>=1 才算「购物车非空」
2. 打开 cart 页等 SPA 渲染,在 HTML 里找 item_id
+ Returns:
+ 渲染后的 cart 页 HTML,供 runner 落证据。
+
登录态失效抛 NotLoggedInError;找不到 item 抛 CartOperationError。
"""
async with self._lock:
@@ -413,7 +620,7 @@ class SiteInteractor:
logger.info("cart count=%s task_id=%s", count, task.task_id)
# 2. 渲染 cart 页确认 item_id 在里面
- await self._verify_item_in_cart_html(item_id, label=f"task_id={task.task_id}")
+ return await self._verify_item_in_cart_html(item_id, label=f"task_id={task.task_id}")
async def cart_status(self) -> dict:
"""轻量查询购物车状态:调 cart count JSONP API,不渲染整页
@@ -437,7 +644,7 @@ class SiteInteractor:
async def clear_cart(self) -> dict:
"""清空购物车:渲染 cart SPA → 反复点第一个「削除」按钮 → count API 校验
- 策略:每次循环都重新查 `button[aria-label="削除"]`,点完一个等 SPA 重渲染
+ 策略:每次循环都重新查 `button[aria-label^="削除"]`,点完一个等 SPA 重渲染
再点下一个,避免索引漂移。最多 _CLEAR_CART_MAX_ITER 次防死循环。
未实测前已知边界:
@@ -497,7 +704,7 @@ class SiteInteractor:
async def remove_item(self, item_id: str) -> dict:
"""删除购物车里指定 item_id 的商品
- 策略:渲染 cart SPA → 在 DOM 里找 button[aria-label="削除"],向上 walk
+ 策略:渲染 cart SPA → 在 DOM 里找 button[aria-label^="削除"],向上 walk
parentElement 找 innerText 包含 item_id 的祖先 → click 那个按钮。
Rakuten cart item 卡片无稳定 data-* 属性,CSS modules hash class 易变,
@@ -526,7 +733,7 @@ class SiteInteractor:
# JS 在 DOM 里定位包含 item_id 的祖先节点的删除按钮,click 它
clicked = await page.evaluate(
"""(itemId) => {
- const buttons = document.querySelectorAll('button[aria-label="削除"]');
+ const buttons = document.querySelectorAll('button[aria-label^="削除"]');
for (const btn of buttons) {
let node = btn.parentElement;
for (let i = 0; i < 12 && node; i++) {
@@ -591,8 +798,11 @@ class SiteInteractor:
count = int(count_match.group(1)) if count_match and count_match.group(1) else 0
return raw_status, count
- async def _verify_item_in_cart_html(self, item_id: str, *, label: str) -> None:
- """渲染 cart SPA,在 HTML 里找 item_id,确认商品确实进了购物车"""
+ async def _verify_item_in_cart_html(self, item_id: str, *, label: str) -> str:
+ """渲染 cart SPA,在 HTML 里找 item_id,确认商品确实进了购物车
+
+ 返回渲染后的 cart 页 HTML(校验通过时),供调用方落证据。
+ """
page = await self._context.new_page()
try:
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
@@ -610,6 +820,7 @@ class SiteInteractor:
f"购物车页未找到 item_id={item_id}(加购可能被服务端静默丢弃)"
)
logger.info("cart 校验通过:%s item_id=%s in cart HTML", label, item_id)
+ return html
finally:
await page.close()
@@ -648,26 +859,530 @@ class SiteInteractor:
except Exception:
continue
- # ---- 未实现:保留 NotImplementedError ----
+ # ---- 已实现:enter_checkout(到下单确认页,中间步骤未经真实 HTML 验证)----
async def enter_checkout(self, task: LeaseTask) -> str:
- """进入下单确认页。**未实现**:session upgrade 是硬墙"""
- raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
+ """进入下单确认页:购物车 → 点「購入手続き」→ 依次处理中间步骤 → 落地确认页
+
+ 流程:
+ 1. 打开购物车页,精确点击「購入手続き」(不是促销用的「カード入会&購入手続き」)
+ 2. 即使 SSO 会话仍有效,Rakuten 也可能整页跳转到
+ login.account.rakuten.com/session/upgrade 强制重输密码——这是站点风控,
+ 不是登录态问题(2026-08-11 真实账号实测,见 checkout-research-20260811/NOTES.md)
+ 3. 落在该页时,从 account.yaml 取密码自动填并提交,轮询等待跳转;提交后长时间
+ 不跳转就认定被拦截,抛 CheckoutBlockedError,由 runner 转 needs_human,
+ 不做无限重试
+ 4. session upgrade 之后可能还有几个中间步骤(2026-08-11 人工手动完成密码这一步
+ 才发现,只有截图依据、没有真实 HTML):账号缺电话号码时的「会員情報の追加登録」
+ 补录页、收货地址确认、支付方式选择——顺序不保证固定,按页面特征逐跳处理,
+ 详见 _complete_phone_registration / _confirm_default_address /
+ _select_payment_method
+ 5. 跳出中间步骤循环后,假定已落地下单确认页,返回其 HTML;2026-08-13 已用
+ 真实证据确认这一步确实会落地到 order-confirmation 页(URL 带
+ `order-confirmation?l2-id=step3_sp_next`),parse_checkout 的金额解析
+ 也已用该证据验证过。不过 payment 步骤后是否**总是**直接落地confirm页
+ (比如没跳过 phone/address 步骤时是否还是同一落地页)仍未覆盖到,
+ parse_checkout 解析失败依旧会如实报错,不会把中间页内容当成确认页误吞
+
+ 成功返回时会把 Page 留存在 self._checkout_pages[task.task_id](不关闭),供
+ submit_order 复用同一个已登录会话继续操作——下单确认页是服务端会话态,关掉
+ 页面重开拿不回同一份订单草稿。
+
+ Returns:
+ 下单确认页(或目前所能到达的最后一页)的 HTML,供 runner 落证据。
+
+ Raises:
+ NotLoggedInError: 登录态失效
+ OrderOperationError: 购物车页找不到结算按钮 / 收货地址页没有默认地址 /
+ 支付方式页找不到选项或「次へ」/ 中间步骤跳转超过 _MAX_CHECKOUT_HOPS 次
+ CheckoutBlockedError: session upgrade 或电话补录页需要人工介入(密码框/
+ 验证码/账号信息缺失/提交后长时间无响应)
+ """
+ async with self._lock:
+ await self._auth_session.require_logged_in("rakuten")
+ await self._refresh_context_if_stale()
+
+ page = await self._context.new_page()
+ success = False
+ try:
+ await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
+ await self._wait_cart_rendered(page, label="enter_checkout")
+
+ btn = page.locator(_CHECKOUT_BUTTON_SELECTOR).last
+ try:
+ await btn.scroll_into_view_if_needed(timeout=10_000)
+ await btn.wait_for(state="visible", timeout=15_000)
+ except Exception as exc:
+ raise OrderOperationError(
+ f"购物车页未找到「購入手続き」按钮:{type(exc).__name__}: {exc}"
+ ) from exc
+
+ url_before = page.url
+ try:
+ await btn.click(timeout=10_000)
+ except Exception as exc:
+ raise OrderOperationError(
+ f"点击「購入手続き」失败:{type(exc).__name__}: {exc}"
+ ) from exc
+ await page.wait_for_timeout(3_000)
+
+ if page.url != url_before and any(
+ marker in page.url for marker in _SESSION_UPGRADE_URL_MARKERS
+ ):
+ logger.info(
+ "触发 session upgrade:task_id=%s url=%s", task.task_id, page.url
+ )
+ await self._complete_session_upgrade(page, task_id=task.task_id)
+
+ for _hop in range(_MAX_CHECKOUT_HOPS):
+ await page.wait_for_timeout(1_500)
+ html = await page.content()
+
+ if _PHONE_REGISTRATION_MARKER in html:
+ await self._complete_phone_registration(page, task_id=task.task_id)
+ continue
+ if _ADDRESS_STEP_URL_MARKER in page.url:
+ await self._confirm_default_address(page, task_id=task.task_id)
+ continue
+ if _PAYMENT_STEP_URL_MARKER in page.url:
+ await self._select_payment_method(page, task_id=task.task_id)
+ continue
+ break
+ else:
+ raise OrderOperationError(
+ f"结算流程跳转超过 {_MAX_CHECKOUT_HOPS} 跳仍未落到确认页,"
+ f"当前 url={page.url}(可能出现了未见过的新步骤)"
+ )
+
+ logger.info(
+ "enter_checkout 落地:task_id=%s url=%s", task.task_id, page.url
+ )
+ html = await page.content()
+ self._checkout_pages[task.task_id] = page
+ success = True
+ return html
+ finally:
+ if not success:
+ await page.close()
+
+ async def _complete_session_upgrade(self, page, *, task_id: str) -> None:
+ """在 session upgrade 密码页用账号密码复核
+
+ 密码来自 account.yaml(与 AuthSession.try_relogin 相同的读取方式,用完即弃,
+ 不持有在实例状态上)。提交后轮询 URL 变化;_SESSION_UPGRADE_POLL_ROUNDS 轮
+ (约 30s)内未变化即认定被站点风控拦截,抛 CheckoutBlockedError。
+ """
+ try:
+ from app.trading.services import login_runner
+
+ accounts_by_site = login_runner.load_accounts()
+ except (FileNotFoundError, ValueError) as exc:
+ raise CheckoutBlockedError(
+ f"session upgrade 需要密码,但读取 account.yaml 失败:{exc}"
+ ) from exc
+
+ account = login_runner.default_account_for("rakuten", accounts_by_site)
+ if account is None:
+ raise CheckoutBlockedError(
+ "session upgrade 需要密码,但 account.yaml 没有 rakuten 账号"
+ )
+
+ try:
+ pwd = await page.wait_for_selector(
+ "input[type='password']", state="visible", timeout=10_000
+ )
+ except Exception as exc:
+ raise CheckoutBlockedError(
+ f"session upgrade 页未出现密码输入框:{type(exc).__name__}: {exc}"
+ ) from exc
+ await pwd.fill(account.password)
+
+ submit = page.locator(_SESSION_UPGRADE_SUBMIT_SELECTOR).first
+ url_before = page.url
+ try:
+ await submit.click(timeout=10_000)
+ except Exception as exc:
+ raise CheckoutBlockedError(
+ "session upgrade 提交按钮点击失败(实测里这一步曾因按钮持续被前端重渲染"
+ f"而等不到可点击的稳定态):{type(exc).__name__}: {exc}"
+ ) from exc
+
+ for _ in range(_SESSION_UPGRADE_POLL_ROUNDS):
+ await page.wait_for_timeout(_SESSION_UPGRADE_POLL_INTERVAL_MS)
+ if page.url != url_before:
+ logger.info(
+ "session upgrade 复核通过:task_id=%s url=%s", task_id, page.url
+ )
+ return
+
+ raise CheckoutBlockedError(
+ f"session upgrade 提交密码后 "
+ f"{_SESSION_UPGRADE_POLL_ROUNDS * _SESSION_UPGRADE_POLL_INTERVAL_MS // 1000}s "
+ "内 URL 未变化,判定被站点风控拦截"
+ )
+
+ async def _complete_phone_registration(self, page, *, task_id: str) -> None:
+ """账号缺电话号码时,session upgrade 后插入的「会員情報の追加登録」补录页
+
+ **2026-08-11 人工实测**:只确认了这一步存在、字段是「電話番号」+「次へ」,
+ 没有验证过提交后是否还会要求输入短信验证码——如果触发,下面的轮询会等到
+ 超时,归入「长时间无响应」分支抛 CheckoutBlockedError 转人工,不会卡死。
+
+ 电话号码来自 account.yaml 的 phone 字段(login_runner.Account.phone),
+ 缺失时直接转 needs_human,不猜/不留空提交。
+ """
+ try:
+ from app.trading.services import login_runner
+
+ accounts_by_site = login_runner.load_accounts()
+ except (FileNotFoundError, ValueError) as exc:
+ raise CheckoutBlockedError(
+ f"账号缺电话号码需要补录,但读取 account.yaml 失败:{exc}"
+ ) from exc
+
+ account = login_runner.default_account_for("rakuten", accounts_by_site)
+ phone = account.phone if account else None
+ if not phone:
+ raise CheckoutBlockedError(
+ "站点要求补录电话号码(会員情報の追加登録),"
+ "但 account.yaml 未配置该账号的 phone 字段"
+ )
+
+ try:
+ tel_input = page.locator(_PHONE_INPUT_SELECTOR).first
+ await tel_input.wait_for(state="visible", timeout=10_000)
+ await tel_input.fill(str(phone))
+ except Exception as exc:
+ raise CheckoutBlockedError(
+ f"「会員情報の追加登録」页未找到电话号码输入框:{type(exc).__name__}: {exc}"
+ ) from exc
+
+ submit = page.locator(_SESSION_UPGRADE_SUBMIT_SELECTOR).first
+ url_before = page.url
+ try:
+ await submit.click(timeout=10_000)
+ except Exception as exc:
+ raise CheckoutBlockedError(
+ f"电话号码提交按钮点击失败:{type(exc).__name__}: {exc}"
+ ) from exc
+
+ for _ in range(_SESSION_UPGRADE_POLL_ROUNDS):
+ await page.wait_for_timeout(_SESSION_UPGRADE_POLL_INTERVAL_MS)
+ if page.url != url_before:
+ logger.info("电话号码补录完成:task_id=%s url=%s", task_id, page.url)
+ return
+
+ raise CheckoutBlockedError(
+ "提交电话号码后长时间未跳转(可能触发了短信验证码,需要人工在浏览器里"
+ "输入),判定被站点风控拦截"
+ )
+
+ async def _confirm_default_address(self, page, *, task_id: str) -> None:
+ """收货地址确认页:点击默认地址卡片继续
+
+ **未经真实 HTML 验证**:2026-08-11 人工走一遍时观察到默认地址卡片本身可点击
+ (点击后直接跳到支付方式页,没有单独的「次へ」),但没拿到这页的真实 HTML,
+ selector 是根据可见文案「デフォルトお届け先」反推的推测。
+ 账号没有默认地址时(新地址、需要人工选/填)直接抛错转人工,不猜表单结构。
+ """
+ html = await page.content()
+ if _ADDRESS_DEFAULT_BADGE_TEXT not in html:
+ raise OrderOperationError(
+ "收货地址页未找到默认地址(可能账号没有保存地址,需要人工选择/填写),"
+ f"url={page.url}"
+ )
+ try:
+ card = page.locator(f':has-text("{_ADDRESS_DEFAULT_BADGE_TEXT}")').last
+ await card.click(timeout=10_000)
+ except Exception as exc:
+ raise OrderOperationError(
+ f"点击默认收货地址卡片失败:{type(exc).__name__}: {exc}"
+ ) from exc
+ await page.wait_for_timeout(2_000)
+ logger.info("收货地址确认完成:task_id=%s url=%s", task_id, page.url)
+
+ async def _select_payment_method(self, page, *, task_id: str) -> None:
+ """支付方式选择页:选配置的支付方式(默认「クレジットカード」)并点「次へ」
+
+ **2026-08-11 人工在真实页面上核对过截图**:选中「クレジットカード」后账号
+ 已有一张已保存卡时会展开显示掩码卡号(VISA ●●●● 8476)+「有効期限:12/2029」,
+ 该卡的 radio 默认就是选中态,不需要额外点选。
+ 判「有没有已保存卡」**不能**用「➕ 新しく追加したカードの情報を変更する」这条
+ 链接是否出现——它不管有没有已保存卡都会显示,之前一版按这个判断是错的。
+ 真正的信号是掩码卡号 + 有効期限(_SAVED_CARD_SIGNAL_PATTERN)。
+
+ 进一步区分两种「没有可用卡」的情况(尾号比对,而不是只看有没有任意已保存卡):
+ - 已保存卡的掩码尾号与 account.yaml 配置的卡号尾号一致 → 直接点「次へ」
+ - 没有已保存卡信号,或已保存卡尾号跟配置不一致(比如账号默认卡换过)→
+ 触发 _fill_new_card_form 代填新卡表单;account.yaml 没配 credit_card
+ 时没法代填,直接抛 CheckoutBlockedError 转人工。
+ """
+ label = self._settings.order_payment_method
+ try:
+ option = page.locator(f'text="{label}"').first
+ await option.click(timeout=10_000)
+ except Exception as exc:
+ raise OrderOperationError(
+ f"支付方式页未找到「{label}」选项:{type(exc).__name__}: {exc}"
+ ) from exc
+
+ await page.wait_for_timeout(1_000)
+ html = await page.content()
+
+ from app.trading.services import login_runner
+
+ try:
+ accounts_by_site = login_runner.load_accounts()
+ except (FileNotFoundError, ValueError) as exc:
+ raise CheckoutBlockedError(
+ f"支付方式页需要账号配置核对已保存卡,但读取 account.yaml 失败:{exc}"
+ ) from exc
+ account = login_runner.default_account_for("rakuten", accounts_by_site)
+ configured_card = account.credit_card if account else None
+
+ saved_last4_match = _MASKED_CARD_LAST4_PATTERN.search(html)
+ card_ready = False
+ if saved_last4_match and configured_card:
+ saved_last4 = saved_last4_match.group(1)
+ configured_last4 = configured_card.number[-4:]
+ if saved_last4 == configured_last4:
+ card_ready = True
+ else:
+ logger.info(
+ "已保存卡尾号与配置不一致:saved=%s configured=%s,将走新卡代填",
+ saved_last4, configured_last4,
+ )
+ elif saved_last4_match and not configured_card:
+ # 有已保存卡但 account.yaml 没配 credit_card,没法核对尾号是不是我们要的那张,
+ # 保守起见不当成「可用」,走下面的分支(没配卡会直接转人工)
+ pass
+
+ if not card_ready:
+ if not configured_card:
+ raise CheckoutBlockedError(
+ f"选择「{label}」后没有找到匹配的已保存卡(掩码卡号 + 有効期限),"
+ "且 account.yaml 未配置 payment.credit-card,无法代填新卡,需要人工处理"
+ )
+ await self._fill_new_card_form(page, card=configured_card, task_id=task_id)
+ raise CheckoutBlockedError(
+ "已按 account.yaml 的卡信息代填新卡表单,但未自动提交/登录该卡"
+ "(表单从未经真实页面验证,且注册新卡是有实际后果的动作),"
+ "需要人工在浏览器里核对填写内容后自己点提交"
+ )
+
+ next_btn = page.locator(_PAYMENT_NEXT_BUTTON_SELECTOR).first
+ try:
+ await next_btn.click(timeout=10_000)
+ except Exception as exc:
+ raise OrderOperationError(
+ f"支付方式页点击「次へ」失败:{type(exc).__name__}: {exc}"
+ ) from exc
+ await page.wait_for_timeout(2_000)
+ 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:
+ """代填新卡表单(卡号/有效期/持卡人姓名),不点提交
+
+ **2026-08-13 真实走过一次这个表单**(人工在浏览器窗口里用 OS 级
+ SendKeys 代填,不是本方法这段 Playwright 代码本身——见
+ _CARD_NUMBER_MOUNT_SELECTOR 等常量注释):卡号/有效期(月)/有效期(年)
+ 分别托管在三个独立跨域 iframe 里(Rakuten PCI 代付 vault),只有持卡人
+ 姓名是主文档里的普通 input。iframe 挂载点 selector 是真实验证过的,但
+ iframe **内部**的具体字段 selector("input, select" 兜底)没有拿到真实
+ HTML,仍是猜的——找不到就直接抛 CheckoutBlockedError 转人工,不做「找不到
+ 就跳过」的静默降级,代填不完整的卡信息比不填更危险。
+ """
+ try:
+ link = page.locator(f'text="{_ADD_CARD_LINK_TEXT}"').first
+ await link.click(timeout=10_000)
+ await page.wait_for_timeout(1_000)
+ except Exception as exc:
+ raise CheckoutBlockedError(
+ f"未找到「{_ADD_CARD_LINK_TEXT}」新增卡入口:{type(exc).__name__}: {exc}"
+ ) from exc
+
+ month = str(card.month).zfill(2)
+ year = str(card.year)
+
+ async def _fill_in_vault_iframe(mount_selector: str, value: str, field_label: str) -> None:
+ try:
+ frame = page.frame_locator(mount_selector)
+ field = frame.locator("input, select").first
+ await field.wait_for(state="visible", timeout=8_000)
+ except Exception as exc:
+ raise CheckoutBlockedError(
+ f"新卡表单未找到「{field_label}」所在 iframe({mount_selector})"
+ f"内的输入元素:{type(exc).__name__}: {exc}"
+ ) from exc
+ tag = await field.evaluate("el => el.tagName.toLowerCase()")
+ try:
+ if tag == "select":
+ await field.select_option(value)
+ else:
+ await field.fill(value)
+ except Exception as exc:
+ raise CheckoutBlockedError(
+ f"新卡表单「{field_label}」填写失败:{type(exc).__name__}: {exc}"
+ ) from exc
+
+ async def _fill_in_main_doc(value: str, field_label: str) -> None:
+ locator = page.locator(f'text="{_CARD_NAME_LABEL_TEXT}"').locator(
+ "xpath=following::input[1]"
+ )
+ try:
+ await locator.wait_for(state="visible", timeout=8_000)
+ await locator.fill(value)
+ except Exception as exc:
+ raise CheckoutBlockedError(
+ f"新卡表单「{field_label}」填写失败:{type(exc).__name__}: {exc}"
+ ) from exc
+
+ await _fill_in_vault_iframe(_CARD_NUMBER_MOUNT_SELECTOR, card.number, "卡号")
+ await _fill_in_vault_iframe(_CARD_MONTH_MOUNT_SELECTOR, month, "有効期限(月)")
+ 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)
+
+ async def _discard_checkout_page(self, task_id: str) -> None:
+ """关闭并丢弃 task_id 对应的已留存确认页 Page
+
+ 只在确认「这次调用没有产生下单提交动作」时调用(比如确认按钮没找到、
+ 点击本身失败)。提交后无法确认成败的情况**不**调用它——保留 page 让
+ needs_human 能接着用同一个已登录会话人工核对,而不是把可能已经提交的
+ 会话直接关掉。
+ """
+ page = self._checkout_pages.pop(task_id, None)
+ if page is None:
+ return
+ try:
+ await page.close()
+ except Exception:
+ logger.debug("关闭 checkout page 失败:task_id=%s", task_id, exc_info=True)
+
+ # ---- 已实现但未经真实确认页验证:parse_checkout / submit_order / pay ----
+ # 至今没有真正走到过下单确认页;这三个方法是应产品要求在缺真实 HTML 的情况下
+ # 写的,selector/正则均为按「日文电商确认页常见文案」的推测。金额解析刻意做成
+ # 「找不到明确匹配就直接抛错」,不做弱规则兜底——这个值直接喂给金额守卫,猜错
+ # 等于守卫形同虚设。submit_order 从未在真实站点上被点击执行过。
async def parse_checkout(self, html: str) -> CheckoutSummary:
- """从下单确认页解析应付金额、付款期限、订单号。**未实现**"""
- raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
+ """从下单确认页解析应付金额、订单号、付款期限
+
+ Raises:
+ OrderOperationError: 找不到「标签+金额」的明确匹配,或匹配出多个互相
+ 矛盾的金额——两种情况都不落回猜测值,直接报错交人工核对页面
+ """
+ return _parse_checkout_summary(html)
async def submit_order(self, task: LeaseTask) -> str:
- """提交下单。**未实现**"""
- raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
+ """点击下单确认页的最终确认按钮,提交订单
+
+ 调用前必须已经过 enter_checkout(本方法复用它留存的 Page)与金额守卫;
+ 本方法自身不做金额二次校验。这一步不可逆(真实下单/可能随之扣款),
+ **本会话内从未被真实执行过**。
+
+ Returns:
+ 从提交后页面解析出的站点订单号;解析不出时抛错,不返回猜测值。
+
+ Raises:
+ OrderOperationError: 找不到 enter_checkout 留存的确认页会话 / 找不到
+ 确认按钮 / 提交后长时间无响应 / 提交后解析不出订单号(这种情况
+ 页面可能已经跳转但订单号没解析到,不能吞掉当失败重试——保留 page
+ 供人工核对,详见 _discard_checkout_page 的调用取舍)
+ """
+ async with self._lock:
+ page = self._checkout_pages.get(task.task_id)
+ if page is None:
+ raise OrderOperationError(
+ "submit_order 找不到 enter_checkout 留存的确认页会话"
+ "(必须先成功调用 enter_checkout)"
+ )
+
+ btn = None
+ for sel in _ORDER_CONFIRM_BUTTON_SELECTORS:
+ candidate = page.locator(sel).first
+ try:
+ await candidate.wait_for(state="visible", timeout=2_000)
+ btn = candidate
+ break
+ except Exception:
+ continue
+ if btn is None:
+ await self._discard_checkout_page(task.task_id)
+ raise OrderOperationError(
+ "下单确认页未找到任何已知文案的确认按钮"
+ f"(候选:{_ORDER_CONFIRM_BUTTON_SELECTORS}),selector 未经真实页面验证"
+ )
+
+ url_before = page.url
+ try:
+ await btn.click(timeout=10_000)
+ except Exception as exc:
+ await self._discard_checkout_page(task.task_id)
+ raise OrderOperationError(
+ f"点击确认下单按钮失败:{type(exc).__name__}: {exc}"
+ ) from exc
+
+ for _ in range(_SESSION_UPGRADE_POLL_ROUNDS):
+ await page.wait_for_timeout(_SESSION_UPGRADE_POLL_INTERVAL_MS)
+ if page.url != url_before:
+ break
+ else:
+ raise OrderOperationError(
+ f"点击确认下单按钮后 url={url_before} 长时间无响应,"
+ "无法确认是否提交成功,需要人工核对(不要按失败重试)"
+ )
+
+ html = await page.content()
+ match = _ORDER_ID_PATTERN.search(html)
+ if not match:
+ raise OrderOperationError(
+ f"提交下单后未能从落地页解析出订单号,url={page.url}"
+ "(可能已经下单成功,需要人工核对站点订单列表,不要按失败重试)"
+ )
+ site_order_id = match.group(1)
+ logger.info(
+ "submit_order 完成:task_id=%s site_order_id=%s", task.task_id, site_order_id
+ )
+ return site_order_id
async def pay(self, task: LeaseTask, site_order_id: str) -> None:
- """付款。**未实现**"""
- raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
+ """检查提交下单后是否已完成付款 / 是否触发了需要人工介入的验证环节
+
+ 按 docs/order-gateway.md §10.1 既定方案实现:检测到常见 3DS/短信 OTP 迹象
+ (iframe、OTP 输入框)就直接抛 CheckoutBlockedError 转 needs_human,不尝试
+ 代填/绕过;没检测到就当作 submit_order 已经完成了同页付款——**这条「信用卡
+ 下单即扣款、没有独立付款步骤」的假设本身也未经真实验证**,一旦线上观察到
+ 与假设不符,应优先修正这里而不是继续往下猜新步骤。
+
+ 本方法结束时无论成败都会关闭 self._checkout_pages 里留存的 Page
+ (提交/付款环节已经走到这一步,没有再复用同一页面的后续步骤)。
+ """
+ async with self._lock:
+ page = self._checkout_pages.pop(task.task_id, None)
+ if page is None:
+ raise OrderOperationError(
+ f"pay 找不到 task_id={task.task_id} 对应的确认页会话"
+ "(submit_order 未成功执行,或会话已被清理)"
+ )
+ try:
+ for sel in _PAYMENT_BLOCK_INDICATORS:
+ if await page.locator(sel).count() > 0:
+ raise CheckoutBlockedError(
+ f"提交订单后检测到需要人工处理的验证环节({sel}),"
+ f"site_order_id={site_order_id},按规格 §10.1 转 needs_human"
+ )
+ logger.info(
+ "pay:未检测到 3DS/OTP 迹象,视为随下单同步完成付款(未经真实验证的"
+ "假设):task_id=%s site_order_id=%s",
+ task.task_id, site_order_id,
+ )
+ finally:
+ await page.close()
async def monitor(self, task: LeaseTask, site_order_id: str) -> None:
- """付款后监控。**未实现**"""
+ """付款后监控。**未实现**:订单列表页的真实结构没有实测过"""
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
@@ -782,3 +1497,37 @@ def _extract_error_message(body: str) -> str:
msgs.append(s)
# 取前两条拼起来(实测错误页一般 1~2 条核心提示)
return " / ".join(msgs[:2])
+
+
+def _parse_checkout_summary(html: str) -> CheckoutSummary:
+ """下单确认页解析核心逻辑(纯函数,供 parse_checkout 调用,便于离线单测)
+
+ 金额解析、订单号解析 2026-08-13 均已用真实下单完成页 HTML 验证过(见
+ _AMOUNT_TAG_BOUNDED_PATTERN / _AMOUNT_INLINE_PATTERN / _ORDER_ID_PATTERN
+ 上方注释);付款期限仍未经验证,见模块顶部说明。找不到金额候选、或候选金额
+ 互相矛盾都直接抛错,不落回弱规则猜数字——这个值直接喂给金额守卫。
+ """
+ label_matches = _AMOUNT_TAG_BOUNDED_PATTERN.findall(html) or _AMOUNT_INLINE_PATTERN.findall(html)
+ if not label_matches:
+ raise OrderOperationError(
+ "下单确认页解析失败:找不到「支払い金額」等标签附近的金额"
+ "(selector 见 _parse_checkout_summary 文档字符串)"
+ )
+ amounts = {int(m.replace(",", "")) for m in label_matches}
+ if len(amounts) > 1:
+ raise OrderOperationError(
+ f"下单确认页解析出多个互相矛盾的金额候选:{sorted(amounts)},无法确定应付金额"
+ )
+ payable_yen = amounts.pop()
+
+ order_id_match = _ORDER_ID_PATTERN.search(html)
+ site_order_id = order_id_match.group(1) if order_id_match else None
+
+ deadline_match = _PAY_DEADLINE_PATTERN.search(html)
+ pay_deadline = deadline_match.group(1) if deadline_match else None
+
+ return CheckoutSummary(
+ payable_yen=payable_yen,
+ site_order_id=site_order_id,
+ pay_deadline=pay_deadline,
+ )
diff --git a/tests/test_site_interact.py b/tests/test_site_interact.py
index 5bd67cb..03ee93f 100644
--- a/tests/test_site_interact.py
+++ b/tests/test_site_interact.py
@@ -15,13 +15,14 @@ from typing import Any
import pytest
-from app.shared.errors import CartOperationError, InvalidRequestError
+from app.shared.errors import CartOperationError, InvalidRequestError, OrderOperationError
from app.trading.worker.models import LeaseTask
from app.trading.worker.site_interact import (
CheckoutSummary,
SiteInteractor,
_extract_error_message,
_extract_purchase_fields,
+ _parse_checkout_summary,
_parse_initial_state,
)
@@ -249,32 +250,116 @@ def test_site_interactor_construction_does_not_require_playwright():
assert site._per_task_state == {}
-# ---- 未实现的方法仍然抛 NotImplementedError ----
+# ---- monitor 仍未实现,抛 NotImplementedError ----
-@pytest.mark.parametrize(
- "method,args",
- [
- ("enter_checkout", (_make_task(),)),
- ("submit_order", (_make_task(),)),
- ("pay", (_make_task(), "ord-1")),
- ("monitor", (_make_task(), "ord-1")),
- ],
-)
-async def test_unimplemented_methods_raise(method: str, args: tuple):
- site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
- fn = getattr(site, method)
- with pytest.raises(NotImplementedError):
- await fn(*args)
-
-
-async def test_parse_checkout_unimplemented():
+async def test_monitor_unimplemented():
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
with pytest.raises(NotImplementedError):
- await site.parse_checkout("")
+ await site.monitor(_make_task(), "ord-1")
-# ---- add_to_cart / verify_cart 在没启动 Playwright 时应失败 ----
+# ---- submit_order / pay:没有 enter_checkout 留存的确认页会话时应报错,不静默成功 ----
+
+
+async def test_submit_order_without_checkout_page_raises():
+ site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
+ with pytest.raises(OrderOperationError):
+ await site.submit_order(_make_task())
+
+
+async def test_pay_without_checkout_page_raises():
+ site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
+ with pytest.raises(OrderOperationError):
+ await site.pay(_make_task(), "ord-1")
+
+
+# ---- parse_checkout / _parse_checkout_summary:金额解析已用真实确认页 HTML 验证
+# (2026-08-13,data/evidence/checkout-research-20260811/i-final-state.html),
+# 订单号/付款期限仍未验证。下面简单 fixture 测的是防御性行为(找不到/矛盾就报错)。
+
+
+async def test_parse_checkout_delegates_to_pure_function():
+ site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
+ html = "お支払い金額 1,980円
"
+ summary = await site.parse_checkout(html)
+ assert summary.payable_yen == 1980
+
+
+def test_parse_checkout_summary_extracts_amount_order_id_deadline():
+ """订单号 fixture 用真实格式(三段数字用「-」连接,见 _ORDER_ID_PATTERN 注释),
+ 2026-08-13 之前这里用的是纯猜测的 "AB-123456" 字母前缀格式,拿到真实订单号后
+ 已确认不是这个形状。"""
+ html = (
+ "お支払い合計 12,345円
"
+ "ご注文番号 306087-20260813-0863947697
"
+ "お支払い期限 2026/08/20
"
+ )
+ summary = _parse_checkout_summary(html)
+ assert summary.payable_yen == 12345
+ assert summary.site_order_id == "306087-20260813-0863947697"
+ assert summary.pay_deadline == "2026/08/20"
+
+
+def test_parse_checkout_summary_missing_amount_raises():
+ """找不到「标签+金额」的明确匹配就报错,不落回猜数字——这个值直接喂给金额守卫"""
+ with pytest.raises(OrderOperationError):
+ _parse_checkout_summary("合計 1,980円
") # 标签不在候选列表里
+
+
+def test_parse_checkout_summary_conflicting_amounts_raises():
+ html = "お支払い金額 1,000円
お支払い合計 2,000円
"
+ with pytest.raises(OrderOperationError):
+ _parse_checkout_summary(html)
+
+
+def test_parse_checkout_summary_optional_fields_default_none():
+ summary = _parse_checkout_summary("お支払い金額 500円
")
+ assert summary.payable_yen == 500
+ assert summary.site_order_id is None
+ assert summary.pay_deadline is None
+
+
+def test_parse_checkout_summary_matches_real_page_dom_shape():
+ """真实确认页金额结构:标签纯「支払い金額」(无「お」前缀),数字与「円」
+ 分别在独立标签里、中间隔着大段 class 属性,不是「标签+数字+円」紧邻文案
+ (2026-08-13 从真实页面简化摘录的结构,见 _AMOUNT_TAG_BOUNDED_PATTERN 注释)"""
+ html = (
+ '支払い金額 '
+ '