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")
|
||||
|
||||
@@ -93,6 +93,29 @@ class FakeCollectDevice:
|
||||
self.swipe_panel_states.append(self.panel_open)
|
||||
|
||||
|
||||
class SoldOutRefreshCollectDevice(FakeCollectDevice):
|
||||
"""模拟深链先显示售罄,向下拉一次后恢复或继续售罄。"""
|
||||
|
||||
def __init__(self, sold_out_xml, recovered_xml, *, persistent=False):
|
||||
super().__init__(recovered_xml, "")
|
||||
self.sold_out_xml = sold_out_xml
|
||||
self.recovered_xml = recovered_xml
|
||||
self.persistent = persistent
|
||||
self.refreshed = False
|
||||
|
||||
def dump_hierarchy(self):
|
||||
if self.opened_url is None:
|
||||
return '<hierarchy><node package="com.xunmeng.pinduoduo" /></hierarchy>'
|
||||
if not self.refreshed or self.persistent:
|
||||
return self.sold_out_xml
|
||||
return self.recovered_xml
|
||||
|
||||
def swipe(self, x1, y1, x2, y2, duration=0.0):
|
||||
self.swipes.append(((x1, y1, x2, y2), {"duration": duration}))
|
||||
if y2 > y1 and x1 == x2:
|
||||
self.refreshed = True
|
||||
|
||||
|
||||
class GoodsMetadataScrollDevice(FakeCollectDevice):
|
||||
"""模拟评价和店铺分别出现在商品页后续屏幕。"""
|
||||
|
||||
@@ -1764,6 +1787,59 @@ class PddCollectParserTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(raised.exception.code, "PDD_PAGE_TIMEOUT")
|
||||
|
||||
def test_sold_out_page_pulls_down_once_then_recovers_goods(self):
|
||||
device = SoldOutRefreshCollectDevice(
|
||||
self.sold_out_xml,
|
||||
self.home_xml,
|
||||
)
|
||||
service = PddCollectService(
|
||||
PddDeviceService(lambda _serial: device),
|
||||
"USB-001",
|
||||
"client-001",
|
||||
sleeper=lambda _seconds: None,
|
||||
)
|
||||
|
||||
service._open_goods(
|
||||
device,
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
{"package": "com.xunmeng.pinduoduo"},
|
||||
)
|
||||
|
||||
self.assertEqual(len(device.swipes), 1)
|
||||
(x1, y1, x2, y2), options = device.swipes[0]
|
||||
self.assertEqual(x1, x2)
|
||||
self.assertGreater(y2, y1)
|
||||
self.assertEqual(options["duration"], 0.5)
|
||||
self.assertEqual(device.clicks, [])
|
||||
|
||||
def test_sold_out_after_one_refresh_is_unavailable(self):
|
||||
device = SoldOutRefreshCollectDevice(
|
||||
self.sold_out_xml,
|
||||
self.home_xml,
|
||||
persistent=True,
|
||||
)
|
||||
service = PddCollectService(
|
||||
PddDeviceService(lambda _serial: device),
|
||||
"USB-001",
|
||||
"client-001",
|
||||
sleeper=lambda _seconds: None,
|
||||
)
|
||||
|
||||
with self.assertRaises(PddCollectError) as raised:
|
||||
service._open_goods(
|
||||
device,
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
{"package": "com.xunmeng.pinduoduo"},
|
||||
)
|
||||
|
||||
self.assertEqual(raised.exception.code, "PDD_GOODS_UNAVAILABLE")
|
||||
self.assertEqual(
|
||||
raised.exception.diagnostics,
|
||||
{"page_reason": "out_of_stock", "refresh_attempts": 1},
|
||||
)
|
||||
self.assertEqual(len(device.swipes), 1)
|
||||
self.assertEqual(device.clicks, [])
|
||||
|
||||
def test_stable_home_reopens_once_then_stops_before_spec_click(self):
|
||||
home = (FIXTURES / "pdd_home_page.xml").read_text(encoding="utf-8")
|
||||
device = FakeCollectDevice(home, self.spec_xml)
|
||||
|
||||
@@ -6,7 +6,9 @@ import xml.etree.ElementTree as ET
|
||||
|
||||
from src.pdd_page_classifier import (
|
||||
ACTION_NETWORK_ERROR,
|
||||
ACTION_OUT_OF_STOCK,
|
||||
ACTION_READY,
|
||||
ACTION_REFRESH,
|
||||
ACTION_REOPEN,
|
||||
ACTION_UNAVAILABLE,
|
||||
ACTION_WAIT,
|
||||
@@ -17,6 +19,7 @@ from src.pdd_page_classifier import (
|
||||
PAGE_LOADING,
|
||||
PAGE_LOGIN_REQUIRED,
|
||||
PAGE_NETWORK_ERROR,
|
||||
PAGE_OUT_OF_STOCK,
|
||||
PAGE_RISK_CONTROL,
|
||||
GoodsOpenTracker,
|
||||
classify_pdd_page,
|
||||
@@ -54,6 +57,7 @@ class PddPageClassifierTest(unittest.TestCase):
|
||||
"请完成验证": PAGE_CAPTCHA,
|
||||
"操作频繁": PAGE_RISK_CONTROL,
|
||||
"立即购买": PAGE_GOODS,
|
||||
"商品已售罄,推荐以下相似商品": PAGE_OUT_OF_STOCK,
|
||||
}
|
||||
for label, expected in cases.items():
|
||||
with self.subTest(label=label):
|
||||
@@ -103,6 +107,35 @@ class PddPageClassifierTest(unittest.TestCase):
|
||||
decision = tracker.observe(observation("网络不给力"), 3.0)
|
||||
self.assertEqual(ACTION_NETWORK_ERROR, decision.action)
|
||||
|
||||
def test_sold_out_page_refreshes_once_then_can_recover(self) -> None:
|
||||
tracker = GoodsOpenTracker(None, 0.0)
|
||||
sold_out = observation("商品已售罄,推荐以下相似商品")
|
||||
|
||||
self.assertEqual(ACTION_REFRESH, tracker.observe(sold_out, 0.2).action)
|
||||
tracker.refreshed_out_of_stock(0.3)
|
||||
self.assertTrue(tracker.out_of_stock_refreshed)
|
||||
self.assertEqual(
|
||||
ACTION_READY,
|
||||
tracker.observe(observation("目标商品 立即购买"), 0.8).action,
|
||||
)
|
||||
|
||||
def test_sold_out_after_refresh_is_unavailable_without_second_refresh(self) -> None:
|
||||
tracker = GoodsOpenTracker(None, 0.0)
|
||||
sold_out = observation("该商品已售罄")
|
||||
|
||||
self.assertEqual(ACTION_REFRESH, tracker.observe(sold_out, 0.2).action)
|
||||
tracker.refreshed_out_of_stock(0.3)
|
||||
self.assertEqual(
|
||||
ACTION_OUT_OF_STOCK,
|
||||
tracker.observe(sold_out, 0.8).action,
|
||||
)
|
||||
with self.assertRaises(RuntimeError):
|
||||
tracker.refreshed_out_of_stock(0.9)
|
||||
|
||||
def test_sku_sold_out_text_is_still_a_normal_goods_page(self) -> None:
|
||||
result = observation("立即购买 尺码 L(售罄)")
|
||||
self.assertEqual(PAGE_GOODS, result.kind)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -234,6 +234,29 @@ class FakeDevice:
|
||||
raise AssertionError(f"未预期的 selector: {selector}")
|
||||
|
||||
|
||||
class SoldOutRefreshPurchaseDevice(FakeDevice):
|
||||
"""模拟采购深链先显示售罄,下拉一次后恢复或继续售罄。"""
|
||||
|
||||
def __init__(self, sold_out_xml: str, *, persistent: bool = False) -> None:
|
||||
super().__init__(sold_out_xml)
|
||||
self.sold_out_xml = sold_out_xml
|
||||
self.persistent = persistent
|
||||
self.refreshed = False
|
||||
self.swipes = []
|
||||
|
||||
def dump_hierarchy(self):
|
||||
if not self.has_opened:
|
||||
return '<hierarchy><node package="com.xunmeng.pinduoduo" /></hierarchy>'
|
||||
if not self.refreshed or self.persistent:
|
||||
return self.sold_out_xml
|
||||
return home_xml()
|
||||
|
||||
def swipe(self, x1, y1, x2, y2, duration=0.0):
|
||||
self.swipes.append((x1, y1, x2, y2, duration))
|
||||
if y2 > y1 and x1 == x2:
|
||||
self.refreshed = True
|
||||
|
||||
|
||||
class VerticallyScrolledColorPanelDevice(FakeDevice):
|
||||
"""模拟 PDD 记住规格面板纵向位置。"""
|
||||
|
||||
@@ -2143,6 +2166,46 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
||||
self.assertEqual(raised.exception.code, "PDD_PAGE_CAPTCHA")
|
||||
self.assertEqual(device.clicks, [])
|
||||
|
||||
def test_sold_out_page_pulls_down_once_then_recovers_goods(self):
|
||||
sold_out_xml = (
|
||||
FIXTURES / "pdd_sold_out_recommendations.xml"
|
||||
).read_text(encoding="utf-8")
|
||||
device = SoldOutRefreshPurchaseDevice(sold_out_xml)
|
||||
adapter = self._adapter(device, [])
|
||||
|
||||
adapter.open_goods(GOODS_URL)
|
||||
|
||||
self.assertEqual(len(device.swipes), 1)
|
||||
x1, y1, x2, y2, duration = device.swipes[0]
|
||||
self.assertEqual(x1, x2)
|
||||
self.assertGreater(y2, y1)
|
||||
self.assertEqual(duration, 0.5)
|
||||
self.assertEqual(device.clicks, [])
|
||||
adapter.close()
|
||||
|
||||
def test_sold_out_after_one_refresh_is_unavailable(self):
|
||||
sold_out_xml = (
|
||||
FIXTURES / "pdd_sold_out_recommendations.xml"
|
||||
).read_text(encoding="utf-8")
|
||||
device = SoldOutRefreshPurchaseDevice(
|
||||
sold_out_xml,
|
||||
persistent=True,
|
||||
)
|
||||
adapter = self._adapter(device, [])
|
||||
|
||||
with self.assertRaises(PddPurchaseError) as raised:
|
||||
adapter.open_goods(GOODS_URL)
|
||||
|
||||
self.assertEqual(raised.exception.code, "PDD_GOODS_UNAVAILABLE")
|
||||
self.assertFalse(raised.exception.retryable)
|
||||
self.assertEqual(
|
||||
raised.exception.diagnostics,
|
||||
{"page_reason": "out_of_stock", "refresh_attempts": 1},
|
||||
)
|
||||
self.assertEqual(len(device.swipes), 1)
|
||||
self.assertEqual(device.clicks, [])
|
||||
adapter.close()
|
||||
|
||||
def test_stable_home_reopens_once_then_stops_before_click(self):
|
||||
device = FakeDevice(
|
||||
(FIXTURES / "pdd_home_page.xml").read_text(encoding="utf-8")
|
||||
|
||||
@@ -145,6 +145,12 @@ Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode`
|
||||
商品失效。打开前后的商品页控件树签名相同则视为旧页面,不能据此执行规格选择或
|
||||
下单;采购进入不可逆阶段前仍须按商品编号等业务字段再次确认目标身份。
|
||||
|
||||
深链打开阶段若出现明确的页面级“商品已售罄”或“该商品暂时缺货”,采集和采购
|
||||
共用的状态机只允许在 PDD 内容区向下拉刷新一次,不点击推荐商品或其他控件。
|
||||
刷新后恢复正常商品页才继续;仍显示售罄则以 `PDD_GOODS_UNAVAILABLE` 结束当前
|
||||
任务。SKU、尺码或规格按钮中的普通“售罄”不属于整页售罄,不能触发刷新。采购
|
||||
刷新必须发生在规格选择和不可逆标记之前。
|
||||
|
||||
商品页和规格面板的页面轮询不得在每一轮依赖 `app_current()`:部分设备的前台查询会阻塞十秒以上,
|
||||
还可能把已经显示 PDD 的页面报告为系统设置。连接时可以读取一次前台状态;后续
|
||||
商品页和规格面板判断优先使用最新控件树中的包名与组合语义。单次设备调用即使跨过
|
||||
|
||||
Reference in New Issue
Block a user