- 新卡代填改用真实 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>
1534 lines
76 KiB
Python
1534 lines
76 KiB
Python
"""站点交互:加购 / 校验 / 清空 / 删除 / 进入下单确认页 / 金额守卫 / 提交 / 付款 / 监控
|
|
|
|
实测进度(详见 project://jp-rakuten/checkout-flow-probe-findings 与
|
|
data/evidence/checkout-research-20260811/NOTES.md):
|
|
- add_to_cart、verify_cart 已实测可用(Playwright + storage_state 走 SP 通道)
|
|
- clear_cart、remove_item 用 UI 点击 `button[aria-label^="削除"]` 路径实现,
|
|
2026-08-11 用真账号跑通下单测试时确认实际 aria-label 是「削除する」而非旧探针
|
|
记录的「削除」,selector 已改前缀匹配;modal 是否存在仍未实测确认
|
|
- enter_checkout 已实现到「进入 session upgrade 密码页并尝试自动复核」:购物车页
|
|
点击真正的「購入手続き」(注意页面上还有个促销用的「カード入会&購入手続き」
|
|
按钮,selector 必须精确匹配,不能 .first 模糊选)后,即使 SSO 会话仍有效,
|
|
Rakuten 也会整页跳转到 login.account.rakuten.com/session/upgrade 强制重输密码,
|
|
这是站点自己的风控设计,不是登录态问题。前 4 次真实账号自动化尝试里自动填密码后
|
|
点「次へ」均未能稳定提交(按钮持续被前端重渲染替换,Playwright 等不到可点击的
|
|
稳定态),行为更像是站点对自动化环境的针对性降级,不是偶发问题——命中时抛
|
|
CheckoutBlockedError 转 needs_human,**不做无限重试**。
|
|
- 2026-08-11 第 5 次由人工在浏览器里手动完成密码这一步后,实测发现 session upgrade
|
|
之后还有几个此前完全没见过的中间步骤:账号缺电话号码时插入的「会員情報の追加登録」
|
|
(补录电话号码)→ 确认收货地址(点默认地址卡片)→ 选支付方式 → 才是下单确认页。
|
|
enter_checkout 已按这个顺序扩展(_complete_phone_registration /
|
|
_confirm_default_address / _select_payment_method),但**这三步全部只有人工走
|
|
一遍时的截图作为依据,没有拿到任何一步的真实 HTML**,selector 是按可见文案反推
|
|
的推测,未经自动化路径真实验证。
|
|
- parse_checkout / submit_order / pay **有实现,且 2026-08-13 真实完整跑通了一次**:
|
|
账号 trdian022,297円小额验证购买(BACKYARD FAMILY 墙钩),人工在浏览器窗口里
|
|
核对过金额/收货地址/卡号后,现场手动触发了 submit_order 对应的「注文を確定する」
|
|
点击(不是本模块自动决策触发的——production 代码路径本身仍然只有「金额/商品
|
|
校验通过才点」这一层自动化安全阀,见 submit_order 与 CONFIRM 校验逻辑),
|
|
真实跳转到 sp.cart.step.rakuten.co.jp/complete?l2-id=step4_sp_purchase_top
|
|
(下单完成页,「ご注文がショップに送信されました」),未检测到 3DS/OTP,拿到
|
|
真订单号「注文番号 306087-20260813-0863947697」。已用这份完整证据(
|
|
data/evidence/checkout-live-20260813/、以及更早一份提交前快照
|
|
data/evidence/checkout-research-20260811/i-final-state.html)验证/修正:
|
|
- 金额解析:真实标签是「支払い金額」,无「お」前缀,数字和「円」分别在独立
|
|
标签里、中间隔着大段 class 属性——之前按简单文案猜的正则在真实页面上完全
|
|
匹配不到,现改成 tag-bounded 优先、inline 简单文案兜底的两段式匹配,详见
|
|
_AMOUNT_TAG_BOUNDED_PATTERN。金额解析仍然「找不到明确匹配就直接抛错」,
|
|
不落回弱规则猜数字——这个值要直接喂给金额守卫,猜错等于守卫形同虚设。
|
|
- 「注文を確定する」确认是真实存在的确认按钮,点击后的跳转/完成页判据也已验证。
|
|
- 订单号格式与真实 DOM 结构(标签后跟 ` ` 实体、三段数字用「-」连接)——
|
|
旧版 _ORDER_ID_PATTERN 卡在 ` ` 上完全没命中,已修正,见 _ORDER_ID_PATTERN
|
|
注释。付款期限这份证据里没有出现(大概率因为当场就付款完成、没有独立的
|
|
「期限」概念),_PAY_DEADLINE_PATTERN 仍未经验证,维持原猜测。
|
|
- 新卡代填表单的真实 DOM(卡号/月/年三个字段各自在独立跨域 iframe 里,只有
|
|
持卡人姓名是主文档普通 input),见 _CARD_NUMBER_MOUNT_SELECTOR 等常量注释
|
|
与 _fill_new_card_form;iframe 挂载点选择器验证过,iframe 内部字段选择器
|
|
仍是猜的。
|
|
这次真实提交**只此一单**,其余账号/商品/金额组合下的中间步骤分支(电话补录、
|
|
非默认地址、3DS/OTP 拦截等)仍未被真实数据验证过,继续按原有猜测处理并保留
|
|
CheckoutBlockedError 兜底,不做无限重试。
|
|
|
|
**httpx 不能用于带账号的写操作**:Rakuten 对账号操作有 TLS/HTTP2 指纹校验,
|
|
同一份 cookie Playwright 能用、httpx 不能。所以本模块全程使用 Playwright
|
|
的 BrowserContext + APIRequestContext(共享 cookie,绕过 CORS)。
|
|
|
|
**并发约束**:所有公开方法入口 acquire `self._lock`,保证 HTTP 路由与 worker
|
|
主循环不会同时操作同一个 Playwright context——同一账号必须串行
|
|
(per project://jp-rakuten/trading-split「全局并发度 1」)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
from app.shared.errors import (
|
|
CartOperationError,
|
|
CheckoutBlockedError,
|
|
InvalidRequestError,
|
|
NotLoggedInError,
|
|
OrderOperationError,
|
|
)
|
|
from app.shared.purchase_contract import (
|
|
INVENTORY_FLAG_DEFAULT,
|
|
INVENTORY_FLAG_MULTIPLE,
|
|
base_form_fields,
|
|
basket_domain_of,
|
|
inventory_flag_for,
|
|
)
|
|
from app.trading.core import auth_site
|
|
from app.trading.worker.models import LeaseTask
|
|
|
|
if TYPE_CHECKING:
|
|
from playwright.async_api import Page
|
|
|
|
from app.shared.config import Settings
|
|
from app.trading.services.auth_session import AuthSession
|
|
from app.trading.services.login_runner import CreditCard
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 普通购买事件标识、inventory_flag 映射、basketDomain 反转义、form_fields 基础构造
|
|
# 均在 app.shared.purchase_contract,与 scraping/parsers/item.py::_purchase_info 共用同一份契约
|
|
_BASKET_PATH = "/rms/mall/bss/cartadd/set"
|
|
|
|
# 购物车页(SP)与 cart 数量 JSONP API(探针实测)
|
|
_CART_PAGE = auth_site.RAKUTEN_CART_URL
|
|
_CART_COUNT_API = "https://cart-api.step.rakuten.co.jp/rms/mall/cart/count/all/jsonp/"
|
|
|
|
# 购物车页未登录标记(旧 marker;新 SPA 上不可靠,这里只作辅助判据)
|
|
_LEGACY_LOGGED_OUT_MARKER = auth_site.RAKUTEN_LOGGED_OUT_MARKER
|
|
|
|
# 删除按钮稳定 selector:真实账号实测(2026-08-11 order-gateway 下单测试,
|
|
# data/evidence/test-order-20260811-01/02-cart-check.html)显示实际 aria-label
|
|
# 是「削除する」而非旧探针记录的「削除」,故改用前缀匹配防止站点文案再漂移一点点。
|
|
# aria-label 是 a11y 属性,比 CSS modules hash class 稳定;Rakuten 不用 data-testid
|
|
_DELETE_BUTTON_SELECTOR = 'button[aria-label^="削除"]'
|
|
|
|
# clear_cart 安全上限:防止 SPA 异常时死循环
|
|
_CLEAR_CART_MAX_ITER = 50
|
|
|
|
# 确认 modal 候选 selector:点击「削除」后站点可能弹「本当に削除しますか?」
|
|
_CONFIRM_BUTTON_SELECTORS = ('button:has-text("はい")', 'button:has-text("OK")')
|
|
|
|
# 购物车页真正的结算入口。**必须精确匹配**:页面上还有一个视觉更醒目的促销按钮
|
|
# 「カード入会&購入手続き」(楽天カード 办卡组合按钮),selector 不精确(比如用
|
|
# .first)会点错——2026-08-11 实测踩过这个坑,详见 checkout-research-20260811/NOTES.md
|
|
_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("次へ")'
|
|
|
|
# 提交密码后等待页面跳转的轮询:每次 3s,共 _SESSION_UPGRADE_POLL_ROUNDS 次
|
|
# (合计 ~30s)。前 4 次真实尝试里从未在这个窗口内看到跳转,超时即认定被拦截。
|
|
_SESSION_UPGRADE_POLL_INTERVAL_MS = 3_000
|
|
_SESSION_UPGRADE_POLL_ROUNDS = 10
|
|
|
|
# ---- 2026-08-11 人工走一遍才发现的中间步骤(session upgrade 之后、下单确认页之前)
|
|
# 全部只有截图依据,没有真实 HTML;selector 是按可见文案反推的推测。
|
|
|
|
# 账号缺电话号码时插入的补录页;用文案 marker 判断(没抓到真实 DOM,不用更精确的选择器)
|
|
_PHONE_REGISTRATION_MARKER = "会員情報の追加登録"
|
|
_PHONE_INPUT_SELECTOR = 'input[type="tel"], input[name*="phone" i], input[name*="tel" i]'
|
|
|
|
# 收货地址确认页:URL 里带 /ship;默认地址卡片带「デフォルトお届け先」徽章文案,
|
|
# 点卡片本身即可继续(实测点击后直接跳到支付方式页,没有单独的「次へ」)
|
|
_ADDRESS_STEP_URL_MARKER = "/ship"
|
|
_ADDRESS_DEFAULT_BADGE_TEXT = "デフォルトお届け先"
|
|
|
|
# 支付方式选择页:URL 里带 /pay;radio 按可见文案匹配,选完点「次へ」
|
|
_PAYMENT_STEP_URL_MARKER = "/pay"
|
|
_PAYMENT_NEXT_BUTTON_SELECTOR = 'button:has-text("次へ")'
|
|
# 选中「クレジットカード」后,账号已保存卡的真实信号(2026-08-11 人工在真实页面上
|
|
# 核对过一次截图):掩码卡号(如 VISA ●●●● 8476)+「有効期限:MM/YYYY」。
|
|
# 页面上「➕ 新しく追加したカードの情報を変更する」这条链接不管有没有已保存卡都会
|
|
# 显示,**不能**当「没有已保存卡」的信号用。
|
|
_SAVED_CARD_SIGNAL_PATTERN = re.compile(r"(?:[•●\*]{2,}\s*\d{3,4}|有効期限[:\s]*\d{1,2}/\d{4})")
|
|
# 掩码卡号的末 4 位数字,单独抽出来跟 account.yaml 配置的卡号做尾号比对,
|
|
# 判断已保存卡是不是我们想用的那张——不匹配(比如账号换过默认卡)就当没有可用卡处理。
|
|
_MASKED_CARD_LAST4_PATTERN = re.compile(r"[•●\*]{2,}\s*(\d{3,4})")
|
|
|
|
# ---- 新卡代填(账号无已保存卡 / 已保存卡尾号不匹配时触发):
|
|
# 2026-08-13 真实走过一次「新しいカードを追加する」表单(人工在浏览器窗口里用
|
|
# OS 级 SendKeys 代填,不是 Playwright .fill(),见下方说明),并从当时的页面
|
|
# 快照(data/evidence/checkout-live-20260813/i-final-state.html,url 带
|
|
# payment?l2-id=c_reg)里拿到了真实 DOM 结构:
|
|
# - 链接文案随状态变化:还没加过卡时是「新しいカードを追加する」(触发新增表单,
|
|
# 本模块用这条);已经加过一张后,同一位置的链接会变成「新しく追加したカードの
|
|
# 情報を変更する」(改已加的那张),两条文案都真实存在,含义不同,不能混用。
|
|
# - 卡号 / 有效期(月) / 有效期(年) 三个字段**分别托管在三个独立的跨域 iframe 里**
|
|
# (Rakuten 的 PCI 代付 vault:static-content.payment.global.rakuten.com/
|
|
# pv/payvault/...),不是主文档里的 <input>——之前按 autocomplete/name 属性
|
|
# 猜的 selector 在主文档里根本找不到元素,是错的。真实可定位的是外层挂载点
|
|
# (见 _CARD_NUMBER_MOUNT_SELECTOR 等),Playwright 用 frame_locator 可以正常
|
|
# 跨域访问;至于 iframe **内部**具体是 <input> 还是 <select>,只从 vault URL
|
|
# 的查询参数里看到 expirationMonth/Year 的 inputType 是 "select"、卡号那个
|
|
# 没标 inputType(推测是 input),iframe 内部真实 HTML 没抓到,仍是按这点线索
|
|
# 猜的(见 _fill_or_select_in_frame 的 "input, select" 兜底)。
|
|
# - 只有「名義人」(持卡人姓名)是主文档里的普通 <input>,但没有 name/autocomplete
|
|
# 等属性,唯一稳定线索是 placeholder="TARO RAKUTEN",故用它做 selector。
|
|
# - 代填后仍然**不会自动点「追加する」提交**——原则不变:注册新卡是有实际后果的
|
|
# 动作,iframe 内部字段是否真填对了也没法从外层验证,交给人工核对后自己点。
|
|
# - 另外发现:本次注册的卡「追加する」成功后当场展示为已选中的已保存卡(含掩码
|
|
# 卡号+有効期限),但换一个全新浏览器 context(沿用同一份 storage_state 登录态)
|
|
# 重新走到支付方式页时,这张卡**没有**出现在已保存卡列表里,需要重新走一遍新增
|
|
# 表单——具体是「只在这笔订单草稿内有效」还是「要等订单真正提交完成才永久保存」
|
|
# 没有进一步验证,_select_payment_method 不应假设新卡注册跨 SiteInteractor
|
|
# context 重建后仍然可用。
|
|
_ADD_CARD_LINK_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"
|
|
# 「名義人」输入框没有 name/autocomplete/id,唯一看到的线索是 placeholder=
|
|
# "TARO RAKUTEN"——但 placeholder 只是示例文案(楽天太郎的英文转写),不是这个
|
|
# 字段的稳定标识:换语言/地区渲染、或站点改示例文案都可能变,不能拿它当 selector
|
|
# 用。改成相对「名義人」这个标签文字定位后面第一个 input,跟标签本身(真实存在,
|
|
# 见截图)绑定,比 placeholder 更稳。
|
|
_CARD_NAME_LABEL_TEXT = "名義人"
|
|
|
|
# 结算流程从点「購入手続き」到落地下单确认页之间,中间步骤跳转次数上限(防死循环);
|
|
# 顺序不保证固定,每一跳都重新按已知特征判断,超过这个数认定出现了未见过的新步骤
|
|
_MAX_CHECKOUT_HOPS = 6
|
|
|
|
# ---- 下单确认页:2026-08-13 拿到第一份真实 HTML(脚本 2 小时轮询超时兜底时自动
|
|
# 落盘,见 data/evidence/checkout-research-20260811/i-final-state.html/png,
|
|
# URL 带 order-confirmation?l2-id=step3_sp_next)。已用它验证/修正的部分:
|
|
# - 金额标签是纯「支払い金額」(没有「お」前缀),数字和「円」分别在独立的
|
|
# <div>/<span> 里,中间隔着上百字符的 class 属性,不是「标签+数字+円」紧邻
|
|
# 的简单文案——之前那版按纯文本猜的 pattern 在真实页面上完全匹配不到,已改成
|
|
# 按「标签 → 数字 → 円」三段分别定位、允许中间跨大段 HTML 标签的写法
|
|
# - 「注文を確定する」是唯一真实存在的确认按钮(<button aria-label="..."
|
|
# type="button"><span>注文を確定する</span></button>,页面上出现两次,内容一致),
|
|
# 在 _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。"
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class CheckoutSummary:
|
|
"""下单确认页解析结果(待实测确认字段位置)
|
|
|
|
payable_yen 用于金额守卫;其他字段在实测确认后补全。
|
|
"""
|
|
|
|
payable_yen: int
|
|
site_order_id: str | None = None
|
|
pay_deadline: str | None = None
|
|
|
|
|
|
class SiteInteractor:
|
|
"""Rakuten 站点交互器:持有 Playwright 浏览器 context,复用账号 cookie
|
|
|
|
生命周期:
|
|
- start() 在 trading 服务 lifespan 启动时调用一次:启动 Playwright + 创建带 cookie 的 context
|
|
- add_to_cart / verify_cart / cart_status / clear_cart / remove_item 在任务或 HTTP 请求里调用
|
|
- close() 在服务关闭时调用
|
|
|
|
浏览器 context 复用同一份登录态,所有任务串行(self._lock + worker 主循环本就串行),
|
|
不需要为每个任务开新 context——开销大且 cookie 状态会乱。
|
|
|
|
调用方:
|
|
- worker runner:传入 LeaseTask,调 add_to_cart(task) / verify_cart(task)
|
|
- HTTP 路由 /api/cart/*:调 add_to_cart_payload(...) / cart_status() /
|
|
clear_cart() / remove_item(item_id)
|
|
"""
|
|
|
|
# 每任务保留的临时状态:task_id → {"item_id": str, "shop_bid": str}
|
|
# 用于 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
|
|
|
|
def __init__(self, *, auth_session: "AuthSession", settings: "Settings"):
|
|
self._auth_session = auth_session
|
|
self._settings = settings
|
|
self._playwright = None
|
|
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 重建。
|
|
self._state_mtime: float | None = None
|
|
|
|
# ---- 生命周期 ----
|
|
|
|
async def start(self) -> None:
|
|
"""启动 Playwright 与带 cookie 的浏览器 context
|
|
|
|
登录态文件不存在时同样启动(context 没 cookie),后续 add_to_cart 会
|
|
在 require_logged_in 里报错。这样保持启动路径一致。
|
|
"""
|
|
from playwright.async_api import async_playwright
|
|
|
|
state_path = self._settings.auth_state_path / auth_site.profile("rakuten").state_filename
|
|
storage_state = str(state_path) if state_path.exists() else None
|
|
if storage_state is None:
|
|
logger.warning(
|
|
"登录态文件不存在:site_interactor 以无 cookie 状态启动,"
|
|
"加购请求会被站点拒认"
|
|
)
|
|
else:
|
|
self._state_mtime = state_path.stat().st_mtime
|
|
|
|
self._playwright = await async_playwright().start()
|
|
self._browser = await self._playwright.chromium.launch(
|
|
headless=True,
|
|
channel=self._settings.browser_channel or None,
|
|
args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
|
|
)
|
|
self._context = await self._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,
|
|
)
|
|
logger.info("SiteInteractor 已就绪:storage_state=%s", storage_state or "(none)")
|
|
|
|
async def _refresh_context_if_stale(self) -> None:
|
|
"""检查 storage_state 文件 mtime,变化则重建 context
|
|
|
|
AuthSession.try_relogin 成功后会重写 storage_state 文件。本 context 启动时
|
|
用快照式 storage_state 创建,cookie 不会自动同步——必须关掉旧 context、
|
|
用新文件重建。在 add_to_cart / verify_cart 开头各调一次,开销可接受
|
|
(只在 mtime 变了才重建)。
|
|
"""
|
|
state_path = self._settings.auth_state_path / auth_site.profile("rakuten").state_filename
|
|
if not state_path.exists():
|
|
return
|
|
mtime = state_path.stat().st_mtime
|
|
if mtime == self._state_mtime:
|
|
return
|
|
|
|
logger.info(
|
|
"storage_state 文件变化(mtime %s → %s),重建 context",
|
|
self._state_mtime, mtime,
|
|
)
|
|
if self._context is not None:
|
|
try:
|
|
await self._context.close()
|
|
except Exception:
|
|
logger.debug("关闭旧 context 失败", exc_info=True)
|
|
self._context = await self._browser.new_context(
|
|
storage_state=str(state_path),
|
|
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,
|
|
)
|
|
self._state_mtime = mtime
|
|
|
|
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"),
|
|
(self._playwright, "playwright"),
|
|
):
|
|
if resource is None:
|
|
continue
|
|
try:
|
|
closer = resource.close() if name != "playwright" else resource.stop()
|
|
await closer
|
|
except Exception:
|
|
logger.debug("关闭 %s 失败", name, exc_info=True)
|
|
self._context = None
|
|
self._browser = None
|
|
self._playwright = None
|
|
|
|
# ---- 已实现:add_to_cart / verify_cart / cart_status ----
|
|
|
|
async def add_to_cart(self, task: LeaseTask) -> str | None:
|
|
"""加购(worker 入口):从 task.intent 取字段,调 _add_to_cart_with_fields
|
|
|
|
调用方需在 task.intent 提供:
|
|
- item_url: 商品详情页 URL(必填)
|
|
- quantity: 数量,默认 1
|
|
- variant_id: 多规格商品的 variant_id;不传则从 sku.variants[] 自动选第一个非售罄
|
|
- choice: 必填选项的取值列表;不传则每个必填选项用第一个候选值(站点不严格校验)
|
|
|
|
Returns:
|
|
加购成功后落地页(cart 页)的 HTML,供 runner 落证据;查不到时为 None。
|
|
|
|
Raises:
|
|
InvalidRequestError: intent.item_url 缺失
|
|
NotLoggedInError: 登录态失效
|
|
CartOperationError: 商品页打不开、state 解析失败、商品不可购买、加购返回错误页
|
|
"""
|
|
intent = task.intent or {}
|
|
item_url = intent.get("item_url")
|
|
if not item_url:
|
|
raise InvalidRequestError("intent.item_url 必填")
|
|
quantity = int(intent.get("quantity") or 1)
|
|
if quantity <= 0:
|
|
raise InvalidRequestError(f"intent.quantity 必须为正整数,收到 {quantity}")
|
|
|
|
async with self._lock:
|
|
result = await self._add_to_cart_with_fields(
|
|
item_url=item_url,
|
|
quantity=quantity,
|
|
variant_id=intent.get("variant_id"),
|
|
choice=intent.get("choice"),
|
|
)
|
|
self._per_task_state[task.task_id] = {
|
|
"item_id": result["item_id"],
|
|
"shop_bid": result["shop_bid"],
|
|
"basket_domain": result["basket_domain"],
|
|
}
|
|
return result.get("response_html")
|
|
|
|
async def add_to_cart_payload(
|
|
self,
|
|
*,
|
|
item_url: str,
|
|
quantity: int = 1,
|
|
variant_id: str | None = None,
|
|
choice: str | list[str] | None = None,
|
|
) -> dict:
|
|
"""加购(HTTP 入口):返回加购结果与最新 cart count,不写 _per_task_state
|
|
|
|
与 add_to_cart(task) 共享 _add_to_cart_with_fields,差异仅在:
|
|
- 入参形态(关键字 vs intent dict)
|
|
- 返回值(dict vs None,结果记在 _per_task_state)
|
|
- 不带 task_id(HTTP 调用方自己持有结果)
|
|
"""
|
|
if not item_url:
|
|
raise InvalidRequestError("item_url 必填")
|
|
if quantity <= 0:
|
|
raise InvalidRequestError(f"quantity 必须为正整数,收到 {quantity}")
|
|
async with self._lock:
|
|
return await self._add_to_cart_with_fields(
|
|
item_url=item_url,
|
|
quantity=quantity,
|
|
variant_id=variant_id,
|
|
choice=choice,
|
|
)
|
|
|
|
async def _add_to_cart_with_fields(
|
|
self,
|
|
*,
|
|
item_url: str,
|
|
quantity: int,
|
|
variant_id: str | None,
|
|
choice: str | list[str] | None,
|
|
) -> dict:
|
|
"""加购核心逻辑(不持锁,由调用方包裹 self._lock)
|
|
|
|
返回 dict:{item_id, shop_bid, basket_domain, cart_count}
|
|
cart_count 在加购成功后顺带查一次 cart count API,方便 HTTP 调用方一次性返回。
|
|
"""
|
|
await self._auth_session.require_logged_in("rakuten")
|
|
await self._refresh_context_if_stale()
|
|
|
|
intent_for_extract: dict = {}
|
|
if variant_id is not None:
|
|
intent_for_extract["variant_id"] = variant_id
|
|
if choice is not None:
|
|
intent_for_extract["choice"] = choice
|
|
|
|
page = await self._context.new_page()
|
|
try:
|
|
try:
|
|
await page.goto(item_url, wait_until="domcontentloaded", timeout=30_000)
|
|
await page.wait_for_function(
|
|
"() => window.__INITIAL_STATE__ && window.__INITIAL_STATE__.purchase",
|
|
timeout=10_000,
|
|
)
|
|
except Exception as exc:
|
|
raise CartOperationError(
|
|
f"打开商品页失败或反爬被触发:{type(exc).__name__}: {exc}"
|
|
) from exc
|
|
|
|
html = await page.content()
|
|
state = _parse_initial_state(html)
|
|
if not state:
|
|
raise CartOperationError("无法从商品页抽 __INITIAL_STATE__(可能 PC 模板或反爬)")
|
|
|
|
fields = _extract_purchase_fields(state, intent_override=intent_for_extract)
|
|
|
|
if fields["purchase_condition"] != "enabled":
|
|
raise CartOperationError(
|
|
f"商品不可购买:purchaseCondition={fields['purchase_condition']}"
|
|
)
|
|
if not fields["basket_domain"]:
|
|
raise CartOperationError("basketDomain 为空(商品可能下架)")
|
|
# 多规格商品要求选了 variant_id
|
|
if fields["inventory_flag"] == INVENTORY_FLAG_MULTIPLE and not fields["form_fields"].get("variant_id"):
|
|
raise CartOperationError(
|
|
"多规格商品未选 variant,且 sku.variants 全部售罄或为空"
|
|
)
|
|
# 必填选项要求填了 choice
|
|
if fields["has_required_options"] and not fields["form_fields"].get(fields["options_field"]):
|
|
raise CartOperationError(
|
|
"商品有必填选项但未提供 choice,且选项无候选值"
|
|
)
|
|
|
|
payload = dict(fields["form_fields"])
|
|
payload[fields["quantity_field"]] = str(quantity)
|
|
|
|
logger.info(
|
|
"加购请求:basket=%s payload=%s", fields["basket_domain"], payload,
|
|
)
|
|
|
|
resp = await self._context.request.post(
|
|
fields["basket_domain"],
|
|
form=payload,
|
|
max_redirects=5,
|
|
headers={
|
|
"Referer": item_url,
|
|
"Origin": "https://item.rakuten.co.jp",
|
|
},
|
|
)
|
|
final_url = str(resp.url)
|
|
if "/error" in final_url:
|
|
body = await resp.text()
|
|
msg = _extract_error_message(body) or f"错误页 {final_url}"
|
|
raise CartOperationError(f"加购失败:{msg}")
|
|
|
|
# 成功标志:URL 跳到 cart 且带 added_item 参数(探针实测的落地)
|
|
body = await resp.text()
|
|
if "cart" not in final_url:
|
|
raise CartOperationError(
|
|
f"加购响应异常:final_url={final_url} body_head={body[:200]!r}"
|
|
)
|
|
|
|
logger.info(
|
|
"加购成功:item_id=%s final=%s",
|
|
fields["form_fields"].get("item_id"), final_url,
|
|
)
|
|
finally:
|
|
await page.close()
|
|
|
|
# 加购成功后顺带查 cart count(best-effort:失败时返回 -1,不掩盖加购成功)
|
|
try:
|
|
_, cart_count = await self._query_cart_count()
|
|
except CartOperationError as exc:
|
|
logger.warning("加购后查 cart count 失败(不影响加购结果):%s", exc.message)
|
|
cart_count = -1
|
|
|
|
return {
|
|
"item_id": fields["form_fields"].get("item_id", ""),
|
|
"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) -> 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:
|
|
await self._auth_session.require_logged_in("rakuten")
|
|
await self._refresh_context_if_stale()
|
|
|
|
per_task = self._per_task_state.get(task.task_id, {})
|
|
item_id = (task.intent or {}).get("item_id") or per_task.get("item_id")
|
|
if not item_id:
|
|
raise CartOperationError(
|
|
"无法确定 item_id:intent 未提供且 add_to_cart 未记录"
|
|
)
|
|
|
|
# 1. cart count API
|
|
_, count = await self._query_cart_count()
|
|
if count == 0:
|
|
raise CartOperationError("购物车为空,加购可能未生效")
|
|
logger.info("cart count=%s task_id=%s", count, task.task_id)
|
|
|
|
# 2. 渲染 cart 页确认 item_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,不渲染整页
|
|
|
|
返回 {logged_in, count, raw_status}。
|
|
count 是站点返回的购物车里商品总件数(含数量,非 SKU 数)。
|
|
raw_status 是站点的状态码字符串,"100" 表示正常。
|
|
"""
|
|
async with self._lock:
|
|
await self._auth_session.require_logged_in("rakuten")
|
|
await self._refresh_context_if_stale()
|
|
raw_status, count = await self._query_cart_count()
|
|
return {
|
|
"logged_in": True,
|
|
"count": count,
|
|
"raw_status": raw_status,
|
|
}
|
|
|
|
# ---- 已实现:clear_cart / remove_item(Playwright UI 点击)----
|
|
|
|
async def clear_cart(self) -> dict:
|
|
"""清空购物车:渲染 cart SPA → 反复点第一个「削除」按钮 → count API 校验
|
|
|
|
策略:每次循环都重新查 `button[aria-label^="削除"]`,点完一个等 SPA 重渲染
|
|
再点下一个,避免索引漂移。最多 _CLEAR_CART_MAX_ITER 次防死循环。
|
|
|
|
未实测前已知边界:
|
|
- 确认 modal 不确定是否存在,按「先 try 找再 click,找不到就继续」处理
|
|
- 若 SPA 把按钮渲染在 iframe 里,selector 失败需实测后调整
|
|
- Rakuten cart item 卡片无 data-testid,本方法不依赖 DOM 结构定位
|
|
|
|
返回 {removed_count, cart_count};cart_count=-1 表示末尾 count API 调用失败。
|
|
"""
|
|
async with self._lock:
|
|
await self._auth_session.require_logged_in("rakuten")
|
|
await self._refresh_context_if_stale()
|
|
|
|
page = await self._context.new_page()
|
|
removed = 0
|
|
try:
|
|
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
|
|
await self._wait_cart_rendered(page, label="clear_cart")
|
|
|
|
for i in range(_CLEAR_CART_MAX_ITER):
|
|
btn = page.locator(_DELETE_BUTTON_SELECTOR).first
|
|
try:
|
|
await btn.wait_for(state="visible", timeout=2_000)
|
|
except Exception:
|
|
logger.info("clear_cart:第 %s 次循环未找到删除按钮,结束", i + 1)
|
|
break
|
|
try:
|
|
await btn.click()
|
|
except Exception as exc:
|
|
logger.warning("clear_cart:点击删除按钮失败:%s", exc)
|
|
break
|
|
removed += 1
|
|
await self._handle_confirm_modal(page)
|
|
# 等 SPA 重新渲染:domcontentloaded 或 1s 兜底
|
|
try:
|
|
await page.wait_for_load_state("domcontentloaded", timeout=5_000)
|
|
except Exception:
|
|
await page.wait_for_timeout(1_000)
|
|
else:
|
|
logger.warning(
|
|
"clear_cart 触发安全上限 %s,可能有删除失败或 SPA 异常",
|
|
_CLEAR_CART_MAX_ITER,
|
|
)
|
|
finally:
|
|
await page.close()
|
|
|
|
# 末尾用 count API 校验
|
|
try:
|
|
_, cart_count = await self._query_cart_count()
|
|
except CartOperationError as exc:
|
|
logger.warning("clear_cart 后查 cart count 失败:%s", exc.message)
|
|
cart_count = -1
|
|
|
|
logger.info("clear_cart 完成:removed=%s cart_count=%s", removed, cart_count)
|
|
return {"removed_count": removed, "cart_count": cart_count}
|
|
|
|
async def remove_item(self, item_id: str) -> dict:
|
|
"""删除购物车里指定 item_id 的商品
|
|
|
|
策略:渲染 cart SPA → 在 DOM 里找 button[aria-label^="削除"],向上 walk
|
|
parentElement 找 innerText 包含 item_id 的祖先 → click 那个按钮。
|
|
|
|
Rakuten cart item 卡片无稳定 data-* 属性,CSS modules hash class 易变,
|
|
只能靠「按钮祖先节点的 innerText 包含目标 item_id」做文本回溯定位。
|
|
item_id 在 Rakuten 是 8 位数字,正常页面其他位置误匹配概率低。
|
|
|
|
Raises:
|
|
InvalidRequestError: item_id 为空
|
|
CartOperationError: 购物车里找不到 item_id
|
|
NotLoggedInError: 登录态失效
|
|
|
|
返回 {removed, item_id}。removed=false 表示点击了但 SPA 没在末尾 HTML
|
|
里移除该 item_id(可能删除被站点静默拒绝)。
|
|
"""
|
|
if not item_id:
|
|
raise InvalidRequestError("item_id 必填")
|
|
async with self._lock:
|
|
await self._auth_session.require_logged_in("rakuten")
|
|
await self._refresh_context_if_stale()
|
|
|
|
page = await self._context.new_page()
|
|
try:
|
|
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
|
|
await self._wait_cart_rendered(page, label=f"remove_item {item_id}")
|
|
|
|
# JS 在 DOM 里定位包含 item_id 的祖先节点的删除按钮,click 它
|
|
clicked = await page.evaluate(
|
|
"""(itemId) => {
|
|
const buttons = document.querySelectorAll('button[aria-label^="削除"]');
|
|
for (const btn of buttons) {
|
|
let node = btn.parentElement;
|
|
for (let i = 0; i < 12 && node; i++) {
|
|
const text = node.innerText || "";
|
|
if (text.includes(itemId)) {
|
|
btn.click();
|
|
return true;
|
|
}
|
|
node = node.parentElement;
|
|
}
|
|
}
|
|
return false;
|
|
}""",
|
|
str(item_id),
|
|
)
|
|
if not clicked:
|
|
raise CartOperationError(
|
|
f"购物车里没有 item_id={item_id}(或 SPA 未渲染出来)"
|
|
)
|
|
|
|
await self._handle_confirm_modal(page)
|
|
try:
|
|
await page.wait_for_load_state("domcontentloaded", timeout=5_000)
|
|
except Exception:
|
|
await page.wait_for_timeout(1_000)
|
|
|
|
# 校验:item_id 不再出现在 cart HTML
|
|
html = await page.content()
|
|
if str(item_id) in html:
|
|
# SPA 可能还没刷新完,再等 2s 兜底
|
|
await page.wait_for_timeout(2_000)
|
|
html = await page.content()
|
|
removed = str(item_id) not in html
|
|
finally:
|
|
await page.close()
|
|
|
|
logger.info("remove_item 完成:item_id=%s removed=%s", item_id, removed)
|
|
return {"removed": removed, "item_id": str(item_id)}
|
|
|
|
# ---- 内部辅助:cart count API 与 cart 页渲染 ----
|
|
|
|
async def _query_cart_count(self) -> tuple[str, int]:
|
|
"""调 cart count JSONP API,返回 (raw_status, count)
|
|
|
|
解析失败、status 非 100 都抛 CartOperationError——这是站点在告诉我们
|
|
「请求被拒了」,常见原因是 Referer 错或 cookie 失效。
|
|
"""
|
|
resp = await self._context.request.get(
|
|
_CART_COUNT_API + "?sid=1010",
|
|
headers={"Referer": _CART_PAGE},
|
|
)
|
|
body = await resp.text()
|
|
status_match = re.search(r'"status"\s*:\s*"(\d+)"', body)
|
|
if not status_match:
|
|
raise CartOperationError(f"cart count 响应无法解析:{body[:200]!r}")
|
|
raw_status = status_match.group(1)
|
|
if raw_status != "100":
|
|
raise CartOperationError(
|
|
f"cart count API 异常:status={raw_status} body={body[:200]!r}"
|
|
)
|
|
count_match = re.search(r'"count"\s*:\s*"(\d+)"', body)
|
|
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) -> 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)
|
|
await self._wait_cart_rendered(page, label=label)
|
|
|
|
html = await page.content()
|
|
# 旧 marker 出现一定是登录失效;新 SPA 不渲染 marker,所以这判据是单边的
|
|
if _LEGACY_LOGGED_OUT_MARKER in html:
|
|
raise NotLoggedInError(
|
|
site="rakuten",
|
|
detail="购物车页出现旧版未登录 marker",
|
|
)
|
|
if str(item_id) not in html:
|
|
raise CartOperationError(
|
|
f"购物车页未找到 item_id={item_id}(加购可能被服务端静默丢弃)"
|
|
)
|
|
logger.info("cart 校验通过:%s item_id=%s in cart HTML", label, item_id)
|
|
return html
|
|
finally:
|
|
await page.close()
|
|
|
|
async def _wait_cart_rendered(self, page, *, label: str) -> None:
|
|
"""等 cart SPA 把商品列表渲染出来(shopUrlList 非空),最多 15s
|
|
|
|
失败时只记 warning 不抛——空购物车时 shopUrlList 本就为空,调用方根据
|
|
后续业务逻辑(看 HTML、看 count API)自行判断。
|
|
"""
|
|
try:
|
|
await page.wait_for_function(
|
|
"""() => {
|
|
const s = window.__INITIAL_STATE__;
|
|
return s && s.cart && Array.isArray(s.cart.shopUrlList) && s.cart.shopUrlList.length > 0;
|
|
}""",
|
|
timeout=15_000,
|
|
)
|
|
except Exception:
|
|
logger.warning(
|
|
"cart SPA 15s 内未渲染出 shopUrlList(可能购物车为空):%s", label,
|
|
)
|
|
|
|
async def _handle_confirm_modal(self, page) -> None:
|
|
"""点击「削除」后若弹出确认 modal,尝试找「はい」/「OK」按钮点击
|
|
|
|
站点是否弹 modal 未实测确认,按「先 try 找再 click,找不到就跳过」处理。
|
|
每个候选 selector 给 1.5s 等待,命中后立即返回。
|
|
"""
|
|
for sel in _CONFIRM_BUTTON_SELECTORS:
|
|
try:
|
|
btn = page.locator(sel).first
|
|
await btn.wait_for(state="visible", timeout=1_500)
|
|
await btn.click()
|
|
logger.info("点击确认 modal 按钮:%s", sel)
|
|
return
|
|
except Exception:
|
|
continue
|
|
|
|
# ---- 已实现:enter_checkout(到下单确认页,中间步骤未经真实 HTML 验证)----
|
|
|
|
async def enter_checkout(self, task: LeaseTask) -> str:
|
|
"""进入下单确认页:购物车 → 点「購入手続き」→ 依次处理中间步骤 → 落地确认页
|
|
|
|
流程:
|
|
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:
|
|
"""从下单确认页解析应付金额、订单号、付款期限
|
|
|
|
Raises:
|
|
OrderOperationError: 找不到「标签+金额」的明确匹配,或匹配出多个互相
|
|
矛盾的金额——两种情况都不落回猜测值,直接报错交人工核对页面
|
|
"""
|
|
return _parse_checkout_summary(html)
|
|
|
|
async def submit_order(self, task: LeaseTask) -> str:
|
|
"""点击下单确认页的最终确认按钮,提交订单
|
|
|
|
调用前必须已经过 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:
|
|
"""检查提交下单后是否已完成付款 / 是否触发了需要人工介入的验证环节
|
|
|
|
按 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)
|
|
|
|
|
|
# ---- 模块级辅助函数(纯函数,便于单测)----
|
|
|
|
|
|
def _parse_initial_state(html: str) -> dict | None:
|
|
"""从商品页 HTML 抽 window.__INITIAL_STATE__ 并解析为 dict"""
|
|
m = re.search(
|
|
r"window\.__INITIAL_STATE__\s*=\s*(.+?);\s*window\.",
|
|
html,
|
|
re.DOTALL,
|
|
)
|
|
if not m:
|
|
return None
|
|
try:
|
|
return json.loads(m.group(1))
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
|
|
def _extract_purchase_fields(state: dict, *, intent_override: dict | None) -> dict:
|
|
"""从 __INITIAL_STATE__ 抽加购所需字段
|
|
|
|
与 scraping/parsers/item.py::_purchase_info 共用基础字段构造
|
|
(app.shared.purchase_contract);本函数额外做:
|
|
- variant_id 自动选(多规格挑第一个非售罄;调用方覆盖优先)
|
|
- choice 自动填(必填选项拼「名:值」;调用方覆盖优先)
|
|
- 返回 basket_domain / min_price / purchase_condition / shop_name / item_name
|
|
等业务字段,便于日志与错误信息使用
|
|
"""
|
|
intent_override = intent_override or {}
|
|
purchase = state.get("purchase") or {}
|
|
sell_type = (purchase.get("sellType") or {}).get("normalPurchase") or {}
|
|
raw_sku = purchase.get("sku") or {}
|
|
item = state.get("item") or {}
|
|
shop = (state.get("shop") or {}).get("information") or {}
|
|
information = purchase.get("information") or {}
|
|
|
|
basket_domain = basket_domain_of(sell_type)
|
|
inventory_flag = inventory_flag_for(raw_sku.get("inventoryType"))
|
|
shop_id = shop.get("shopId")
|
|
item_id = item.get("itemId")
|
|
|
|
form_fields = base_form_fields(
|
|
shop_id=shop_id,
|
|
item_id=item_id,
|
|
inventory_flag=inventory_flag,
|
|
)
|
|
|
|
# variant_id 选择:调用方覆盖 > 多规格自动选第一个非售罄 > 单规格用 item.variantId
|
|
chosen_variant = None
|
|
if intent_override.get("variant_id"):
|
|
form_fields["variant_id"] = str(intent_override["variant_id"])
|
|
elif inventory_flag == INVENTORY_FLAG_MULTIPLE:
|
|
for v in raw_sku.get("variants") or []:
|
|
if not v.get("isSoldOut"):
|
|
chosen_variant = v
|
|
break
|
|
if chosen_variant is None and (raw_sku.get("variants") or []):
|
|
chosen_variant = raw_sku["variants"][0]
|
|
if chosen_variant:
|
|
form_fields["variant_id"] = str(
|
|
chosen_variant.get("variantId") or chosen_variant.get("id") or ""
|
|
)
|
|
elif inventory_flag == INVENTORY_FLAG_DEFAULT and item.get("variantId"):
|
|
form_fields["variant_id"] = str(item.get("variantId"))
|
|
|
|
# 必填选项:调用方覆盖 > 自动填第一个候选值
|
|
options = information.get("options") or []
|
|
required_options = [o for o in options if o.get("isRequired")]
|
|
has_required = bool(required_options)
|
|
if intent_override.get("choice"):
|
|
# 调用方给的可能是 list 或 str
|
|
c = intent_override["choice"]
|
|
form_fields["choice"] = ",".join(c) if isinstance(c, list) else str(c)
|
|
elif has_required:
|
|
pairs: list[str] = []
|
|
for opt in required_options:
|
|
values = opt.get("values") or []
|
|
if values:
|
|
pairs.append(f"{opt.get('name')}:{values[0].get('name')}")
|
|
if pairs:
|
|
form_fields["choice"] = ",".join(pairs)
|
|
|
|
return {
|
|
"basket_domain": basket_domain,
|
|
"form_fields": form_fields,
|
|
"quantity_field": "units",
|
|
"variant_field": "variant_id",
|
|
"options_field": "choice" if options else "",
|
|
"options": options,
|
|
"has_required_options": has_required,
|
|
"inventory_flag": inventory_flag,
|
|
"purchase_condition": sell_type.get("purchaseCondition"),
|
|
"min_price": sell_type.get("minPrice"),
|
|
"shop_name": shop.get("shopName"),
|
|
"item_name": item.get("itemName"),
|
|
}
|
|
|
|
|
|
def _extract_error_message(body: str) -> str:
|
|
"""从 Rakuten 错误页 HTML 抽可读的提示文案"""
|
|
msgs: list[str] = []
|
|
for m in re.findall(r">([^<>]{20,200})<", body):
|
|
s = m.strip()
|
|
# 过滤 CSS/JS 标识与版权之类
|
|
if not s or any(c in s for c in ["(", ")", "=", "{", "}", "."]):
|
|
continue
|
|
if "Rakuten Group" in s or "SSL" in s:
|
|
continue
|
|
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,
|
|
)
|