diff --git a/client/src/pdd_collect_service.py b/client/src/pdd_collect_service.py index 01291d3..c02ec56 100644 --- a/client/src/pdd_collect_service.py +++ b/client/src/pdd_collect_service.py @@ -834,6 +834,8 @@ class PddCollectService: ) self._artifact_directory = artifact_directory self._last_goods_xml: Optional[str] = None + self._last_valid_spec_xml: Optional[str] = None + self._last_invalid_spec_xml: Optional[str] = None self._goods_screens_checked = 0 self._artifacts: list[Mapping[str, Any]] = [] @@ -846,6 +848,8 @@ class PddCollectService: goods_id = str(getattr(task, "goods_id", "") or "").strip() goods_id = _validate_goods_url(goods_url, goods_id) self._overall_deadline = self._monotonic() + self._overall_timeout + self._last_valid_spec_xml = None + self._last_invalid_spec_xml = None self._check_cancelled() try: @@ -908,6 +912,21 @@ class PddCollectService: "goods_screens_checked", self._goods_screens_checked ) exc.diagnostics = diagnostics + if exc.code == "PDD_PAGE_SPEC_PANEL_LOST": + diagnostics = dict(exc.diagnostics) + artifacts = list(diagnostics.get("artifacts") or []) + for label, xml_data in ( + ("last-valid-spec", self._last_valid_spec_xml), + ("last-invalid-spec", self._last_invalid_spec_xml), + ): + if not xml_data: + continue + artifact = self._save_xml(label, xml_data) + if artifact and artifact not in artifacts: + artifacts.append(artifact) + if artifacts: + diagnostics["artifacts"] = artifacts + exc.diagnostics = diagnostics raise except PddDeviceError as exc: raise PddCollectError(exc.code, exc.message) from exc @@ -1167,15 +1186,59 @@ class PddCollectService: except PddCollectError as exc: if exc.code != "PDD_DATA_SPEC_INCOMPLETE": raise + self._last_invalid_spec_xml = xml_data else: if _is_spec_panel_open(xml_data): + self._last_valid_spec_xml = xml_data return snapshot + self._last_invalid_spec_xml = xml_data self._sleep(0.25) raise PddCollectError( "PDD_PAGE_SPEC_PANEL_TIMEOUT", "点击规格入口后,等待规格面板加载超时", ) + def _read_valid_spec_panel( + self, + device: Any, + initial_xml: Optional[str] = None, + ) -> tuple[str, SpecSnapshot]: + """跳过规格操作期间的临时空树,返回最新有效面板和解析结果。""" + + deadline = self._monotonic() + min(2.0, self._spec_panel_timeout) + xml_data = initial_xml + transient_reads = 0 + # 次数上限避免测试时钟或设备时钟异常导致无限循环。 + for attempt in range(20): + self._check_cancelled() + if xml_data is None: + xml_data = self._dump_hierarchy(device) + try: + snapshot = parse_spec_panel(xml_data) + panel_open = _is_spec_panel_open(xml_data) + except PddCollectError as exc: + if exc.code != "PDD_DATA_SPEC_INCOMPLETE": + raise + panel_open = False + snapshot = None + + if panel_open and snapshot is not None: + self._last_valid_spec_xml = xml_data + return xml_data, snapshot + + self._last_invalid_spec_xml = xml_data + transient_reads += 1 + if attempt >= 19 or self._monotonic() >= deadline: + break + self._sleep(0.1) + xml_data = None + + raise PddCollectError( + "PDD_PAGE_SPEC_PANEL_LOST", + "规格面板操作期间控件树持续为空或面板已经消失", + {"transient_spec_reads": transient_reads}, + ) + def _save_xml(self, label: str, xml_data: str) -> Optional[Mapping[str, Any]]: """保存本地诊断 XML;测试未提供目录时不写文件。""" @@ -1216,7 +1279,7 @@ class PddCollectService: """按行蛇形遍历颜色,并在每次点击后立即读取颜色级价格。""" self._move_color_list_to_left(device) - first_xml = self._dump_hierarchy(device) + first_xml, _ = self._read_valid_spec_panel(device) first_rows = self._visible_color_rows(first_xml) if not first_rows: raise PddCollectError( @@ -1240,7 +1303,10 @@ class PddCollectService: # 每次只处理一个节点;点击可能让列表自动移动,下一项必须 # 从最新 XML 重新计算,不能继续使用点击前的旧坐标。 while True: - xml_data = pending_xml or self._dump_hierarchy(device) + if pending_xml is not None: + xml_data = pending_xml + else: + xml_data, _ = self._read_valid_spec_panel(device) pending_xml = None rows = self._visible_color_rows(xml_data) if row_index >= len(rows): @@ -1314,7 +1380,7 @@ class PddCollectService: previous_signature: Optional[tuple[tuple[str, Bounds], ...]] = None stable_edge_reads = 0 - xml_data = self._dump_hierarchy(device) + xml_data, _ = self._read_valid_spec_panel(device) for _ in range(self._max_spec_swipes): self._check_cancelled() signature = self._color_view_signature(xml_data) @@ -1340,7 +1406,7 @@ class PddCollectService: ) -> ColorPriceSample: """点击最新树中的颜色,等待选择和价格连续两次稳定。""" - xml_data = self._dump_hierarchy(device) + xml_data, _ = self._read_valid_spec_panel(device) root = _parse_xml(xml_data) latest_visible = next( ( @@ -1384,8 +1450,10 @@ class PddCollectService: if self._is_big_image_viewer(latest_xml): self._recover_spec_panel_from_big_image(device) return ColorPriceSample(None, None, None) + latest_xml, snapshot = self._read_valid_spec_panel( + device, initial_xml=latest_xml + ) latest_root = _parse_xml(latest_xml) - snapshot = parse_spec_panel(latest_xml) latest_node = self._find_option_node(latest_root, target) selection_exposed = self._color_selection_state_exposed(latest_xml) selected = bool( @@ -1446,9 +1514,8 @@ class PddCollectService: stable_edge_reads = 0 for swipe_count in range(self._max_spec_swipes + 1): self._check_cancelled() - xml_data = self._dump_hierarchy(device) + xml_data, snapshot = self._read_valid_spec_panel(device) root = _parse_xml(xml_data) - snapshot = parse_spec_panel(xml_data) size_dimension = next( (item for item in snapshot.dimensions if item.key == "size"), None, @@ -1471,7 +1538,8 @@ class PddCollectService: (bounds[1] + bounds[3]) // 2, ) self._sleep(self._color_poll_interval) - latest_root = _parse_xml(self._dump_hierarchy(device)) + latest_xml, _ = self._read_valid_spec_panel(device) + latest_root = _parse_xml(latest_xml) latest_node = self._find_option_node(latest_root, target) if latest_node is None or not self._node_is_selected( latest_node @@ -1508,7 +1576,7 @@ class PddCollectService: stable_edge_reads = 0 for _ in range(self._max_spec_swipes): self._check_cancelled() - xml_data = self._dump_hierarchy(device) + xml_data, _ = self._read_valid_spec_panel(device) root = _parse_xml(xml_data) signature = tuple( ( @@ -1557,19 +1625,17 @@ class PddCollectService: """误入大图后只返回一次,并确认规格面板已经恢复。""" device.press("back") - for _ in range(3): - self._check_cancelled() - self._sleep(self._color_poll_interval) - xml_data = self._dump_hierarchy(device) - try: - if _is_spec_panel_open(xml_data): - return - except PddCollectError: - break - raise PddCollectError( - "PDD_PAGE_SPEC_PANEL_LOST", - "点击颜色后进入大图,返回一次仍未恢复规格面板", - ) + self._sleep(self._color_poll_interval) + try: + self._read_valid_spec_panel(device) + except PddCollectError as exc: + if exc.code != "PDD_PAGE_SPEC_PANEL_LOST": + raise + raise PddCollectError( + "PDD_PAGE_SPEC_PANEL_LOST", + "点击颜色后进入大图,返回一次仍未恢复规格面板", + exc.diagnostics, + ) from exc def _color_view_signature( self, xml_data: str | bytes @@ -1593,7 +1659,7 @@ class PddCollectService: for _ in range(2): self._check_cancelled() self._sleep(self._horizontal_swipe_settle_interval) - latest_xml = self._dump_hierarchy(device) + latest_xml, _ = self._read_valid_spec_panel(device) if self._color_view_signature(latest_xml) != previous_signature: break return latest_xml @@ -1609,8 +1675,7 @@ class PddCollectService: for swipe_count in range(self._max_spec_swipes + 1): self._check_cancelled() - xml_data = self._dump_hierarchy(device) - snapshot = parse_spec_panel(xml_data) + xml_data, snapshot = self._read_valid_spec_panel(device) for dimension in snapshot.dimensions: if dimension.key not in ("color", "size"): raise PddCollectError( diff --git a/client/test/test_pdd_collect_service.py b/client/test/test_pdd_collect_service.py index c14d5e7..07805a1 100644 --- a/client/test/test_pdd_collect_service.py +++ b/client/test/test_pdd_collect_service.py @@ -133,6 +133,45 @@ class PanelDoesNotOpenDevice(FakeCollectDevice): self.clicks.append((x, y)) +TRANSIENT_EMPTY_SPEC_XML = """ + + + +""" + + +class TransientSpecTreeDevice(FakeCollectDevice): + """规格面板阶段交替返回临时空树和有效树。""" + + def __init__(self, home_xml, spec_xml): + super().__init__(home_xml, spec_xml) + self.panel_reads = 0 + + def dump_hierarchy(self): + if self.panel_open: + self.panel_reads += 1 + if self.panel_reads % 2 == 0: + return TRANSIENT_EMPTY_SPEC_XML + return super().dump_hierarchy() + + +class SpecTreeStaysBlankDevice(FakeCollectDevice): + """面板首次有效,随后持续返回空树。""" + + def __init__(self, home_xml, spec_xml): + super().__init__(home_xml, spec_xml) + self.panel_reads = 0 + + def dump_hierarchy(self): + if self.panel_open: + self.panel_reads += 1 + if self.panel_reads > 1: + return TRANSIENT_EMPTY_SPEC_XML + return super().dump_hierarchy() + + class SnakeColorDevice(FakeCollectDevice): """模拟两行横向颜色网格和滚动后才出现的尺码区域。""" @@ -1441,6 +1480,84 @@ class PddCollectParserTest(unittest.TestCase): artifacts = raised.exception.diagnostics["artifacts"] self.assertTrue(Path(artifacts[0]["path"]).is_file()) + def test_transient_empty_spec_trees_are_skipped_during_full_collection(self): + device = TransientSpecTreeDevice( + self.home_xml, 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.assertTrue(result.skus) + self.assertGreater(device.panel_reads, 4) + self.assertEqual(TRANSIENT_EMPTY_SPEC_XML, service._last_invalid_spec_xml) + self.assertIn("颜色分类", service._last_valid_spec_xml) + + def test_persistent_empty_spec_tree_has_lost_code_and_dual_evidence(self): + with tempfile.TemporaryDirectory() as directory: + device = SpecTreeStaysBlankDevice( + self.home_xml, keep_only_one_sku(self.spec_xml) + ) + clock = FakeClock() + service = PddCollectService( + PddDeviceService(lambda _serial: device), + "USB-001", + "client-001", + sleeper=clock.sleep, + monotonic=clock.monotonic, + spec_panel_timeout=1.0, + max_spec_swipes=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("PDD_PAGE_SPEC_PANEL_LOST", raised.exception.code) + paths = [ + Path(item["path"]).name + for item in raised.exception.diagnostics["artifacts"] + ] + self.assertTrue(any(name.startswith("last-valid-spec-") for name in paths)) + self.assertTrue(any(name.startswith("last-invalid-spec-") for name in paths)) + self.assertLessEqual(clock.now, 1.2) + + def test_special_page_is_not_retried_as_transient_empty_tree(self): + device = FakeCollectDevice(self.home_xml, self.spec_xml) + device.panel_open = True + device.opened_url = "https://mobile.yangkeduo.com/goods.html?goods_id=123" + device.spec_xml = ( + '' + ) + clock = FakeClock() + service = PddCollectService( + PddDeviceService(lambda _serial: device), + "USB-001", + "client-001", + sleeper=clock.sleep, + monotonic=clock.monotonic, + ) + + with self.assertRaises(PddCollectError) as raised: + service._read_valid_spec_panel(device) + + self.assertEqual("PDD_PAGE_CAPTCHA", raised.exception.code) + self.assertEqual([], clock.sleeps) + if __name__ == "__main__": unittest.main()