169 lines
4.9 KiB
Python
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
|