diff --git a/client/src/pdd_collect_service.py b/client/src/pdd_collect_service.py
index 31a242f..b5005a5 100644
--- a/client/src/pdd_collect_service.py
+++ b/client/src/pdd_collect_service.py
@@ -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")
diff --git a/client/src/pdd_goods_refresh.py b/client/src/pdd_goods_refresh.py
new file mode 100644
index 0000000..badac59
--- /dev/null
+++ b/client/src/pdd_goods_refresh.py
@@ -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)
diff --git a/client/src/pdd_page_classifier.py b/client/src/pdd_page_classifier.py
index ba39346..7987965 100644
--- a/client/src/pdd_page_classifier.py
+++ b/client/src/pdd_page_classifier.py
@@ -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)
diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py
index 0d5810c..296313f 100644
--- a/client/src/pdd_u2_purchase_adapter.py
+++ b/client/src/pdd_u2_purchase_adapter.py
@@ -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")
diff --git a/client/test/test_pdd_collect_service.py b/client/test/test_pdd_collect_service.py
index 5e635d2..847b253 100644
--- a/client/test/test_pdd_collect_service.py
+++ b/client/test/test_pdd_collect_service.py
@@ -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 ''
+ 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)
diff --git a/client/test/test_pdd_page_classifier.py b/client/test/test_pdd_page_classifier.py
index 1913868..1086fc7 100644
--- a/client/test/test_pdd_page_classifier.py
+++ b/client/test/test_pdd_page_classifier.py
@@ -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()
diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py
index d67e717..c6531fe 100644
--- a/client/test/test_pdd_u2_purchase_adapter.py
+++ b/client/test/test_pdd_u2_purchase_adapter.py
@@ -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 ''
+ 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")
diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md
index 4b038b1..7c1fe1e 100644
--- a/docs/client/06-quality-security.md
+++ b/docs/client/06-quality-security.md
@@ -145,6 +145,12 @@ Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode`
商品失效。打开前后的商品页控件树签名相同则视为旧页面,不能据此执行规格选择或
下单;采购进入不可逆阶段前仍须按商品编号等业务字段再次确认目标身份。
+深链打开阶段若出现明确的页面级“商品已售罄”或“该商品暂时缺货”,采集和采购
+共用的状态机只允许在 PDD 内容区向下拉刷新一次,不点击推荐商品或其他控件。
+刷新后恢复正常商品页才继续;仍显示售罄则以 `PDD_GOODS_UNAVAILABLE` 结束当前
+任务。SKU、尺码或规格按钮中的普通“售罄”不属于整页售罄,不能触发刷新。采购
+刷新必须发生在规格选择和不可逆标记之前。
+
商品页和规格面板的页面轮询不得在每一轮依赖 `app_current()`:部分设备的前台查询会阻塞十秒以上,
还可能把已经显示 PDD 的页面报告为系统设置。连接时可以读取一次前台状态;后续
商品页和规格面板判断优先使用最新控件树中的包名与组合语义。单次设备调用即使跨过