"""站点交互:加购 / 校验 / 清空 / 删除 / 进入下单确认页 / 金额守卫 / 提交 / 付款 / 监控 实测进度(详见 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 兜底,不做无限重试。 - check_order_status(付款后监控的单次探测,循环轮询在 runner.py)**2026-08-13 用真实订单号 306087-20260813-0863947697 实测过** order.my.rakuten.co.jp 的 订单列表页与详情页:两者共用同一套「配送阶段进度条」组件,固定 4 阶段 (ショップ→出荷→配達店→配達完了),当前阶段的 class 里带 `-active--` 中缀, 详见 _ORDER_STEPPER_ITEM_PATTERN 上方注释。这次真实订单当时还停在「ショップ」 (刚接单,未发货)这一阶段,「出荷」「配達完了」两个状态转换点没有被真实数据 验证过,只是按进度条文案直译映射(_ORDER_STAGE_TO_STATE);取消/退款没有 找到可靠信号,检测不到,遇到需要人工核对订单列表页。 - list_recent_orders(规格 §5 恢复核对用,verify.verify_on_site 调用)**2026-08-13 用真账号实测过** order.my.rakuten.co.jp/purchase-history/order-list:这是与 check_order_status 不同的另一个页面(真正的「我的订单列表」,不是 detail_page_view 那个 act 分支),pageType="ph-list" 时 __INITIAL_STATE__. orderListData 直接是结构化 JSON(ordersFound/orderList[].items[].itemUrl 等), 不需要正则抠 DOM。当时账号只有 1 笔订单、1 页,验证了单页解析与 `?page=2` 超出范围返回空列表;多页翻页时「新订单排在前面」的排序假设、以及 分页游标本身,都没有被真实多页数据验证过。 - fetch_order_detail / list_recent_orders 同时供「账号只读查询通道」使用 (docs/order-gateway.md §11,上游经网关问「账号里真实的订单长什么样」)。 查询返回的规范化字段全部来自上面这两条已实测路径;额外带出的站点原始 JSON 里, 订单列表页的 orderListData(order-list 页)与订单详情页的 __INITIAL_STATE__ (detail 页,2026-08-16 首次拿到真实样本,见 scripts/probe_order_detail.py 与 .probe/order_detail/)**两个都是实测过的结构**,其中详情页配送阶段走 orderData.shippingList[].deliveryInfo.deliveryStatus 结构化枚举(只实测过 CHECKING_ORDER,见 _parse_order_detail_status)。其余字段(金额/地址/支付方式等) 仍在 raw 里原样透传,本模块不重复造抽取与解释层。 **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, field from datetime import datetime from typing import TYPE_CHECKING, TypeVar 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.shared.proxy import playwright_launch_proxy from app.shared.task_state import OrderState from app.trading.core import auth_site from app.trading.worker.models import LeaseTask if TYPE_CHECKING: from collections.abc import Awaitable, Callable 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__) # _read_with_relogin_retry 的返回值类型:只读操作的结果原样透传 _T = TypeVar("_T") # 普通购买事件标识、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 密码页/电话补录页的「次へ」提交按钮:2026-08-14 非无头浏览器 # 实测抓到真实 HTML 才发现,此前的 button:has-text("次へ") 是猜的、根本不存在 # 匹配的 ,页面上出现两次,内容一致), # 在 _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"]', ) # ---- 付款后监控:订单列表/详情页轮询(2026-08-13 用真实订单号 # 306087-20260813-0863947697 实测过,见模块顶部说明)---- # 详情页 URL 可以直接从 site_order_id 构造,不需要额外查询:shop_id 就是订单号 # 第一段(本单验证 306087-20260813-... 对应 shop_id=306087)。 _ORDER_DETAIL_URL_TEMPLATE = ( "https://order.my.rakuten.co.jp/purchase-history/" "?order_number={order_number}&shop_id={shop_id}&act=detail_page_view" ) # 订单列表页与详情页共用同一套「配送阶段进度条」组件:4 个固定阶段的
  • , # 当前阶段比其余几个多一段形如 `item-shipping-active--{hash}` 的 class # (hash 是 CSS modules 编译产物,逐次构建会变;`-active--` 这个中缀是本模块 # 唯一依赖的稳定信号)。用 re.findall 顺序返回 (class, 阶段文案) 二元组。 # 2026-08-16 用真账号实测详情页(scripts/probe_order_detail.py,样本落盘 # .probe/order_detail/)发现:**li class 必须锚定进度条 item 前缀 `item--3gWCU`**, # 否则页面上还有一套面包屑
  • (class 也是 `...--hash` 且紧邻 `title--2uGVi`, # label 同样是「ショップ」)会先于进度条第 0 项被 `findall` 抓走,导致: # 1. 面包屑 li 被当成第 0 阶段(active 恒为 False); # 2. 真正带 `-active--` 的进度条第 0 项被错位跳过。 # 真实样本上不锚定会 4 项全判 active=False、永远拿不到当前配送阶段。锚定后 4 项 # 正确命中、active 落在第 0 项。两个 class 前缀(`item--3gWCU` / `title--2uGVi`) # 均为 CSS modules 产物,逐次构建可能变——本模块把 `
  • ` 视为唯一 # 依赖的稳定信号之一,结构化配送状态另见 _parse_order_detail_status。 _ORDER_STEPPER_ITEM_PATTERN = re.compile( r'
  • .*?
    ([^<]*)
  • ', re.DOTALL, ) _ORDER_STEPPER_ACTIVE_MARKER = "-active--" # 「ショップ」(店铺已接单,未发货)阶段没有对应的 OrderState 取值——报告过 # ORDERED/PAID 了,不需要 monitor 再报一次;「配達店」(配送中转)也没有单独 # 状态,归入 SHIPPED。只有「出荷」→SHIPPED、「配達完了」→DELIVERED 两个转换点 # 会触发 runner._monitor_order 的 report。真实订单当时仍停在「ショップ」阶段, # 这两个映射本身未经真实状态转换验证,只是按进度条文案直译。 _ORDER_STAGE_TO_STATE: dict[str, OrderState] = { "出荷": OrderState.SHIPPED, "配達店": OrderState.SHIPPED, "配達完了": OrderState.DELIVERED, } # ---- 订单详情页结构化配送状态(2026-08-16 首次拿到真实样本才补的路径)---- # 详情页 __INITIAL_STATE__(pageType="ph-detail")里的 orderData.shippingList[] # 每项的 deliveryInfo.deliveryStatus 是站点侧的结构化配送状态码,比解析进度条 # CSS class 稳得多。但本模块**只映射实测见过的值**,没见过的枚举码(出荷/配達完了 # 对应的 deliveryStatus 字符串具体长什么样,还没等到那一步的真实样本)不接管、 # 交回 stepper _ORDER_STAGE_TO_STATE 兜底(避免掐掉 monitor 的 SHIPPED/DELIVERED # 上报)——绝不瞎猜映射成 OrderState,测一单真实进入「出荷/配達完了」的订单就能补上。 # 2026-08-16 真实样本(订单仍停在「ご注文確認中」)只覆盖了 CHECKING_ORDER 这一个值。 _DELIVERY_STATUS_TO_STATE: dict[str, OrderState] = {} # 配送状态码 → 站点给的状态文案;这张表独立于 stepper 文案映射,键是稳定的 # deliveryStatus 枚举值。CHECKING_ORDER 是 2026-08-16 实测值(页面同时给出 # deliveryStatusTitle="ご注文確認中"),其余条目留待真实样本补充,不做猜测。 # stage_label 直接取 deliveryStatusTitle 的站点原文(比这张表更不易失真)。 _DELIVERY_STATUS_TITLE: dict[str, str] = { "CHECKING_ORDER": "ご注文確認中", } # ---- 订单列表反查:verify_on_site 恢复核对用(规格 §5,2026-08-13 实测确认)---- # order.my.rakuten.co.jp/purchase-history/order-list 是真实的「我的订单列表」页 # (不是 _ORDER_DETAIL_URL_TEMPLATE 那个 detail_page_view 分支,两者 act 不同)。 # 探测路径:my.rakuten.co.jp 首页有 scid=myr_popular_purchasehist 链接指向 # order.my.rakuten.co.jp/,服务端把它 302 到 /purchase-history/order-list;直接 # 访问该固定路径同样命中,不需要带 scid。pageType="ph-list" 时 __INITIAL_STATE__. # orderListData 是结构化 JSON(ordersFound/pageSize/orderList[],每条订单带 # items[].itemUrl),不需要正则抠 DOM——这是与订单详情页最大的不同。 # ?page=N 控制分页(2026-08-13 用 page=2 验证过「超出范围返回空 orderList」, # 但账号当时只有 1 笔订单、1 页,真实多页场景的排序/翻页边界未被验证过)。 _ORDER_LIST_URL = "https://order.my.rakuten.co.jp/purchase-history/order-list" # 安全上限:避免账号订单极多时无限翻页;命中上限仍未翻完窗口时 # window_fully_covered=False,调用方(verify.verify_on_site)转 unknown,不猜。 _ORDER_LIST_MAX_PAGES = 20 class _LoggedOutMidRead(Exception): """内部信号:只读操作执行到一半,发现页面像是因为掉登录才长这样 不对外暴露(`_read_with_relogin_retry` 一定会把它转成 `NotLoggedInError` 或吞掉后重试)。存在的理由是「掉登录」在页面解析层与业务失败长得完全不一样: 订单页被踢到 SSO 后既不会报错也不会有订单号,静默走进解析逻辑就会被当成 「订单还没出现」,日志里查不出真正原因。用一个专门的信号把这条路径截出来。 """ @dataclass(slots=True) class OrderListItem: """订单列表页单笔订单里的一个商品条目""" item_url: str | None item_name: str = "" item_id: int | str | None = None @dataclass(slots=True) class OrderListEntry: """订单列表页单笔订单""" order_number: str order_date: str # 站点原始 ISO8601 字符串,如 "2026-08-13T09:47:28.000Z" shop_id: int | str | None = None shop_name: str = "" items: list[OrderListItem] = field(default_factory=list) @dataclass(slots=True) class OrderListPage: """order-list 单页解析结果(_parse_order_list 的返回值) `raw` 是站点 `__INITIAL_STATE__.orderListData` 的原文,供只读查询接口原样 透传给上游(见 docs/order-gateway.md §11)——站点比我们的 dataclass 多给的 字段(金额、配送、状态文案等)不该在这一层被悄悄丢掉。解析不到时为 None。 """ entries: list[OrderListEntry] orders_found: int | None = None page_size: int | None = None raw: dict | None = None @dataclass(slots=True) class OrderListWindow: """list_recent_orders 的返回值:某个时间窗口内翻页汇总的结果 window_fully_covered=True 才代表「窗口内的订单已经看全」——可能是因为翻到了 比 since 更早的订单、可能是列表本身翻完了、也可能是 ordersFound 已经对上。 False 表示翻页在覆盖完窗口前就停了(命中 max_pages 上限,或页面结构 解析不出 ordersFound 之类的异常),此时 entries 里「没有匹配」不能当作 「确实没下单」——调用方必须转 unknown,不能默认 NOT_ORDERED。 raw_pages 按翻页顺序保存每页的站点原始 orderListData,只读查询接口用。 """ entries: list[OrderListEntry] window_fully_covered: bool raw_pages: list[dict] = field(default_factory=list) def parse_order_datetime(raw: str | None) -> datetime | None: """把站点返回的 ISO8601 字符串解析成 datetime,解析不出就返回 None(不猜)""" if not raw: return None try: return datetime.fromisoformat(raw.replace("Z", "+00:00")) except ValueError: return None @dataclass(slots=True) class _OrderListAccumulator: """list_recent_orders 翻页时的累积状态(内部用,不对外暴露)""" entries: list[OrderListEntry] = field(default_factory=list) total_found: int | None = None is_first_page: bool = True gave_up: bool = False # True=第一页就拿不到结构化数据,翻页无意义,直接放弃 raw_pages: list[dict] = field(default_factory=list) def _accumulate_order_list_page( acc: _OrderListAccumulator, page: OrderListPage, *, since: datetime ) -> tuple[_OrderListAccumulator, bool]: """把一页解析结果并入累积状态,返回 (新累积状态, 是否应该停止翻页) 纯函数,不碰 Playwright——list_recent_orders 每翻一页调一次,离线单测可以 直接喂一串 OrderListPage 模拟多页翻页,不需要真实浏览器(与本文件其余 _parse_* 纯函数同一套测试思路)。 「停止翻页」不等于「窗口已覆盖完」:acc.gave_up=True 表示第一页就拿不到 结构化列表数据(页面结构变了/act 分支不对/未登录跳转),此时也会停止翻页, 但调用方必须按「没覆盖」处理,不能当成真的翻完了。 """ raw_pages = acc.raw_pages + [page.raw] if page.raw is not None else acc.raw_pages if acc.is_first_page and page.orders_found is None: return ( _OrderListAccumulator( entries=acc.entries, total_found=acc.total_found, is_first_page=False, gave_up=True, raw_pages=raw_pages, ), True, ) total_found = acc.total_found if acc.total_found is not None else page.orders_found if not page.entries: return ( _OrderListAccumulator( entries=acc.entries, total_found=total_found, is_first_page=False, raw_pages=raw_pages, ), True, ) new_entries = acc.entries + page.entries new_acc = _OrderListAccumulator( entries=new_entries, total_found=total_found, is_first_page=False, raw_pages=raw_pages, ) oldest_dt = parse_order_datetime(page.entries[-1].order_date) if oldest_dt is not None and oldest_dt < since: return new_acc, True if total_found is not None and len(new_entries) >= total_found: return new_acc, True return new_acc, False @dataclass(slots=True) class CheckoutSummary: """下单确认页解析结果(待实测确认字段位置) payable_yen 用于金额守卫;其他字段在实测确认后补全。 """ payable_yen: int site_order_id: str | None = None pay_deadline: str | None = None @dataclass(slots=True) class OrderStatusSnapshot: """订单列表/详情页单次探测结果(check_order_status 的返回值) found=False 表示页面上没找到这个订单号——**不当错误处理**:站点自己说 「ご注文の反映に10分ほどかかります」,下单后短时间内查不到是正常的,调用方 (runner._monitor_order)应该继续下一轮轮询,不是放弃。 stage_label 是真实站点用的阶段原文(如「出荷」或「ご注文確認中」),供日志/ 证据留原始信号;order_state 是映射到 OrderState 的结果,映射不到(比如仍在 「ショップ」这个起始阶段、或 deliveryStatus 枚举还没实测到的值)时为 None—— 同样不当错误,只是「这次没有新状态可报」。html 是抓取到的整页内容,供调用方 按需落证据。 delivery_status 是详情页 orderData 侧的结构化配送状态码(如 "CHECKING_ORDER"),列表页(check_order_status / 进度条路径)拿不到时为 None。 它不是 OrderState 的中间表示,只是把站点原始码原样带出来给调用方/上游用—— 规范化映射见 _parse_order_detail_status 的 deliveryStatus 分支。 """ found: bool stage_label: str | None = None order_state: OrderState | None = None delivery_status: str | None = None html: str = "" @dataclass(slots=True) class OrderDetailSnapshot: """订单详情页的一次完整读取结果(fetch_order_detail 的返回值) `status` 是已实测的那部分:配送阶段(`_parse_order_status` / stepper,2026-08-13 验证)、以及 2026-08-16 首次从真实详情页样本补上的结构化配送状态 (`orderData.shippingList[].deliveryInfo.deliveryStatus`,见 `_parse_order_detail_status`)。`raw` 是整页 `window.__INITIAL_STATE__` 的 原文,原样带出去给需要金额/地址/支付方式等更全字段的上游自取——本模块只做 「解析得动就带走」并补了配送阶段这一层的规范化,其余字段不猜。 """ status: OrderStatusSnapshot raw: dict | 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( # 2026-08-14 实测确认:headless=True 会让站点的购物车/结算 SPA 表现 # 异常(购入手続き点了不跳转、shopUrlList 渲染不出来),换 headless=False # 后行为与真实下单一致(能正常触发 session upgrade)——所有会改动站点 # 状态的操作(加购/结算/支付)都必须走非无头浏览器 headless=False, channel=self._settings.browser_channel or None, proxy=playwright_launch_proxy(self._settings), 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 _read_with_relogin_retry( self, label: str, read: "Callable[[], Awaitable[_T]]" ) -> "_T": """**只读**站点操作的统一外壳:前置登录检查 → 执行 → 中途掉登录则重登重跑 `read` 在 `self._lock` 内、`require_logged_in` 与 context 刷新之后执行; 它通过抛 `_LoggedOutMidRead` 表达「这页看着是掉登录了」,本方法负责重登 一次并把 `read` 整个重跑一遍。 为什么只给只读操作用:写操作(加购 / 提交订单 / 付款)中途掉登录**不能** 重跑——上一次动作可能已经在站点侧生效,只是响应没拿到,重跑等于重复提交。 这条边界在 `AuthSession.require_logged_in` 的文档里已经写明,本外壳不动它, 只把「读」这一侧本来就安全的可重试性补上。 只重试一次:重登一次还是没登上,就是需要人工(验证码 / 密码错 / 风控), 再试只是把调用方多卡一个 relogin_timeout,结果不会变。 """ async with self._lock: for attempt in (1, 2): await self._auth_session.require_logged_in("rakuten") # 重登会重写 storage_state,本 context 的 cookie 是启动时的快照, # 必须在每次 attempt 前都刷一遍,否则重登后仍拿旧 cookie 去读。 await self._refresh_context_if_stale() try: return await read() except _LoggedOutMidRead as exc: if attempt == 2: raise NotLoggedInError( site="rakuten", detail=f"{label}:重登后仍被判定未登录({exc})", ) from exc logger.warning( "%s:执行中检测到登录态失效(%s),尝试重登后重跑一次", label, exc, ) # require_logged_in 只在「前置 check 判未登录」时才会重登,而这里 # 是它放行之后才掉的登录——缓存里还是 logged_in=True,下一轮 # require_logged_in 的 check 未必立刻翻转。所以主动触发一次重登, # 让下一轮 attempt 有新 cookie 可用。 if not await self._auth_session.try_relogin("rakuten"): raise NotLoggedInError( site="rakuten", detail=f"{label}:执行中登录态失效且自动重登未成功({exc})", ) from exc raise AssertionError("unreachable") # pragma: no cover 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 async def _dump_debug_snapshot(self, page, *, task_id: str, label: str) -> None: """结算流程失败时尽力落一份页面快照(HTML + 截图),排查用 写到 `{evidence_dir}/{task_id}/` 下(跟 EvidenceStore 落地的编号步骤文件 同目录,文件名前缀 `debug-` 区分),不经过 EvidenceStore/本地 DB 索引—— 这条路径在 SiteInteractor 失败抛错、page 即将被关闭之前调用,此时 WorkerRunner 那边还没收到异常,没法替我们落证据;本方法自己出错(页面已 关闭、content()/screenshot() 失败等)只记日志,绝不能把排查用的副作用 变成掩盖原始异常的新异常。 """ try: debug_dir = self._settings.evidence_path / task_id debug_dir.mkdir(parents=True, exist_ok=True) stem = f"debug-{datetime.now():%Y%m%d-%H%M%S}-{label}" html = await page.content() (debug_dir / f"{stem}.html").write_text(html, encoding="utf-8") await page.screenshot(path=str(debug_dir / f"{stem}.png"), full_page=True) logger.info( "失败快照已落盘:task_id=%s label=%s url=%s file=%s", task_id, label, page.url, debug_dir / stem, ) except Exception: logger.warning( "失败快照落盘失败(忽略,不影响原始异常):task_id=%s label=%s", task_id, label, exc_info=True, ) # ---- 已实现: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}(可能出现了未见过的新步骤)" ) if page.url.startswith(_CART_PAGE): raise OrderOperationError( "点「購入手続き」后没有跳转出购物车页(未触发 session upgrade/" "电话补录/地址确认/支付方式任一已知中间步骤),可能是点击被站点" f"忽略或按钮短暂失效弹回;当前仍在 url={page.url},不能当成已落地" "确认页处理" ) logger.info( "enter_checkout 落地:task_id=%s url=%s", task.task_id, page.url ) html = await page.content() self._checkout_pages[task.task_id] = page success = True return html except Exception: await self._dump_debug_snapshot( page, task_id=task.task_id, label="enter_checkout-error" ) raise 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 是根据可见文案 「デフォルトお届け先」反推的推测。2026-08-13 真实下单成功一次、2026-08-14 非无头浏览器真实探测三次(均已修复 headless 导致 SPA 异常的问题、修好了 session upgrade 提交按钮选择器),URL 里都没出现过 `/ship`——当前这个账号 (只有一个已保存地址)会直接从 session upgrade 跳到 order-confirmation, 跳过地址确认这一步。也就是说这个方法目前是真实死代码:只有账号出现多个 地址或没有默认地址时才可能触发,selector 依旧是未经验证的推测,遇到时会 直接抛错转人工,不会静默用错误的 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-14 本方法自身被真实调用验证过两条分支**: - 「直接点次へ」分支(scripts/probe_payment_method.py,从 order-confirmation 页的「変更」按钮手动跳回 /payment,因为该账号默认 流程会跳过这一页直落确认页):账号已保存卡尾号(8476)与 account.yaml 配置一致,成功把页面带回 order-confirmation——这条分支和 _PAYMENT_NEXT_BUTTON_SELECTOR 不再是纯猜测。 - 「新卡代填/提交」分支(scripts/probe_new_card.py,绕开尾号匹配判断, 强制走 _fill_new_card_form + _submit_new_card_form):真实注册了一张 新卡(account.yaml 里配置的同一张卡号),发现并修了两个真实站点问题—— 见 _fill_new_card_form/_submit_new_card_form 各自文档。 **2026-08-11 人工在真实页面上核对过截图**:选中「クレジットカード」后账号 已有一张已保存卡时会展开显示掩码卡号(VISA ●●●● 8476)+「有効期限:12/2029」, 该卡的 radio 默认就是选中态,不需要额外点选。 判「有没有已保存卡」**不能**用「➕ 新しく追加したカードの情報を変更する」这条 链接是否出现——它不管有没有已保存卡都会显示,之前一版按这个判断是错的。 真正的信号是掩码卡号 + 有効期限(_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,无法代填新卡,需要人工处理" ) # 2026-08-14 真实站点验证发现:选中「クレジットカード」radio 后其 # 详情面板(含「新しいカードを追加する」链接)默认是折叠的 # (visibility:hidden; height:0),即使 radio 本身已是选中态; # 对同一个 label 再点一次才会展开,之后该链接才 is_visible()。 try: await option.click(timeout=10_000) await page.wait_for_timeout(1_000) except Exception as exc: raise CheckoutBlockedError( f"展开「{label}」详情面板失败:{type(exc).__name__}: {exc}" ) from exc await self._fill_new_card_form(page, card=configured_card, task_id=task_id) await self._submit_new_card_form(page, task_id=task_id) 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: """代填新卡表单(卡号/有效期/持卡人姓名);提交交给 _submit_new_card_form **2026-08-14 用 scripts/probe_new_card.py 真实调用这段 Playwright 代码本身 验证成功**(此前 2026-08-13 那次是人工用 OS 级 SendKeys 代填,不是这段代码): 卡号/有效期(月)/有效期(年)分别托管在三个独立跨域 iframe 里(Rakuten PCI 代付 vault),只有持卡人姓名是主文档里的普通 input。iframe 挂载点 selector 与 iframe 内部字段 selector("input, select" 兜底)在真实站点上都命中成功, 不再是纯猜测。找不到就直接抛 CheckoutBlockedError 转人工,不做「找不到 就跳过」的静默降级,代填不完整的卡信息比不填更危险。 进入本方法前调用方(_select_payment_method)已经对支付方式 label 点了 第二次以展开折叠的详情面板,所以这里能找到「新しいカードを追加する」链接。 """ try: link = page.locator(f'text="{_ADD_CARD_LINK_TEXT}"').first 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 _submit_new_card_form(self, page, *, task_id: str) -> None: """点「追加する」提交新卡表单,确认站点回显该卡已被选中后才放行 2026-08-14 用 scripts/probe_new_card.py 真实调用这段代码验证成功。真实 页面上 `button:has-text("追加する")` 会命中 2 个匹配,且不能默认 `.first` 就是能点的那个——`.first` 那个虽然 is_visible()=True,但点击会被同页面 「名義人」input 所在的 aria-modal="true" 弹层拦截("element intercepts pointer events" 超时),只有第二个候选真正可点。因此这里遍历所有匹配, 对 is_visible() 为真的逐个尝试点击,命中第一个真正点得中的就停止;全部 失败才转人工。 """ candidates = page.locator(f'button:has-text("{_ADD_CARD_SUBMIT_TEXT}")') count = await candidates.count() clicked = False last_exc: Exception | None = None for i in range(count): candidate = candidates.nth(i) if not await candidate.is_visible(): continue try: await candidate.click(timeout=5_000) clicked = True break except Exception as exc: # noqa: BLE001 last_exc = exc if not clicked: if last_exc is not None: raise CheckoutBlockedError( f"新卡表单未找到或点击「{_ADD_CARD_SUBMIT_TEXT}」提交按钮失败" f"(匹配数量={count}):{type(last_exc).__name__}: {last_exc}" ) from last_exc raise CheckoutBlockedError( f"新卡表单未找到「{_ADD_CARD_SUBMIT_TEXT}」提交按钮(匹配数量={count})" ) for _ in range(_SESSION_UPGRADE_POLL_ROUNDS): await page.wait_for_timeout(_SESSION_UPGRADE_POLL_INTERVAL_MS) html = await page.content() if _SAVED_CARD_SIGNAL_PATTERN.search(html): logger.info("新卡注册并选中成功:task_id=%s url=%s", task_id, page.url) return raise CheckoutBlockedError( "新卡表单提交后未看到该卡被选中的回显(掩码卡号+有効期限)," "无法确认是否注册成功,需要人工核对" ) async def _discard_checkout_page(self, task_id: str) -> None: """关闭并丢弃 task_id 对应的已留存确认页 Page 只在确认「这次调用没有产生下单提交动作」时调用(比如确认按钮没找到、 点击本身失败)。提交后无法确认成败的情况**不**调用它——保留 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._dump_debug_snapshot( page, task_id=task.task_id, label="submit_order-no-confirm-button" ) 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._dump_debug_snapshot( page, task_id=task.task_id, label="submit_order-click-failed" ) 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: await self._dump_debug_snapshot( page, task_id=task.task_id, label="submit_order-no-response" ) raise OrderOperationError( f"点击确认下单按钮后 url={url_before} 长时间无响应," "无法确认是否提交成功,需要人工核对(不要按失败重试)" ) html = await page.content() match = _ORDER_ID_PATTERN.search(html) if not match: await self._dump_debug_snapshot( page, task_id=task.task_id, label="submit_order-no-order-id" ) 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: await self._dump_debug_snapshot( page, task_id=task.task_id, label="pay-blocked" ) 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 check_order_status(self, site_order_id: str) -> OrderStatusSnapshot: """付款后监控的单次探测:查一次订单详情页的配送阶段,不循环 `fetch_order_detail` 的薄封装——监控只关心配送阶段,不需要页面原始状态。 循环轮询(间隔、次数上限、状态变化时 report)由 `runner.WorkerRunner._monitor_order` 负责。 Returns: OrderStatusSnapshot;订单号暂时查不到、或进度条解析不出新阶段都 **不算错误**(详见该 dataclass 文档),由调用方决定是否继续轮询。 Raises: NotLoggedInError: 登录态失效且自动重登没能恢复 OrderOperationError: 订单详情页打开/渲染失败 """ return (await self.fetch_order_detail(site_order_id)).status async def fetch_order_detail(self, site_order_id: str) -> OrderDetailSnapshot: """读一次订单详情页:配送阶段 + 页面原始 __INITIAL_STATE__ 两个调用方:付款后监控(`check_order_status`,只要配送阶段)与账号只读 查询通道的 order_detail(规格 §11,还要 raw 原文)。站点交互与轮询节奏 解耦,也方便离线单测轮询逻辑(用桩替换本方法)与解析逻辑 (`_parse_order_status` / `_parse_order_detail_status`,纯函数)。 配送阶段解析优先走详情页结构化 `orderData`(`_parse_order_detail_status`, 2026-08-16 拿到真实样本后新增),抽样不到或枚举未识别时降级回进度条 (`_parse_order_status`)——两条路径都拿不到才返回 found=False。 与 enter_checkout 不同,本方法开自己的临时 Page 并在返回前关闭—— submit_order/pay 已经处理完并关闭了 `_checkout_pages` 里留存的会话, 监控阶段没有需要跨调用复用的页面状态。 执行中掉登录会自动重登一次并重跑(详见 `_read_with_relogin_retry`): 本方法是纯读,重跑没有副作用;不加这层的话订单页被踢到 SSO 会静默走进 解析逻辑、被当成「订单号还没出现」,轮询白转几个小时也看不出原因。 Raises: NotLoggedInError: 登录态失效且自动重登没能恢复 OrderOperationError: 订单详情页打开/渲染失败 """ shop_id = site_order_id.split("-", 1)[0] url = _ORDER_DETAIL_URL_TEMPLATE.format(order_number=site_order_id, shop_id=shop_id) async def read() -> OrderDetailSnapshot: page = await self._context.new_page() try: try: await page.goto(url, wait_until="domcontentloaded", timeout=30_000) await page.wait_for_timeout(2_000) except Exception as exc: raise OrderOperationError( f"订单详情页打开失败:site_order_id={site_order_id} " f"{type(exc).__name__}: {exc}" ) from exc html = await page.content() final_url = page.url finally: await page.close() # 只在「没解析到订单」时才查掉登录:解析到了就说明页面是真的订单页, # 没必要再判一次;反过来,found=False 有两种可能(订单还没反映出来 / # 被踢去登录),这里把后者摘出来,不让它伪装成前者。 # # 2026-08-16 拿到真实详情页样本后,解析优先走结构化 orderData # (_parse_order_detail_status),解析不到时降级回 stepper # (_parse_order_status)。两者都拿到 state/order_id 才算 found。 state = _parse_initial_state(html) snapshot = _parse_order_detail_status(state, site_order_id) or _parse_order_status( html, site_order_id ) if not snapshot.found and auth_site.looks_logged_out( "rakuten", final_url=final_url, body=html ): raise _LoggedOutMidRead(f"订单详情页落地 {final_url}") return OrderDetailSnapshot(status=snapshot, raw=state) return await self._read_with_relogin_retry( f"check_order_status site_order_id={site_order_id}", read ) async def list_recent_orders( self, *, since: datetime, max_pages: int | None = None ) -> OrderListWindow: """拉取「since 之后」的订单列表 两个调用方: - `verify.verify_on_site` 恢复核对(规格 §5 依赖它) - 账号只读查询通道的 order_list(规格 §11),此时 `max_pages` 由上游给, `raw_pages` 会被原样透传出去 翻页直到看到 order_date 早于 since 的订单(说明窗口内的都已经看过一遍)、或 ordersFound 已经全部翻完、或到达 max_pages 上限(缺省 `_ORDER_LIST_MAX_PAGES`)。命中上限仍没能确认覆盖完整窗口时, `OrderListWindow.window_fully_covered=False`——调用方据此转 unknown,绝不能 把「没翻完」当成「翻完了但没有」。 2026-08-13 只用「账号只有 1 笔订单、1 页」的真实数据验证过单页解析与 page=2 返回空列表这两点;多页翻页的排序假设(新订单在前)未经真实数据 验证,多页场景上线前应重新探测确认。 执行中掉登录会自动重登一次并从第一页重跑(详见 `_read_with_relogin_retry`)。 这条路径对恢复核对尤其要紧:掉登录时 `_parse_order_list` 拿不到 `pageType="ph-list"`,会退化成「空列表 + 没覆盖完窗口」,调用方 (verify_on_site)只能转 unknown 卡住等人工——本来自动重登一次就能查下去。 Raises: NotLoggedInError: 登录态失效且自动重登没能恢复 OrderOperationError: 订单列表页打开/渲染失败 """ page_limit = max(1, min(max_pages or _ORDER_LIST_MAX_PAGES, _ORDER_LIST_MAX_PAGES)) async def read() -> OrderListWindow: acc = _OrderListAccumulator() stop = False page = await self._context.new_page() try: for page_num in range(1, page_limit + 1): url = _ORDER_LIST_URL if page_num == 1 else f"{_ORDER_LIST_URL}?page={page_num}" try: await page.goto(url, wait_until="domcontentloaded", timeout=30_000) await page.wait_for_timeout(2_000) except Exception as exc: raise OrderOperationError( f"订单列表页打开失败:page={page_num} {type(exc).__name__}: {exc}" ) from exc html = await page.content() final_url = page.url page_result = _parse_order_list(html) acc, stop = _accumulate_order_list_page(acc, page_result, since=since) # gave_up=第一页就拿不到结构化列表数据。原因可能是页面改版,也 # 可能是掉登录被踢走;后者可以自愈,摘出来重登重试,改版则继续 # 按原路径返回「没覆盖完」交人工。 if acc.gave_up and auth_site.looks_logged_out( "rakuten", final_url=final_url, body=html ): raise _LoggedOutMidRead(f"订单列表页落地 {final_url}") if stop: break finally: await page.close() return OrderListWindow( entries=acc.entries, window_fully_covered=stop and not acc.gave_up, raw_pages=acc.raw_pages, ) return await self._read_with_relogin_retry("list_recent_orders", read) # ---- 模块级辅助函数(纯函数,便于单测)---- 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, ) def _parse_order_status(html: str, site_order_id: str) -> OrderStatusSnapshot: """订单列表/详情页解析核心逻辑(纯函数,供 check_order_status 调用,便于离线单测) 2026-08-13 用真实订单号 306087-20260813-0863947697 的订单详情页 HTML 验证过: 见 _ORDER_STEPPER_ITEM_PATTERN 上方注释。找不到订单号、或进度条没解析出 「当前阶段」都返回 found/order_state 相应地为 False/None,不抛错——这是 「暂时没有新信息」而不是「站点交互失败」,抛错的语义留给导航/渲染失败。 """ if site_order_id not in html: return OrderStatusSnapshot(found=False, html=html) for cls, label in _ORDER_STEPPER_ITEM_PATTERN.findall(html): if _ORDER_STEPPER_ACTIVE_MARKER in cls: return OrderStatusSnapshot( found=True, stage_label=label, order_state=_ORDER_STAGE_TO_STATE.get(label), html=html, ) return OrderStatusSnapshot(found=True, html=html) def _parse_order_detail_status(state: dict | None, site_order_id: str) -> OrderStatusSnapshot | None: """订单详情页结构化配送状态解析(纯函数,供 fetch_order_detail 调用) 只负责「从详情页 __INITIAL_STATE__.orderData 抽配送阶段」这一条路径。 抽不到(state 为 None / 缺 orderData / 缺 deliveryStatus)返回 None,告诉调用方 该降级回 stepper 路径(_parse_order_status)——因为那是从 html 解析的,而本函数 只拿到 state,不持有 html,降级必须由拿到 html 的调用方来做。 这样两条解析路径各自保持纯函数、可独立单测,互不污染。 2026-08-16 首次拿到真实详情页样本(scripts/probe_order_detail.py 与 .probe/order_detail/01-detail-initial-state.json,订单仍停在「ご注文確認中」) 后新增本路径。deliveryStatus 的规范化映射遵循「只映射实测值」原则 (_DELIVERY_STATUS_TO_STATE / _DELIVERY_STATUS_TITLE 目前都只有 CHECKING_ORDER): **未被识别的枚举码直接返回 None、交回 stepper 兜底**,绝不瞎猜映射成 OrderState——避免真实订单走到「出荷/配達完了」时把 monitor 的 SHIPPED/DELIVERED 上报掐掉,测一单真实进入那一步的订单后再补映射。 Args: state: `_parse_initial_state(html)` 的结果。 site_order_id: 站点注文番号,用于 found 判定。 Returns: 识别出的 deliveryStatus 时返回 OrderStatusSnapshot;否则返回 None(调用方降级)。 """ if state is None: return None # 先确认这份 state 确实是所查的这笔订单,再信任它的配送状态——否则要是 SPA # 复用了别的订单草稿/导航到别的页,光看到 deliveryStatus 就当 found 会误报。 # 用站点自己给的 orderNumber 对账(requestParams 与 orderData.orderSummary 都带, # 任一命中即可;两处都拿不到就按「不是这笔订单」降级回 stepper 的 html 判定)。 order_number = ( (state.get("requestParams") or {}).get("orderNumber") or (state.get("orderData") or {}).get("orderSummary", {}).get("orderNumber") ) if order_number != site_order_id: return None order_data = state.get("orderData") or {} shipping = order_data.get("shippingList") or [] delivery_info = (shipping[0].get("deliveryInfo") or {}) if shipping else {} delivery_status = delivery_info.get("deliveryStatus") if not delivery_status: return None # 只在该枚举值「已被实测认识」时才接管这份状态:多了就返回 None 交回 stepper # 路径兜底——否则这张新表还没见过「出荷/配達完了」对应的 deliveryStatus 字符串, # 一旦真实订单走到那一步,结构化路径会给 order_state=None 把 monitor 的 # SHIPPED/DELIVERED 上报掐掉,而 stepper 的 _ORDER_STAGE_TO_STATE 早就能映射。 if delivery_status not in _DELIVERY_STATUS_TO_STATE and delivery_status not in _DELIVERY_STATUS_TITLE: return None # deliveryStatus 是结构化枚举,用它作为权威阶段信号 stage_label = delivery_info.get("deliveryStatusTitle") or _DELIVERY_STATUS_TITLE.get( delivery_status ) order_state = _DELIVERY_STATUS_TO_STATE.get(delivery_status) return OrderStatusSnapshot( found=True, stage_label=stage_label, order_state=order_state, delivery_status=delivery_status, html="", ) def _parse_order_list(html: str) -> OrderListPage: """订单列表页解析核心逻辑(纯函数,供 list_recent_orders 调用,便于离线单测) 2026-08-13 用真账号真实探测过 order.my.rakuten.co.jp/purchase-history/order-list: pageType="ph-list" 时 __INITIAL_STATE__.orderListData 是结构化 JSON,不需要 正则抠 DOM——直接复用 _parse_initial_state。pageType 不是 "ph-list"(比如 命中了旧的 detail_page_view 错误分支、或未登录被跳转)时 orderListData 拿 不到,返回空列表 + orders_found=None,调用方据此判断「没拿到列表数据」而不 是「列表是空的」。 """ state = _parse_initial_state(html) if not state or state.get("pageType") != "ph-list": return OrderListPage(entries=[]) data = state.get("orderListData") or {} entries: list[OrderListEntry] = [] for order in data.get("orderList") or []: items = [ OrderListItem( item_url=it.get("itemUrl"), item_name=it.get("itemName", ""), item_id=it.get("itemId"), ) for it in order.get("items") or [] ] entries.append( OrderListEntry( order_number=order.get("orderNumber", ""), order_date=order.get("orderDate", ""), shop_id=order.get("shopId"), shop_name=order.get("shopName", ""), items=items, ) ) return OrderListPage( entries=entries, orders_found=data.get("ordersFound"), page_size=data.get("pageSize"), raw=data or None, )