From 93c1a834060177fcff6cb86e320eb2fe2624fcfc Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Thu, 13 Aug 2026 23:58:54 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=A7=9F=E7=BA=A6=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E5=AF=B9=EF=BC=9Averify=5Fon=5Fsite=20?= =?UTF-8?q?=E6=8E=A5=E7=9C=9F=E5=AE=9E=E8=AE=A2=E5=8D=95=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=8F=8D=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 SiteInteractor.list_recent_orders(分页拉 order.my.rakuten.co.jp 订单列表, 按商品 URL 反查)+ GatewayClient.get_task,替换掉恒返回 UNKNOWN 的桩。 NOT_ORDERED 分支目前只有逻辑验证、没有真实多单数据支撑,刻意仍路由到 needs_human,不自动重新下单。全程只读查询,未触发任何真实付款操作。 --- app/trading/worker/client.py | 10 ++ app/trading/worker/runner.py | 12 +- app/trading/worker/site_interact.py | 213 +++++++++++++++++++++++++++- app/trading/worker/verify.py | 102 +++++++++++-- scripts/probe_order_list.py | 130 +++++++++++++++++ tests/test_site_interact.py | 188 ++++++++++++++++++++++++ tests/test_verify.py | 188 ++++++++++++++++++++++++ tests/test_worker_runner.py | 47 +++++- 8 files changed, 868 insertions(+), 22 deletions(-) create mode 100644 scripts/probe_order_list.py create mode 100644 tests/test_verify.py diff --git a/app/trading/worker/client.py b/app/trading/worker/client.py index 28a9404..b085e58 100644 --- a/app/trading/worker/client.py +++ b/app/trading/worker/client.py @@ -127,6 +127,16 @@ class GatewayClient: body = await self._request("POST", f"/api/orders/{task_id}/report", json=payload) return body["data"] + async def get_task(self, task_id: str) -> dict[str, Any]: + """任务详情,返回网关响应里的 data 字段(含 created_at) + + 供 verify.verify_on_site 恢复核对用:LeaseTask(lease/reclaim 的响应) + 不带 created_at,规格 §5「按创建时间 ~ stale 之间」的窗口核对必须单独 + 查一次这个接口才能拿到。 + """ + body = await self._request("GET", f"/api/orders/{task_id}") + return body["data"] + async def reclaim(self, task_id: str, worker_id: str) -> LeaseTask: """恢复领取。返回的 LeaseTask 必然 lease_count > 1""" body = await self._request( diff --git a/app/trading/worker/runner.py b/app/trading/worker/runner.py index d000dbb..3a5678e 100644 --- a/app/trading/worker/runner.py +++ b/app/trading/worker/runner.py @@ -162,7 +162,7 @@ class WorkerRunner: task.lease_count, task.task_id, ) - verdict = await verify.verify_on_site(task) + verdict = await verify.verify_on_site(task, gateway=self._gateway, site=self._site) if verdict.verdict == verify.VerifyVerdict.ALREADY_ORDERED: await self._report_safe( task, @@ -176,12 +176,16 @@ class WorkerRunner: await self._db.mark_finished(task.task_id, OrderState.ORDERED.value) return - # NOT_ORDERED 也走 needs_human:当前 verify 桩不会返回这个值,但留接口给 - # 未来真正能可靠核对时——那时再决定 NOT_ORDERED 是否直接重新执行 + # NOT_ORDERED 也走 needs_human(2026-08-13 verify_on_site 已实现,会真的 + # 返回这个值,但刻意仍不自动重新执行):核对逻辑目前只有「账号 1 笔订单」 + # 的真实数据支撑 ALREADY_ORDERED 分支,NOT_ORDERED 分支只验证过逻辑本身, + # 没有被真实多单场景跑过——在这条判断被更多真实数据验证之前,即使确认 + # 未下单也交人工决定是否重新提交,不自动触发新的下单动作。这是刻意的 + # 保守选择,不是遗漏;要不要放开需要显式决定,不在这里静默改。 await self._report_safe( task, state=_coerce_state(task.known_state), - detail=f"恢复核对无法定论:{verdict.detail}", + detail=f"恢复核对结论({verdict.verdict.value}):{verdict.detail}", terminal=True, terminal_status=TaskStatus.NEEDS_HUMAN, ) diff --git a/app/trading/worker/site_interact.py b/app/trading/worker/site_interact.py index c251b91..dccc3b3 100644 --- a/app/trading/worker/site_interact.py +++ b/app/trading/worker/site_interact.py @@ -56,6 +56,14 @@ data/evidence/checkout-research-20260811/NOTES.md): (刚接单,未发货)这一阶段,「出荷」「配達完了」两个状态转换点没有被真实数据 验证过,只是按进度条文案直译映射(_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` 超出范围返回空列表;多页翻页时「新订单排在前面」的排序假设、以及 + 分页游标本身,都没有被真实多页数据验证过。 **httpx 不能用于带账号的写操作**:Rakuten 对账号操作有 TLS/HTTP2 指纹校验, 同一份 cookie Playwright 能用、httpx 不能。所以本模块全程使用 Playwright @@ -71,7 +79,8 @@ import asyncio import json import logging import re -from dataclasses import dataclass +from dataclasses import dataclass, field +from datetime import datetime from typing import TYPE_CHECKING from app.shared.errors import ( @@ -295,6 +304,122 @@ _ORDER_STAGE_TO_STATE: dict[str, OrderState] = { "配達完了": OrderState.DELIVERED, } +# ---- 订单列表反查: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 + + +@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 的返回值)""" + + entries: list[OrderListEntry] + orders_found: int | None = None + page_size: int | None = None + + +@dataclass(slots=True) +class OrderListWindow: + """list_recent_orders 的返回值:某个时间窗口内翻页汇总的结果 + + window_fully_covered=True 才代表「窗口内的订单已经看全」——可能是因为翻到了 + 比 since 更早的订单、可能是列表本身翻完了、也可能是 ordersFound 已经对上。 + False 表示翻页在覆盖完窗口前就停了(命中 _ORDER_LIST_MAX_PAGES,或页面结构 + 解析不出 ordersFound 之类的异常),此时 entries 里「没有匹配」不能当作 + 「确实没下单」——调用方必须转 unknown,不能默认 NOT_ORDERED。 + """ + + entries: list[OrderListEntry] + window_fully_covered: bool + + +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=第一页就拿不到结构化数据,翻页无意义,直接放弃 + + +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 分支不对/未登录跳转),此时也会停止翻页, + 但调用方必须按「没覆盖」处理,不能当成真的翻完了。 + """ + 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), + 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), + True, + ) + + new_entries = acc.entries + page.entries + new_acc = _OrderListAccumulator(entries=new_entries, total_found=total_found, is_first_page=False) + + 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: @@ -1474,6 +1599,51 @@ class SiteInteractor: return _parse_order_status(html, site_order_id) + async def list_recent_orders(self, *, since: datetime) -> OrderListWindow: + """恢复核对用:拉取「任务创建时间之后」的订单列表(规格 §5 依赖它) + + 由 verify.verify_on_site 调用,不由 worker 主循环直接调。翻页直到看到 + order_date 早于 since 的订单(说明窗口内的都已经看过一遍)、或 + ordersFound 已经全部翻完、或到达 _ORDER_LIST_MAX_PAGES 上限。命中上限仍 + 没能确认覆盖完整窗口时,`OrderListWindow.window_fully_covered=False`—— + 调用方据此转 unknown,绝不能把「没翻完」当成「翻完了但没有」。 + + 2026-08-13 只用「账号只有 1 笔订单、1 页」的真实数据验证过单页解析与 + page=2 返回空列表这两点;多页翻页的排序假设(新订单在前)未经真实数据 + 验证,多页场景上线前应重新探测确认。 + + Raises: + NotLoggedInError: 登录态失效 + OrderOperationError: 订单列表页打开/渲染失败 + """ + async with self._lock: + await self._auth_session.require_logged_in("rakuten") + await self._refresh_context_if_stale() + + acc = _OrderListAccumulator() + stop = False + + page = await self._context.new_page() + try: + for page_num in range(1, _ORDER_LIST_MAX_PAGES + 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() + page_result = _parse_order_list(html) + acc, stop = _accumulate_order_list_page(acc, page_result, since=since) + if stop: + break + finally: + await page.close() + + return OrderListWindow(entries=acc.entries, window_fully_covered=stop and not acc.gave_up) + # ---- 模块级辅助函数(纯函数,便于单测)---- @@ -1642,3 +1812,44 @@ def _parse_order_status(html: str, site_order_id: str) -> OrderStatusSnapshot: html=html, ) return OrderStatusSnapshot(found=True, html=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"), + ) diff --git a/app/trading/worker/verify.py b/app/trading/worker/verify.py index bfb6789..e22cd4a 100644 --- a/app/trading/worker/verify.py +++ b/app/trading/worker/verify.py @@ -4,16 +4,27 @@ 此时 worker 不能盲目重新提交(可能上次已经下单成功,只是回报断网),必须先查 站点订单列表比对。 -订单列表反查的实现需要实测(规格 §10 第 3 条:订单列表页能否按商品 + 时间窗口 -可靠地反查出「这单下没下」)。当前为桩:恒返回 `unknown`,让 runner 走 -needs_human 分支。**绝不**默认按「没下单」处理——那是猜,可能变成重复下单。 +2026-08-13 实现:核对逻辑用真实账号 + 真实订单验证过订单列表的数据结构(见 +app/trading/worker/site_interact.py::list_recent_orders 与 _parse_order_list 的 +模块文档),但当时账号只有 1 笔订单,ALREADY_ORDERED 分支有真实数据支撑, +NOT_ORDERED / 多笔命中 / 多页翻页这几个分支目前只有逻辑,没有被真实多单数据 +跑过——**绝不**在没有把握时猜:任何一步信息不足都转 UNKNOWN,宁可卡住等人看 +一眼,也不赌一次重复下单。 """ from __future__ import annotations from dataclasses import dataclass from enum import StrEnum +from typing import TYPE_CHECKING +from urllib.parse import urlsplit +from app.shared.errors import AppError from app.trading.worker.models import LeaseTask +from app.trading.worker.site_interact import parse_order_datetime + +if TYPE_CHECKING: + from app.trading.worker.client import GatewayClient + from app.trading.worker.site_interact import SiteInteractor class VerifyVerdict(StrEnum): @@ -31,16 +42,85 @@ class VerifyResult: detail: str = "" -async def verify_on_site(task: LeaseTask) -> VerifyResult: +def _normalize_item_url(url: str | None) -> str | None: + """去掉查询串与末尾斜杠,只留 scheme+host+path 用于比对 + + 订单列表返回的 itemUrl 实测带 `?variantId=...`(见 _parse_order_list 的真实 + 样例),intent.item_url 通常不带——按查询串比对会产生假阴性,所以只比 + 路径本身。 + """ + if not url: + return None + parts = urlsplit(url) + return f"{parts.scheme}://{parts.netloc}{parts.path.rstrip('/')}" + + +async def verify_on_site( + task: LeaseTask, *, gateway: "GatewayClient", site: "SiteInteractor" +) -> VerifyResult: """核对一笔任务是否已在站点上下过单 - 桩实现:永远返回 UNKNOWN。**绝不返回 NOT_ORDERED**——除非真实订单列表反查 - 能可靠证明这一点,否则视为「无法判断」交人工,宁可卡住等人看一眼。 - - 实现方需要补的实测:用 intent 里的商品 + 时间窗口(created_at 到 stale 之间) - 比对订单列表,能拿到 site_order_id 最好。 + 核对链路:intent.item_url → 查任务创建时间(GET /api/orders/{task_id}, + LeaseTask 本身不带 created_at)→ 拉「创建时间之后」的订单列表 → 按商品 URL + 比对。任何一环拿不到足够信息都返回 UNKNOWN,不猜——尤其是 NOT_ORDERED, + 只有在确认翻完了窗口内的全部订单后才允许返回,否则「没找到」可能只是没翻 + 到那一页。 """ + intent = task.intent or {} + item_url = intent.get("item_url") + if not item_url: + return VerifyResult( + VerifyVerdict.UNKNOWN, detail="intent 缺 item_url,无法比对商品" + ) + target = _normalize_item_url(item_url) + + try: + task_detail = await gateway.get_task(task.task_id) + except AppError as exc: + return VerifyResult( + VerifyVerdict.UNKNOWN, detail=f"查询任务创建时间失败:{exc.message}" + ) + + created_at_raw = task_detail.get("created_at") + created_at = parse_order_datetime(created_at_raw) if created_at_raw else None + if created_at is None: + return VerifyResult( + VerifyVerdict.UNKNOWN, detail=f"任务创建时间不可解析:{created_at_raw!r}" + ) + + try: + window = await site.list_recent_orders(since=created_at) + except Exception as exc: # noqa: BLE001 — 站点交互失败一律转 unknown,不重试 + return VerifyResult( + VerifyVerdict.UNKNOWN, + detail=f"订单列表查询失败:{type(exc).__name__}: {exc}", + ) + + matches = [ + entry + for entry in window.entries + if any(_normalize_item_url(it.item_url) == target for it in entry.items) + ] + if len(matches) == 1: + return VerifyResult( + VerifyVerdict.ALREADY_ORDERED, + site_order_id=matches[0].order_number, + detail=f"订单列表命中 1 笔匹配商品的订单({matches[0].order_number})", + ) + if len(matches) > 1: + return VerifyResult( + VerifyVerdict.UNKNOWN, + detail=( + f"订单列表命中 {len(matches)} 笔匹配商品的订单," + f"无法唯一确定:{[m.order_number for m in matches]}" + ), + ) + if window.window_fully_covered: + return VerifyResult( + VerifyVerdict.NOT_ORDERED, + detail="已核对任务创建时间之后的全部订单,未找到匹配商品", + ) return VerifyResult( - verdict=VerifyVerdict.UNKNOWN, - detail="订单列表反查未实现:见 docs/order-gateway.md §10 第 3 条", + VerifyVerdict.UNKNOWN, + detail="订单列表未能确认翻完任务创建时间之后的全部订单", ) diff --git a/scripts/probe_order_list.py b/scripts/probe_order_list.py new file mode 100644 index 0000000..380364f --- /dev/null +++ b/scripts/probe_order_list.py @@ -0,0 +1,130 @@ +"""订单列表页探针:为实现 verify_on_site(规格 §5 恢复核对)取真实 DOM 结构 + +只读导航,不做任何加购/下单操作。目标:确认 order.my.rakuten.co.jp 的订单列表页 +(不带 order_number/shop_id 参数)能否按「商品名 + 时间窗口」反查出某个任务是否已 +下单——这是 app/trading/worker/verify.py::verify_on_site 目前的桩要补的实测。 + +用法: + .venv/Scripts/python.exe scripts/probe_order_list.py + .venv/Scripts/python.exe scripts/probe_order_list.py --headful +""" +from __future__ import annotations + +import argparse +import asyncio +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +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_list" +PROBE_DIR.mkdir(parents=True, exist_ok=True) + +ORDER_LIST_URL = "https://order.my.rakuten.co.jp/purchase-history/" +KNOWN_ORDER_ID = "306087-20260813-0863947697" + + +def save(name: str, content: str) -> Path: + path = PROBE_DIR / name + path.write_text(content, encoding="utf-8") + print(f" saved -> {path} ({len(content)} bytes)") + return path + + +async def run(*, headful: bool) -> 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 + + async with async_playwright() as pw: + browser = await pw.chromium.launch( + headless=not headful, + channel=settings.browser_channel or None, + 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): + url = str(r.url) + if any(kw in url for kw in ["purchase-history", "order", "history"]): + api_calls.append((r.status, url)) + + page.on("response", on_response) + + print("=== 访问订单列表页(不带参数)===") + await page.goto(ORDER_LIST_URL, wait_until="domcontentloaded", timeout=30_000) + await page.wait_for_timeout(3000) + html = await page.content() + save("00-list-default.html", html) + print(f" final_url={page.url} body_len={len(html)}") + print(f" 含已知订单号 {KNOWN_ORDER_ID}: {KNOWN_ORDER_ID in html}") + + # 尝试常见的日期范围/分页查询参数,看服务端是否支持按时间窗口过滤 + candidate_qs = [ + "?period=3months", + "?range=3", + f"?order_number={KNOWN_ORDER_ID.split('-')[0]}", + ] + for qs in candidate_qs: + url = ORDER_LIST_URL + qs + print(f"\n=== 尝试 {url} ===") + try: + resp = await page.goto(url, wait_until="domcontentloaded", timeout=20_000) + await page.wait_for_timeout(2000) + html2 = await page.content() + status = resp.status if resp else None + print(f" status={status} final_url={page.url} body_len={len(html2)}") + safe_name = re.sub(r"[^\w]+", "_", qs) or "root" + save(f"01{safe_name}.html", html2) + except Exception as exc: + print(f" 失败:{type(exc).__name__}: {exc}") + + print("\n=== 捕获的 XHR ===") + for status, url in api_calls[-30:]: + print(f" {status} {url[:160]}") + + # 在默认列表页上找商品名/日期相关的文本线索 + print("\n=== 默认列表页文本线索(商品名/日期候选片段)===") + for pat in [ + r'"itemName"\s*:\s*"([^"]{2,60})"', + r'"orderDate"\s*:\s*"([^"]{2,40})"', + r'"orderNumber"\s*:\s*"([^"]{2,40})"', + r"\d{4}[年/]\d{1,2}[月/]\d{1,2}日?", + ]: + matches = re.findall(pat, html) + print(f" pattern={pat!r} -> {matches[:5]}") + + await browser.close() + + print(f"\n探针输出目录:{PROBE_DIR}") + return 0 + + +async def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--headful", action="store_true") + args = parser.parse_args() + return await run(headful=args.headful) + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/tests/test_site_interact.py b/tests/test_site_interact.py index 65e45a4..40b629b 100644 --- a/tests/test_site_interact.py +++ b/tests/test_site_interact.py @@ -15,18 +15,26 @@ from typing import Any import pytest +from datetime import datetime, timezone + from app.shared.errors import CartOperationError, InvalidRequestError, OrderOperationError from app.trading.worker.models import LeaseTask from app.shared.task_state import OrderState from app.trading.worker.site_interact import ( CheckoutSummary, + OrderListEntry, + OrderListPage, OrderStatusSnapshot, SiteInteractor, + _accumulate_order_list_page, _extract_error_message, _extract_purchase_fields, + _OrderListAccumulator, _parse_checkout_summary, _parse_initial_state, + _parse_order_list, _parse_order_status, + parse_order_datetime, ) FIXTURES = Path(__file__).parent / "fixtures" @@ -329,6 +337,186 @@ async def test_check_order_status_without_start_raises(): await site.check_order_status(_REAL_ORDER_ID) +# ---- _parse_order_list:verify_on_site 恢复核对用,2026-08-13 用真实账号跑 +# order.my.rakuten.co.jp/purchase-history/order-list 验证过这套 __INITIAL_STATE__ +# 结构(pageType="ph-list" 时 orderListData 直接是结构化 JSON,不需要正则抠 DOM)。 +# 下面 fixture 里的字段名/嵌套结构与真实响应一致,只是精简掉了大量与匹配逻辑 +# 无关的字段(推荐组件、通知、会员信息等)。 + + +def _real_order_list_state( + *, + orders_found: int = 1, + page_size: int = 25, + orders: list[dict] | None = None, + page_type: str = "ph-list", +) -> dict: + if orders is None: + orders = [ + { + "orderNumber": _REAL_ORDER_ID, + "orderDate": "2026-08-13T09:47:28.000Z", + "shopId": 306087, + "shopName": "BACKYARD FAMILY インテリアタウン", + "items": [ + { + "itemId": 10422789, + "itemName": "ウォールフック 粘着フック", + "itemUrl": "https://item.rakuten.co.jp/moccasin/ds001iwrgesaaa2/?variantId=ds001iwrgesaaa2-1a-2a", + "itemPrice": 297, + } + ], + } + ] + return { + "pageType": page_type, + "orderListData": { + "ordersFound": orders_found, + "pageSize": page_size, + "orderList": orders, + }, + } + + +def test_parse_order_list_extracts_entries_from_real_shape(): + html = _wrap_state(_real_order_list_state()) + page = _parse_order_list(html) + assert page.orders_found == 1 + assert page.page_size == 25 + assert len(page.entries) == 1 + entry = page.entries[0] + assert entry.order_number == _REAL_ORDER_ID + assert entry.order_date == "2026-08-13T09:47:28.000Z" + assert entry.shop_id == 306087 + assert len(entry.items) == 1 + assert entry.items[0].item_url == ( + "https://item.rakuten.co.jp/moccasin/ds001iwrgesaaa2/?variantId=ds001iwrgesaaa2-1a-2a" + ) + + +def test_parse_order_list_multiple_items_in_one_order(): + orders = [ + { + "orderNumber": "111-20260101-0000000001", + "orderDate": "2026-01-01T00:00:00.000Z", + "items": [ + {"itemUrl": "https://item.rakuten.co.jp/shop/a/", "itemName": "A"}, + {"itemUrl": "https://item.rakuten.co.jp/shop/b/", "itemName": "B"}, + ], + } + ] + html = _wrap_state(_real_order_list_state(orders=orders)) + page = _parse_order_list(html) + assert len(page.entries[0].items) == 2 + + +def test_parse_order_list_wrong_page_type_returns_empty_and_no_orders_found(): + """pageType 不是 ph-list(比如命中了 detail_page_view 的错误分支):拿不到结构化数据""" + html = _wrap_state(_real_order_list_state(page_type="ph-error")) + page = _parse_order_list(html) + assert page.entries == [] + assert page.orders_found is None + + +def test_parse_order_list_no_initial_state_returns_empty(): + page = _parse_order_list("没有 state 的页面") + assert page.entries == [] + assert page.orders_found is None + + +def test_parse_order_list_zero_orders_still_has_orders_found(): + """账号没有任何订单:orderList 空,但 orders_found=0(不是 None)——区分「真的没有」与「解析不出」""" + html = _wrap_state(_real_order_list_state(orders_found=0, orders=[])) + page = _parse_order_list(html) + assert page.entries == [] + assert page.orders_found == 0 + + +# ---- parse_order_datetime ---- + + +def test_parse_order_datetime_parses_real_format(): + dt = parse_order_datetime("2026-08-13T09:47:28.000Z") + assert dt == datetime(2026, 8, 13, 9, 47, 28, tzinfo=timezone.utc) + + +def test_parse_order_datetime_none_and_invalid(): + assert parse_order_datetime(None) is None + assert parse_order_datetime("") is None + assert parse_order_datetime("不是日期") is None + + +# ---- _accumulate_order_list_page:list_recent_orders 翻页决策的纯函数核心, +# 2026-08-13 只有「账号 1 笔订单、1 页」的真实数据支撑第一个分支,其余分支 +# (多页、命中窗口边界、拿不到结构化数据)目前只验证逻辑正确,未经真实多页数据跑过 ---- + + +def _page(entries: list[OrderListEntry], *, orders_found: int | None) -> OrderListPage: + return OrderListPage(entries=entries, orders_found=orders_found, page_size=25) + + +def _entry(order_number: str, order_date: str) -> OrderListEntry: + return OrderListEntry(order_number=order_number, order_date=order_date, items=[]) + + +_SINCE = datetime(2026, 8, 1, tzinfo=timezone.utc) + + +def test_accumulate_single_page_all_within_window_and_matches_orders_found(): + acc = _OrderListAccumulator() + page = _page([_entry("o1", "2026-08-10T00:00:00Z")], orders_found=1) + new_acc, stop = _accumulate_order_list_page(acc, page, since=_SINCE) + assert stop is True + assert new_acc.gave_up is False + assert [e.order_number for e in new_acc.entries] == ["o1"] + + +def test_accumulate_stops_when_oldest_entry_older_than_since(): + """翻到比 since 更早的订单:窗口内数据已经看全,停止翻页且视为覆盖完整""" + acc = _OrderListAccumulator(entries=[_entry("o1", "2026-08-10T00:00:00Z")], total_found=5, is_first_page=False) + page = _page([_entry("o2", "2026-07-01T00:00:00Z")], orders_found=5) + new_acc, stop = _accumulate_order_list_page(acc, page, since=_SINCE) + assert stop is True + assert new_acc.gave_up is False + assert [e.order_number for e in new_acc.entries] == ["o1", "o2"] + + +def test_accumulate_continues_when_more_pages_remain(): + """orders_found 还没凑够、最老的一条也没早于 since:应该继续翻下一页""" + acc = _OrderListAccumulator() + page = _page([_entry("o1", "2026-08-10T00:00:00Z")], orders_found=3) + new_acc, stop = _accumulate_order_list_page(acc, page, since=_SINCE) + assert stop is False + assert new_acc.total_found == 3 + + +def test_accumulate_empty_page_means_fully_covered(): + """翻到空页(没有更多订单了):不是错误,视为窗口已覆盖完""" + acc = _OrderListAccumulator(total_found=1, is_first_page=False) + page = _page([], orders_found=1) + new_acc, stop = _accumulate_order_list_page(acc, page, since=_SINCE) + assert stop is True + assert new_acc.gave_up is False + + +def test_accumulate_first_page_missing_orders_found_gives_up(): + """第一页就拿不到结构化数据(orders_found=None):停止翻页,但 gave_up=True,不算覆盖完""" + acc = _OrderListAccumulator() + page = _page([], orders_found=None) + new_acc, stop = _accumulate_order_list_page(acc, page, since=_SINCE) + assert stop is True + assert new_acc.gave_up is True + + +# ---- list_recent_orders 在没启动 Playwright 时应失败 ---- + + +async def test_list_recent_orders_without_start_raises(): + site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type] + with pytest.raises((AttributeError, TypeError)): + await site.list_recent_orders(since=_SINCE) + + # ---- submit_order / pay:没有 enter_checkout 留存的确认页会话时应报错,不静默成功 ---- diff --git a/tests/test_verify.py b/tests/test_verify.py new file mode 100644 index 0000000..aabe775 --- /dev/null +++ b/tests/test_verify.py @@ -0,0 +1,188 @@ +"""verify.verify_on_site 单元测试 + +用桩 gateway(只需要 get_task)和桩 site(只需要 list_recent_orders)跑通全部 +分支——不涉及 Playwright/httpx,纯逻辑测试。核对链路本身(intent.item_url → +查任务创建时间 → 拉订单列表 → 按商品 URL 比对)2026-08-13 用真实账号验证过 +数据结构(见 test_site_interact.py 里 _parse_order_list 那组测试),但当时账号 +只有 1 笔订单,这里 NOT_ORDERED / 多笔命中 / 查询失败这几个分支只覆盖逻辑本身, +没有被真实多单数据跑过。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +import pytest + +from app.shared.errors import AppError +from app.trading.worker import verify +from app.trading.worker.models import LeaseTask +from app.trading.worker.site_interact import OrderListEntry, OrderListItem, OrderListWindow + + +def _make_task(*, item_url: str | None = "https://item.rakuten.co.jp/shop/x/") -> LeaseTask: + intent: dict[str, Any] = {} + if item_url is not None: + intent["item_url"] = item_url + return LeaseTask(task_id="t1", site="rakuten", intent=intent, lease_count=2) + + +@dataclass +class FakeGateway: + created_at: str | None = "2026-08-01T00:00:00Z" + fail_with: Exception | None = None + calls: list[str] = field(default_factory=list) + + async def get_task(self, task_id: str) -> dict[str, Any]: + self.calls.append(task_id) + if self.fail_with is not None: + raise self.fail_with + return {"task_id": task_id, "created_at": self.created_at} + + +@dataclass +class FakeSite: + window: OrderListWindow | None = None + fail_with: Exception | None = None + received_since: datetime | None = None + + async def list_recent_orders(self, *, since: datetime) -> OrderListWindow: + self.received_since = since + if self.fail_with is not None: + raise self.fail_with + assert self.window is not None + return self.window + + +def _entry(order_number: str, item_url: str) -> OrderListEntry: + return OrderListEntry( + order_number=order_number, + order_date="2026-08-10T00:00:00Z", + items=[OrderListItem(item_url=item_url)], + ) + + +# ---- intent 缺 item_url:不查任何东西,直接 unknown ---- + + +async def test_missing_item_url_returns_unknown_without_calling_gateway(): + gateway = FakeGateway() + site = FakeSite() + result = await verify.verify_on_site(_make_task(item_url=None), gateway=gateway, site=site) + assert result.verdict == verify.VerifyVerdict.UNKNOWN + assert gateway.calls == [] + + +# ---- 查任务创建时间失败/不可解析 ---- + + +async def test_get_task_failure_returns_unknown(): + gateway = FakeGateway(fail_with=AppError(message="网关挂了", code="X", err_code=9999)) + site = FakeSite() + result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site) + assert result.verdict == verify.VerifyVerdict.UNKNOWN + assert "网关挂了" in result.detail + + +async def test_unparseable_created_at_returns_unknown(): + gateway = FakeGateway(created_at="不是日期") + site = FakeSite() + result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site) + assert result.verdict == verify.VerifyVerdict.UNKNOWN + + +# ---- 站点订单列表查询失败 ---- + + +async def test_site_list_failure_returns_unknown(): + gateway = FakeGateway() + site = FakeSite(fail_with=RuntimeError("Playwright 挂了")) + result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site) + assert result.verdict == verify.VerifyVerdict.UNKNOWN + assert "Playwright 挂了" in result.detail + + +# ---- 命中 1 笔:已下单(2026-08-13 真实账号验证过的分支)---- + + +async def test_exact_one_match_returns_already_ordered(): + gateway = FakeGateway() + site = FakeSite( + window=OrderListWindow( + entries=[_entry("306087-20260813-0863947697", "https://item.rakuten.co.jp/shop/x/")], + window_fully_covered=True, + ) + ) + result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site) + assert result.verdict == verify.VerifyVerdict.ALREADY_ORDERED + assert result.site_order_id == "306087-20260813-0863947697" + + +async def test_match_ignores_query_string_difference(): + """订单列表的 itemUrl 带 ?variantId=...,intent.item_url 通常不带——只比路径""" + gateway = FakeGateway() + site = FakeSite( + window=OrderListWindow( + entries=[_entry("o1", "https://item.rakuten.co.jp/shop/x/?variantId=abc-1")], + window_fully_covered=True, + ) + ) + result = await verify.verify_on_site( + _make_task(item_url="https://item.rakuten.co.jp/shop/x/"), gateway=gateway, site=site + ) + assert result.verdict == verify.VerifyVerdict.ALREADY_ORDERED + + +# ---- 命中 0 笔且窗口确认覆盖完:未下单 ---- + + +async def test_no_match_and_fully_covered_returns_not_ordered(): + gateway = FakeGateway() + site = FakeSite( + window=OrderListWindow( + entries=[_entry("o1", "https://item.rakuten.co.jp/other-shop/y/")], + window_fully_covered=True, + ) + ) + result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site) + assert result.verdict == verify.VerifyVerdict.NOT_ORDERED + + +# ---- 命中 0 笔但窗口没确认覆盖完:不能猜没下单 ---- + + +async def test_no_match_and_not_fully_covered_returns_unknown(): + gateway = FakeGateway() + site = FakeSite(window=OrderListWindow(entries=[], window_fully_covered=False)) + result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site) + assert result.verdict == verify.VerifyVerdict.UNKNOWN + + +# ---- 命中多笔:无法唯一确定 ---- + + +async def test_multiple_matches_returns_unknown(): + gateway = FakeGateway() + site = FakeSite( + window=OrderListWindow( + entries=[ + _entry("o1", "https://item.rakuten.co.jp/shop/x/"), + _entry("o2", "https://item.rakuten.co.jp/shop/x/"), + ], + window_fully_covered=True, + ) + ) + result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site) + assert result.verdict == verify.VerifyVerdict.UNKNOWN + assert "o1" in result.detail and "o2" in result.detail + + +# ---- since 正确传递给 site.list_recent_orders(用任务创建时间,不是别的)---- + + +async def test_since_passed_to_site_matches_task_created_at(): + gateway = FakeGateway(created_at="2026-08-05T12:00:00Z") + site = FakeSite(window=OrderListWindow(entries=[], window_fully_covered=True)) + await verify.verify_on_site(_make_task(), gateway=gateway, site=site) + assert site.received_since == datetime(2026, 8, 5, 12, 0, 0, tzinfo=timezone.utc) diff --git a/tests/test_worker_runner.py b/tests/test_worker_runner.py index b6fed54..1caea2d 100644 --- a/tests/test_worker_runner.py +++ b/tests/test_worker_runner.py @@ -40,11 +40,18 @@ class FakeGateway: renews: int = 0 fail_report_with: Exception | None = None + # verify_on_site 恢复核对会调 get_task 拿 created_at;测试默认给一个可解析 + # 的时间戳,具体核对分支由 monkeypatch 替换 verify.verify_on_site 控制 + task_created_at: str = "2026-01-01T00:00:00Z" + async def lease( self, worker_id: str, *, wait: int = 30, site: str | None = None ) -> LeaseTask | None: return None # 主循环测试不通过 lease 喂数据,直接调 handle + async def get_task(self, task_id: str) -> dict[str, Any]: + return {"task_id": task_id, "created_at": self.task_created_at} + async def renew(self, task_id: str, worker_id: str) -> dict[str, Any]: self.renews += 1 return {"task_id": task_id, "lease_expires_at": "2099-01-01T00:00:00Z"} @@ -193,15 +200,17 @@ async def test_already_finished_task_is_reported_not_re_executed( async def test_recovery_task_with_unknown_verdict_reports_needs_human( runner: WorkerRunner, local_db: LocalDB, monkeypatch ): - """lease_count > 1 + verify 桩返回 unknown → 转 needs_human,不执行""" + """lease_count > 1 + verify 返回 unknown(intent 缺 item_url)→ 转 needs_human,不执行""" - # verify 桩默认返回 unknown,不用 monkeypatch + # verify.verify_on_site 已是真实实现(2026-08-13),但 _make_task 默认 intent 为空, + # 缺 item_url 时函数第一步就短路返回 unknown,不用 monkeypatch 也能测这条分支 await runner.handle(_make_task(task_id="t1", lease_count=2)) gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined] terminal = gateway.last_terminal_report() assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN - assert "无法定论" in terminal["detail"] + assert "unknown" in terminal["detail"] + assert "item_url" in terminal["detail"] async def test_recovery_task_with_already_ordered_verdict_reports_succeeded( @@ -209,7 +218,7 @@ async def test_recovery_task_with_already_ordered_verdict_reports_succeeded( ): """lease_count > 1 + verify 返回 already_ordered → 补报 succeeded,不重新下单""" - async def _already_ordered(task): # noqa: ANN001 + async def _already_ordered(task, **kwargs): # noqa: ANN001 return verify.VerifyResult( verdict=verify.VerifyVerdict.ALREADY_ORDERED, site_order_id="ord-1", @@ -226,8 +235,34 @@ async def test_recovery_task_with_already_ordered_verdict_reports_succeeded( assert terminal["site_order_id"] == "ord-1" assert terminal["terminal_status"] == TaskStatus.SUCCEEDED - # 本地 DB 也应当被标记为已完成 - assert await local_db.has_finished("t1") is True + +async def test_recovery_task_with_not_ordered_verdict_still_reports_needs_human( + runner: WorkerRunner, local_db: LocalDB, monkeypatch +): + """lease_count > 1 + verify 确认 not_ordered → 仍转 needs_human,不自动重新下单 + + 刻意的保守选择(见 runner._handle_recovery 里的注释):NOT_ORDERED 分支目前 + 只有逻辑正确性,没有被真实多单数据验证过,放开自动重新执行前必须显式决定。 + """ + + async def _not_ordered(task, **kwargs): # noqa: ANN001 + return verify.VerifyResult( + verdict=verify.VerifyVerdict.NOT_ORDERED, + detail="已核对任务创建时间之后的全部订单,未找到匹配商品", + ) + + monkeypatch.setattr(verify, "verify_on_site", _not_ordered) + + await runner.handle(_make_task(task_id="t1", lease_count=2)) + + gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined] + terminal = gateway.last_terminal_report() + assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN + assert "not_ordered" in terminal["detail"] + + # needs_human 分支不标记本地完成(与 UNKNOWN 分支一致):只有 ALREADY_ORDERED + # 才 mark_finished,避免把「转人工」误当成本地已完成的终态 + assert await local_db.has_finished("t1") is False # ---- 站点交互未实现 → needs_human(§10 接口缝)----