feat(trading): 下单流程每步证据带整页截图(html + png + meta 三件套)
- site_interact 新增 PageSnapshot{html, screenshot} 证据载体:
add_to_cart 截商品页、verify_cart 截 cart 页、enter_checkout 截落地
确认页、pay 截付款检查后的完成页;截图全部 best-effort,失败记
warning 留空,不掩盖动作结果
- submit_order 改返回 SubmitOutcome{site_order_id, evidence}:完成页
HTML+截图首次随 step 4 落盘(此前只有 meta)
- fetch_order_detail 详情页截图挂在 OrderStatusSnapshot.screenshot,
监控步骤 write_step 带 png;只读查询通道按字段挑出,不受影响
- runner._run_step 认 PageSnapshot(旧 str 契约保留兼容),step 0/3/4
显式传 png
- 测试:桩同步新契约;新增每步三件套齐全的全流程断言;clear_cart
场景单测补截图断言
- 真账号复验 verify_cart_clear.py:两场景截图均合法 PNG,落盘
.probe/cart_clear/ 并目检为空车页渲染
This commit is contained in:
@@ -529,6 +529,30 @@ def _accumulate_order_list_page(
|
||||
return new_acc, False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PageSnapshot:
|
||||
"""一步站点交互带回的页面证据:渲染后 HTML + 整页截图
|
||||
|
||||
供 runner 给每个步骤落证据(html → {step}.html,screenshot → {step}.png)。
|
||||
两者都是 best-effort:页面抓取/截图失败时对应字段为空,不掩盖动作本身的结果。
|
||||
"""
|
||||
|
||||
html: str = ""
|
||||
screenshot: bytes = b""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SubmitOutcome:
|
||||
"""submit_order 的返回值:站点订单号 + 下单完成页证据
|
||||
|
||||
完成页(「ご注文がショップに送信されました」)是「订单真的提交了」最直接的
|
||||
现场,HTML 与截图随返回值带出,供 runner step 4 落证据。
|
||||
"""
|
||||
|
||||
site_order_id: str
|
||||
evidence: PageSnapshot
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CheckoutSummary:
|
||||
"""下单确认页解析结果(待实测确认字段位置)
|
||||
@@ -565,6 +589,8 @@ class OrderStatusSnapshot:
|
||||
order_state: OrderState | None = None
|
||||
delivery_status: str | None = None
|
||||
html: str = ""
|
||||
# 详情页整页截图(fetch_order_detail 填,best-effort),供监控步骤落证据
|
||||
screenshot: bytes = b""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -770,7 +796,7 @@ class SiteInteractor:
|
||||
|
||||
# ---- 已实现:add_to_cart / verify_cart / cart_status ----
|
||||
|
||||
async def add_to_cart(self, task: LeaseTask) -> str | None:
|
||||
async def add_to_cart(self, task: LeaseTask) -> PageSnapshot:
|
||||
"""加购(worker 入口):从 task.intent 取字段,调 _add_to_cart_with_fields
|
||||
|
||||
调用方需在 task.intent 提供:
|
||||
@@ -780,7 +806,7 @@ class SiteInteractor:
|
||||
- choice: 必填选项的取值列表;不传则每个必填选项用第一个候选值(站点不严格校验)
|
||||
|
||||
Returns:
|
||||
加购成功后落地页(cart 页)的 HTML,供 runner 落证据;查不到时为 None。
|
||||
PageSnapshot:加购响应的落地页 HTML + 商品页整页截图,供 runner 落证据。
|
||||
|
||||
Raises:
|
||||
InvalidRequestError: intent.item_url 缺失
|
||||
@@ -807,7 +833,10 @@ class SiteInteractor:
|
||||
"shop_bid": result["shop_bid"],
|
||||
"basket_domain": result["basket_domain"],
|
||||
}
|
||||
return result.get("response_html")
|
||||
return PageSnapshot(
|
||||
html=result.get("response_html") or "",
|
||||
screenshot=result.get("screenshot") or b"",
|
||||
)
|
||||
|
||||
async def add_to_cart_payload(
|
||||
self,
|
||||
@@ -846,8 +875,9 @@ class SiteInteractor:
|
||||
) -> dict:
|
||||
"""加购核心逻辑(不持锁,由调用方包裹 self._lock)
|
||||
|
||||
返回 dict:{item_id, shop_bid, basket_domain, cart_count}
|
||||
返回 dict:{item_id, shop_bid, basket_domain, cart_count, response_html, screenshot}
|
||||
cart_count 在加购成功后顺带查一次 cart count API,方便 HTTP 调用方一次性返回。
|
||||
response_html / screenshot 供 worker 入口包成 PageSnapshot 落证据。
|
||||
"""
|
||||
await self._auth_session.require_logged_in("rakuten")
|
||||
await self._refresh_context_if_stale()
|
||||
@@ -928,6 +958,14 @@ class SiteInteractor:
|
||||
"加购成功:item_id=%s final=%s",
|
||||
fields["form_fields"].get("item_id"), final_url,
|
||||
)
|
||||
|
||||
# 商品页整页截图随结果带出(runner step 1 落证据用);
|
||||
# 截图失败只记日志,不掩盖加购结果
|
||||
screenshot = b""
|
||||
try:
|
||||
screenshot = await page.screenshot(full_page=True)
|
||||
except Exception:
|
||||
logger.warning("加购后截取商品页截图失败(不影响加购结果)", exc_info=True)
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
@@ -944,9 +982,10 @@ class SiteInteractor:
|
||||
"basket_domain": fields["basket_domain"],
|
||||
"cart_count": cart_count,
|
||||
"response_html": body,
|
||||
"screenshot": screenshot,
|
||||
}
|
||||
|
||||
async def verify_cart(self, task: LeaseTask) -> str | None:
|
||||
async def verify_cart(self, task: LeaseTask) -> PageSnapshot:
|
||||
"""校验购物车里有没有刚加的商品
|
||||
|
||||
策略(探针实测最稳的两步):
|
||||
@@ -955,7 +994,7 @@ class SiteInteractor:
|
||||
2. 打开 cart 页等 SPA 渲染,在 HTML 里找 item_id
|
||||
|
||||
Returns:
|
||||
渲染后的 cart 页 HTML,供 runner 落证据。
|
||||
PageSnapshot:渲染后的 cart 页 HTML + 整页截图,供 runner 落证据。
|
||||
|
||||
登录态失效抛 NotLoggedInError;找不到 item 抛 CartOperationError。
|
||||
"""
|
||||
@@ -1010,11 +1049,11 @@ class SiteInteractor:
|
||||
- 若 SPA 把按钮渲染在 iframe 里,selector 失败需实测后调整
|
||||
- Rakuten cart item 卡片无 data-testid,本方法不依赖 DOM 结构定位
|
||||
|
||||
返回 {removed_count, cart_count, html};cart_count=-1 表示末尾 count API
|
||||
调用失败(空车属正常结果返回 0,见 _query_cart_count 的 status=101 处理)。
|
||||
html 是清理结束后 cart 页的最终渲染结果,供调用方落证据排查——清理没跑干净
|
||||
被 runner 闸门拦下时这份现场就是排查依据;HTTP /api/cart/clear 入口的响应
|
||||
模型不携带它。
|
||||
返回 {removed_count, cart_count, html, screenshot};cart_count=-1 表示末尾
|
||||
count API 调用失败(空车属正常结果返回 0,见 _query_cart_count 的
|
||||
status=101 处理)。html 与 screenshot 是清理结束后 cart 页的最终渲染结果
|
||||
与整页截图,供调用方落证据排查——清理没跑干净被 runner 闸门拦下时这份现场
|
||||
就是排查依据;HTTP /api/cart/clear 入口的响应模型不携带它们。
|
||||
"""
|
||||
async with self._lock:
|
||||
await self._auth_session.require_logged_in("rakuten")
|
||||
@@ -1023,6 +1062,7 @@ class SiteInteractor:
|
||||
page = await self._context.new_page()
|
||||
removed = 0
|
||||
html = ""
|
||||
screenshot = b""
|
||||
try:
|
||||
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
|
||||
await self._wait_cart_rendered(page, label="clear_cart")
|
||||
@@ -1052,8 +1092,8 @@ class SiteInteractor:
|
||||
_CLEAR_CART_MAX_ITER,
|
||||
)
|
||||
|
||||
# 清理结束后的最终页面留给调用方落证据(worker runner step 0);
|
||||
# 抓取失败只记日志,不把排查用的副作用变成新的失败源
|
||||
# 清理结束后的最终页面与整页截图留给调用方落证据(worker runner
|
||||
# step 0);抓取失败只记日志,不把排查用的副作用变成新的失败源
|
||||
try:
|
||||
html = await page.content()
|
||||
except Exception:
|
||||
@@ -1061,6 +1101,13 @@ class SiteInteractor:
|
||||
"clear_cart:抓取最终页面 HTML 失败(不影响清理结果)",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
screenshot = await page.screenshot(full_page=True)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"clear_cart:截取最终页面截图失败(不影响清理结果)",
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
@@ -1072,7 +1119,12 @@ class SiteInteractor:
|
||||
cart_count = -1
|
||||
|
||||
logger.info("clear_cart 完成:removed=%s cart_count=%s", removed, cart_count)
|
||||
return {"removed_count": removed, "cart_count": cart_count, "html": html}
|
||||
return {
|
||||
"removed_count": removed,
|
||||
"cart_count": cart_count,
|
||||
"html": html,
|
||||
"screenshot": screenshot,
|
||||
}
|
||||
|
||||
async def remove_item(self, item_id: str) -> dict:
|
||||
"""删除购物车里指定 item_id 的商品
|
||||
@@ -1179,10 +1231,10 @@ class SiteInteractor:
|
||||
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:
|
||||
async def _verify_item_in_cart_html(self, item_id: str, *, label: str) -> PageSnapshot:
|
||||
"""渲染 cart SPA,在 HTML 里找 item_id,确认商品确实进了购物车
|
||||
|
||||
返回渲染后的 cart 页 HTML(校验通过时),供调用方落证据。
|
||||
返回渲染后的 cart 页 HTML + 整页截图(校验通过时),供调用方落证据。
|
||||
"""
|
||||
page = await self._context.new_page()
|
||||
try:
|
||||
@@ -1201,7 +1253,13 @@ class SiteInteractor:
|
||||
f"购物车页未找到 item_id={item_id}(加购可能被服务端静默丢弃)"
|
||||
)
|
||||
logger.info("cart 校验通过:%s item_id=%s in cart HTML", label, item_id)
|
||||
return html
|
||||
# 校验通过的 cart 页整页截图随结果带出;失败不掩盖校验结果
|
||||
screenshot = b""
|
||||
try:
|
||||
screenshot = await page.screenshot(full_page=True)
|
||||
except Exception:
|
||||
logger.warning("cart 校验后截图失败(不影响校验结果)", exc_info=True)
|
||||
return PageSnapshot(html=html, screenshot=screenshot)
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
@@ -1269,7 +1327,7 @@ class SiteInteractor:
|
||||
|
||||
# ---- 已实现:enter_checkout(到下单确认页,中间步骤未经真实 HTML 验证)----
|
||||
|
||||
async def enter_checkout(self, task: LeaseTask) -> str:
|
||||
async def enter_checkout(self, task: LeaseTask) -> PageSnapshot:
|
||||
"""进入下单确认页:购物车 → 点「購入手続き」→ 依次处理中间步骤 → 落地确认页
|
||||
|
||||
流程:
|
||||
@@ -1297,7 +1355,8 @@ class SiteInteractor:
|
||||
页面重开拿不回同一份订单草稿。
|
||||
|
||||
Returns:
|
||||
下单确认页(或目前所能到达的最后一页)的 HTML,供 runner 落证据。
|
||||
PageSnapshot:下单确认页(或目前所能到达的最后一页)的 HTML + 整页截图,
|
||||
供 runner 落证据。
|
||||
|
||||
Raises:
|
||||
NotLoggedInError: 登录态失效
|
||||
@@ -1374,9 +1433,18 @@ class SiteInteractor:
|
||||
"enter_checkout 落地:task_id=%s url=%s", task.task_id, page.url
|
||||
)
|
||||
html = await page.content()
|
||||
# 落地页整页截图随结果带出(step 3 落证据用);页面要保持打开供
|
||||
# submit_order 复用,截图失败只记日志不掩盖落地结果
|
||||
screenshot = b""
|
||||
try:
|
||||
screenshot = await page.screenshot(full_page=True)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"enter_checkout 落地页截图失败(不影响落地结果)", exc_info=True,
|
||||
)
|
||||
self._checkout_pages[task.task_id] = page
|
||||
success = True
|
||||
return html
|
||||
return PageSnapshot(html=html, screenshot=screenshot)
|
||||
except Exception:
|
||||
await self._dump_debug_snapshot(
|
||||
page, task_id=task.task_id, label="enter_checkout-error"
|
||||
@@ -1770,7 +1838,7 @@ class SiteInteractor:
|
||||
"""
|
||||
return _parse_checkout_summary(html)
|
||||
|
||||
async def submit_order(self, task: LeaseTask) -> str:
|
||||
async def submit_order(self, task: LeaseTask) -> SubmitOutcome:
|
||||
"""点击下单确认页的最终确认按钮,提交订单
|
||||
|
||||
调用前必须已经过 enter_checkout(本方法复用它留存的 Page)与金额守卫;
|
||||
@@ -1778,7 +1846,8 @@ class SiteInteractor:
|
||||
**本会话内从未被真实执行过**。
|
||||
|
||||
Returns:
|
||||
从提交后页面解析出的站点订单号;解析不出时抛错,不返回猜测值。
|
||||
SubmitOutcome:从提交后页面解析出的站点订单号 + 完成页证据(HTML +
|
||||
整页截图);订单号解析不出时抛错,不返回猜测值。
|
||||
|
||||
Raises:
|
||||
OrderOperationError: 找不到 enter_checkout 留存的确认页会话 / 找不到
|
||||
@@ -1849,12 +1918,24 @@ class SiteInteractor:
|
||||
"(可能已经下单成功,需要人工核对站点订单列表,不要按失败重试)"
|
||||
)
|
||||
site_order_id = match.group(1)
|
||||
# 完成页整页截图随返回值带出(step 4 落证据用);页面留着给 pay 复用,
|
||||
# 截图失败只记日志不掩盖提交结果
|
||||
screenshot = b""
|
||||
try:
|
||||
screenshot = await page.screenshot(full_page=True)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"submit_order 完成页截图失败(不影响提交结果)", exc_info=True,
|
||||
)
|
||||
logger.info(
|
||||
"submit_order 完成:task_id=%s site_order_id=%s", task.task_id, site_order_id
|
||||
)
|
||||
return site_order_id
|
||||
return SubmitOutcome(
|
||||
site_order_id=site_order_id,
|
||||
evidence=PageSnapshot(html=html, screenshot=screenshot),
|
||||
)
|
||||
|
||||
async def pay(self, task: LeaseTask, site_order_id: str) -> None:
|
||||
async def pay(self, task: LeaseTask, site_order_id: str) -> PageSnapshot:
|
||||
"""检查提交下单后是否已完成付款 / 是否触发了需要人工介入的验证环节
|
||||
|
||||
按 docs/order-gateway.md §10.1 既定方案实现:检测到常见 3DS/短信 OTP 迹象
|
||||
@@ -1865,6 +1946,10 @@ class SiteInteractor:
|
||||
|
||||
本方法结束时无论成败都会关闭 self._checkout_pages 里留存的 Page
|
||||
(提交/付款环节已经走到这一步,没有再复用同一页面的后续步骤)。
|
||||
|
||||
Returns:
|
||||
PageSnapshot:付款检查通过后的页面(通常仍是下单完成页)HTML +
|
||||
整页截图,供 runner step 5 落证据。
|
||||
"""
|
||||
async with self._lock:
|
||||
page = self._checkout_pages.pop(task.task_id, None)
|
||||
@@ -1888,6 +1973,19 @@ class SiteInteractor:
|
||||
"假设):task_id=%s site_order_id=%s",
|
||||
task.task_id, site_order_id,
|
||||
)
|
||||
# 付款检查通过后的页面留证据(runner step 5):此时通常仍是下单
|
||||
# 完成页。抓取失败只记日志,不掩盖付款判定
|
||||
html = ""
|
||||
screenshot = b""
|
||||
try:
|
||||
html = await page.content()
|
||||
except Exception:
|
||||
logger.warning("pay 页面 HTML 抓取失败(不影响付款判定)", exc_info=True)
|
||||
try:
|
||||
screenshot = await page.screenshot(full_page=True)
|
||||
except Exception:
|
||||
logger.warning("pay 页面截图失败(不影响付款判定)", exc_info=True)
|
||||
return PageSnapshot(html=html, screenshot=screenshot)
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
@@ -1948,6 +2046,12 @@ class SiteInteractor:
|
||||
) from exc
|
||||
html = await page.content()
|
||||
final_url = page.url
|
||||
# 详情页整页截图随 snapshot 带出(监控步骤落证据用,best-effort)
|
||||
screenshot = b""
|
||||
try:
|
||||
screenshot = await page.screenshot(full_page=True)
|
||||
except Exception:
|
||||
logger.warning("订单详情页截图失败(不影响读取结果)", exc_info=True)
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
@@ -1962,6 +2066,7 @@ class SiteInteractor:
|
||||
snapshot = _parse_order_detail_status(state, site_order_id) or _parse_order_status(
|
||||
html, site_order_id
|
||||
)
|
||||
snapshot.screenshot = screenshot
|
||||
if not snapshot.found and auth_site.looks_logged_out(
|
||||
"rakuten", final_url=final_url, body=html
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user