ascript 1.3.27__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ascript/__init__.py +8 -0
- ascript/hooks/__init__.py +0 -0
- ascript/hooks/hook-ascript.py +2 -0
- ascript/windows/__init__.py +29 -0
- ascript/windows/daos/__init__.py +0 -0
- ascript/windows/daos/screen.py +143 -0
- ascript/windows/hardware/__init__.py +5 -0
- ascript/windows/hardware/keyboard.py +279 -0
- ascript/windows/hardware/logitech_driver.py +179 -0
- ascript/windows/hardware/mouse.py +308 -0
- ascript/windows/license/__init__.py +16 -0
- ascript/windows/license/achttp.cp310-win_amd64.pyd +0 -0
- ascript/windows/license/achttp.cp311-win_amd64.pyd +0 -0
- ascript/windows/license/achttp.cp312-win_amd64.pyd +0 -0
- ascript/windows/license/achttp.cp313-win_amd64.pyd +0 -0
- ascript/windows/license/achttp.cp314-win_amd64.pyd +0 -0
- ascript/windows/license/achttp.cp38-win_amd64.pyd +0 -0
- ascript/windows/license/achttp.cp39-win_amd64.pyd +0 -0
- ascript/windows/media/__init__.py +0 -0
- ascript/windows/resources/__init__.py +0 -0
- ascript/windows/resources/libs/logitech/x64/LogitechGkeyEnginesWrapper.dll +0 -0
- ascript/windows/resources/libs/logitech/x86/LogitechGkeyEnginesWrapper.dll +0 -0
- ascript/windows/resources/templates/__init__.py +0 -0
- ascript/windows/resources/templates/__init__py.win.template +51 -0
- ascript/windows/resources/templates/build.as.win.template +14 -0
- ascript/windows/resources/templates/build.py +71 -0
- ascript/windows/resources/templates/logo_windows.ico +0 -0
- ascript/windows/resources/templates/windows/main.py +50 -0
- ascript/windows/resources/templates/windows/pyproject.toml +44 -0
- ascript/windows/resources/templates/windows/res/img/logo.ico +0 -0
- ascript/windows/screen/__init__.py +13 -0
- ascript/windows/screen/as_cv.py +382 -0
- ascript/windows/screen/canny.py +128 -0
- ascript/windows/screen/code_scanner.py +72 -0
- ascript/windows/screen/color_algorithms.py +277 -0
- ascript/windows/screen/find_colors.py +251 -0
- ascript/windows/screen/find_images.py +217 -0
- ascript/windows/screen/ocr/__init__.py +4 -0
- ascript/windows/screen/ocr/ocr.py +223 -0
- ascript/windows/screen/ocr/rapidocr_hook.py +301 -0
- ascript/windows/screen/ocrx.py +169 -0
- ascript/windows/screen/screen_core.py +139 -0
- ascript/windows/store/__init__.py +21 -0
- ascript/windows/system/__init__.py +8 -0
- ascript/windows/system/device.py +355 -0
- ascript/windows/system/easy_print.py +115 -0
- ascript/windows/system/env.py +492 -0
- ascript/windows/system/exception/__init__.py +1 -0
- ascript/windows/system/key_value.py +145 -0
- ascript/windows/system/pip.py +175 -0
- ascript/windows/system/r.py +165 -0
- ascript/windows/system/runtime.py +62 -0
- ascript/windows/tools/__init__.py +27 -0
- ascript/windows/tools/__main__.py +21 -0
- ascript/windows/tools/action.json +33 -0
- ascript/windows/tools/action.py +168 -0
- ascript/windows/tools/colors_tool.py +391 -0
- ascript/windows/tools/config.py +26 -0
- ascript/windows/tools/dumptool.py +18 -0
- ascript/windows/tools/eel_api.py +495 -0
- ascript/windows/tools/gpplug/__init__.py +0 -0
- ascript/windows/tools/gpplug/plug.py +70 -0
- ascript/windows/tools/mouse_catch.py +168 -0
- ascript/windows/tools/plug_manager.py +38 -0
- ascript/windows/tools/tool_api.py +906 -0
- ascript/windows/tools/tool_window.py +56 -0
- ascript/windows/tools/uia_worker.py +365 -0
- ascript/windows/tools/web/app_store_window.html +481 -0
- ascript/windows/tools/web/colors.html +532 -0
- ascript/windows/tools/web/css/screen.css +1342 -0
- ascript/windows/tools/web/css/viewtree.css +357 -0
- ascript/windows/tools/web/flot_control.html +195 -0
- ascript/windows/tools/web/img/ico/favicon.png +0 -0
- ascript/windows/tools/web/img/ico/paintpalette.png +0 -0
- ascript/windows/tools/web/img/svg/find_colors.svg +1 -0
- ascript/windows/tools/web/img/svg/find_images.svg +1 -0
- ascript/windows/tools/web/img/svg/illustration-6.svg +89 -0
- ascript/windows/tools/web/img/svg/img_default.svg +1 -0
- ascript/windows/tools/web/img/svg/ocr.svg +1 -0
- ascript/windows/tools/web/js/screen/colors.js +466 -0
- ascript/windows/tools/web/js/screen/gp_plug.js +108 -0
- ascript/windows/tools/web/js/screen/image_mouse.js +464 -0
- ascript/windows/tools/web/js/screen/img_view.js +558 -0
- ascript/windows/tools/web/js/screen/plug.js +246 -0
- ascript/windows/tools/web/js/screen/screen.js +690 -0
- ascript/windows/tools/web/js/screen/screen_api.js +778 -0
- ascript/windows/tools/web/js/screen/screen_maker.js +10 -0
- ascript/windows/tools/web/js/utils/map.js +73 -0
- ascript/windows/tools/web/js/utils/upload.js +157 -0
- ascript/windows/tools/web/js/viewtree/niv_js.js +27 -0
- ascript/windows/tools/web/js/viewtree/vtree.js +1510 -0
- ascript/windows/tools/web/libs/bootstrap/css/bootstrap.min.css +6 -0
- ascript/windows/tools/web/libs/bootstrap/js/bootstrap.bundle.min.js +7 -0
- ascript/windows/tools/web/libs/bootstrap-icons/0-circle.svg +4 -0
- ascript/windows/tools/web/libs/bootstrap-icons/0-square.svg +4 -0
- ascript/windows/tools/web/libs/bootstrap-icons/font/bootstrap-icons.css +2078 -0
- ascript/windows/tools/web/libs/bootstrap-icons/font/bootstrap-icons.json +2052 -0
- ascript/windows/tools/web/libs/bootstrap-icons/font/bootstrap-icons.min.css +5 -0
- ascript/windows/tools/web/libs/bootstrap-icons/font/bootstrap-icons.scss +2090 -0
- ascript/windows/tools/web/libs/bootstrap-icons/font/fonts/bootstrap-icons.woff +0 -0
- ascript/windows/tools/web/libs/bootstrap-icons/font/fonts/bootstrap-icons.woff2 +0 -0
- ascript/windows/tools/web/libs/feather-icons/dist/feather.min.js +13 -0
- ascript/windows/tools/web/libs/feather-icons/dist/icons.json +1 -0
- ascript/windows/tools/web/libs/jquery/core.js +399 -0
- ascript/windows/tools/web/libs/jquery/jquery.js +10598 -0
- ascript/windows/tools/web/libs/jquery/jquery.min.js +4 -0
- ascript/windows/tools/web/libs/jquery/jquery.min.js.zip +0 -0
- ascript/windows/tools/web/libs/jquery/jquery.slim.js +8495 -0
- ascript/windows/tools/web/libs/jquery/jquery.slim.min.js +2 -0
- ascript/windows/tools/web/libs/jquery-menu/jquery.contextMenu.js +2134 -0
- ascript/windows/tools/web/libs/jquery-menu/jquery.contextMenu.min.css +16 -0
- ascript/windows/tools/web/libs/jquery-zTree/css/zTreeStyle.css +97 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/1_close.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/1_open.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/2.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/3.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/4.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/5.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/6.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/7.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/8.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/9.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/line_conn.gif +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/loading.gif +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/tree-node-btn.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/zTreeStandard.gif +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/zTreeStandard.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/js/jquery.ztree.all.min.js +3 -0
- ascript/windows/tools/web/libs/treeview/css/jquery.treeview.css +79 -0
- ascript/windows/tools/web/libs/treeview/img/file.png +0 -0
- ascript/windows/tools/web/libs/treeview/img/ico_loading.png +0 -0
- ascript/windows/tools/web/libs/treeview/img/icon4.png +0 -0
- ascript/windows/tools/web/libs/treeview/img/icon5.png +0 -0
- ascript/windows/tools/web/libs/treeview/img/icon6.png +0 -0
- ascript/windows/tools/web/libs/treeview/img/py.png +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-black-line.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-black.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-default-line.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-default.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-famfamfam-line.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-famfamfam.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-gray-line.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-gray.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-red-line.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-red.gif +0 -0
- ascript/windows/tools/web/libs/treeview/js/jquery.treeview.edit.js +37 -0
- ascript/windows/tools/web/libs/treeview/js/jquery.treeview.js +250 -0
- ascript/windows/tools/web/license_window.html +255 -0
- ascript/windows/tools/web/plugs/a.html +653 -0
- ascript/windows/tools/web/plugs/demo.html +52 -0
- ascript/windows/tools/web/plugs/find_colors.html +629 -0
- ascript/windows/tools/web/plugs/find_images.html +699 -0
- ascript/windows/tools/web/plugs/rapidocr.html +954 -0
- ascript/windows/tools/web/vtree.html +496 -0
- ascript/windows/tools/webview/assets/index-HymBPl-I.css +1 -0
- ascript/windows/tools/webview/assets/index-PqA_m8tL.js +17 -0
- ascript/windows/tools/webview/index.html +13 -0
- ascript/windows/ui/__init__.py +6 -0
- ascript/windows/ui/app_store_window.py +272 -0
- ascript/windows/ui/draw_window.py +231 -0
- ascript/windows/ui/flot_test.py +297 -0
- ascript/windows/ui/flot_window.py +488 -0
- ascript/windows/ui/license_window.py +111 -0
- ascript/windows/ui/web_window.py +466 -0
- ascript/windows/utils/__init__.py +0 -0
- ascript/windows/utils/node_utils.py +20 -0
- ascript/windows/utils/screen_utils.py +280 -0
- ascript/windows/window/Selector.py +1015 -0
- ascript/windows/window/Window.py +747 -0
- ascript/windows/window/__init__.py +5 -0
- ascript/windows/window/launcher.py +213 -0
- ascript/windows/window/uia_runtime.py +26 -0
- ascript-1.3.27.dist-info/METADATA +41 -0
- ascript-1.3.27.dist-info/RECORD +177 -0
- ascript-1.3.27.dist-info/WHEEL +5 -0
- ascript-1.3.27.dist-info/entry_points.txt +2 -0
- ascript-1.3.27.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1015 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
6
|
+
|
|
7
|
+
import uiautomation as auto
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
from .Window import Window
|
|
11
|
+
from .uia_runtime import prepare_comtypes_cache
|
|
12
|
+
|
|
13
|
+
prepare_comtypes_cache()
|
|
14
|
+
|
|
15
|
+
class UIElement:
|
|
16
|
+
"""UI 元素封装类。
|
|
17
|
+
|
|
18
|
+
将 uiautomation 的 Control 对象封装为更易用的 UI 元素,
|
|
19
|
+
提供属性访问、坐标转换、点击、输入、截图等操作。
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, node: auto.Control, window: Any, depth: int):
|
|
23
|
+
"""初始化 UI 元素。
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
node: uiautomation 的 Control 控件对象
|
|
27
|
+
window: 所属的 Window 窗口对象
|
|
28
|
+
depth: 该元素在 UI 树中的深度层级
|
|
29
|
+
"""
|
|
30
|
+
self.node = node
|
|
31
|
+
self.window = window
|
|
32
|
+
self.depth = depth
|
|
33
|
+
self.children: List['UIElement'] = []
|
|
34
|
+
|
|
35
|
+
# --- 1. 基础标识属性 (原生映射,不拼凑) ---
|
|
36
|
+
self.name: str = self._read(node, "Name", "")
|
|
37
|
+
self.type: str = self._read(node, "ControlTypeName", "")
|
|
38
|
+
self.class_name: str = self._read(node, "ClassName", "")
|
|
39
|
+
self.handle: int = self._read(node, "NativeWindowHandle", 0) or 0
|
|
40
|
+
|
|
41
|
+
# 资源 ID: 仅存储原生的 AutomationId
|
|
42
|
+
self.res_id: str = self._read(node, "AutomationId", "")
|
|
43
|
+
|
|
44
|
+
# 内存 ID: 仅存储对象在内存中的物理唯一地址 (Python Object ID)
|
|
45
|
+
self.memory_id: int = id(self.node)
|
|
46
|
+
|
|
47
|
+
# --- 2. 交互状态属性 ---
|
|
48
|
+
self.is_enabled: bool = bool(self._read(node, "IsEnabled", False))
|
|
49
|
+
self.is_visible: bool = not bool(self._read(node, "IsOffscreen", True))
|
|
50
|
+
self.is_clickable: bool = self._check_clickable(node)
|
|
51
|
+
self.is_password: bool = bool(self._read(node, "IsPassword", False))
|
|
52
|
+
self.is_focusable: bool = bool(self._read(node, "IsKeyboardFocusable", False))
|
|
53
|
+
self.is_focused: bool = bool(self._read(node, "HasKeyboardFocus", False))
|
|
54
|
+
|
|
55
|
+
toggle_pattern = self._get_pattern(node, "GetTogglePattern")
|
|
56
|
+
selection_pattern = self._get_pattern(node, "GetSelectionItemPattern")
|
|
57
|
+
value_pattern = self._get_pattern(node, "GetValuePattern")
|
|
58
|
+
self.is_checkable: bool = toggle_pattern is not None
|
|
59
|
+
self.is_checked: bool = bool(
|
|
60
|
+
self._read(toggle_pattern, "ToggleState", 0)
|
|
61
|
+
) if toggle_pattern is not None else False
|
|
62
|
+
self.is_selected: bool = bool(
|
|
63
|
+
self._read(selection_pattern, "IsSelected", False)
|
|
64
|
+
) if selection_pattern is not None else False
|
|
65
|
+
self.is_editable: bool = bool(
|
|
66
|
+
value_pattern is not None
|
|
67
|
+
and not bool(self._read(value_pattern, "IsReadOnly", True))
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# 内容获取
|
|
71
|
+
self.value: str = self._get_node_value(node)
|
|
72
|
+
self.description: str = self._read(node, "HelpText", "")
|
|
73
|
+
|
|
74
|
+
# --- 3. 坐标转换 (窗口相对坐标) ---
|
|
75
|
+
try:
|
|
76
|
+
rect = node.BoundingRectangle
|
|
77
|
+
# Window.capture() returns the client area, so element overlays must
|
|
78
|
+
# use the same physical-coordinate origin rather than the outer frame.
|
|
79
|
+
w_rect = self.window.client_rect
|
|
80
|
+
win_l = w_rect.left if hasattr(w_rect, 'left') else w_rect[0]
|
|
81
|
+
win_t = w_rect.top if hasattr(w_rect, 'top') else w_rect[1]
|
|
82
|
+
self.relative_rect = (
|
|
83
|
+
rect.left - win_l, rect.top - win_t,
|
|
84
|
+
rect.right - win_l, rect.bottom - win_t,
|
|
85
|
+
)
|
|
86
|
+
except Exception:
|
|
87
|
+
self.relative_rect = (0, 0, 0, 0)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _read(node: auto.Control, attr: str, default):
|
|
91
|
+
try:
|
|
92
|
+
value = getattr(node, attr)
|
|
93
|
+
return default if value is None else value
|
|
94
|
+
except Exception:
|
|
95
|
+
return default
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _get_pattern(node: auto.Control, method: str):
|
|
99
|
+
try:
|
|
100
|
+
getter = getattr(node, method, None)
|
|
101
|
+
return getter() if getter else None
|
|
102
|
+
except Exception:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def _check_clickable(self, node: auto.Control) -> bool:
|
|
106
|
+
"""检查控件节点是否支持点击交互。
|
|
107
|
+
|
|
108
|
+
通过检测 InvokePattern、TogglePattern、SelectionItemPattern 来判断。
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
node: uiautomation 的 Control 控件对象
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
支持点击返回 True,否则返回 False
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
return any([
|
|
118
|
+
hasattr(node, 'GetInvokePattern') and node.GetInvokePattern(),
|
|
119
|
+
hasattr(node, 'GetTogglePattern') and node.GetTogglePattern(),
|
|
120
|
+
hasattr(node, 'GetSelectionItemPattern') and node.GetSelectionItemPattern()
|
|
121
|
+
])
|
|
122
|
+
except:
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
def _get_node_value(self, node: auto.Control) -> str:
|
|
126
|
+
"""获取控件的文本内容值。
|
|
127
|
+
|
|
128
|
+
优先通过 ValuePattern 获取,若失败则对 EditControl 类型回退到 Name 属性。
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
node: uiautomation 的 Control 控件对象
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
控件的文本值,获取失败返回空字符串
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
if hasattr(node, 'GetValuePattern'):
|
|
138
|
+
val = node.GetValuePattern().Value
|
|
139
|
+
if val and val != self.name:
|
|
140
|
+
return val
|
|
141
|
+
if self.type == "EditControl":
|
|
142
|
+
return node.Name or ""
|
|
143
|
+
except:
|
|
144
|
+
pass
|
|
145
|
+
return ""
|
|
146
|
+
|
|
147
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
148
|
+
"""将 UI 元素及其子元素递归转换为字典结构。
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
包含元素所有属性和子元素的嵌套字典
|
|
152
|
+
"""
|
|
153
|
+
return {
|
|
154
|
+
"name": self.name,
|
|
155
|
+
"type": self.type,
|
|
156
|
+
"res_id": self.res_id, # 有就有,没有就是 ""
|
|
157
|
+
"memory_id": self.memory_id, # 纯数字内存地址
|
|
158
|
+
"class_name": self.class_name,
|
|
159
|
+
"handle": self.handle,
|
|
160
|
+
"rect": {
|
|
161
|
+
"left": self.relative_rect[0],
|
|
162
|
+
"top": self.relative_rect[1],
|
|
163
|
+
"right": self.relative_rect[2],
|
|
164
|
+
"bottom": self.relative_rect[3]
|
|
165
|
+
},
|
|
166
|
+
"enabled": self.is_enabled,
|
|
167
|
+
"visible": self.is_visible,
|
|
168
|
+
"clickable": self.is_clickable,
|
|
169
|
+
"value": self.value,
|
|
170
|
+
"description": self.description,
|
|
171
|
+
"password": self.is_password,
|
|
172
|
+
"checkable": self.is_checkable,
|
|
173
|
+
"checked": self.is_checked,
|
|
174
|
+
"editable": self.is_editable,
|
|
175
|
+
"focusable": self.is_focusable,
|
|
176
|
+
"focused": self.is_focused,
|
|
177
|
+
"selected": self.is_selected,
|
|
178
|
+
"depth": self.depth,
|
|
179
|
+
"child_count": len(self.children),
|
|
180
|
+
"children": [child.to_dict() for child in self.children]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def child_count(self) -> int:
|
|
185
|
+
"""获取子节点数量。
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
子元素的个数
|
|
189
|
+
"""
|
|
190
|
+
return len(self.children)
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def center(self) -> Tuple[int, int]:
|
|
194
|
+
"""获取元素相对于窗口的中心点坐标。
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
(x, y) 形式的中心点坐标元组
|
|
198
|
+
"""
|
|
199
|
+
l, t, r, b = self.relative_rect
|
|
200
|
+
return (l + r) // 2, (t + b) // 2
|
|
201
|
+
|
|
202
|
+
def click(self, simulate: bool = False):
|
|
203
|
+
"""点击当前 UI 元素。
|
|
204
|
+
|
|
205
|
+
支持底层指令点击和物理模拟点击两种模式。底层指令点击不移动鼠标,
|
|
206
|
+
物理模拟点击会真实移动鼠标到元素位置。
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
simulate: True 为强制物理模拟点击,False 为优先使用底层指令点击(失败时自动降级)
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
返回自身(UIElement),支持链式调用
|
|
213
|
+
"""
|
|
214
|
+
if not simulate:
|
|
215
|
+
# 1. 尝试使用 InvokePattern (最通用的底层点击)
|
|
216
|
+
try:
|
|
217
|
+
pattern = self.node.GetInvokePattern()
|
|
218
|
+
if pattern:
|
|
219
|
+
pattern.Invoke()
|
|
220
|
+
return self
|
|
221
|
+
except:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
# 2. 尝试使用 TogglePattern (适用于复选框、某些开关按钮)
|
|
225
|
+
try:
|
|
226
|
+
pattern = self.node.GetTogglePattern()
|
|
227
|
+
if pattern:
|
|
228
|
+
pattern.Toggle()
|
|
229
|
+
return self
|
|
230
|
+
except:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
# 3. 降级方案:如果上述底层接口不支持,或者明确要求 simulate=True
|
|
234
|
+
# 使用物理模拟点击。
|
|
235
|
+
# 注意:uiautomation 的 Click 默认就是移动鼠标。
|
|
236
|
+
# 如果不想移动鼠标但还要发物理点击,基本不可能,除非用 PostMessage。
|
|
237
|
+
cx, cy = self.center
|
|
238
|
+
if simulate:
|
|
239
|
+
self.node.Click() # 这里的 Click 会移动鼠标
|
|
240
|
+
else:
|
|
241
|
+
# 使用你 Window 封装的底层坐标点击逻辑
|
|
242
|
+
self.window.click(cx, cy)
|
|
243
|
+
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
def input(self, text: str, simulate: bool = False):
|
|
247
|
+
"""向当前 UI 元素输入文本。
|
|
248
|
+
|
|
249
|
+
支持底层直接赋值和模拟键盘敲击两种模式。
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
text: 要输入的文本字符串
|
|
253
|
+
simulate: False 为优先使用 ValuePattern 直接设值(静默且快),
|
|
254
|
+
True 为使用 SendKeys 模拟键盘敲击(适合需要触发监听事件的输入框)
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
返回自身(UIElement),支持链式调用
|
|
258
|
+
"""
|
|
259
|
+
if not simulate:
|
|
260
|
+
try:
|
|
261
|
+
# 1. 尝试 ValuePattern (最通用的设置文本方式)
|
|
262
|
+
pattern = self.node.GetValuePattern()
|
|
263
|
+
if pattern:
|
|
264
|
+
pattern.SetValue(text)
|
|
265
|
+
return self
|
|
266
|
+
except:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
# 2. 降级方案 或 强制模拟:先点击聚焦,再发送按键
|
|
270
|
+
# 注意:这里调用的是你已有的 click()
|
|
271
|
+
self.click(simulate=simulate)
|
|
272
|
+
self.node.SendKeys(text)
|
|
273
|
+
return self
|
|
274
|
+
|
|
275
|
+
def capture(self, save_path: str = None) -> Image:
|
|
276
|
+
"""捕获当前 UI 元素的截图。
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
save_path: 图片保存路径,为 None 则不保存到文件
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Pillow Image 对象
|
|
283
|
+
"""
|
|
284
|
+
# uiautomation 的 CaptureToImage 返回的是 Pillow 的 Image 对象
|
|
285
|
+
img = self.node.CaptureToImage()
|
|
286
|
+
|
|
287
|
+
if save_path:
|
|
288
|
+
img.save(save_path)
|
|
289
|
+
|
|
290
|
+
return img
|
|
291
|
+
|
|
292
|
+
def __repr__(self):
|
|
293
|
+
return f"<UIElement: [{self.type}] res_id: {self.res_id} memory_id: {self.memory_id}>"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class Selector:
|
|
298
|
+
"""UI 元素选择器,支持链式查询和操作。
|
|
299
|
+
|
|
300
|
+
提供类似 CSS 选择器的链式 API,可通过属性过滤、层级关系(子节点、父节点、兄弟节点)
|
|
301
|
+
组合定位 UI 元素,并支持在查询链中插入点击和输入动作。
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
def __init__(
|
|
305
|
+
self,
|
|
306
|
+
window: Any = None,
|
|
307
|
+
title: str = None,
|
|
308
|
+
depth: int = 0xFFFFFFFF,
|
|
309
|
+
*,
|
|
310
|
+
app: Optional[str] = None,
|
|
311
|
+
title_re: Optional[str] = None,
|
|
312
|
+
class_name: Optional[str] = None,
|
|
313
|
+
content: Optional[Union[bool, str]] = None,
|
|
314
|
+
timeout: float = 10,
|
|
315
|
+
):
|
|
316
|
+
"""初始化选择器。
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
window: 已存在的 Window 对象实例,作为搜索的根窗口
|
|
320
|
+
title: 窗口标题,当 window 为 None 时通过标题自动查找窗口
|
|
321
|
+
depth: 最大搜索深度,默认为 0xFFFFFFFF(不限制)
|
|
322
|
+
app: 进程名,例如 ``chrome.exe``,运行时动态查找窗口
|
|
323
|
+
title_re: 用于区分同进程窗口的标题正则表达式
|
|
324
|
+
class_name: 用于区分同进程窗口的窗口类名
|
|
325
|
+
content: True 自动进入 Chromium 内容窗口;字符串指定内容窗口类名;
|
|
326
|
+
None 时会自动识别 Chromium/Electron 窗口
|
|
327
|
+
timeout: 等待应用窗口和指定内容窗口的超时时间
|
|
328
|
+
"""
|
|
329
|
+
self.window = window
|
|
330
|
+
|
|
331
|
+
if self.window is None and any((app, title_re, class_name)):
|
|
332
|
+
self.window = self._resolve_app_window(
|
|
333
|
+
process_name=app,
|
|
334
|
+
title_re=title_re,
|
|
335
|
+
class_name=class_name,
|
|
336
|
+
content=content,
|
|
337
|
+
timeout=timeout,
|
|
338
|
+
)
|
|
339
|
+
elif self.window is None and title:
|
|
340
|
+
try:
|
|
341
|
+
self.window = Window.find(title)
|
|
342
|
+
except:
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
self._steps: List[Dict] = []
|
|
346
|
+
self._current_filters: Dict[str, Any] = {}
|
|
347
|
+
self._tree_depth = depth
|
|
348
|
+
self._max_depth = depth
|
|
349
|
+
|
|
350
|
+
self._attr_map = {
|
|
351
|
+
'name': 'Name',
|
|
352
|
+
'type': 'ControlTypeName',
|
|
353
|
+
'class_name': 'ClassName',
|
|
354
|
+
'handle': 'NativeWindowHandle',
|
|
355
|
+
'res_id': 'AutomationId',
|
|
356
|
+
'description': 'HelpText',
|
|
357
|
+
'is_enabled': 'IsEnabled',
|
|
358
|
+
'is_password': 'IsPassword',
|
|
359
|
+
'is_focusable': 'IsKeyboardFocusable',
|
|
360
|
+
'is_focused': 'HasKeyboardFocus',
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
@classmethod
|
|
364
|
+
def _resolve_app_window(
|
|
365
|
+
cls,
|
|
366
|
+
*,
|
|
367
|
+
process_name: Optional[str],
|
|
368
|
+
title_re: Optional[str],
|
|
369
|
+
class_name: Optional[str],
|
|
370
|
+
content: Optional[Union[bool, str]],
|
|
371
|
+
timeout: float,
|
|
372
|
+
) -> Window:
|
|
373
|
+
if not any((process_name, title_re, class_name)):
|
|
374
|
+
raise ValueError("app, title_re or class_name is required")
|
|
375
|
+
window = Window.find(
|
|
376
|
+
title_re=title_re,
|
|
377
|
+
process_name=process_name,
|
|
378
|
+
class_name=class_name,
|
|
379
|
+
timeout=timeout,
|
|
380
|
+
)
|
|
381
|
+
if window is None:
|
|
382
|
+
raise LookupError("target application window was not found")
|
|
383
|
+
|
|
384
|
+
root_class = str(getattr(window, "class_name", "") or "")
|
|
385
|
+
auto_content = content is None and root_class.startswith("Chrome_WidgetWin_")
|
|
386
|
+
if content or auto_content:
|
|
387
|
+
child_class = "Chrome_RenderWidgetHostHWND" if content in (None, True) else str(content)
|
|
388
|
+
content_window = window.find_child(
|
|
389
|
+
class_name=child_class, visible_only=True, timeout=timeout,
|
|
390
|
+
)
|
|
391
|
+
if content_window is not None:
|
|
392
|
+
return content_window
|
|
393
|
+
if content:
|
|
394
|
+
raise LookupError(f"application content window was not found: {child_class}")
|
|
395
|
+
return window
|
|
396
|
+
|
|
397
|
+
@classmethod
|
|
398
|
+
def app(
|
|
399
|
+
cls,
|
|
400
|
+
process_name: Optional[str] = None,
|
|
401
|
+
*,
|
|
402
|
+
title_re: Optional[str] = None,
|
|
403
|
+
class_name: Optional[str] = None,
|
|
404
|
+
content: Union[bool, str] = False,
|
|
405
|
+
timeout: float = 10,
|
|
406
|
+
depth: int = 0xFFFFFFFF,
|
|
407
|
+
) -> 'Selector':
|
|
408
|
+
"""Create a selector scoped to an application instead of a volatile HWND.
|
|
409
|
+
|
|
410
|
+
``content=True`` automatically enters the active Chromium/Electron
|
|
411
|
+
renderer window. A custom descendant class name may also be supplied.
|
|
412
|
+
"""
|
|
413
|
+
return cls(
|
|
414
|
+
app=process_name,
|
|
415
|
+
title_re=title_re,
|
|
416
|
+
class_name=class_name,
|
|
417
|
+
content=content,
|
|
418
|
+
timeout=timeout,
|
|
419
|
+
depth=depth,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
def _add_filter(self, key: str, value: Any):
|
|
423
|
+
"""添加属性过滤条件到当前查询步骤。
|
|
424
|
+
|
|
425
|
+
如果上一步是关系跳转(child/parent/brother),则将过滤条件附加到该步骤;
|
|
426
|
+
否则添加到当前待提交的过滤条件集合。
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
key: 过滤属性名(如 'name'、'type'、'res_id' 等)
|
|
430
|
+
value: 过滤属性值
|
|
431
|
+
"""
|
|
432
|
+
if self._steps and self._steps[-1]['method'] in ['child', 'parent', 'brother']:
|
|
433
|
+
self._steps[-1]['filters'][key] = value
|
|
434
|
+
else:
|
|
435
|
+
self._current_filters[key] = value
|
|
436
|
+
|
|
437
|
+
def _flush_to_step(self):
|
|
438
|
+
"""将当前累积的过滤条件和深度设置提交为一个 find 查询步骤。
|
|
439
|
+
|
|
440
|
+
如果当前没有待提交的过滤条件且深度为默认值,则不执行任何操作。
|
|
441
|
+
"""
|
|
442
|
+
if self._current_filters or self._max_depth != 0xFFFFFFFF:
|
|
443
|
+
self._steps.append({
|
|
444
|
+
'method': 'find',
|
|
445
|
+
'param': None,
|
|
446
|
+
'filters': self._current_filters.copy(),
|
|
447
|
+
'max_depth': self._max_depth
|
|
448
|
+
})
|
|
449
|
+
self._current_filters = {}
|
|
450
|
+
self._max_depth = 0xFFFFFFFF
|
|
451
|
+
|
|
452
|
+
def _add_relation(self, method: str, param: Any):
|
|
453
|
+
"""添加关系跳转步骤(child/parent/brother)。
|
|
454
|
+
|
|
455
|
+
先提交当前累积的过滤条件,再添加一个关系跳转步骤。
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
method: 关系类型,可选 'child'、'parent'、'brother'
|
|
459
|
+
param: 跳转参数(索引值),None 表示获取全部
|
|
460
|
+
"""
|
|
461
|
+
self._flush_to_step()
|
|
462
|
+
self._steps.append({
|
|
463
|
+
'method': method,
|
|
464
|
+
'param': param,
|
|
465
|
+
'filters': {},
|
|
466
|
+
'max_depth': 0xFFFFFFFF
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
# --- 属性链式调用 ---
|
|
470
|
+
def name(self, val: str) -> 'Selector':
|
|
471
|
+
"""按控件名称过滤(支持正则匹配)。
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
val: 名称匹配模式(正则表达式)
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
Selector 自身,支持链式调用
|
|
478
|
+
"""
|
|
479
|
+
self._add_filter('name', val); return self
|
|
480
|
+
|
|
481
|
+
def type(self, val: str) -> 'Selector':
|
|
482
|
+
"""按控件类型过滤(如 'ButtonControl'、'EditControl')。
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
val: 控件类型名称(支持正则匹配)
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Selector 自身,支持链式调用
|
|
489
|
+
"""
|
|
490
|
+
self._add_filter('type', val); return self
|
|
491
|
+
|
|
492
|
+
def class_name(self, val: str) -> 'Selector':
|
|
493
|
+
"""按窗口类名过滤。
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
val: 窗口类名(支持正则匹配)
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
Selector 自身,支持链式调用
|
|
500
|
+
"""
|
|
501
|
+
self._add_filter('class_name', val); return self
|
|
502
|
+
|
|
503
|
+
def res_id(self, val: str) -> 'Selector':
|
|
504
|
+
"""按 AutomationId 资源 ID 过滤。
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
val: AutomationId 值(支持正则匹配)
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
Selector 自身,支持链式调用
|
|
511
|
+
"""
|
|
512
|
+
self._add_filter('res_id', val); return self
|
|
513
|
+
|
|
514
|
+
def value(self, val: str) -> 'Selector':
|
|
515
|
+
"""按控件的文本值过滤。
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
val: 文本值(支持正则匹配)
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
Selector 自身,支持链式调用
|
|
522
|
+
"""
|
|
523
|
+
self._add_filter('value', val); return self
|
|
524
|
+
|
|
525
|
+
def description(self, val: str) -> 'Selector':
|
|
526
|
+
"""按控件的帮助文本描述过滤。
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
val: 描述文本(支持正则匹配)
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
Selector 自身,支持链式调用
|
|
533
|
+
"""
|
|
534
|
+
self._add_filter('description', val); return self
|
|
535
|
+
|
|
536
|
+
def handle(self, val: int) -> 'Selector':
|
|
537
|
+
"""按原生窗口句柄过滤。
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
val: 窗口句柄值(精确匹配)
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
Selector 自身,支持链式调用
|
|
544
|
+
"""
|
|
545
|
+
self._add_filter('handle', val); return self
|
|
546
|
+
|
|
547
|
+
def enabled(self, val: bool = True) -> 'Selector':
|
|
548
|
+
"""按是否启用状态过滤。
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
val: True 为只匹配启用的控件,False 为只匹配禁用的控件
|
|
552
|
+
|
|
553
|
+
Returns:
|
|
554
|
+
Selector 自身,支持链式调用
|
|
555
|
+
"""
|
|
556
|
+
self._add_filter('is_enabled', val); return self
|
|
557
|
+
|
|
558
|
+
def visible(self, val: bool = True) -> 'Selector':
|
|
559
|
+
"""按是否可见状态过滤。
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
val: True 为只匹配可见的控件,False 为只匹配不可见的控件
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
Selector 自身,支持链式调用
|
|
566
|
+
"""
|
|
567
|
+
self._add_filter('is_visible', val); return self
|
|
568
|
+
|
|
569
|
+
def clickable(self, val: bool = True) -> 'Selector':
|
|
570
|
+
"""按是否可点击状态过滤。
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
val: True 为只匹配可点击的控件,False 为只匹配不可点击的控件
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
Selector 自身,支持链式调用
|
|
577
|
+
"""
|
|
578
|
+
self._add_filter('is_clickable', val); return self
|
|
579
|
+
|
|
580
|
+
def password(self, val: bool = True) -> 'Selector':
|
|
581
|
+
"""按是否为密码输入框过滤。
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
val: True 为只匹配密码框,False 为只匹配非密码框
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
Selector 自身,支持链式调用
|
|
588
|
+
"""
|
|
589
|
+
self._add_filter('is_password', val); return self
|
|
590
|
+
|
|
591
|
+
def checkable(self, val: bool = True) -> 'Selector':
|
|
592
|
+
"""按控件是否支持 TogglePattern 过滤。"""
|
|
593
|
+
self._add_filter('is_checkable', val); return self
|
|
594
|
+
|
|
595
|
+
def checked(self, val: bool = True) -> 'Selector':
|
|
596
|
+
"""按复选框或开关当前是否选中过滤。"""
|
|
597
|
+
self._add_filter('is_checked', val); return self
|
|
598
|
+
|
|
599
|
+
def editable(self, val: bool = True) -> 'Selector':
|
|
600
|
+
"""按控件是否支持可写 ValuePattern 过滤。"""
|
|
601
|
+
self._add_filter('is_editable', val); return self
|
|
602
|
+
|
|
603
|
+
def focusable(self, val: bool = True) -> 'Selector':
|
|
604
|
+
"""按控件是否可获取键盘焦点过滤。"""
|
|
605
|
+
self._add_filter('is_focusable', val); return self
|
|
606
|
+
|
|
607
|
+
def focused(self, val: bool = True) -> 'Selector':
|
|
608
|
+
"""按控件当前是否拥有键盘焦点过滤。"""
|
|
609
|
+
self._add_filter('is_focused', val); return self
|
|
610
|
+
|
|
611
|
+
def selected(self, val: bool = True) -> 'Selector':
|
|
612
|
+
"""按 SelectionItemPattern 的选择状态过滤。"""
|
|
613
|
+
self._add_filter('is_selected', val); return self
|
|
614
|
+
|
|
615
|
+
def child_count(self, count: int) -> 'Selector':
|
|
616
|
+
"""按子节点数量过滤。
|
|
617
|
+
|
|
618
|
+
Args:
|
|
619
|
+
count: 精确匹配的子节点数量
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
Selector 自身,支持链式调用
|
|
623
|
+
"""
|
|
624
|
+
self._add_filter('child_count', count); return self
|
|
625
|
+
|
|
626
|
+
def node_depth(self, depth: int) -> 'Selector':
|
|
627
|
+
"""按元素在当前查询树中的实际深度过滤。"""
|
|
628
|
+
self._add_filter('_node_depth', int(depth)); return self
|
|
629
|
+
|
|
630
|
+
# --- 关系链式调用 ---
|
|
631
|
+
def depth(self, d: int) -> 'Selector':
|
|
632
|
+
"""设置后续查找的最大搜索深度。
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
d: 最大搜索深度
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
Selector 自身,支持链式调用
|
|
639
|
+
"""
|
|
640
|
+
self._max_depth = d; return self
|
|
641
|
+
|
|
642
|
+
def child(self, index: Any = None) -> 'Selector':
|
|
643
|
+
"""跳转到子节点。
|
|
644
|
+
|
|
645
|
+
Args:
|
|
646
|
+
index: 子节点索引(正数从1开始,负数从末尾,float 为相对偏移,None 为全部子节点)
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
Selector 自身,支持链式调用
|
|
650
|
+
"""
|
|
651
|
+
self._add_relation('child', index); return self
|
|
652
|
+
|
|
653
|
+
def parent(self, index: Any = None) -> 'Selector':
|
|
654
|
+
"""跳转到父节点。
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
index: 向上跳转的层数,默认为 1 层
|
|
658
|
+
|
|
659
|
+
Returns:
|
|
660
|
+
Selector 自身,支持链式调用
|
|
661
|
+
"""
|
|
662
|
+
self._add_relation('parent', index); return self
|
|
663
|
+
|
|
664
|
+
def brother(self, index: Any = None) -> 'Selector':
|
|
665
|
+
"""跳转到兄弟节点。
|
|
666
|
+
|
|
667
|
+
Args:
|
|
668
|
+
index: 兄弟节点索引(正数从1开始,负数从末尾,float 为相对偏移,None 为全部兄弟)
|
|
669
|
+
|
|
670
|
+
Returns:
|
|
671
|
+
Selector 自身,支持链式调用
|
|
672
|
+
"""
|
|
673
|
+
self._add_relation('brother', index); return self
|
|
674
|
+
|
|
675
|
+
# --- 动作插值 ---
|
|
676
|
+
def click(self, simulate: bool = False) -> 'Selector':
|
|
677
|
+
"""在查询链中插入点击动作,对当前匹配的所有元素执行点击。
|
|
678
|
+
|
|
679
|
+
Args:
|
|
680
|
+
simulate: True 为物理模拟点击,False 为底层指令点击
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
Selector 自身,支持链式调用
|
|
684
|
+
"""
|
|
685
|
+
self._flush_to_step()
|
|
686
|
+
self._steps.append({'method': 'action', 'action_type': 'click', 'param': simulate, 'filters': {}})
|
|
687
|
+
return self
|
|
688
|
+
|
|
689
|
+
def input(self, msg: str) -> 'Selector':
|
|
690
|
+
"""在查询链中插入文本输入动作,对当前匹配的所有元素执行输入。
|
|
691
|
+
|
|
692
|
+
Args:
|
|
693
|
+
msg: 要输入的文本字符串
|
|
694
|
+
|
|
695
|
+
Returns:
|
|
696
|
+
Selector 自身,支持链式调用
|
|
697
|
+
"""
|
|
698
|
+
self._flush_to_step()
|
|
699
|
+
self._steps.append({'method': 'action', 'action_type': 'input', 'param': msg, 'filters': {}})
|
|
700
|
+
return self
|
|
701
|
+
|
|
702
|
+
# --- 匹配内核 ---
|
|
703
|
+
def _match_node(self, node: auto.Control, filters: Dict, depth: Optional[int] = None) -> bool:
|
|
704
|
+
"""检查控件节点是否满足所有过滤条件。
|
|
705
|
+
|
|
706
|
+
支持布尔精确匹配、整数精确匹配和字符串正则匹配(不区分大小写)。
|
|
707
|
+
|
|
708
|
+
Args:
|
|
709
|
+
node: uiautomation 的 Control 控件对象
|
|
710
|
+
filters: 过滤条件字典,键为属性名,值为期望匹配的值
|
|
711
|
+
|
|
712
|
+
Returns:
|
|
713
|
+
所有条件都满足返回 True,否则返回 False
|
|
714
|
+
"""
|
|
715
|
+
if not filters: return True
|
|
716
|
+
for key, target in filters.items():
|
|
717
|
+
actual = None
|
|
718
|
+
if key == '_node_depth':
|
|
719
|
+
actual = depth
|
|
720
|
+
elif key == 'value':
|
|
721
|
+
try:
|
|
722
|
+
p = node.GetValuePattern()
|
|
723
|
+
v = p.Value if p else ""
|
|
724
|
+
actual = v if (v and v != node.Name) else (
|
|
725
|
+
"" if node.ControlTypeName != "EditControl" else node.Name)
|
|
726
|
+
except:
|
|
727
|
+
actual = ""
|
|
728
|
+
elif key == 'is_clickable':
|
|
729
|
+
try:
|
|
730
|
+
actual = any([hasattr(node, f'Get{p}Pattern') and getattr(node, f'Get{p}Pattern')()
|
|
731
|
+
for p in ['Invoke', 'Toggle', 'SelectionItem']])
|
|
732
|
+
except:
|
|
733
|
+
actual = False
|
|
734
|
+
elif key in ('is_checkable', 'is_checked'):
|
|
735
|
+
pattern = UIElement._get_pattern(node, 'GetTogglePattern')
|
|
736
|
+
actual = pattern is not None if key == 'is_checkable' else bool(
|
|
737
|
+
UIElement._read(pattern, 'ToggleState', 0)
|
|
738
|
+
) if pattern is not None else False
|
|
739
|
+
elif key == 'is_selected':
|
|
740
|
+
pattern = UIElement._get_pattern(node, 'GetSelectionItemPattern')
|
|
741
|
+
actual = bool(UIElement._read(pattern, 'IsSelected', False)) if pattern else False
|
|
742
|
+
elif key == 'is_editable':
|
|
743
|
+
pattern = UIElement._get_pattern(node, 'GetValuePattern')
|
|
744
|
+
actual = bool(pattern and not bool(UIElement._read(pattern, 'IsReadOnly', True)))
|
|
745
|
+
elif key == 'child_count':
|
|
746
|
+
try:
|
|
747
|
+
actual = len(node.GetChildren())
|
|
748
|
+
except:
|
|
749
|
+
actual = 0
|
|
750
|
+
elif key == 'is_visible':
|
|
751
|
+
try:
|
|
752
|
+
actual = not node.IsOffscreen
|
|
753
|
+
except:
|
|
754
|
+
actual = False
|
|
755
|
+
else:
|
|
756
|
+
native_key = self._attr_map.get(key, key)
|
|
757
|
+
try:
|
|
758
|
+
actual = getattr(node, native_key, None)
|
|
759
|
+
except Exception:
|
|
760
|
+
actual = None
|
|
761
|
+
|
|
762
|
+
if isinstance(target, bool):
|
|
763
|
+
if bool(actual) != target: return False
|
|
764
|
+
elif isinstance(target, int):
|
|
765
|
+
if actual != target: return False
|
|
766
|
+
else:
|
|
767
|
+
try:
|
|
768
|
+
if not re.search(str(target), str(actual or ""), re.IGNORECASE): return False
|
|
769
|
+
except:
|
|
770
|
+
if str(target).lower() not in str(actual or "").lower(): return False
|
|
771
|
+
return True
|
|
772
|
+
|
|
773
|
+
# --- 执行引擎 ---
|
|
774
|
+
def _execute(self, first_only: bool = False) -> List[Any]:
|
|
775
|
+
"""执行查询链,返回所有匹配的 UIElement 列表。
|
|
776
|
+
|
|
777
|
+
按照之前通过链式调用构建的查询步骤依次执行,
|
|
778
|
+
包括属性过滤、关系跳转和动作执行,最终返回去重后的结果。
|
|
779
|
+
|
|
780
|
+
Returns:
|
|
781
|
+
匹配的 UIElement 对象列表
|
|
782
|
+
"""
|
|
783
|
+
self._flush_to_step()
|
|
784
|
+
|
|
785
|
+
if self.window and hasattr(self.window, 'hwnd') and self.window.hwnd:
|
|
786
|
+
root = auto.ControlFromHandle(self.window.hwnd)
|
|
787
|
+
current_pool = [(root, 0)]
|
|
788
|
+
else:
|
|
789
|
+
current_pool = [(auto.GetRootControl(), 0)]
|
|
790
|
+
|
|
791
|
+
for step_index, step in enumerate(self._steps):
|
|
792
|
+
next_pool = []
|
|
793
|
+
method = step['method']
|
|
794
|
+
param = step.get('param')
|
|
795
|
+
filters = step.get('filters', {})
|
|
796
|
+
|
|
797
|
+
if method == 'action':
|
|
798
|
+
action_type = step.get('action_type')
|
|
799
|
+
for node, d in current_pool:
|
|
800
|
+
try:
|
|
801
|
+
temp_el = UIElement(node, self.window, d)
|
|
802
|
+
if action_type == 'click':
|
|
803
|
+
temp_el.click(simulate=param)
|
|
804
|
+
elif action_type == 'input':
|
|
805
|
+
val_pat = node.GetValuePattern()
|
|
806
|
+
if val_pat:
|
|
807
|
+
val_pat.SetValue(param)
|
|
808
|
+
else:
|
|
809
|
+
node.SendKeys(param)
|
|
810
|
+
except Exception as e:
|
|
811
|
+
print(f"执行插值动作 {action_type} 失败: {e}")
|
|
812
|
+
continue
|
|
813
|
+
|
|
814
|
+
max_d = step.get('max_depth', 0xFFFFFFFF)
|
|
815
|
+
|
|
816
|
+
for node, d in current_pool:
|
|
817
|
+
if method == 'find':
|
|
818
|
+
candidates = [(node, 0)]
|
|
819
|
+
candidates.extend(self._walk_controls(node, max_d))
|
|
820
|
+
for c, rel_d in candidates:
|
|
821
|
+
if self._match_node(c, filters, d + rel_d):
|
|
822
|
+
next_pool.append((c, d + rel_d))
|
|
823
|
+
if first_only and step_index == len(self._steps) - 1:
|
|
824
|
+
break
|
|
825
|
+
else:
|
|
826
|
+
# --- 轴跳转逻辑 ---
|
|
827
|
+
if method == 'child':
|
|
828
|
+
try:
|
|
829
|
+
candidates = node.GetChildren()
|
|
830
|
+
except Exception:
|
|
831
|
+
candidates = []
|
|
832
|
+
new_d = d + 1
|
|
833
|
+
if isinstance(param, float):
|
|
834
|
+
picked = self._pick_by_float(candidates, node, param)
|
|
835
|
+
next_pool.extend([(p, new_d) for p in picked if self._match_node(p, filters, new_d)])
|
|
836
|
+
else:
|
|
837
|
+
matched = [c for c in candidates if self._match_node(c, filters, new_d)]
|
|
838
|
+
picked = self._pick_by_int(matched, param)
|
|
839
|
+
next_pool.extend([(p, new_d) for p in picked])
|
|
840
|
+
|
|
841
|
+
elif method == 'parent':
|
|
842
|
+
# 修改后的 parent 逻辑:支持向上跳 n 层
|
|
843
|
+
# param 为调用 .parent(n) 时传入的值
|
|
844
|
+
up_level = param if (isinstance(param, int) and param > 0) else 1
|
|
845
|
+
curr_p = node
|
|
846
|
+
for _ in range(up_level):
|
|
847
|
+
try:
|
|
848
|
+
curr_p = curr_p.GetParentControl()
|
|
849
|
+
except Exception:
|
|
850
|
+
curr_p = None
|
|
851
|
+
if not curr_p: break
|
|
852
|
+
|
|
853
|
+
# 跳转完成后进行属性匹配,并计算新的深度
|
|
854
|
+
parent_depth = max(0, d - up_level)
|
|
855
|
+
if curr_p and self._match_node(curr_p, filters, parent_depth):
|
|
856
|
+
next_pool.append((curr_p, parent_depth))
|
|
857
|
+
|
|
858
|
+
elif method == 'brother':
|
|
859
|
+
try:
|
|
860
|
+
p = node.GetParentControl()
|
|
861
|
+
candidates = p.GetChildren() if p else []
|
|
862
|
+
except Exception:
|
|
863
|
+
candidates = []
|
|
864
|
+
new_d = d
|
|
865
|
+
if isinstance(param, float):
|
|
866
|
+
picked = self._pick_by_float(candidates, node, param)
|
|
867
|
+
next_pool.extend([(p, new_d) for p in picked if self._match_node(p, filters, new_d)])
|
|
868
|
+
else:
|
|
869
|
+
matched = [c for c in candidates if not self._same_control(c, node) and self._match_node(c, filters, new_d)]
|
|
870
|
+
picked = self._pick_by_int(matched, param)
|
|
871
|
+
next_pool.extend([(p, new_d) for p in picked])
|
|
872
|
+
|
|
873
|
+
current_pool = next_pool
|
|
874
|
+
if not current_pool: break
|
|
875
|
+
|
|
876
|
+
final_list = []
|
|
877
|
+
seen_ids = set()
|
|
878
|
+
for node, depth in current_pool:
|
|
879
|
+
try:
|
|
880
|
+
rid = tuple(node.GetRuntimeId())
|
|
881
|
+
if rid in seen_ids: continue
|
|
882
|
+
seen_ids.add(rid)
|
|
883
|
+
except:
|
|
884
|
+
pass
|
|
885
|
+
final_list.append(UIElement(node, self.window, depth))
|
|
886
|
+
|
|
887
|
+
return final_list[:1] if first_only else final_list
|
|
888
|
+
|
|
889
|
+
def find_all(self) -> List[Any]:
|
|
890
|
+
"""执行查询链,返回所有去重后的匹配元素。"""
|
|
891
|
+
return self._execute(first_only=False)
|
|
892
|
+
|
|
893
|
+
@staticmethod
|
|
894
|
+
def _same_control(left, right) -> bool:
|
|
895
|
+
try:
|
|
896
|
+
return bool(auto.CompareControl(left, right))
|
|
897
|
+
except Exception:
|
|
898
|
+
return left is right
|
|
899
|
+
|
|
900
|
+
@staticmethod
|
|
901
|
+
def _walk_controls(root, max_depth):
|
|
902
|
+
"""Depth-first Raw View traversal with per-provider fault isolation."""
|
|
903
|
+
if max_depth <= 0:
|
|
904
|
+
return
|
|
905
|
+
depth = 0
|
|
906
|
+
try:
|
|
907
|
+
first = root.GetFirstChildControl()
|
|
908
|
+
except Exception:
|
|
909
|
+
first = None
|
|
910
|
+
levels = [first]
|
|
911
|
+
while depth >= 0:
|
|
912
|
+
current = levels[-1]
|
|
913
|
+
if current is None:
|
|
914
|
+
levels.pop()
|
|
915
|
+
depth -= 1
|
|
916
|
+
continue
|
|
917
|
+
yield current, depth + 1
|
|
918
|
+
try:
|
|
919
|
+
levels[depth] = current.GetNextSiblingControl()
|
|
920
|
+
except Exception:
|
|
921
|
+
levels[depth] = None
|
|
922
|
+
if depth + 1 < max_depth:
|
|
923
|
+
try:
|
|
924
|
+
child = current.GetFirstChildControl()
|
|
925
|
+
except Exception:
|
|
926
|
+
child = None
|
|
927
|
+
if child is not None:
|
|
928
|
+
depth += 1
|
|
929
|
+
levels.append(child)
|
|
930
|
+
|
|
931
|
+
def _pick_by_float(self, candidates, current, param) -> List[auto.Control]:
|
|
932
|
+
"""通过浮点数相对偏移量从候选列表中选取控件。
|
|
933
|
+
|
|
934
|
+
以当前控件在候选列表中的位置为基准,按 param * 10 的偏移量选取目标。
|
|
935
|
+
|
|
936
|
+
Args:
|
|
937
|
+
candidates: 候选控件列表
|
|
938
|
+
current: 当前基准控件
|
|
939
|
+
param: 浮点数偏移量(如 0.1 表示偏移 1 位,-0.2 表示偏移 -2 位)
|
|
940
|
+
|
|
941
|
+
Returns:
|
|
942
|
+
选中的控件列表(0 或 1 个元素)
|
|
943
|
+
"""
|
|
944
|
+
try:
|
|
945
|
+
curr_idx = -1
|
|
946
|
+
for i, c in enumerate(candidates):
|
|
947
|
+
if auto.CompareControl(c, current):
|
|
948
|
+
curr_idx = i;
|
|
949
|
+
break
|
|
950
|
+
if curr_idx == -1: return []
|
|
951
|
+
target_idx = curr_idx + int(round(param * 10))
|
|
952
|
+
return [candidates[target_idx]] if 0 <= target_idx < len(candidates) else []
|
|
953
|
+
except:
|
|
954
|
+
return []
|
|
955
|
+
|
|
956
|
+
def _pick_by_int(self, matched, param) -> List[auto.Control]:
|
|
957
|
+
"""通过整数索引从已匹配的控件列表中选取。
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
matched: 已通过过滤条件匹配的控件列表
|
|
961
|
+
param: 整数索引(正数从1开始,负数从末尾),None 返回全部
|
|
962
|
+
|
|
963
|
+
Returns:
|
|
964
|
+
选中的控件列表
|
|
965
|
+
"""
|
|
966
|
+
if param is None: return matched
|
|
967
|
+
try:
|
|
968
|
+
idx = int(param)
|
|
969
|
+
if idx > 0: return [matched[idx - 1]] if idx <= len(matched) else []
|
|
970
|
+
if idx < 0: return [matched[idx]] if abs(idx) <= len(matched) else []
|
|
971
|
+
except:
|
|
972
|
+
pass
|
|
973
|
+
return []
|
|
974
|
+
|
|
975
|
+
def find(self) -> Optional[Any]:
|
|
976
|
+
"""执行查询链,返回第一个匹配的 UIElement。
|
|
977
|
+
|
|
978
|
+
Returns:
|
|
979
|
+
匹配的第一个 UIElement 对象,未找到则返回 None
|
|
980
|
+
"""
|
|
981
|
+
res = self._execute(first_only=True)
|
|
982
|
+
return res[0] if res else None
|
|
983
|
+
|
|
984
|
+
def find_first(self) -> Optional[Any]:
|
|
985
|
+
"""Compatibility alias used by generated inspector selectors."""
|
|
986
|
+
return self.find()
|
|
987
|
+
|
|
988
|
+
def get_uielement_tree(self, as_json: bool = False) -> Union[Any, str]:
|
|
989
|
+
"""获取窗口的完整 UI 元素树。
|
|
990
|
+
|
|
991
|
+
遍历窗口下所有控件,构建层级化的 UIElement 树结构。
|
|
992
|
+
|
|
993
|
+
Args:
|
|
994
|
+
as_json: True 返回 JSON 字符串,False 返回根 UIElement 对象
|
|
995
|
+
|
|
996
|
+
Returns:
|
|
997
|
+
UI 元素树的根节点(UIElement)或其 JSON 字符串表示;窗口无效时返回 None 或空字符串
|
|
998
|
+
"""
|
|
999
|
+
if not self.window.hwnd: return "" if as_json else None
|
|
1000
|
+
root = auto.ControlFromHandle(self.window.hwnd)
|
|
1001
|
+
if not root.Exists(0): return "" if as_json else None
|
|
1002
|
+
|
|
1003
|
+
root_element = UIElement(root, self.window, 0)
|
|
1004
|
+
stack = {0: root_element}
|
|
1005
|
+
for control, depth in self._walk_controls(root, self._tree_depth):
|
|
1006
|
+
try:
|
|
1007
|
+
new_el = UIElement(control, self.window, depth)
|
|
1008
|
+
parent = stack.get(depth - 1)
|
|
1009
|
+
if parent: parent.children.append(new_el)
|
|
1010
|
+
stack[depth] = new_el
|
|
1011
|
+
except Exception as e:
|
|
1012
|
+
print(f"跳过异常 UIA 节点 depth={depth}: {e}")
|
|
1013
|
+
|
|
1014
|
+
if as_json: return json.dumps(root_element.to_dict(), ensure_ascii=False, indent=2)
|
|
1015
|
+
return root_element
|