Files
cmautobuy/client/src/util/get_order_button.py
T

169 lines
4.9 KiB
Python

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