"""PDD 商品采集与 XML 解析测试,不连接真机。""" from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path import tempfile import unittest import xml.etree.ElementTree as ET from src.pdd_collect_service import ( PddCollectError, PddCollectService, parse_goods_page, parse_quantity, parse_spec_panel, ) from src.pdd_device_service import PddDeviceService FIXTURES = Path(__file__).parent / "fixtures" @dataclass(frozen=True) class FakeTask: goods_url: str goods_id: str | None = None class FakeCollectDevice: def __init__(self, home_xml: str, spec_xml: str): self.home_xml = home_xml self.spec_xml = spec_xml self.panel_open = False self.clicks = [] self.swipes = [] self.swipe_panel_states = [] self.app_wait_calls = 0 self.app_current_calls = 0 self.opened_url = None def app_current(self): self.app_current_calls += 1 return {"package": "com.xunmeng.pinduoduo"} def app_start(self, _package): raise AssertionError("PDD 已经在前台,不应重复启动") def app_wait(self, _package, timeout=10): self.app_wait_calls += 1 return 123 def open_url(self, url): self.opened_url = url def dump_hierarchy(self): if self.opened_url is None: return '' return self.spec_xml if self.panel_open else self.home_xml def click(self, x, y): self.clicks.append((x, y)) if y > 1600: self.panel_open = True def swipe(self, *args, **kwargs): self.swipes.append((args, kwargs)) self.swipe_panel_states.append(self.panel_open) class GoodsMetadataScrollDevice(FakeCollectDevice): """模拟评价和店铺分别出现在商品页后续屏幕。""" def __init__(self, pages, spec_xml): super().__init__(pages[0], spec_xml) self.pages = pages 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] def swipe(self, *args, **kwargs): super().swipe(*args, **kwargs) if not self.panel_open: self.page_index = min(self.page_index + 1, len(self.pages) - 1) class LoadingDevice(FakeCollectDevice): def dump_hierarchy(self): return '' class DisconnectingDevice(FakeCollectDevice): def __init__(self, home_xml, spec_xml): super().__init__(home_xml, spec_xml) self.hierarchy_calls = 0 def dump_hierarchy(self): self.hierarchy_calls += 1 if self.hierarchy_calls > 1: raise RuntimeError("device not found") return super().dump_hierarchy() class FocusMismatchDevice(FakeCollectDevice): """系统焦点报告为设置页,但无障碍树实际属于 PDD。""" def app_current(self): return {"package": "com.oplus.wirelesssettings"} def app_start(self, _package): self.app_started = True class SlowFocusMismatchDevice(FocusMismatchDevice): def __init__(self, home_xml, spec_xml, clock): super().__init__(home_xml, spec_xml) self.clock = clock self.app_current_calls = 0 def app_current(self): self.app_current_calls += 1 self.clock.sleep(11.0) return {"package": "com.android.settings"} class PanelDoesNotOpenDevice(FakeCollectDevice): def click(self, x, y): self.clicks.append((x, y)) class SnakeColorDevice(FakeCollectDevice): """模拟两行横向颜色网格和滚动后才出现的尺码区域。""" prices = { "A色": 1000, "B色": 1000, "C色": 1200, "D色": 1300, "E色": 1400, "F色": 1500, } def __init__(self, home_xml): super().__init__(home_xml, "") self.page = 1 self.selected_color = "A色" self.sizes_visible = False 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() def click(self, x, y): self.clicks.append((x, y)) if not self.panel_open: self.panel_open = True return for name, bounds in self._current_color_bounds().items(): left, top, right, bottom = bounds if left <= x <= right and top <= y <= bottom: self.selected_color = name self.clicked_colors.append(name) return def swipe(self, x1, y1, x2, y2, duration=0.35): self.swipes.append(((x1, y1, x2, y2), {"duration": duration})) self.swipe_panel_states.append(self.panel_open) if abs(x2 - x1) > abs(y2 - y1): self.page = 1 if x1 > x2 else 0 else: self.sizes_visible = True def _current_color_bounds(self): if self.page == 0: return { "A色": (36, 740, 340, 850), "B色": (370, 740, 674, 850), "C色": (1044, 740, 1080, 850), "D色": (36, 900, 340, 1010), "E色": (370, 900, 674, 1010), "F色": (1044, 900, 1080, 1010), } return { "A色": (0, 740, 20, 850), "B色": (36, 740, 340, 850), "C色": (370, 740, 674, 850), "D色": (0, 900, 20, 1010), "E色": (36, 900, 340, 1010), "F色": (370, 900, 674, 1010), } def _spec_xml(self): color_nodes = [] for name, bounds in self._current_color_bounds().items(): left, top, right, bottom = bounds selected = "true" if name == self.selected_color else "false" color_nodes.append( f'' ) size_nodes = "" if self.sizes_visible: size_nodes = """ """ price = self.prices[self.selected_color] / 100 return f""" {''.join(color_nodes)} {size_nodes} """ class IgnoredColorClickDevice(SnakeColorDevice): """页面暴露选中状态,但模拟一次没有生效的颜色点击。""" def click(self, x, y): if not self.panel_open: super().click(x, y) return self.clicks.append((x, y)) class AllMissingColorPriceDevice(IgnoredColorClickDevice): """所有颜色点击都未生效,模拟全部颜色采价为空。""" def _spec_xml(self): return super()._spec_xml().replace('selected="true"', 'selected="false"') class NoColorPriceDevice(SnakeColorDevice): """颜色可以选中,但价格节点一直没有出现。""" def _spec_xml(self): price = self.prices[self.selected_color] / 100 return super()._spec_xml().replace( f'text="¥{price:.2f}"', 'text="价格加载中"' ) class DelayedHorizontalSwipeDevice(SnakeColorDevice): """水平滑动后第一次读取仍返回旧视口,第二次才更新。""" def __init__(self, home_xml): super().__init__(home_xml) self.pending_page = None self.old_page_reads = 0 def dump_hierarchy(self): if self.panel_open and self.pending_page is not None: if self.old_page_reads > 0: self.old_page_reads -= 1 else: self.page = self.pending_page self.pending_page = None return super().dump_hierarchy() def swipe(self, x1, y1, x2, y2, duration=0.35): self.swipes.append(((x1, y1, x2, y2), {"duration": duration})) self.swipe_panel_states.append(self.panel_open) if abs(x2 - x1) > abs(y2 - y1): self.pending_page = 1 if x1 > x2 else 0 self.old_page_reads = 1 else: self.sizes_visible = True class FakeClock: """测试用时钟:sleep 只推进虚拟时间,不真的等待。""" def __init__(self): self.now = 0.0 self.sleeps = [] def monotonic(self): return self.now def sleep(self, seconds): self.sleeps.append(seconds) self.now += seconds def keep_only_one_sku(xml_data: str) -> str: """从脱敏固件中删除蓝色和 L,只保留一个组合。""" root = ET.fromstring(xml_data) for parent in root.iter(): for child in list(parent): own_label = ( child.get("text") or child.get("content-desc") or "" ).strip() labels = " ".join( (node.get("text") or node.get("content-desc") or "").strip() for node in child.iter("node") ) if own_label == "蓝色" or labels.strip() == "L": parent.remove(child) return ET.tostring(root, encoding="unicode") class PddCollectParserTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.home_xml = (FIXTURES / "pdd_goods_page.xml").read_text(encoding="utf-8") cls.spec_xml = (FIXTURES / "pdd_spec_panel.xml").read_text(encoding="utf-8") cls.count_heading_spec_xml = ( FIXTURES / "pdd_spec_panel_count_heading.xml" ).read_text(encoding="utf-8") def test_quantity_keeps_raw_value_and_approximate_flag(self): result = parse_quantity("已拼1.2万+件") self.assertEqual(result.value, 12000) self.assertEqual(result.raw, "已拼1.2万+件") self.assertTrue(result.approximate) def test_parse_goods_page(self): result = parse_goods_page(self.home_xml) self.assertEqual(result.title, "测试纯棉短袖商品") self.assertEqual(result.shop_name, "测试服饰旗舰店") self.assertEqual(result.sales.value, 12000) self.assertTrue(result.sales.approximate) self.assertEqual(result.reviews.value, 2356) def test_parse_review_title_and_shop_name_next_to_enter_shop(self): xml_data = """ """ result = parse_goods_page(xml_data) self.assertEqual(result.reviews.value, 191) self.assertEqual(result.shop_name, "小熊服饰店") def test_parse_spec_panel_uses_generic_dimensions_and_cents(self): result = parse_spec_panel(self.spec_xml) self.assertEqual(result.price_cent, 1000) self.assertEqual([item.key for item in result.dimensions], ["color", "size"]) self.assertEqual( [value.text for value in result.dimensions[0].values], ["红色", "蓝色"], ) self.assertFalse(result.dimensions[0].values[1].available) self.assertEqual( [value.text for value in result.dimensions[1].values], ["M", "L"], ) def test_parse_spec_panel_accepts_count_in_color_heading(self): for heading in ("颜色 (6)", "颜色(6)"): with self.subTest(heading=heading): xml_data = self.count_heading_spec_xml.replace("颜色 (6)", heading) result = parse_spec_panel(xml_data) colors = next( item for item in result.dimensions if item.key == "color" ) self.assertEqual(colors.name, heading) self.assertEqual( [value.text for value in colors.values], ["黑色", "红色", "蓝色", "紫色", "浅粉", "本色"], ) def test_open_panel_without_visible_options_is_not_reported_as_timeout(self): panel_xml = """ """ device = FakeCollectDevice(self.home_xml, panel_xml) device.opened_url = "https://mobile.yangkeduo.com/goods.html?goods_id=123" device.panel_open = True clock = FakeClock() service = PddCollectService( PddDeviceService(lambda _serial: device), "USB-001", "client-001", sleeper=clock.sleep, monotonic=clock.monotonic, spec_panel_timeout=1.0, ) snapshot = service._wait_spec_panel(device) self.assertEqual(snapshot.dimensions, ()) self.assertEqual(clock.sleeps, []) def test_open_panel_with_incomplete_specs_reports_spec_incomplete(self): panel_xml = """ """ device = FakeCollectDevice(self.home_xml, panel_xml) service = PddCollectService( PddDeviceService(lambda _serial: device), "USB-001", "client-001", sleeper=lambda _seconds: None, max_spec_swipes=0, ) with self.assertRaises(PddCollectError) as raised: service.collect( FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123") ) self.assertEqual(raised.exception.code, "PDD_DATA_SPEC_INCOMPLETE") def test_truncated_spec_name_prefers_full_content_description(self): xml_data = self.spec_xml.replace( 'content-desc="红色" clickable="true"', 'text="红…" content-desc="红色" clickable="true"', ) result = parse_spec_panel(xml_data) self.assertEqual(result.dimensions[0].values[0].text, "红色") without_description = xml_data.replace(' content-desc="红色"', "") with self.assertRaises(PddCollectError) as raised: parse_spec_panel(without_description) self.assertEqual(raised.exception.code, "PDD_DATA_SKU_NAME_TRUNCATED") def test_login_and_invalid_xml_have_different_error_codes(self): with self.assertRaises(PddCollectError) as login: parse_goods_page('') with self.assertRaises(PddCollectError) as invalid: parse_goods_page("") self.assertEqual(login.exception.code, "PDD_PAGE_LOGIN_REQUIRED") self.assertEqual(invalid.exception.code, "PDD_DATA_XML_INVALID") def test_captcha_and_incomplete_spec_have_specific_codes(self): with self.assertRaises(PddCollectError) as captcha: parse_goods_page('') with self.assertRaises(PddCollectError) as incomplete: parse_spec_panel('') self.assertEqual(captcha.exception.code, "PDD_PAGE_CAPTCHA") self.assertEqual(incomplete.exception.code, "PDD_DATA_SPEC_INCOMPLETE") def test_collect_returns_versioned_pdd_data_without_real_device(self): # 收窄到一个可用组合,便于验证完整流程而不模拟真实页面切换。 one_sku_xml = keep_only_one_sku(self.spec_xml) device = FakeCollectDevice(self.home_xml, one_sku_xml) service = PddCollectService( PddDeviceService(lambda _serial: device), "USB-001", "client-001", sleeper=lambda _seconds: None, now=lambda: datetime(2026, 8, 7, 8, 0, tzinfo=timezone.utc), max_spec_swipes=1, ) result = service.collect( FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123") ) data = result.to_pdd_data() self.assertEqual(data["schema_version"], 1) self.assertEqual(data["goods_id"], "123") self.assertEqual(data["title"], "测试纯棉短袖商品") self.assertEqual(data["shop_name"], "测试服饰旗舰店") self.assertEqual(data["price_granularity"], "color") self.assertEqual(data["metrics"]["sales"]["value"], 12000) self.assertEqual(data["dimensions"][0]["key"], "color") self.assertEqual(data["skus"][0]["options"], {"color": "红色", "size": "M"}) self.assertEqual(data["skus"][0]["price_cent"], 1000) self.assertEqual( data["skus"][0]["price_observed_at"], {"color": "红色"}, ) self.assertEqual(data["source"]["device_address"], "USB-001") self.assertIsNone(data["purchase"]) self.assertEqual(device.app_wait_calls, 0) self.assertEqual(device.app_current_calls, 1) def test_collect_scrolls_before_spec_panel_and_accumulates_metadata(self): initial_page = self.home_xml.replace( '', "", ).replace( '', "", ) buy_button = """ """ review_page = f""" {buy_button} """ shop_page = f""" {buy_button} """ device = GoodsMetadataScrollDevice( [initial_page, review_page, shop_page], keep_only_one_sku(self.spec_xml), ) service = PddCollectService( PddDeviceService(lambda _serial: device), "USB-001", "client-001", sleeper=lambda _seconds: None, max_spec_swipes=0, ) result = service.collect( FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123") ) self.assertEqual(result.title, "测试纯棉短袖商品") self.assertEqual(result.sales.value, 12000) self.assertEqual(result.reviews.value, 191) self.assertEqual(result.shop_name, "小熊服饰店") self.assertEqual(device.swipe_panel_states[:2], [False, False]) self.assertEqual(device.page_index, 2) self.assertTrue(device.clicks) def test_pdd_hierarchy_is_ready_even_when_focused_package_is_settings(self): pdd_home_xml = self.home_xml.replace( "', self.spec_xml, ) ticks = iter((0.0, 0.0, 0.0, 2.0)) service = PddCollectService( PddDeviceService(lambda _serial: device), "USB-001", "client-001", sleeper=lambda _seconds: None, monotonic=lambda: next(ticks), page_timeout=1.0, ) with self.assertRaises(PddCollectError) as raised: service.collect( FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123") ) self.assertEqual(raised.exception.code, "PDD_PAGE_TIMEOUT") def test_page_timeout_saves_last_xml_diagnostic(self): with tempfile.TemporaryDirectory() as directory: device = LoadingDevice(self.home_xml, self.spec_xml) clock = FakeClock() service = PddCollectService( PddDeviceService(lambda _serial: device), "USB-001", "client-001", sleeper=clock.sleep, monotonic=clock.monotonic, page_timeout=1.0, artifact_directory=Path(directory), ) with self.assertRaises(PddCollectError) as raised: service.collect( FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123") ) artifacts = raised.exception.diagnostics["artifacts"] self.assertEqual(len(artifacts), 1) self.assertTrue(Path(artifacts[0]["path"]).is_file()) self.assertEqual(len(artifacts[0]["sha256"]), 64) def test_runtime_device_disconnect_has_stable_error_code(self): service = PddCollectService( PddDeviceService( lambda _serial: DisconnectingDevice(self.home_xml, self.spec_xml) ), "USB-001", "client-001", ) with self.assertRaises(PddCollectError) as raised: service.collect( FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123") ) self.assertEqual(raised.exception.code, "DEVICE_DISCONNECTED") def test_missing_shop_name_is_allowed(self): incomplete_home = self.home_xml.replace( '', "", ) device = FakeCollectDevice(incomplete_home, self.spec_xml) service = PddCollectService( PddDeviceService(lambda _serial: device), "USB-001", "client-001", sleeper=lambda _seconds: None, ) result = service.collect( FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123") ) self.assertIsNone(result.shop_name) def test_missing_reviews_stops_when_goods_page_no_longer_changes(self): home_without_reviews = self.home_xml.replace( '', "", ) device = FakeCollectDevice( home_without_reviews, keep_only_one_sku(self.spec_xml) ) service = PddCollectService( PddDeviceService(lambda _serial: device), "USB-001", "client-001", sleeper=lambda _seconds: None, max_spec_swipes=0, ) result = service.collect( FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123") ) self.assertIsNone(result.reviews.raw) self.assertTrue(device.clicks) self.assertEqual(device.swipe_panel_states[:2], [False, False]) def test_cancel_during_goods_page_scroll_stops_before_spec_panel(self): incomplete_home = self.home_xml.replace( '', "", ).replace( '', "", ) cancelled = {"value": False} device = FakeCollectDevice(incomplete_home, self.spec_xml) service = PddCollectService( PddDeviceService(lambda _serial: device), "USB-001", "client-001", sleeper=lambda _seconds: cancelled.update(value=True), cancelled=lambda: cancelled["value"], ) with self.assertRaises(PddCollectError) as raised: service.collect( FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123") ) self.assertEqual(raised.exception.code, "PDD_CANCELLED") self.assertEqual(device.swipe_panel_states, [False]) self.assertEqual(device.clicks, []) def test_spec_panel_timeout_has_stable_code_and_xml_evidence(self): with tempfile.TemporaryDirectory() as directory: device = PanelDoesNotOpenDevice(self.home_xml, self.spec_xml) tick = {"value": -0.5} def clock(): tick["value"] += 0.5 return tick["value"] service = PddCollectService( PddDeviceService(lambda _serial: device), "USB-001", "client-001", sleeper=lambda _seconds: None, monotonic=clock, spec_panel_timeout=1.0, artifact_directory=Path(directory), ) with self.assertRaises(PddCollectError) as raised: service.collect( FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123") ) self.assertEqual( raised.exception.code, "PDD_PAGE_SPEC_PANEL_TIMEOUT" ) artifacts = raised.exception.diagnostics["artifacts"] self.assertTrue(Path(artifacts[0]["path"]).is_file()) if __name__ == "__main__": unittest.main()