From 9c7e7154898bd389f7c7a4dc7dcd685e69d7d37f Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 11 Aug 2026 17:07:17 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=E6=AC=BE=E5=BC=8F?= =?UTF-8?q?=E4=B8=8E=E5=A5=97=E9=A4=90=E8=A7=84=E6=A0=BC=E5=88=86=E7=BB=84?= =?UTF-8?q?=20(#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pdd_collect_service.py | 48 ++++++- client/src/pdd_u2_purchase_adapter.py | 4 + client/src/util/select_color_size.py | 11 +- client/test/test_pdd_collect_service.py | 141 ++++++++++++++++++++ client/test/test_pdd_u2_purchase_adapter.py | 9 ++ 5 files changed, 204 insertions(+), 9 deletions(-) diff --git a/client/src/pdd_collect_service.py b/client/src/pdd_collect_service.py index 1e5f201..cdf1a9f 100644 --- a/client/src/pdd_collect_service.py +++ b/client/src/pdd_collect_service.py @@ -44,7 +44,17 @@ Bounds = tuple[int, int, int, int] _BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$") _PRICE_PATTERN = re.compile(r"[¥¥]\s*(\d+(?:\.\d{1,2})?)") _QUANTITY_PATTERN = re.compile(r"(\d+(?:\.\d+)?)\s*(万|亿)?\s*(\+)?") -_DIMENSION_NAMES = ("颜色分类", "颜色", "尺码", "尺寸", "规格", "型号", "款式") +_DIMENSION_NAMES = ( + "颜色分类", + "颜色", + "花色", + "款式", + "尺码", + "尺寸", + "套餐", + "规格", + "型号", +) _LOGIN_MARKERS = ("手机号登录", "登录后继续", "验证码登录", "账号登录") _CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击图中") _READY_MARKERS = ("发起拼单", "立即购买", "单独购买", "免拼购买", "快要抢光") @@ -420,10 +430,15 @@ def _is_available(node: ET.Element) -> bool: def _dimension_key(name: str, used: set[str]) -> str: - if "颜色" in name or "款式" in name: + if any(word in name for word in ("颜色", "花色", "款式")): base = "color" elif "尺码" in name or "尺寸" in name: base = "size" + elif "套餐" in name: + # 本项目的稳定结构只有主规格 color 和第二规格 size。商品只有 + # “套餐”一组时,它必须作为主规格采价;前面已有款式/颜色时, + # 套餐才是第二规格。不能无条件映射成 size,否则单规格商品无法采集。 + base = "size" if "color" in used else "color" elif "型号" in name: base = "model" else: @@ -446,7 +461,20 @@ def _is_dimension_heading(label: str) -> bool: if compact in ("确认款式", "確認款式"): return False return compact in _DIMENSION_NAMES or compact.endswith( - ("分类", "规格", "尺寸", "尺码", "颜色", "型号", "款式", "容量", "类型", "版本", "口味") + ( + "分类", + "规格", + "尺寸", + "尺码", + "颜色", + "型号", + "款式", + "套餐", + "容量", + "类型", + "版本", + "口味", + ) ) @@ -1279,9 +1307,13 @@ class PddCollectService: """按行蛇形遍历颜色,并在每次点击后立即读取颜色级价格。""" self._move_color_list_to_left(device) - first_xml, _ = self._read_valid_spec_panel(device) + first_xml, first_snapshot = self._read_valid_spec_panel(device) + source_dimension = next( + (item for item in first_snapshot.dimensions if item.key == "color"), + None, + ) first_rows = self._visible_color_rows(first_xml) - if not first_rows: + if source_dimension is None or not first_rows: raise PddCollectError( "PDD_DATA_SPEC_INCOMPLETE", "规格面板没有可识别的颜色分类" ) @@ -1371,7 +1403,7 @@ class PddCollectService: "PDD_DATA_SPEC_INCOMPLETE", "规格面板没有采集到颜色" ) return ( - SpecDimension("color", "颜色分类", tuple(ordered_colors)), + SpecDimension("color", source_dimension.name, tuple(ordered_colors)), samples, ) @@ -1668,6 +1700,7 @@ class PddCollectService: """颜色采价完成后只滚动并收集尺码文字,不点击尺码。""" sizes: dict[str, bool] = {} + size_name = "尺码" previous_signature: Optional[ tuple[tuple[str, tuple[str, ...]], ...] ] = None @@ -1683,6 +1716,7 @@ class PddCollectService: f"发现尚未支持的规格维度:{dimension.name}", ) if dimension.key == "size": + size_name = dimension.name for value in dimension.values: sizes[value.text] = sizes.get(value.text, False) or value.available @@ -1711,7 +1745,7 @@ class PddCollectService: return None return SpecDimension( "size", - "尺码", + size_name, tuple(DimensionValue(text, available) for text, available in sizes.items()), ) diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py index 89f7273..193cfc1 100644 --- a/client/src/pdd_u2_purchase_adapter.py +++ b/client/src/pdd_u2_purchase_adapter.py @@ -77,7 +77,9 @@ _DIAGNOSTIC_MARKERS = ( "现在买", "确认购买", "颜色分类", + "款式", "尺码", + "套餐", "安全验证", "手机号登录", "操作频繁", @@ -420,6 +422,8 @@ def _has_confirmation_panel_evidence(panel: ET.Element) -> bool: "尺碼", "规格", "規格", + "款式", + "套餐", } for label in labels ) diff --git a/client/src/util/select_color_size.py b/client/src/util/select_color_size.py index 0921f88..b5e9864 100644 --- a/client/src/util/select_color_size.py +++ b/client/src/util/select_color_size.py @@ -8,7 +8,8 @@ XmlData = Union[str, bytes] Bounds = tuple[int, int, int, int] _BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$") -_COLOR_HEADINGS = ("颜色分类", "颜色", "款式", "颜色款式") +_COLOR_HEADINGS = ("颜色分类", "颜色", "款式", "颜色款式", "花色") +_PACKAGE_HEADINGS = ("套餐",) def _parse_xml(xml_data: XmlData) -> ET.Element: @@ -91,6 +92,7 @@ def _screen_bounds(root: ET.Element) -> Optional[Bounds]: def _heading_bounds(root: ET.Element) -> Optional[Bounds]: fuzzy_match: Optional[Bounds] = None + package_fallback: Optional[Bounds] = None for node in root.iter("node"): text = node.get("text", "").strip() bounds = _parse_bounds(node.get("bounds", "")) @@ -102,7 +104,12 @@ def _heading_bounds(root: ET.Element) -> Optional[Bounds]: return bounds if fuzzy_match is None and any(heading in text for heading in _COLOR_HEADINGS): fuzzy_match = bounds - return fuzzy_match + if package_fallback is None and any( + heading in text for heading in _PACKAGE_HEADINGS + ): + package_fallback = bounds + # “款式 + 套餐”优先定位款式;只有套餐一个维度时才用套餐兜底。 + return fuzzy_match or package_fallback def _target_scrollable_ancestor( diff --git a/client/test/test_pdd_collect_service.py b/client/test/test_pdd_collect_service.py index bf19be2..52667e7 100644 --- a/client/test/test_pdd_collect_service.py +++ b/client/test/test_pdd_collect_service.py @@ -21,6 +21,31 @@ from src.pdd_device_service import PddDeviceService FIXTURES = Path(__file__).parent / "fixtures" +def style_package_spec_xml() -> str: + """模拟口罩商品的“款式 + 套餐”规格面板。""" + + return """ + + + + + + + + + + + + + + """ + + @dataclass(frozen=True) class FakeTask: goods_url: str @@ -428,6 +453,13 @@ class DefaultSizeDevice(SnakeColorDevice): return xml_data +class DefaultPackageDevice(DefaultSizeDevice): + """“套餐”作为第二规格时沿用安全的默认选择取消流程。""" + + def _spec_xml(self): + return super()._spec_xml().replace('text="尺码"', 'text="套餐"') + + class FakeClock: """测试用时钟:sleep 只推进虚拟时间,不真的等待。""" @@ -538,6 +570,97 @@ class PddCollectParserTest(unittest.TestCase): ["黑色", "红色", "蓝色", "紫色", "浅粉", "本色"], ) + def test_style_and_package_are_separate_color_and_size_dimensions(self): + result = parse_spec_panel(style_package_spec_xml()) + + self.assertEqual( + [(item.key, item.name) 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], + ["单个装", "两个装"], + ) + + def test_package_only_is_primary_dimension(self): + root = ET.fromstring(style_package_spec_xml()) + for parent in root.iter(): + for child in list(parent): + if child.get("text") in {"款式", "成人款", "儿童款"}: + parent.remove(child) + + result = parse_spec_panel(ET.tostring(root, encoding="unicode")) + + self.assertEqual( + [(item.key, item.name) for item in result.dimensions], + [("color", "套餐")], + ) + + def test_third_dimension_is_not_silently_merged(self): + xml_data = style_package_spec_xml().replace( + '', + '\n' + '\n' + '', + ) + parsed = parse_spec_panel(xml_data) + self.assertEqual( + [item.key for item in parsed.dimensions], + ["color", "size", "size_2"], + ) + device = FakeCollectDevice(self.home_xml, xml_data) + device.panel_open = True + device.opened_url = "https://mobile.yangkeduo.com/goods.html?goods_id=123" + 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_size_dimension(device) + + self.assertEqual(raised.exception.code, "PDD_DATA_SPEC_UNSUPPORTED") + + def test_collect_preserves_style_and_package_names(self): + xml_data = style_package_spec_xml().replace( + '', + "", + ).replace( + '', + "", + ) + device = FakeCollectDevice(self.home_xml, xml_data) + 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( + [(item.key, item.name) for item in result.dimensions], + [("color", "款式"), ("size", "套餐")], + ) + self.assertEqual( + result.skus[0].options, + {"color": "成人款", "size": "单个装"}, + ) + def test_parse_non_scrollable_spec_panel(self): result = parse_spec_panel(self.non_scrollable_spec_xml) @@ -1030,6 +1153,24 @@ class PddCollectParserTest(unittest.TestCase): self.assertEqual(1, device.size_clicks) self.assertTrue(device.size_selected) + def test_default_package_is_cleared_and_keeps_original_heading(self): + device = DefaultPackageDevice(self.home_xml) + service = PddCollectService( + PddDeviceService(lambda _serial: device), + "USB-001", + "client-001", + sleeper=lambda _seconds: None, + ) + + data = service.collect( + FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123") + ).to_pdd_data() + + self.assertEqual(1, device.size_clicks) + self.assertFalse(device.size_selected) + self.assertEqual(data["dimensions"][1]["key"], "size") + self.assertEqual(data["dimensions"][1]["name"], "套餐") + def test_visible_click_is_fallback_when_page_exposes_no_selection_state(self): xml_data = keep_only_one_sku(self.spec_xml) root = ET.fromstring(xml_data) diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py index d569b12..bdcb67d 100644 --- a/client/test/test_pdd_u2_purchase_adapter.py +++ b/client/test/test_pdd_u2_purchase_adapter.py @@ -337,6 +337,15 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): self.assertEqual(device.clicks, [(790, 2214)]) adapter.close() + def test_purchase_panel_recognizes_style_and_package_headings(self): + xml_data = contextual_confirm_panel_xml().replace( + 'text="颜色分类"', 'text="款式"' + ).replace('text="尺码"', 'text="套餐"') + root = ET.fromstring(xml_data) + + self.assertEqual(_page_kind(root, ""), "order_confirmation") + self.assertEqual(len(_final_submit_targets(root)), 1) + def test_plain_confirm_dialog_is_not_a_submit_target(self): root = ET.fromstring("""