diff --git a/client/src/util/select_color_size.py b/client/src/util/select_color_size.py index 6489dee..8ac02c8 100644 --- a/client/src/util/select_color_size.py +++ b/client/src/util/select_color_size.py @@ -194,6 +194,45 @@ def _heading_bounds(root: ET.Element) -> Optional[Bounds]: return fuzzy_match or package_fallback +def _scrollable_region_node( + root: ET.Element, + region: Bounds, +) -> Optional[ET.Element]: + """返回边界与目标区域一致的最内层可滚动容器。""" + + parents = _parent_map(root) + candidates = [ + node + for node in root.iter("node") + if node.get("scrollable") == "true" + and _is_available(node) + and _parse_bounds(node.get("bounds", "")) == region + ] + if not candidates: + return None + + def depth(node: ET.Element) -> int: + result = 0 + current = parents.get(node) + while current is not None: + result += 1 + current = parents.get(current) + return result + + return max(candidates, key=depth) + + +def _color_heading_in_region(root: ET.Element, region: Bounds) -> bool: + """只在当前颜色滚动容器内寻找标题,忽略背后商品页文字。""" + + container = _scrollable_region_node(root, region) + if container is not None: + return _heading_bounds(container) is not None + + heading = _heading_bounds(root) + return heading is not None and _center_in(heading, region) + + def _target_scrollable_ancestor( root: ET.Element, target: str, @@ -237,6 +276,19 @@ def _horizontal_color_region( width = bounds[2] - bounds[0] height = bounds[3] - bounds[1] + contained_heading = _heading_bounds(node) + + if ( + contained_heading is not None + and screen is not None + and _center_in(contained_heading, bounds) + and width >= screen[2] * 0.60 + ): + # 纵向长列表的颜色标题位于滚动容器内部;页面背后的同名 + # 文本不能参与定位。面积较小的内层容器优先。 + score = 50_000.0 - width * height / 1_000_000 + candidates.append((score, bounds)) + continue if heading is not None and screen is not None: distance_below_heading = bounds[1] - heading[3] @@ -659,14 +711,17 @@ def _color_scroll_direction( ) -> Optional[str]: """判断颜色容器的主要滚动方向;证据不足时不猜测。""" + container = _scrollable_region_node(root, region) + nodes = container.iter("node") if container is not None else root.iter("node") option_bounds: list[Bounds] = [] - for node in root.iter("node"): + for node in nodes: if node.get("clickable") != "true" or not _is_available(node): continue bounds = _parse_bounds(node.get("bounds", "")) if bounds is None or not _center_in(bounds, region): continue - if _intersection_area(bounds, region) <= 0: + area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]) + if area <= 0 or _intersection_area(bounds, region) != area: continue option_bounds.append(bounds) @@ -784,7 +839,7 @@ def select_color( if direction == "vertical": # Adapter 正常会先把颜色标题恢复到顶部;独立调用若标题已经 # 滚出视口,则先有界回到起点,随后只向下扫描一次。 - if _heading_bounds(root) is None: + if not _color_heading_in_region(root, region): previous_signature = _visible_signature(root, region) unchanged_count = 0 for _ in range(max_swipes): @@ -792,7 +847,7 @@ def select_color( time.sleep(action_delay) root = _parse_xml(device.dump_hierarchy()) region = _target_scrollable_ancestor(root, target_color) or region - if _heading_bounds(root) is not None: + if _color_heading_in_region(root, region): break signature = _visible_signature(root, region) unchanged_count = ( diff --git a/client/test/test_select_color_size.py b/client/test/test_select_color_size.py index 33e2cfa..bbbad7f 100644 --- a/client/test/test_select_color_size.py +++ b/client/test/test_select_color_size.py @@ -60,10 +60,12 @@ class ScrollColorDevice: direction: str, target: str = "目标白色", include_target: bool = True, + polluted: bool = False, ) -> None: self.direction = direction self.target = target self.include_target = include_target + self.polluted = polluted self.page = 0 self.selected = False self.clicks = [] @@ -104,14 +106,23 @@ class ScrollColorDevice: container = ''.join(options) summary = f"已选:{self.target}" if self.selected else "请选择:颜色 尺码" - return f''' - - {container} + ''' + if self.polluted: + panel = f''' + + {panel} + + ''' + return f''' + + {panel} ''' @@ -332,6 +343,26 @@ class StaticColorSelectionTest(unittest.TestCase): ) ) + def test_external_clickables_do_not_pollute_vertical_direction(self): + device = ScrollColorDevice("vertical", polluted=True) + + selected = select_color( + device, + device.dump_hierarchy(), + device.target, + action_delay=0.001, + ) + + self.assertTrue(selected) + self.assertEqual(len(device.clicks), 1) + self.assertTrue(device.swipes) + self.assertTrue( + all( + abs(y2 - y1) > abs(x2 - x1) + for x1, y1, x2, y2, _duration in device.swipes + ) + ) + def test_horizontal_color_list_keeps_horizontal_scan(self): device = ScrollColorDevice("horizontal")