diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py index c26fde7..f793c0a 100644 --- a/client/src/pdd_u2_purchase_adapter.py +++ b/client/src/pdd_u2_purchase_adapter.py @@ -277,10 +277,10 @@ def _has_visible_color_region(root: ET.Element) -> bool: def _needs_color_region_restore(root: ET.Element) -> bool: - """只有第二规格已出现且颜色摘要仍存在时,才判定面板停在中部。""" + """用维度名或强结构证据判断颜色区域是否已经滑出视口。""" labels = [_label(node).strip() for node in root.iter("node")] - has_color_summary = any( + has_named_color_summary = any( label.replace(" ", "").startswith( ("请选择", "請選擇", "已选", "已選", "已选择", "已選擇") ) @@ -291,7 +291,87 @@ def _needs_color_region_restore(root: ET.Element) -> bool: _dimension_heading(label) in _SECOND_DIMENSION_HEADINGS for label in labels ) - return has_color_summary and has_second_heading + if not has_second_heading: + return False + if has_named_color_summary: + return True + + # 部分 PDD 面板的摘要只写“已选:具体颜色 具体尺码”,不再带 + # “颜色分类”维度名。只有唯一纵向容器内、第二规格标题上方同时 + # 存在至少两个完整可点击卡片时,才把它认作已滚动的第一规格。 + has_plain_selected_summary = any( + label.replace(" ", "").startswith( + ("已选", "已選", "已选择", "已選擇") + ) + for label in labels + ) + return has_plain_selected_summary and _has_first_dimension_structure(root) + + +def _has_first_dimension_structure(root: ET.Element) -> bool: + """确认唯一纵向容器的第二规格标题上方仍有多个第一规格卡片。""" + + region = _purchase_panel_scroll_region(root) + if region is None: + return False + width = region[2] - region[0] + height = region[3] - region[1] + if height < width * 0.70: + return False + + second_heading_tops = [ + bounds[1] + for node in root.iter("node") + if _dimension_heading(_label(node)) in _SECOND_DIMENSION_HEADINGS + if (bounds := _parse_bounds(node.get("bounds", ""))) is not None + if region[0] <= bounds[0] + and region[1] <= bounds[1] + and bounds[2] <= region[2] + and bounds[3] <= region[3] + ] + if not second_heading_tops: + return False + second_heading_top = min(second_heading_tops) + + excluded = { + "增加数量", + "减少数量", + "提交订单", + "确定", + "打开大图", + } + candidate_bounds: set[Bounds] = set() + for node in root.iter("node"): + if ( + node.get("clickable") != "true" + or node.get("enabled", "true") == "false" + or node.get("visible-to-user", "true") == "false" + ): + continue + bounds = _parse_bounds(node.get("bounds", "")) + label = _label(node).strip() + if not label or bounds is None: + continue + if not ( + region[0] <= bounds[0] + and region[1] <= bounds[1] + and bounds[2] <= region[2] + and bounds[3] <= second_heading_top + ): + continue + compact = label.replace(" ", "") + if ( + compact.startswith( + ("请选择", "請選擇", "已选", "已選", "选择", "選擇") + ) + or any(marker in compact for marker in excluded) + or _dimension_heading(label) in ( + _COLOR_DIMENSION_HEADINGS | _SECOND_DIMENSION_HEADINGS + ) + ): + continue + candidate_bounds.add(bounds) + return len(candidate_bounds) >= 2 def _purchase_panel_scroll_region(root: ET.Element) -> Optional[Bounds]: diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py index a5dccd2..7abb56e 100644 --- a/client/test/test_pdd_u2_purchase_adapter.py +++ b/client/test/test_pdd_u2_purchase_adapter.py @@ -19,6 +19,7 @@ from src.pdd_u2_purchase_adapter import ( U2PddLivePurchaseAdapter, U2PddPurchaseAdapter, _final_submit_targets, + _needs_color_region_restore, _page_kind, _tagged_shipping_address, ) @@ -152,6 +153,98 @@ def vertically_scrolled_purchase_panel_xml(*, at_color_top: bool) -> str: """ +def numbered_vertical_color_panel_xml( + *, + at_color_top: bool, + target_selected: bool = False, + middle_option_count: int = 8, + extra_scrollable: bool = False, +) -> str: + """模拟摘要不含维度名、当前停在 E09~E16 的纵向颜色列表。""" + + if at_color_top: + selected = ' selected="true"' if target_selected else "" + content = f""" + + + + + + + """ + else: + cards = [] + for index in range(middle_option_count): + number = index + 9 + top = 997 + index * 115 + bottom = top + 85 + cards.append( + f'' + ) + content = "\n".join(cards) + """ + + + """ + extra = """ + + """ if extra_scrollable else "" + summary = ( + "已选: 宠粉诱惑-E01【一件】 均码【70-140斤】" + if target_selected + else "已选: 宠粉诱惑-E10【一件】 均码【70-140斤】" + ) + return f""" + + + + + + + {content} + + {extra} + + + """ + + +def size_only_panel_xml() -> str: + """单规格尺码面板不能被结构兜底误认为颜色列表中部。""" + + return """ + + + + + + + + + + + """ + + class FakeEditor: def __init__(self, device) -> None: self.device = device @@ -316,6 +409,40 @@ class VerticallyScrolledColorPanelDevice(FakeDevice): self.at_color_top = True +class NumberedVerticalColorPanelDevice(FakeDevice): + """模拟目标 E01 在纵向列表顶部、当前视口停在 E09~E16。""" + + def __init__(self) -> None: + super().__init__() + self.at_color_top = False + self.target_selected = False + self.swipes = [] + + def dump_hierarchy(self): + if not self.has_opened: + return super().dump_hierarchy() + if self.mode == "home": + return home_xml() + if self.mode == "panel": + return numbered_vertical_color_panel_xml( + at_color_top=self.at_color_top, + target_selected=self.target_selected, + ) + return super().dump_hierarchy() + + def click(self, x, y): + self.clicks.append((x, y)) + if self.mode == "panel" and self.at_color_top and 1090 <= y <= 1175: + self.target_selected = True + return + self.mode = "panel" + + def swipe(self, x1, y1, x2, y2, duration=0.0): + self.swipes.append((x1, y1, x2, y2, duration)) + if y2 > y1: + self.at_color_top = True + + class ColdStartDevice(FakeDevice): def __init__(self) -> None: super().__init__() @@ -1622,6 +1749,58 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): self.assertIn('text="颜色分类"', selected_xml[0]) adapter.close() + def test_purchase_restores_plain_summary_and_matches_traditional_target(self): + clock = FakeClock() + device = NumberedVerticalColorPanelDevice() + adapter = U2PddPurchaseAdapter( + "USB-001", + device_service=PddDeviceService(connector=lambda _serial: device), + sleeper=clock.sleep, + monotonic=clock.monotonic, + ) + adapter.open_goods(GOODS_URL) + + adapter.select_options({"color": "寵粉誘惑-E01【一件】"}) + + self.assertTrue(device.at_color_top) + self.assertTrue(device.target_selected) + self.assertEqual(len(device.swipes), 1) + # 一次打开规格面板、一次点击唯一的简体 E01 卡片。 + self.assertEqual(len(device.clicks), 2) + adapter.close() + + def test_plain_summary_restore_requires_strong_first_dimension_structure(self): + cases = ( + ( + "valid_vertical_first_dimension", + numbered_vertical_color_panel_xml(at_color_top=False), + True, + ), + ( + "only_one_candidate_before_size", + numbered_vertical_color_panel_xml( + at_color_top=False, + middle_option_count=1, + ), + False, + ), + ( + "multiple_scrollables", + numbered_vertical_color_panel_xml( + at_color_top=False, + extra_scrollable=True, + ), + False, + ), + ("size_only", size_only_panel_xml(), False), + ) + for name, xml_data, expected in cases: + with self.subTest(name=name): + self.assertEqual( + _needs_color_region_restore(ET.fromstring(xml_data)), + expected, + ) + def test_purchase_does_not_scroll_when_color_region_is_already_visible(self): device = VerticallyScrolledColorPanelDevice(starts_at_top=True) adapter = self._live_adapter( diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md index ed49f51..f136365 100644 --- a/docs/client/06-quality-security.md +++ b/docs/client/06-quality-security.md @@ -119,6 +119,9 @@ Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode` 同一滚动容器内候选坐标分布和容器形状判断主要方向,再按方向有界遍历; 方向证据不足时不滑动、不点击。找到目标后仍须通过精确或唯一繁简等价匹配, 不能用近似颜色代替任务规格。 +- 纵向颜色列表停在中部时,“已选”摘要可能不包含“颜色分类”维度名。只有唯一 + 纵向规格容器、第二规格标题及其上方多个完整可点击卡片同时存在,才允许有界 + 归位到正式颜色标题;单规格、候选不足或容器不唯一时不得猜测滑动。 - 运行时规格解析请求必须在网络发送前写入独立 SQLite 记录;只调用一次专用命令, 不轮询 Admin。只有 `matched` 响应的哈希、候选编号、原文和 options 都是本次候选的 逐字副本时才继续。响应返回后重新完整遍历真机候选并复算哈希,页面、颜色或候选