"""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()