From 3b53b8f28ba9f0ace7d438647449e2b24b641a6b Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Sun, 16 Aug 2026 23:04:59 +0800 Subject: [PATCH] =?UTF-8?q?@=20feat(trading):=20=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=E6=B4=BE=E9=80=81=E5=88=B0=E7=9C=9F=E5=AE=9E=E6=A0=B7?= =?UTF-8?q?=E6=9C=AC=EF=BC=8C=E8=A1=A5=E7=BB=93=E6=9E=84=E5=8C=96=E9=85=8D?= =?UTF-8?q?=E9=80=81=E7=8A=B6=E6=80=81=E5=B9=B6=E4=BF=AE=20stepper=20?= =?UTF-8?q?=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用真账号实测爬取订单详情页(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 会抢走进度条第0项、导致 -active-- 永远判不出当前阶段(真实样本复现)。 - OrderStatusSnapshot 增 delivery_status 字段;query_runner order_detail 透传。 - docs/order-gateway.md §11 更新实测边界;新增详情页样本回溯测试。 Co-Authored-By: Claude Opus 5 (1M context) @ --- app/trading/worker/query_runner.py | 7 +- app/trading/worker/site_interact.py | 148 ++++++++++++++++--- docs/order-gateway.md | 25 +++- scripts/probe_order_detail.py | 212 ++++++++++++++++++++++++++++ tests/test_site_interact.py | 113 +++++++++++++++ 5 files changed, 479 insertions(+), 26 deletions(-) create mode 100644 scripts/probe_order_detail.py diff --git a/app/trading/worker/query_runner.py b/app/trading/worker/query_runner.py index 6e7d377..8a989be 100644 --- a/app/trading/worker/query_runner.py +++ b/app/trading/worker/query_runner.py @@ -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, } diff --git a/app/trading/worker/site_interact.py b/app/trading/worker/site_interact.py index 04cf853..f684cde 100644 --- a/app/trading/worker/site_interact.py +++ b/app/trading/worker/site_interact.py @@ -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 个固定阶段的
  • , # 当前阶段比其余几个多一段形如 `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`**, +# 否则页面上还有一套面包屑
  • (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, + r'
  • .*?
    ([^<]*)
  • ', 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 调用,便于离线单测) diff --git a/docs/order-gateway.md b/docs/order-gateway.md index 0e77375..4f5348f 100644 --- a/docs/order-gateway.md +++ b/docs/order-gateway.md @@ -435,18 +435,31 @@ queued ──lease──> leased ──result──> succeeded "kind": "order_detail", "order_number": "306087-20260813-0863947697", "found": true, // false 不是错误:站点自己说下单后要 ~10 分钟才反映 - "stage_label": "出荷", // 站点原文(已实测的配送阶段进度条) + "stage_label": "出荷", // 站点原文(配送阶段) "order_state": "shipped", // 映射到 OrderState;映射不到为 null + "delivery_status": "CHECKING_ORDER", // 站点结构化配送状态码;列表页/stepper 路径为 null "raw": { /* 页面 __INITIAL_STATE__ 原文 */ }, "raw_available": true } ``` -> **实测边界(重要)**:`stage_label` / `order_state` 走的是 2026-08-13 用真实订单号 -> 验证过的进度条解析;但**详情页的 `__INITIAL_STATE__` 结构从未拿到真实样本**。 -> 因此本服务对它只做「解析得动就原样透传」,不抽任何字段——想要金额、收货地址、 -> 付款方式的调用方自己从 `raw` 里取并自担结构变动风险。等拿到真实样本后再在 -> `site_interact.py` 里补规范化解析,**不要在没有样本的情况下先猜着写**。 +> **实测边界(2026-08-16 更新)**:上次评估时「详情页 `__INITIAL_STATE__` 结构从未 +> 拿到真实样本」——**已用真账号真实爬过一次**(`scripts/probe_order_detail.py`, +> 样本落盘 `.probe/order_detail/`,订单号 306087-20260813-0863947697)。确认: +> - 详情页 `pageType="ph-detail"`,配送阶段有两处可解析信号:CSS 进度条 +> (`_parse_order_status`)**以及** `orderData.shippingList[].deliveryInfo. +> deliveryStatus` 结构化枚举(`_parse_order_detail_status`,2026-08-16 新增)。 +> `stage_label` 优先取 `deliveryStatusTitle` 站点原文(如「ご注文確認中」)。 +> - 进度条解析修了一个此前未暴露的回归:不锚定 `
  • ` 时, +> 详情页的面包屑 `
  • ` 会先于进度条第 0 项被 `findall` 抓走,导致带 `-active--` +> 的当前阶段被错位跳过、永远判不出阶段(见 `_ORDER_STEPPER_ITEM_PATTERN` 注释)。 +> - `deliveryStatus` 的枚举→`OrderState` 映射**仍只实测过 `CHECKING_ORDER` 一个值**, +> 未识别的枚举值一律不接管、交回 stepper 兜底(避免掐掉 monitor 的 +> SHIPPED/DELIVERED 上报)。`delivery_status` 字段把这个原始码原样带给上游对账。 +> - `raw` 仍原样透传,想要金额/收货地址/付款方式的调用方从 `raw.orderData` 自取 +> (2026-08-16 样本确认其结构:`orderSummary` / `itemObject.itemList[]` / +> `shippingList[].deliveryInfo.deliveryAddress` / `paymentInfoModel` 等), +> 本服务仍不自担字段抽取的去重/解释责任。 结果体积上限 `RAKUTEN_QUERY_RESULT_MAX_BYTES`:超限先丢 `raw_pages`/`raw` 并置 `raw_omitted`;丢完仍超限则整单判失败,让上游缩小窗口重来——不把几 MB 页面状态 diff --git a/scripts/probe_order_detail.py b/scripts/probe_order_detail.py new file mode 100644 index 0000000..a5f5022 --- /dev/null +++ b/scripts/probe_order_detail.py @@ -0,0 +1,212 @@ +"""订单详情页探针:为账号只读查询通道(docs/order-gateway.md §11)取详情页真实 DOM 结构 + +目标:`fetch_order_detail` 目前只把详情页 `window.__INITIAL_STATE__` 原样透传 +(从未有过真实样本,只做「解析得动就带走」,见 site_interact.py::_parse_initial_state +上方与 OrderDetailSnapshot 文档)。本探针用真账号登录态打开一条真实订单的详情页 +(`_ORDER_DETAIL_URL_TEMPLATE` 那条 `act=detail_page_view` 路径),把整页 HTML、 +以及解析出的 `__INITIAL_STATE__` JSON 都落盘,作为后续补规范化字段抽取的第一份 +真实样本。 + +只读导航,不做任何加购/下单操作。 + +用法: + .venv/Scripts/python.exe scripts/probe_order_detail.py + .venv/Scripts/python.exe scripts/probe_order_detail.py --headful + .venv/Scripts/python.exe scripts/probe_order_detail.py --order 306087-20260813-0863947697 +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +# Windows 控制台默认 GBK,打印含日文/波浪符(如 U+301C 〜)的中文会抛 +# UnicodeEncodeError——统一改 stdout 为 UTF-8,跟 save() 落盘的编码保持一致 +# (记忆 project://jp-rakuten/index 里「Git Bash 打中文 body 乱码」是同一问题的另一面) +if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +from app.shared.config import get_settings # noqa: E402 +from app.trading.core import auth_site # noqa: E402 + +PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "order_detail" +PROBE_DIR.mkdir(parents=True, exist_ok=True) + +# 与 site_interact.py::_ORDER_DETAIL_URL_TEMPLATE 保持一致 +ORDER_DETAIL_URL_TEMPLATE = ( + "https://order.my.rakuten.co.jp/purchase-history/" + "?order_number={order_number}&shop_id={shop_id}&act=detail_page_view" +) +DEFAULT_ORDER_ID = "306087-20260813-0863947697" + + +def save(name: str, content: str | bytes) -> Path: + path = PROBE_DIR / name + if isinstance(content, bytes): + path.write_bytes(content) + else: + path.write_text(content, encoding="utf-8") + print(f" saved -> {path} ({len(content)} bytes)") + return path + + +def parse_initial_state(html: str) -> dict | None: + 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 + + +async def run(*, headful: bool, order_id: str) -> int: + from playwright.async_api import async_playwright + + settings = get_settings() + state_path = settings.auth_state_path / "rakuten_state.json" + if not state_path.exists(): + print(f"找不到登录态文件:{state_path}") + return 1 + + shop_id = order_id.split("-", 1)[0] + url = ORDER_DETAIL_URL_TEMPLATE.format(order_number=order_id, shop_id=shop_id) + + async with async_playwright() as pw: + browser = await pw.chromium.launch( + headless=not headful, + channel=settings.browser_channel or None, + proxy=settings.playwright_proxy, + args=["--no-first-run", "--disable-blink-features=AutomationControlled"], + ) + context = await browser.new_context( + storage_state=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, + ) + page = await context.new_page() + + api_calls: list[tuple[int, str]] = [] + + def on_response(r): + api_calls.append((r.status, str(r.url))) + + page.on("response", on_response) + + print(f"=== 访问订单详情页(order_id={order_id})===") + try: + await page.goto(url, wait_until="domcontentloaded", timeout=30_000) + await page.wait_for_timeout(3000) + except Exception as exc: + print(f"导航失败:{type(exc).__name__}: {exc}") + + html = await page.content() + final_url = page.url + print(f" final_url={final_url} body_len={len(html)}") + print(f" 含订单号 {order_id}: {order_id in html}") + logged_out = auth_site.looks_logged_out( + "rakuten", final_url=final_url, body=html + ) + print(f" looks_logged_out={logged_out}") + + save("00-detail-default.html", html) + + state = parse_initial_state(html) + if state is None: + print(" !! 解析不到 __INITIAL_STATE__(可能 PC 模板 / 掉登录 / 反爬)") + else: + print(f" __INITIAL_STATE__ 顶层键:{sorted(state.keys())}") + print(f" pageType={state.get('pageType')!r}") + + # 打印与订单/阶段相关的子结构概览,方便人眼核对 + print("\n=== 与订单字段相关的线索 ===") + flat_search = {} + def walk(node, prefix=""): + if isinstance(node, dict): + for k, v in node.items(): + p = f"{prefix}.{k}" if prefix else k + if isinstance(v, (dict, list)): + walk(v, p) + elif isinstance(v, (str, int, float)) and 0 < len(str(v)) <= 80: + flat_search[p] = v + elif isinstance(node, list): + for i, v in enumerate(node[:5]): + walk(v, f"{prefix}[{i}]") + walk(state) + for pat in ["order", "stage", "配送", "出荷", "配達", "金額", "amount", + "address", "payment", "shop", "item", "status"]: + hits = {k: v for k, v in flat_search.items() if pat in k.lower()} + if hits: + print(f" 键含 {pat!r}(前 15 条):") + for k, v in list(hits.items())[:15]: + print(f" {k} = {v!r}") + + save("01-detail-initial-state.json", json.dumps( + state, ensure_ascii=False, indent=2, + )) + # 顶层平铺一份,方便快速看 + save("02-detail-initial-state-top.json", json.dumps( + {k: (v if not isinstance(v, (dict, list)) else "…") for k, v in state.items()}, + ensure_ascii=False, indent=2, + )) + + # 配送阶段进度条(_ORDER_STEPPER_ITEM_PATTERN 那套),确认详情页同样适用 + steps = re.findall( + r'
  • .*?
    ([^<]*)
  • ', + html, + re.DOTALL, + ) + print(f"\n=== 配送阶段进度条(_ORDER_STEPPER_ITEM_PATTERN)===") + if steps: + for cls, label in steps: + print(f" stage={label!r} active={'-active--' in cls}") + else: + print(" (未匹配到进度条 li 结构)") + + print("\n=== 捕获的 XHR(末 20 条)===") + for status, u in api_calls[-20:]: + print(f" {status} {u[:160]}") + + # 截图留底(无头模式下可能是白屏/异常渲染,正好暴露反爬) + try: + await page.screenshot(path=str(PROBE_DIR / "03-detail-screenshot.png"), full_page=True) + print(" 截图 ->", PROBE_DIR / "03-detail-screenshot.png") + except Exception as exc: + print(f" 截图失败:{type(exc).__name__}: {exc}") + + await browser.close() + + print(f"\n探针输出目录:{PROBE_DIR}") + return 0 + + +async def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--headful", action="store_true") + parser.add_argument( + "--order", + default=DEFAULT_ORDER_ID, + help="要探测的订单号(默认已知订单 2026-08-13 实测那单)", + ) + args = parser.parse_args() + return await run(headful=args.headful, order_id=args.order) + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/tests/test_site_interact.py b/tests/test_site_interact.py index 64ef005..2494b24 100644 --- a/tests/test_site_interact.py +++ b/tests/test_site_interact.py @@ -39,6 +39,7 @@ from app.trading.worker.site_interact import ( _parse_checkout_summary, _parse_initial_state, _parse_order_list, + _parse_order_detail_status, _parse_order_status, parse_order_datetime, ) @@ -334,6 +335,118 @@ def test_order_status_snapshot_defaults(): assert s.html == "" +# ---- _parse_order_status:2026-08-16 真实详情页样本暴露出面包屑干扰的回归 ---- +# 真实详情页除了配送进度条,还有一套面包屑
  • (class 同样含 CSS modules hash、 +# label 也是「ショップ」且紧邻 title--2uGVi)。不锚定 li class 前缀时,findall +# 会先抓走面包屑、再把带 -active-- 的进度条第 0 项错位跳过,导致永远判不出当前 +# 阶段。修复后锚定 item--3gWCU 前缀,下面用脚本 probe_order_detail.py 落盘的真实 +# 片段回归防倒退回退化。 + +# 摘自 .probe/order_detail/00-detail-default.html 的可复现片段: +# 面包屑 li(应被排除)在前,真正的进度条第 0 项(active)夹在中间。 +_REAL_STEPPER_HTML = ( + '' + '
  • ' + '' + '' + f'
    注文番号:{_REAL_ORDER_ID}
    ' + '' +) + + +def test_parse_order_status_breadcrumb_sibling_does_not_shadow_active_item0(): + """真实详情页有面包屑 li,不得让它替代进度条 item-0 挡在 -active-- 判定前""" + snapshot = _parse_order_status(_REAL_STEPPER_HTML, _REAL_ORDER_ID) + assert snapshot.found is True + assert snapshot.stage_label == "ショップ" + assert snapshot.order_state is None + + +# ---- _parse_order_detail_status:2026-08-16 首次拿到详情页真实样本才有的路径 ---- +# orderData.shippingList[].deliveryInfo.deliveryStatus 是站点结构化配送状态,比 +# CSS 进度条稳。但映射遵循「只映射实测值」,当前只有 CHECKING_ORDER 一个样本。 + + +def _detail_state(*, delivery_status: str | None, title: str | None, order_ok: bool = True) -> dict: + """构造详情页 __INITIAL_STATE__ 最小 fixture(真实样本 .probe/order_detail)""" + return { + "pageType": "ph-detail", + "requestParams": {"orderNumber": _REAL_ORDER_ID if order_ok else "other-order"}, + "orderData": { + "orderSummary": {"orderNumber": _REAL_ORDER_ID if order_ok else "other-order"}, + "shippingList": [ + { + "deliveryInfo": ( + {"deliveryStatus": delivery_status, "deliveryStatusTitle": title} + if delivery_status + else {} + ) + } + ], + }, + } + + +def test_parse_order_detail_status_uses_delivery_status_when_present(): + """结构化的 deliveryStatus 优先于 stepper,stage_label 取站点原文""" + snapshot = _parse_order_detail_status( + _detail_state(delivery_status="CHECKING_ORDER", title="ご注文確認中"), _REAL_ORDER_ID + ) + assert snapshot is not None + assert snapshot.found is True + assert snapshot.delivery_status == "CHECKING_ORDER" + assert snapshot.stage_label == "ご注文確認中" + # 尚无实测映射的枚举值不猜 order_state(CHECKING_ORDER 对应「ショップ」阶段, + # 已报告过 ORDERED/PAID,不需要 monitor 再报,与 stepper 行为一致) + assert snapshot.order_state is None + + +def test_parse_order_detail_status_unknown_code_falls_back_to_none(): + """没实测过的 deliveryStatus 不接管——交回 stepper 兜底(避免掐掉 SHIPPED/DELIVERED 上报) + + 实测只认识 CHECKING_ORDER;一旦真实订单走到「出荷/配達完了」,其 deliveryStatus + 字符串还是未知,此时若结构化路径返回 order_state=None 会把 monitor 的状态上报 + 掐掉。所以未识别的枚举值直接返回 None,由调用方降级到 stepper 的 + _ORDER_STAGE_TO_STATE(后者早就能映射出荷/配達完了)。 + """ + assert ( + _parse_order_detail_status( + _detail_state(delivery_status="SOME_FUTURE_STATUS", title="出荷待ち"), + _REAL_ORDER_ID, + ) + is None + ) + + +def test_parse_order_detail_status_returns_none_wrong_order(): + """state 的 orderNumber 对不上所查订单时不得当 found(防 SPA 复用到别的订单)""" + assert ( + _parse_order_detail_status( + _detail_state(delivery_status="CHECKING_ORDER", title="ご注文確認中", order_ok=False), + _REAL_ORDER_ID, + ) + is None + ) + + +def test_parse_order_detail_status_returns_none_when_no_order_data(): + assert _parse_order_detail_status(None, _REAL_ORDER_ID) is None + assert ( + _parse_order_detail_status(_detail_state(delivery_status=None, title=None), _REAL_ORDER_ID) + is None + ) + + # ---- check_order_status 在没启动 Playwright 时应失败 ----