import re import xml.etree.ElementTree as ET from typing import Optional, Union XmlData = Union[str, bytes] Bounds = tuple[int, int, int, int] Coord = tuple[int, int] _BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$") _CURRENCY_PATTERN = re.compile(r"[¥¥]") _ORDER_WORDS = ( "提交订单", "现在买", "立即购买", "确认购买", "去结算", "确认订单", "购买", "下单", "提交", "确认", "结算", ) def _parse_bounds(value: str) -> Optional[Bounds]: match = _BOUNDS_PATTERN.fullmatch(value.strip()) if not match: return None left, top, right, bottom = map(int, match.groups()) if right <= left or bottom <= top: return None return left, top, right, bottom def _is_available(node: ET.Element) -> bool: return ( node.get("visible-to-user", "true") == "true" and node.get("enabled", "true") == "true" ) def _node_label(node: ET.Element) -> str: return f'{node.get("text", "")} {node.get("content-desc", "")}'.strip() def _subtree_label(node: ET.Element) -> str: return " ".join(_node_label(item) for item in node.iter("node")) def _screen_size(nodes: list[ET.Element]) -> tuple[int, int]: bounds = [ parsed for node in nodes if (parsed := _parse_bounds(node.get("bounds", ""))) is not None ] if not bounds: return 0, 0 return max(item[2] for item in bounds), max(item[3] for item in bounds) def _nearest_order_ancestor( node: ET.Element, parents: dict[ET.Element, ET.Element], screen_width: int, screen_height: int, ) -> Optional[ET.Element]: current: Optional[ET.Element] = node while current is not None: bounds = _parse_bounds(current.get("bounds", "")) if ( bounds is not None and current.get("clickable") == "true" and _is_available(current) ): width = bounds[2] - bounds[0] height = bounds[3] - bounds[1] center_y = (bounds[1] + bounds[3]) / 2 # 下单按钮应位于屏幕底部,且不能是覆盖大半屏幕的可点击根节点。 if ( center_y >= screen_height * 0.80 and bounds[3] >= screen_height * 0.88 and width >= screen_width * 0.15 and height <= screen_height * 0.30 ): return current current = parents.get(current) return None def get_order_button_coord(xml_data: XmlData) -> Optional[Coord]: """从规格面板控件树中获取可靠的下单按钮中心坐标。 ``xml_data`` 应为 ``device.dump_hierarchy()`` 返回的 XML。函数只解析 坐标,不执行点击;找不到底部价格、可点击祖先或下单语义时返回 None。 """ try: root = ET.fromstring(xml_data) except (ET.ParseError, TypeError) as exc: raise ValueError("xml_data 不是有效的无障碍控件树 XML") from exc nodes = list(root.iter("node")) screen_width, screen_height = _screen_size(nodes) if screen_width <= 0 or screen_height <= 0: return None parents = { child: parent for parent in root.iter() for child in parent if child.tag == "node" } # 多个价格文字可能属于同一个下单按钮,使用节点身份去重。 candidates: dict[ET.Element, float] = {} for node in nodes: if not _is_available(node): continue label = _node_label(node) if not _CURRENCY_PATTERN.search(label): continue bounds = _parse_bounds(node.get("bounds", "")) if bounds is None: continue center_y = (bounds[1] + bounds[3]) / 2 if center_y < screen_height * 0.80: continue button = _nearest_order_ancestor( node, parents, screen_width, screen_height, ) if button is None: continue button_label = _subtree_label(button) matched_words = [word for word in _ORDER_WORDS if word in button_label] if not matched_words: continue button_bounds = _parse_bounds(button.get("bounds", "")) if button_bounds is None: continue button_center_y = (button_bounds[1] + button_bounds[3]) / 2 strongest_word = max(len(word) for word in matched_words) score = strongest_word * 10 + button_center_y / screen_height candidates[button] = max(score, candidates.get(button, float("-inf"))) if not candidates: return None button = max(candidates, key=candidates.get) left, top, right, bottom = _parse_bounds(button.get("bounds", "")) # type: ignore[misc] return (left + right) // 2, (top + bottom) // 2 # 简短别名,推荐使用语义更明确的 get_order_button_coord。 get_order_button = get_order_button_coord