fix: 售罄临时页仅下拉刷新一次 (#265)

This commit is contained in:
chengma
2026-08-18 11:58:23 +08:00
parent fc13b2c7a3
commit e7ac5a470f
8 changed files with 304 additions and 0 deletions
+25
View File
@@ -20,10 +20,13 @@ from typing import Any, Callable, Iterable, Mapping, Optional, Sequence
from urllib.parse import parse_qs, urlparse
from .pdd_device_service import PDD_PACKAGE_NAME, PddDeviceError, PddDeviceService
from .pdd_goods_refresh import pull_down_to_refresh
from .performance_timing import current_performance_trace
from .pdd_page_classifier import (
ACTION_NETWORK_ERROR,
ACTION_OUT_OF_STOCK,
ACTION_READY,
ACTION_REFRESH,
ACTION_REOPEN,
ACTION_UNAVAILABLE,
PAGE_CAPTCHA,
@@ -1276,6 +1279,28 @@ class PddCollectService:
if trace is not None:
trace.checkpoint("end_to_end_total")
return
if decision.action == ACTION_REFRESH:
try:
pull_down_to_refresh(device, root)
except Exception as exc:
raise PddCollectError(
"PDD_PAGE_REFRESH_FAILED",
f"商品售罄临时页无法安全下拉刷新:{exc}",
{"page_reason": "out_of_stock"},
) from exc
tracker.refreshed_out_of_stock(self._monotonic())
self._sleep(0.5)
last_recorded = ""
continue
if decision.action == ACTION_OUT_OF_STOCK:
raise PddCollectError(
"PDD_GOODS_UNAVAILABLE",
"商品下拉刷新后仍显示已售罄",
{
"page_reason": "out_of_stock",
"refresh_attempts": 1,
},
)
if decision.action == ACTION_REOPEN:
with (
trace.stage("open_url_retry")
+36
View File
@@ -0,0 +1,36 @@
"""PDD 商品页一次性下拉刷新手势;不负责页面分类或重试决策。"""
import re
import xml.etree.ElementTree as ET
from typing import Any
_BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
def pull_down_to_refresh(device: Any, root: ET.Element) -> None:
"""在最新控件树的内容区执行一次向下拉手势。
调用方必须已经把页面分类为明确的整页售罄。手势从屏幕中上部开始,
避开系统状态栏,并且不点击任何页面节点。
"""
right = 0
bottom = 0
for node in root.iter("node"):
match = _BOUNDS_PATTERN.fullmatch(node.get("bounds", ""))
if match is None:
continue
_left, _top, node_right, node_bottom = map(int, match.groups())
right = max(right, node_right)
bottom = max(bottom, node_bottom)
if right < 300 or bottom < 600:
raise ValueError("售罄页面没有可靠的屏幕边界,不能安全下拉刷新")
x = right // 2
start_y = int(bottom * 0.28)
end_y = int(bottom * 0.72)
if end_y - start_y < 240:
raise ValueError("售罄页面可用刷新区域过小")
device.swipe(x, start_y, x, end_y, duration=0.5)
+36
View File
@@ -24,6 +24,7 @@ PAGE_CAPTCHA = "captcha"
PAGE_RISK_CONTROL = "risk_control"
PAGE_PAYMENT = "payment"
PAGE_ORDER_CONFIRMATION = "order_confirmation"
PAGE_OUT_OF_STOCK = "out_of_stock"
PAGE_UNKNOWN = "unknown"
ACTION_WAIT = "wait"
@@ -31,6 +32,8 @@ ACTION_READY = "ready"
ACTION_REOPEN = "reopen"
ACTION_UNAVAILABLE = "unavailable"
ACTION_NETWORK_ERROR = "network_error"
ACTION_REFRESH = "refresh"
ACTION_OUT_OF_STOCK = "out_of_stock"
_READY_MARKERS = ("发起拼单", "立即购买", "单独购买", "免拼购买", "快要抢光")
_LOGIN_MARKERS = ("手机号登录", "登录后继续", "验证码登录", "账号登录")
@@ -38,6 +41,12 @@ _CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击
_RISK_MARKERS = ("操作频繁", "异常请求", "风险提示", "账号异常")
_PAYMENT_MARKERS = ("输入支付密码", "立即支付", "支付成功", "支付失败")
_FINAL_SUBMIT_MARKERS = ("提交订单", "现在买,仅", "确认购买")
_OUT_OF_STOCK_PAGE_MARKERS = (
"商品已售罄",
"该商品已售罄",
"商品暂时缺货",
"该商品暂时缺货",
)
_NETWORK_MARKERS = (
"网络不给力",
"网络异常",
@@ -95,6 +104,10 @@ def classify_pdd_page(
kind = PAGE_PAYMENT
elif any(marker in combined for marker in _FINAL_SUBMIT_MARKERS):
kind = PAGE_ORDER_CONFIRMATION
elif is_pdd and any(
marker in combined for marker in _OUT_OF_STOCK_PAGE_MARKERS
):
kind = PAGE_OUT_OF_STOCK
elif is_pdd and any(marker in combined for marker in _NETWORK_MARKERS):
kind = PAGE_NETWORK_ERROR
elif is_pdd and any(marker in combined for marker in _LOADING_MARKERS):
@@ -137,6 +150,7 @@ class GoodsOpenTracker:
self._attempt = 1
self._home_reads = 0
self._stale_goods_seen = False
self._out_of_stock_refreshed = False
@property
def attempt(self) -> int:
@@ -146,6 +160,10 @@ class GoodsOpenTracker:
def stale_goods_seen(self) -> bool:
return self._stale_goods_seen
@property
def out_of_stock_refreshed(self) -> bool:
return self._out_of_stock_refreshed
def reopened(self, opened_at: float) -> None:
"""调用方重新打开同一 URL 后开始第二次、也是最后一次观察。"""
@@ -155,6 +173,15 @@ class GoodsOpenTracker:
self._opened_at = float(opened_at)
self._home_reads = 0
def refreshed_out_of_stock(self, opened_at: float) -> None:
"""记录唯一一次售罄页下拉刷新,后续售罄直接失败。"""
if self._out_of_stock_refreshed:
raise RuntimeError("售罄页面最多只允许下拉刷新一次")
self._out_of_stock_refreshed = True
self._opened_at = float(opened_at)
self._home_reads = 0
def observe(
self,
observation: PddPageObservation,
@@ -178,6 +205,15 @@ class GoodsOpenTracker:
self._home_reads = 0
return GoodsOpenDecision(ACTION_NETWORK_ERROR, self._attempt)
if observation.kind == PAGE_OUT_OF_STOCK:
self._home_reads = 0
action = (
ACTION_OUT_OF_STOCK
if self._out_of_stock_refreshed
else ACTION_REFRESH
)
return GoodsOpenDecision(action, self._attempt)
if observation.kind != PAGE_HOME:
self._home_reads = 0
return GoodsOpenDecision(ACTION_WAIT, self._attempt)
+29
View File
@@ -24,6 +24,7 @@ from .pdd_device_service import (
PddDeviceService,
current_thread_device_service,
)
from .pdd_goods_refresh import pull_down_to_refresh
from .performance_timing import current_performance_trace
from .pdd_collect_service import (
PddCollectError,
@@ -32,7 +33,9 @@ from .pdd_collect_service import (
)
from .pdd_page_classifier import (
ACTION_NETWORK_ERROR,
ACTION_OUT_OF_STOCK,
ACTION_READY,
ACTION_REFRESH,
ACTION_REOPEN,
ACTION_UNAVAILABLE,
PAGE_CAPTCHA,
@@ -1601,6 +1604,32 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
if trace is not None:
trace.checkpoint("end_to_end_total")
return xml_data
if decision.action == ACTION_REFRESH:
try:
pull_down_to_refresh(device, root)
except Exception as exc:
raise PddPurchaseError(
"PDD_PAGE_REFRESH_FAILED",
f"商品售罄临时页无法安全下拉刷新:{exc}",
step="purchase_open_goods",
retryable=True,
diagnostics={"page_reason": "out_of_stock"},
) from exc
tracker.refreshed_out_of_stock(self._monotonic())
self._sleep(0.5)
last_recorded = ""
continue
if decision.action == ACTION_OUT_OF_STOCK:
raise PddPurchaseError(
"PDD_GOODS_UNAVAILABLE",
"商品下拉刷新后仍显示已售罄",
step="purchase_open_goods",
retryable=False,
diagnostics={
"page_reason": "out_of_stock",
"refresh_attempts": 1,
},
)
if decision.action == ACTION_REOPEN:
with (
trace.stage("open_url_retry")