fix: 售罄临时页仅下拉刷新一次 (#265)
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user