feat(trading): 详情页派送到真实样本,补结构化配送状态并修 stepper 回归

用真账号实测爬取订单详情页(scripts/probe_order_detail.py,样本落盘
.probe/order_detail/),首次拿到 pageType="ph-detail" 的真实 __INITIAL_STATE__,
此前「详情页结构从未有样本、只原样透传」的缺口由此闭合:

- _parse_order_detail_status:新增,优先从 orderData.shippingList[].deliveryInfo
  .deliveryStatus 结构化枚举判配送阶段;映射遵循「只映射实测值」,目前仅
  CHECKING_ORDER,未识别枚举交回 stepper 兜底(防掐掉 SHIPPED/DELIVERED 上报)。
- _ORDER_STEPPER_ITEM_PATTERN:修回归——不锚定 <li class="item--3gWCU"> 时面包屑
  li 会抢走进度条第0项、导致 -active-- 永远判不出当前阶段(真实样本复现)。
- OrderStatusSnapshot 增 delivery_status 字段;query_runner order_detail 透传。
- docs/order-gateway.md §11 更新实测边界;新增详情页样本回溯测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
2026-08-16 23:04:59 +08:00
parent c03158488b
commit 3b53b8f28b
5 changed files with 479 additions and 26 deletions
+5 -2
View File
@@ -240,8 +240,11 @@ class QueryRunner:
"found": status.found,
"stage_label": status.stage_label,
"order_state": status.order_state.value if status.order_state else None,
# 详情页的 __INITIAL_STATE__ 结构从未拿到真实样本,本服务不做字段抽取,
# 原样透传由上游自担结构变动风险(见 site_interact.OrderDetailSnapshot)
# 站点侧结构化配送状态码(如 "CHECKING_ORDER");列表页/stepper 路径
# 拿不到时为 None。这是 raw 里 orderData 的规范化摘要,供上游快速对账
"delivery_status": status.delivery_status,
# 详情页额外带出的站点原始 JSON 原样透传,由上游自担结构变动风险
# (见 site_interact.OrderDetailSnapshot)。
"raw": detail.raw,
"raw_available": detail.raw is not None,
}
+130 -18
View File
@@ -67,8 +67,12 @@ data/evidence/checkout-research-20260811/NOTES.md):
- fetch_order_detail / list_recent_orders 同时供「账号只读查询通道」使用
(docs/order-gateway.md §11,上游经网关问「账号里真实的订单长什么样」)。
查询返回的规范化字段全部来自上面这两条已实测路径;额外带出的站点原始 JSON 里,
**只有订单列表页的 orderListData 是实测过的结构**,详情页的 __INITIAL_STATE__
从未拿到真实样本,只做「解析得动就原样透传」,本模块不猜它的字段。
订单列表页的 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
@@ -317,9 +321,19 @@ _ORDER_DETAIL_URL_TEMPLATE = (
# 订单列表页与详情页共用同一套「配送阶段进度条」组件:4 个固定阶段的 <li>,
# 当前阶段比其余几个多一段形如 `item-shipping-active--{hash}` 的 class
# (hash 是 CSS modules 编译产物,逐次构建会变;`-active--` 这个中缀是本模块
# 唯一依赖的稳定信号)。用 re.findall 顺序返回 4 个 (class, 阶段文案) 二元组。
# 唯一依赖的稳定信号)。用 re.findall 顺序返回 (class, 阶段文案) 二元组。
# 2026-08-16 用真账号实测详情页(scripts/probe_order_detail.py,样本落盘
# .probe/order_detail/)发现:**li class 必须锚定进度条 item 前缀 `item--3gWCU`**,
# 否则页面上还有一套面包屑 <li>(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 产物,逐次构建可能变——本模块把 `<li item--3gWCU>` 视为唯一
# 依赖的稳定信号之一,结构化配送状态另见 _parse_order_detail_status。
_ORDER_STEPPER_ITEM_PATTERN = re.compile(
r'<li class="([^"]*)">.*?<div class="title--2uGVi">([^<]*)</div></li>', re.DOTALL,
r'<li class="item--3gWCU([^"]*)">.*?<div class="title--2uGVi">([^<]*)</div></li>', re.DOTALL,
)
_ORDER_STEPPER_ACTIVE_MARKER = "-active--"
# 「ショップ」(店铺已接单,未发货)阶段没有对应的 OrderState 取值——报告过
@@ -333,6 +347,24 @@ _ORDER_STAGE_TO_STATE: dict[str, OrderState] = {
"配達完了": 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 不同)。
@@ -507,15 +539,22 @@ class OrderStatusSnapshot:
found=False 表示页面上没找到这个订单号——**不当错误处理**:站点自己说
「ご注文の反映に10分ほどかかります」,下单后短时间内查不到是正常的,调用方
(runner._monitor_order)应该继续下一轮轮询,不是放弃。
stage_label 是真实站点用的阶段原文(如「出荷」),供日志/证据留原始信号;
order_state 是映射到 OrderState 的结果,映射不到(比如仍在「ショップ」这个
起始阶段,或者进度条没解析出来)时为 None——同样不当错误,只是「这次没有
新状态可报」。html 是抓取到的整页内容,供调用方按需落证据。
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 = ""
@@ -523,13 +562,12 @@ class OrderStatusSnapshot:
class OrderDetailSnapshot:
"""订单详情页的一次完整读取结果(fetch_order_detail 的返回值)
`status` 是已实测的那部分配送阶段进度条,见 _parse_order_status);
`raw` 是整页 `window.__INITIAL_STATE__` 的原文——**详情页的这份结构从未被
真实数据验证过**(2026-08-13 那次实测只验了进度条组件),因此这里刻意
只做「能解析成 JSON 就原样带出去」,不写任何字段抽取逻辑。要金额、收货
地址、付款方式这些字段的调用方,自己从 raw 里取并自担结构变动风险;等拿到
真实详情页样本后再在本模块补规范化解析,不要在没有样本的情况下先猜着写
解析不出(页面没有内联状态、或不是 JSON)时为 None。
`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
@@ -1842,7 +1880,11 @@ class SiteInteractor:
两个调用方:付款后监控(`check_order_status`,只要配送阶段)与账号只读
查询通道的 order_detail(规格 §11,还要 raw 原文)。站点交互与轮询节奏
解耦,也方便离线单测轮询逻辑(用桩替换本方法)与解析逻辑
(`_parse_order_status`,纯函数)。
(`_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` 里留存的会话,
@@ -1878,12 +1920,19 @@ class SiteInteractor:
# 只在「没解析到订单」时才查掉登录:解析到了就说明页面是真的订单页,
# 没必要再判一次;反过来,found=False 有两种可能(订单还没反映出来 /
# 被踢去登录),这里把后者摘出来,不让它伪装成前者。
snapshot = _parse_order_status(html, site_order_id)
#
# 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=_parse_initial_state(html))
return OrderDetailSnapshot(status=snapshot, raw=state)
return await self._read_with_relogin_retry(
f"check_order_status site_order_id={site_order_id}", read
@@ -2129,6 +2178,69 @@ def _parse_order_status(html: str, site_order_id: str) -> OrderStatusSnapshot:
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 调用,便于离线单测)