fix: 识别失效商品链接返回首页 (#111)

This commit is contained in:
chengma
2026-08-10 18:20:34 +08:00
parent 3b659eba0a
commit 8c9dbfed44
15 changed files with 802 additions and 62 deletions
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
2026-08-10 从真实 PDD 首页控件树裁剪并脱敏。
只保留页面分类需要的包名、首页导航、选中状态和真实坐标;
已删除推荐商品、未读数量、账号及其他业务内容。
-->
<hierarchy rotation="0">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout"
bounds="[0,0][1080,2376]" visible-to-user="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
text="推荐" selected="true" bounds="[42,292][144,361]"
visible-to-user="true" />
<node package="com.xunmeng.pinduoduo" class="android.widget.RelativeLayout"
content-desc="首页" clickable="true" selected="false"
bounds="[0,2181][216,2328]" visible-to-user="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
resource-id="com.xunmeng.pinduoduo:id/pdd" text="首页"
selected="true" bounds="[78,2281][138,2316]"
visible-to-user="true" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
resource-id="com.xunmeng.pinduoduo:id/pdd" text="聊天"
selected="false" bounds="[726,2281][786,2316]"
visible-to-user="true" />
<node package="com.xunmeng.pinduoduo" class="android.widget.RelativeLayout"
content-desc="个人中心" clickable="true" selected="false"
bounds="[864,2181][1080,2328]" visible-to-user="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
resource-id="com.xunmeng.pinduoduo:id/pdd" text="个人中心"
selected="false" bounds="[912,2281][1032,2316]"
visible-to-user="true" />
</node>
</node>
</hierarchy>
+13
View File
@@ -164,6 +164,19 @@ class CollectTaskServiceTest(unittest.TestCase):
)
self.assertEqual(self.gateway.submission_count, 1)
def test_unavailable_goods_is_terminal_but_allows_next_task(self):
message = "商品链接已失效,PDD 无法打开商品详情页并返回了首页"
outcome = self._service(
[], PddCollectError("PDD_GOODS_UNAVAILABLE", message)
).execute_one()
self.assertEqual("business_failed", outcome.kind)
self.assertEqual(message, outcome.message)
detail = self.repository.get_task("COL-001")
self.assertEqual(TaskStatus.FAILED, detail.status)
self.assertEqual("PDD_GOODS_UNAVAILABLE", detail.last_error_code)
self.assertEqual(1, self.gateway.submission_count)
def test_execute_selected_only_runs_requested_local_task(self):
for task_id in ("COL-SELECTED", "COL-OTHER"):
self.repository.add_claimed_task(
+32 -3
View File
@@ -35,6 +35,7 @@ class FakeCollectDevice:
self.swipes = []
self.swipe_panel_states = []
self.app_wait_calls = 0
self.opened_url = None
def app_current(self):
return {"package": "com.xunmeng.pinduoduo"}
@@ -50,6 +51,8 @@ class FakeCollectDevice:
self.opened_url = url
def dump_hierarchy(self):
if self.opened_url is None:
return '<hierarchy><node package="com.xunmeng.pinduoduo" /></hierarchy>'
return self.spec_xml if self.panel_open else self.home_xml
def click(self, x, y):
@@ -71,6 +74,8 @@ class GoodsMetadataScrollDevice(FakeCollectDevice):
self.page_index = 0
def dump_hierarchy(self):
if self.opened_url is None and not self.panel_open:
return super().dump_hierarchy()
if self.panel_open:
return self.spec_xml
return self.pages[self.page_index]
@@ -133,6 +138,8 @@ class SnakeColorDevice(FakeCollectDevice):
self.clicked_colors = []
def dump_hierarchy(self):
if self.opened_url is None and not self.panel_open:
return super().dump_hierarchy()
if not self.panel_open:
return self.home_xml
return self._spec_xml()
@@ -794,6 +801,28 @@ class PddCollectParserTest(unittest.TestCase):
)
self.assertEqual(raised.exception.code, "PDD_PAGE_TIMEOUT")
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)
clock = FakeClock()
service = PddCollectService(
PddDeviceService(lambda _serial: device),
"USB-001",
"client-001",
sleeper=clock.sleep,
monotonic=clock.monotonic,
page_timeout=10.0,
)
with self.assertRaises(PddCollectError) as raised:
service.collect(
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
)
self.assertEqual("PDD_GOODS_UNAVAILABLE", raised.exception.code)
self.assertEqual(2, raised.exception.diagnostics["open_attempts"])
self.assertEqual([], device.clicks)
def test_non_pdd_tree_with_purchase_words_is_not_ready(self):
device = FocusMismatchDevice(
'<hierarchy><node package="com.android.settings" text="免拼购买" '
@@ -819,13 +848,13 @@ class PddCollectParserTest(unittest.TestCase):
def test_page_timeout_saves_last_xml_diagnostic(self):
with tempfile.TemporaryDirectory() as directory:
device = LoadingDevice(self.home_xml, self.spec_xml)
ticks = iter((0.0, 0.0, 0.0, 0.0, 0.0, 2.0))
clock = FakeClock()
service = PddCollectService(
PddDeviceService(lambda _serial: device),
"USB-001",
"client-001",
sleeper=lambda _seconds: None,
monotonic=lambda: next(ticks),
sleeper=clock.sleep,
monotonic=clock.monotonic,
page_timeout=1.0,
artifact_directory=Path(directory),
)
+108
View File
@@ -0,0 +1,108 @@
"""PDD 页面分类与商品深链状态机测试。"""
from pathlib import Path
import unittest
import xml.etree.ElementTree as ET
from src.pdd_page_classifier import (
ACTION_NETWORK_ERROR,
ACTION_READY,
ACTION_REOPEN,
ACTION_UNAVAILABLE,
ACTION_WAIT,
PAGE_CAPTCHA,
PAGE_EXTERNAL,
PAGE_GOODS,
PAGE_HOME,
PAGE_LOADING,
PAGE_LOGIN_REQUIRED,
PAGE_NETWORK_ERROR,
PAGE_RISK_CONTROL,
GoodsOpenTracker,
classify_pdd_page,
)
FIXTURES = Path(__file__).parent / "fixtures"
PDD = "com.xunmeng.pinduoduo"
def observation(text: str, package: str = PDD):
xml = (
'<hierarchy><node package="{0}" class="android.widget.TextView" '
'text="{1}" bounds="[0,0][900,300]" visible-to-user="true"/>'
'<node package="{0}" text="占位一"/><node package="{0}" '
'text="占位二"/></hierarchy>'
).format(package, text)
return classify_pdd_page(ET.fromstring(xml), package)
class PddPageClassifierTest(unittest.TestCase):
def test_real_home_fixture_ignores_incorrect_focused_package(self) -> None:
root = ET.parse(FIXTURES / "pdd_home_page.xml").getroot()
result = classify_pdd_page(root, "com.android.settings")
self.assertEqual(PAGE_HOME, result.kind)
self.assertTrue(result.pdd_hierarchy)
def test_special_pages_are_not_home(self) -> None:
cases = {
"加载中": PAGE_LOADING,
"网络不给力": PAGE_NETWORK_ERROR,
"手机号登录": PAGE_LOGIN_REQUIRED,
"请完成验证": PAGE_CAPTCHA,
"操作频繁": PAGE_RISK_CONTROL,
"立即购买": PAGE_GOODS,
}
for label, expected in cases.items():
with self.subTest(label=label):
self.assertEqual(expected, observation(label).kind)
def test_browser_word_does_not_count_as_goods(self) -> None:
result = observation("立即购买", "com.android.chrome")
self.assertEqual(PAGE_EXTERNAL, result.kind)
def test_transient_home_can_reach_goods_without_reopen(self) -> None:
home = classify_pdd_page(
ET.parse(FIXTURES / "pdd_home_page.xml").getroot(), PDD
)
tracker = GoodsOpenTracker(None, 0.0)
self.assertEqual(ACTION_WAIT, tracker.observe(home, 2.0).action)
self.assertEqual(ACTION_WAIT, tracker.observe(home, 2.1).action)
self.assertEqual(
ACTION_READY, tracker.observe(observation("立即购买"), 2.2).action
)
def test_stable_home_reopens_once_then_marks_unavailable(self) -> None:
home = classify_pdd_page(
ET.parse(FIXTURES / "pdd_home_page.xml").getroot(), PDD
)
tracker = GoodsOpenTracker(None, 0.0)
for at in (2.0, 2.1):
self.assertEqual(ACTION_WAIT, tracker.observe(home, at).action)
self.assertEqual(ACTION_REOPEN, tracker.observe(home, 2.2).action)
tracker.reopened(3.0)
for at in (5.0, 5.1):
self.assertEqual(ACTION_WAIT, tracker.observe(home, at).action)
self.assertEqual(ACTION_UNAVAILABLE, tracker.observe(home, 5.2).action)
def test_old_goods_tree_is_not_accepted_as_new_target(self) -> None:
old = observation("旧商品标题 立即购买")
tracker = GoodsOpenTracker(old, 0.0)
self.assertEqual(ACTION_WAIT, tracker.observe(old, 0.2).action)
self.assertTrue(tracker.stale_goods_seen)
new = observation("目标商品标题 立即购买")
self.assertEqual(ACTION_READY, tracker.observe(new, 0.3).action)
def test_network_error_has_own_decision(self) -> None:
tracker = GoodsOpenTracker(None, 0.0)
decision = tracker.observe(observation("网络不给力"), 3.0)
self.assertEqual(ACTION_NETWORK_ERROR, decision.action)
if __name__ == "__main__":
unittest.main()
+4 -1
View File
@@ -20,6 +20,7 @@ class SettingsFocusedRealPage:
def __init__(self, xml_data):
self.xml_data = xml_data
self.opened = False
def app_current(self):
return {"package": "com.oplus.wirelesssettings"}
@@ -31,9 +32,11 @@ class SettingsFocusedRealPage:
return 1
def open_url(self, _url):
return None
self.opened = True
def dump_hierarchy(self):
if not self.opened:
return '<hierarchy><node package="com.oplus.wirelesssettings" /></hierarchy>'
return self.xml_data
@@ -1,5 +1,6 @@
"""uiautomator2 采购演练 Adapter 测试;不连接真实手机。"""
from pathlib import Path
import unittest
from src.pdd_device_service import PddDeviceService
@@ -9,6 +10,18 @@ from src.pdd_u2_purchase_adapter import U2PddLivePurchaseAdapter
GOODS_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=753136429979"
FIXTURES = Path(__file__).parent / "fixtures"
class FakeClock:
def __init__(self) -> None:
self.now = 0.0
def monotonic(self) -> float:
return self.now
def sleep(self, seconds: float) -> None:
self.now += seconds
def home_xml() -> str:
@@ -60,6 +73,7 @@ class FakeDevice:
self.clicks = []
self.opened_urls = []
self.app_wait_calls = 0
self.has_opened = False
def app_current(self):
return {"package": "com.xunmeng.pinduoduo"}
@@ -73,8 +87,11 @@ class FakeDevice:
def open_url(self, url):
self.opened_urls.append(url)
self.has_opened = True
def dump_hierarchy(self):
if not self.has_opened:
return '<hierarchy><node package="com.xunmeng.pinduoduo" /></hierarchy>'
if self.mode == "special":
return self.special_xml
return home_xml() if self.mode == "home" else panel_xml(self.quantity)
@@ -108,6 +125,13 @@ class ColdStartDevice(FakeDevice):
self.app_started = True
class StaleGoodsDevice(FakeDevice):
"""深链打开前后都停留在同一个旧商品页。"""
def dump_hierarchy(self):
return home_xml()
class U2PddPurchaseAdapterTest(unittest.TestCase):
def _adapter(self, device, calls):
def select_color_fn(_device, _xml, target, **_kwargs):
@@ -207,6 +231,44 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
self.assertEqual(raised.exception.code, "PDD_PAGE_CAPTCHA")
self.assertEqual(device.clicks, [])
def test_stable_home_reopens_once_then_stops_before_click(self):
device = FakeDevice(
(FIXTURES / "pdd_home_page.xml").read_text(encoding="utf-8")
)
clock = FakeClock()
adapter = U2PddPurchaseAdapter(
"USB-001",
device_service=PddDeviceService(connector=lambda _serial: device),
sleeper=clock.sleep,
monotonic=clock.monotonic,
page_timeout=10.0,
)
with self.assertRaises(PddPurchaseError) as raised:
adapter.open_goods(GOODS_URL)
self.assertEqual("PDD_GOODS_UNAVAILABLE", raised.exception.code)
self.assertFalse(raised.exception.retryable)
self.assertEqual(2, len(device.opened_urls))
self.assertEqual([], device.clicks)
def test_stale_goods_page_is_not_accepted_as_target(self):
device = StaleGoodsDevice()
clock = FakeClock()
adapter = U2PddPurchaseAdapter(
"USB-001",
device_service=PddDeviceService(connector=lambda _serial: device),
sleeper=clock.sleep,
monotonic=clock.monotonic,
page_timeout=1.0,
)
with self.assertRaises(PddPurchaseError) as raised:
adapter.open_goods(GOODS_URL)
self.assertEqual("PDD_GOODS_IDENTITY_UNCONFIRMED", raised.exception.code)
self.assertEqual([], device.clicks)
adapter.close()
def test_invalid_non_pdd_url_is_rejected_before_connect(self):
+24
View File
@@ -1429,6 +1429,30 @@ class PDDTaskPageEventTest(unittest.TestCase):
events.shutdown()
page.deleteLater()
def test_unavailable_goods_continues_auto_fetch_after_current_task(self):
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=RecordingClaimGateway(None),
settings_repository=self._saved_settings(),
next_task_delay_ms=20,
)
events._auto_fetch_running = True
events._on_collect_outcome(
"business_failed",
"商品链接已失效,PDD 无法打开商品详情页并返回了首页",
"PDD-001",
)
self.assertEqual(20, events._cycle_next_delay_ms)
self.assertTrue(events._auto_fetch_running)
self.assertFalse(events._stop_requested)
self.assertIn("商品链接已失效", page.statusLabel.text())
events.shutdown()
page.deleteLater()
def test_duplicate_task_is_normal_status(self):
task = collect_admin_task()
self.repository.add_claimed_task(admin_task_to_new_claimed_task(task))
+23
View File
@@ -108,6 +108,17 @@ class RecordingLiveAdapter(RecordingDryRunAdapter, PddLivePurchaseAdapter):
)
class UnavailableGoodsAdapter(RecordingDryRunAdapter):
def open_goods(self, goods_url: str) -> None:
self.calls.append(("open_goods", goods_url))
raise PddPurchaseError(
"PDD_GOODS_UNAVAILABLE",
"商品链接已失效,PDD 无法打开商品详情页并返回了首页",
step="purchase_open_goods",
retryable=False,
)
class PurchaseTaskServiceTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
@@ -219,6 +230,18 @@ class PurchaseTaskServiceTest(unittest.TestCase):
self.assertEqual(detail.status, TaskStatus.MANUAL_REVIEW)
self.assertEqual(detail.last_error_code, "PURCHASE_PRICE_EXCEEDED")
def test_unavailable_goods_stops_before_specs_and_is_not_retryable(self):
self._prepare_task()
adapter = UnavailableGoodsAdapter()
outcome = self._service(adapter).execute_one_local()
self.assertEqual("business_failed", outcome.kind)
self.assertNotIn(("select_options", OPTIONS), adapter.calls)
detail = self.repository.get_task("PUR-001")
self.assertEqual(TaskStatus.FAILED, detail.status)
self.assertEqual("PDD_GOODS_UNAVAILABLE", detail.last_error_code)
def test_result_submit_timeout_does_not_run_adapter_twice(self):
self._prepare_task()
adapter = RecordingDryRunAdapter()