diff --git a/client/src/pdd_collect_service.py b/client/src/pdd_collect_service.py index b07dd0f..e00a8f9 100644 --- a/client/src/pdd_collect_service.py +++ b/client/src/pdd_collect_service.py @@ -435,17 +435,80 @@ def _is_dimension_heading(label: str) -> bool: # 新版页面会把可选数量写进标题,例如“颜色 (6)”或“颜色(6)”。 # 数量不是规格名称的一部分,只在判断标题类型时去掉,最终展示仍保留原文。 compact = re.sub(r"[((]\d+[))]$", "", compact) + # “确认款式”是新版规格弹层的标题,不是一个可选择的“款式”维度。 + if compact in ("确认款式", "確認款式"): + return False return compact in _DIMENSION_NAMES or compact.endswith( ("分类", "规格", "尺寸", "尺码", "颜色", "型号", "款式", "容量", "类型", "版本", "口味") ) +def _find_non_scrollable_spec_panel( + root: ET.Element, + parents: Mapping[ET.Element, ET.Element], +) -> Optional[ET.Element]: + """用多项强证据定位不暴露 scrollable 属性的自绘规格面板。""" + + headings: list[ET.Element] = [] + summaries: list[ET.Element] = [] + panel_cues: list[ET.Element] = [] + confirms: list[ET.Element] = [] + for node in root.iter("node"): + label = _preferred_node_label(node).strip() + compact = label.replace(" ", "") + if not label or _parse_bounds(node.get("bounds", "")) is None: + continue + if _is_dimension_heading(label): + headings.append(node) + if compact.startswith(("已选", "已選", "请选择", "請選擇")): + summaries.append(node) + if compact in ("确认款式", "確認款式", "关闭", "關閉"): + panel_cues.append(node) + if compact in ("确定", "確定") and node.get("clickable") == "true": + confirms.append(node) + + if not headings or not summaries or not panel_cues or not confirms: + return None + + required = [*headings, summaries[0], panel_cues[0], confirms[0]] + common = set([required[0], *_ancestors(required[0], parents)]) + for node in required[1:]: + common.intersection_update([node, *_ancestors(node, parents)]) + + all_bounds = [ + bounds + for node in root.iter("node") + if (bounds := _parse_bounds(node.get("bounds", ""))) is not None + ] + screen_area = 0 + if all_bounds: + screen_area = max(item[2] for item in all_bounds) * max( + item[3] for item in all_bounds + ) + + candidates: list[tuple[int, ET.Element]] = [] + for node in common: + bounds = _parse_bounds(node.get("bounds", "")) + if bounds is None or node.get("visible-to-user", "true") != "true": + continue + area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]) + if screen_area and area >= screen_area * 0.95: + continue + candidates.append((area, node)) + if not candidates: + return None + return min(candidates, key=lambda item: item[0])[1] + + def _is_spec_panel_open(xml_data: str | bytes) -> bool: """判断规格面板是否已经出现,不要求当前视口已露出规格选项。""" root = _parse_xml(xml_data) labels = _all_labels(root) _raise_special_page(labels) + parents = {child: parent for parent in root.iter() for child in parent} + if _find_non_scrollable_spec_panel(root, parents) is not None: + return True has_scrollable_region = any( node.get("scrollable") == "true" and _parse_bounds(node.get("bounds", "")) is not None @@ -536,11 +599,23 @@ def parse_spec_panel(xml_data: str | bytes) -> SpecSnapshot: for node in root.iter("node") if node.get("scrollable") == "true" and _parse_bounds(node.get("bounds", "")) ] - if not scrollables: + non_scrollable_outer = _find_non_scrollable_spec_panel(root, parents) + panel_scrollables = [] + if non_scrollable_outer is not None: + panel_scrollables = [ + node + for node in scrollables + if node is non_scrollable_outer + or non_scrollable_outer in _ancestors(node, parents) + ] + outer_candidates = panel_scrollables or ( + [non_scrollable_outer] if non_scrollable_outer is not None else scrollables + ) + if not outer_candidates: raise PddCollectError("PDD_DATA_SPEC_INCOMPLETE", "规格面板没有可识别的规格区域") outer = max( - scrollables, + outer_candidates, key=lambda node: ( (_parse_bounds(node.get("bounds", "")) or (0, 0, 0, 0))[2] - (_parse_bounds(node.get("bounds", "")) or (0, 0, 0, 0))[0] @@ -616,8 +691,9 @@ def parse_spec_panel(xml_data: str | bytes) -> SpecSnapshot: dimensions.append(SpecDimension(key, name, tuple(values))) selected_text = next((label for label in labels if label.startswith("已选")), None) + price_boundary = heading_nodes[0][0] if heading_nodes else outer_bounds[1] price_cent, raw_price, list_price_cent = _price_from_nodes( - root.iter("node"), outer_bounds[1] + root.iter("node"), price_boundary ) return SpecSnapshot( tuple(dimensions), selected_text, price_cent, raw_price, list_price_cent diff --git a/client/test/fixtures/pdd_spec_panel_non_scrollable.xml b/client/test/fixtures/pdd_spec_panel_non_scrollable.xml new file mode 100644 index 0000000..488de74 --- /dev/null +++ b/client/test/fixtures/pdd_spec_panel_non_scrollable.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/test/test_pdd_collect_service.py b/client/test/test_pdd_collect_service.py index c1a49e2..6ec6886 100644 --- a/client/test/test_pdd_collect_service.py +++ b/client/test/test_pdd_collect_service.py @@ -10,6 +10,7 @@ import xml.etree.ElementTree as ET from src.pdd_collect_service import ( PddCollectError, PddCollectService, + _is_spec_panel_open, parse_goods_page, parse_quantity, parse_spec_panel, @@ -340,6 +341,9 @@ class PddCollectParserTest(unittest.TestCase): cls.count_heading_spec_xml = ( FIXTURES / "pdd_spec_panel_count_heading.xml" ).read_text(encoding="utf-8") + cls.non_scrollable_spec_xml = ( + FIXTURES / "pdd_spec_panel_non_scrollable.xml" + ).read_text(encoding="utf-8") def test_quantity_keeps_raw_value_and_approximate_flag(self): result = parse_quantity("已拼1.2万+件") @@ -403,6 +407,63 @@ class PddCollectParserTest(unittest.TestCase): ["黑色", "红色", "蓝色", "紫色", "浅粉", "本色"], ) + def test_parse_non_scrollable_spec_panel(self): + result = parse_spec_panel(self.non_scrollable_spec_xml) + + self.assertTrue(_is_spec_panel_open(self.non_scrollable_spec_xml)) + self.assertEqual([item.key for item in result.dimensions], ["color", "size"]) + self.assertEqual( + [value.text for value in result.dimensions[0].values], + [ + "测试黑色", + "测试黑色+网袜", + "测试黑色+丝袜", + "测试黑色+短袜", + "测试黑色+长袜", + ], + ) + self.assertEqual( + [value.text for value in result.dimensions[1].values], + ["均码(适合80-120斤)"], + ) + self.assertEqual(result.price_cent, 817) + self.assertEqual(result.raw_price, "¥8.17") + self.assertNotIn("确认款式", [item.name for item in result.dimensions]) + + def test_wait_accepts_non_scrollable_spec_panel_without_sleep(self): + device = FakeCollectDevice(self.home_xml, self.non_scrollable_spec_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([item.key for item in snapshot.dimensions], ["color", "size"]) + self.assertEqual(clock.sleeps, []) + + def test_goods_page_is_not_non_scrollable_spec_panel(self): + self.assertFalse(_is_spec_panel_open(self.home_xml)) + + def test_full_window_with_scattered_labels_is_not_spec_panel(self): + xml_data = """ + + + + + + + """ + + self.assertFalse(_is_spec_panel_open(xml_data)) + def test_open_panel_without_visible_options_is_not_reported_as_timeout(self): panel_xml = """