mobile-mcp-ai 2.6.12__py3-none-any.whl → 2.7.3__py3-none-any.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.
- mobile_mcp/core/basic_tools_lite.py +571 -52
- mobile_mcp/mcp_tools/mcp_server.py +111 -104
- {mobile_mcp_ai-2.6.12.dist-info → mobile_mcp_ai-2.7.3.dist-info}/METADATA +47 -19
- {mobile_mcp_ai-2.6.12.dist-info → mobile_mcp_ai-2.7.3.dist-info}/RECORD +8 -9
- {mobile_mcp_ai-2.6.12.dist-info → mobile_mcp_ai-2.7.3.dist-info}/WHEEL +1 -1
- mobile_mcp/core/tool_selection_helper.py +0 -168
- {mobile_mcp_ai-2.6.12.dist-info → mobile_mcp_ai-2.7.3.dist-info}/entry_points.txt +0 -0
- {mobile_mcp_ai-2.6.12.dist-info → mobile_mcp_ai-2.7.3.dist-info}/licenses/LICENSE +0 -0
- {mobile_mcp_ai-2.6.12.dist-info → mobile_mcp_ai-2.7.3.dist-info}/top_level.txt +0 -0
|
@@ -158,6 +158,33 @@ class BasicMobileToolsLite:
|
|
|
158
158
|
return info.get('package')
|
|
159
159
|
except Exception:
|
|
160
160
|
return None
|
|
161
|
+
|
|
162
|
+
def _normalize_resource_id(self, resource_id: str) -> str:
|
|
163
|
+
"""标准化 resource-id,支持前端只传简写 id 时自动补全包名
|
|
164
|
+
|
|
165
|
+
约定:
|
|
166
|
+
- Android:
|
|
167
|
+
- 如果传入的是完整 id(包含 ':' 或 '/'),直接返回
|
|
168
|
+
- 如果是简写(如 'qylt_search_input_layout'),自动补全为
|
|
169
|
+
'{package}:id/{resource_id}',package 优先使用 target_package,
|
|
170
|
+
否则使用当前前台应用包名
|
|
171
|
+
- iOS: 直接原样返回
|
|
172
|
+
"""
|
|
173
|
+
# iOS 不做处理,保持与 WDA 一致
|
|
174
|
+
if self._is_ios():
|
|
175
|
+
return resource_id
|
|
176
|
+
|
|
177
|
+
# 已经是完整 id 或者包含路径信息时,不再修改
|
|
178
|
+
if ":" in resource_id or "/" in resource_id:
|
|
179
|
+
return resource_id
|
|
180
|
+
|
|
181
|
+
# 尝试获取包名:优先使用目标应用包名,其次当前前台应用
|
|
182
|
+
package = getattr(self, "target_package", None) or self._get_current_package()
|
|
183
|
+
if not package:
|
|
184
|
+
# 没有包名信息时,回退为原值,避免误拼接错误包名
|
|
185
|
+
return resource_id
|
|
186
|
+
|
|
187
|
+
return f"{package}:id/{resource_id}"
|
|
161
188
|
|
|
162
189
|
def _check_app_switched(self) -> Dict:
|
|
163
190
|
"""检查是否已跳出目标应用
|
|
@@ -1504,17 +1531,23 @@ class BasicMobileToolsLite:
|
|
|
1504
1531
|
else:
|
|
1505
1532
|
return {"success": False, "msg": "iOS未初始化"}
|
|
1506
1533
|
else:
|
|
1507
|
-
|
|
1534
|
+
normalized_id = self._normalize_resource_id(resource_id)
|
|
1535
|
+
elem = self.client.u2(resourceId=normalized_id)
|
|
1508
1536
|
if elem.exists(timeout=0.5):
|
|
1509
1537
|
count = elem.count
|
|
1510
1538
|
if index < count:
|
|
1511
1539
|
elem[index].click()
|
|
1512
1540
|
time.sleep(0.3)
|
|
1513
|
-
|
|
1541
|
+
# 记录时同时保留原始入参和实际使用的 id 信息
|
|
1542
|
+
self._record_click('id', normalized_id, element_desc=resource_id)
|
|
1514
1543
|
return {"success": True}
|
|
1515
1544
|
else:
|
|
1516
1545
|
return {"success": False, "msg": f"索引{index}超出范围(共{count}个)"}
|
|
1517
|
-
return {
|
|
1546
|
+
return {
|
|
1547
|
+
"success": False,
|
|
1548
|
+
"fallback": "vision",
|
|
1549
|
+
"msg": f"未找到ID'{resource_id}' (实际匹配: '{normalized_id}')"
|
|
1550
|
+
}
|
|
1518
1551
|
except Exception as e:
|
|
1519
1552
|
return {"success": False, "msg": str(e)}
|
|
1520
1553
|
|
|
@@ -1777,13 +1810,20 @@ class BasicMobileToolsLite:
|
|
|
1777
1810
|
return {"success": True}
|
|
1778
1811
|
return {"success": False, "msg": f"未找到'{resource_id}'"}
|
|
1779
1812
|
else:
|
|
1780
|
-
|
|
1813
|
+
normalized_id = self._normalize_resource_id(resource_id)
|
|
1814
|
+
elem = self.client.u2(resourceId=normalized_id)
|
|
1781
1815
|
if elem.exists(timeout=0.5):
|
|
1782
1816
|
elem.long_click(duration=duration)
|
|
1783
1817
|
time.sleep(0.3)
|
|
1784
|
-
self._record_long_press('id',
|
|
1785
|
-
return {
|
|
1786
|
-
|
|
1818
|
+
self._record_long_press('id', normalized_id, duration, element_desc=resource_id)
|
|
1819
|
+
return {
|
|
1820
|
+
"success": True,
|
|
1821
|
+
"message": f"✅ 长按成功: {resource_id} (实际匹配: {normalized_id}) 持续 {duration}s"
|
|
1822
|
+
}
|
|
1823
|
+
return {
|
|
1824
|
+
"success": False,
|
|
1825
|
+
"msg": f"未找到'{resource_id}' (实际匹配: '{normalized_id}')"
|
|
1826
|
+
}
|
|
1787
1827
|
except Exception as e:
|
|
1788
1828
|
return {"success": False, "message": f"❌ 长按失败: {e}"}
|
|
1789
1829
|
|
|
@@ -1833,7 +1873,8 @@ class BasicMobileToolsLite:
|
|
|
1833
1873
|
}
|
|
1834
1874
|
return {"success": False, "message": f"❌ 输入框不存在: {resource_id}"}
|
|
1835
1875
|
else:
|
|
1836
|
-
|
|
1876
|
+
normalized_id = self._normalize_resource_id(resource_id)
|
|
1877
|
+
elements = self.client.u2(resourceId=normalized_id)
|
|
1837
1878
|
|
|
1838
1879
|
# 检查是否存在
|
|
1839
1880
|
if elements.exists(timeout=0.5):
|
|
@@ -1843,7 +1884,7 @@ class BasicMobileToolsLite:
|
|
|
1843
1884
|
if count == 1:
|
|
1844
1885
|
elements.set_text(text)
|
|
1845
1886
|
time.sleep(0.3)
|
|
1846
|
-
self._record_input(text, 'id',
|
|
1887
|
+
self._record_input(text, 'id', normalized_id)
|
|
1847
1888
|
|
|
1848
1889
|
# 🎯 关键步骤:检查应用是否跳转,如果跳转则自动返回目标应用
|
|
1849
1890
|
app_check = self._check_app_switched()
|
|
@@ -1851,7 +1892,7 @@ class BasicMobileToolsLite:
|
|
|
1851
1892
|
if app_check['switched']:
|
|
1852
1893
|
return_result = self._return_to_target_app()
|
|
1853
1894
|
|
|
1854
|
-
msg = f"✅ 输入成功: '{text}'"
|
|
1895
|
+
msg = f"✅ 输入成功: '{text}' (id: {resource_id}, 实际匹配: {normalized_id})"
|
|
1855
1896
|
if app_check['switched']:
|
|
1856
1897
|
msg += f"\n{app_check['message']}"
|
|
1857
1898
|
if return_result:
|
|
@@ -2071,13 +2112,16 @@ class BasicMobileToolsLite:
|
|
|
2071
2112
|
|
|
2072
2113
|
# ==================== 导航操作 ====================
|
|
2073
2114
|
|
|
2074
|
-
async def swipe(self, direction: str, y: Optional[int] = None, y_percent: Optional[float] = None
|
|
2115
|
+
async def swipe(self, direction: str, y: Optional[int] = None, y_percent: Optional[float] = None,
|
|
2116
|
+
distance: Optional[int] = None, distance_percent: Optional[float] = None) -> Dict:
|
|
2075
2117
|
"""滑动屏幕
|
|
2076
2118
|
|
|
2077
2119
|
Args:
|
|
2078
2120
|
direction: 滑动方向 (up/down/left/right)
|
|
2079
2121
|
y: 左右滑动时指定的高度坐标(像素)
|
|
2080
2122
|
y_percent: 左右滑动时指定的高度百分比 (0-100)
|
|
2123
|
+
distance: 横向滑动时指定的滑动距离(像素),仅用于 left/right
|
|
2124
|
+
distance_percent: 横向滑动时指定的滑动距离百分比 (0-100),仅用于 left/right
|
|
2081
2125
|
"""
|
|
2082
2126
|
try:
|
|
2083
2127
|
if self._is_ios():
|
|
@@ -2104,20 +2148,53 @@ class BasicMobileToolsLite:
|
|
|
2104
2148
|
swipe_y = y
|
|
2105
2149
|
else:
|
|
2106
2150
|
swipe_y = center_y
|
|
2151
|
+
|
|
2152
|
+
# 计算横向滑动距离
|
|
2153
|
+
if distance_percent is not None:
|
|
2154
|
+
if not (0 <= distance_percent <= 100):
|
|
2155
|
+
return {"success": False, "message": f"❌ distance_percent 必须在 0-100 之间: {distance_percent}"}
|
|
2156
|
+
swipe_distance = int(width * distance_percent / 100)
|
|
2157
|
+
elif distance is not None:
|
|
2158
|
+
if distance <= 0:
|
|
2159
|
+
return {"success": False, "message": f"❌ distance 必须大于 0: {distance}"}
|
|
2160
|
+
if distance > width:
|
|
2161
|
+
return {"success": False, "message": f"❌ distance 不能超过屏幕宽度 ({width}): {distance}"}
|
|
2162
|
+
swipe_distance = distance
|
|
2163
|
+
else:
|
|
2164
|
+
# 默认滑动距离:屏幕宽度的 60%(从 0.8 到 0.2)
|
|
2165
|
+
swipe_distance = int(width * 0.6)
|
|
2166
|
+
|
|
2167
|
+
# 计算起始和结束位置
|
|
2168
|
+
if direction == 'left':
|
|
2169
|
+
# 从右向左滑动:起始点在右侧,结束点在左侧
|
|
2170
|
+
# 确保起始点不超出屏幕右边界
|
|
2171
|
+
start_x = min(center_x + swipe_distance // 2, width - 10)
|
|
2172
|
+
end_x = start_x - swipe_distance
|
|
2173
|
+
# 确保结束点不超出屏幕左边界
|
|
2174
|
+
if end_x < 10:
|
|
2175
|
+
end_x = 10
|
|
2176
|
+
start_x = min(end_x + swipe_distance, width - 10)
|
|
2177
|
+
else: # right
|
|
2178
|
+
# 从左向右滑动:起始点在左侧,结束点在右侧
|
|
2179
|
+
# 确保起始点不超出屏幕左边界
|
|
2180
|
+
start_x = max(center_x - swipe_distance // 2, 10)
|
|
2181
|
+
end_x = start_x + swipe_distance
|
|
2182
|
+
# 确保结束点不超出屏幕右边界
|
|
2183
|
+
if end_x > width - 10:
|
|
2184
|
+
end_x = width - 10
|
|
2185
|
+
start_x = max(end_x - swipe_distance, 10)
|
|
2186
|
+
|
|
2187
|
+
x1, y1, x2, y2 = start_x, swipe_y, end_x, swipe_y
|
|
2107
2188
|
else:
|
|
2108
2189
|
swipe_y = center_y
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
if direction not in swipe_map:
|
|
2118
|
-
return {"success": False, "message": f"❌ 不支持的方向: {direction}"}
|
|
2119
|
-
|
|
2120
|
-
x1, y1, x2, y2 = swipe_map[direction]
|
|
2190
|
+
# 纵向滑动保持原有逻辑
|
|
2191
|
+
swipe_map = {
|
|
2192
|
+
'up': (center_x, int(height * 0.8), center_x, int(height * 0.2)),
|
|
2193
|
+
'down': (center_x, int(height * 0.2), center_x, int(height * 0.8)),
|
|
2194
|
+
}
|
|
2195
|
+
if direction not in swipe_map:
|
|
2196
|
+
return {"success": False, "message": f"❌ 不支持的方向: {direction}"}
|
|
2197
|
+
x1, y1, x2, y2 = swipe_map[direction]
|
|
2121
2198
|
|
|
2122
2199
|
if self._is_ios():
|
|
2123
2200
|
ios_client.wda.swipe(x1, y1, x2, y2)
|
|
@@ -2138,10 +2215,21 @@ class BasicMobileToolsLite:
|
|
|
2138
2215
|
# 构建返回消息
|
|
2139
2216
|
msg = f"✅ 滑动成功: {direction}"
|
|
2140
2217
|
if direction in ['left', 'right']:
|
|
2218
|
+
msg_parts = []
|
|
2141
2219
|
if y_percent is not None:
|
|
2142
|
-
|
|
2220
|
+
msg_parts.append(f"高度: {y_percent}% = {swipe_y}px")
|
|
2143
2221
|
elif y is not None:
|
|
2144
|
-
|
|
2222
|
+
msg_parts.append(f"高度: {y}px")
|
|
2223
|
+
|
|
2224
|
+
if distance_percent is not None:
|
|
2225
|
+
msg_parts.append(f"距离: {distance_percent}% = {swipe_distance}px")
|
|
2226
|
+
elif distance is not None:
|
|
2227
|
+
msg_parts.append(f"距离: {distance}px")
|
|
2228
|
+
else:
|
|
2229
|
+
msg_parts.append(f"距离: 默认 {swipe_distance}px")
|
|
2230
|
+
|
|
2231
|
+
if msg_parts:
|
|
2232
|
+
msg += f" ({', '.join(msg_parts)})"
|
|
2145
2233
|
|
|
2146
2234
|
# 如果检测到应用跳转,添加警告和返回结果
|
|
2147
2235
|
if app_check['switched']:
|
|
@@ -2197,8 +2285,179 @@ class BasicMobileToolsLite:
|
|
|
2197
2285
|
def wait(self, seconds: float) -> Dict:
|
|
2198
2286
|
"""等待指定时间"""
|
|
2199
2287
|
time.sleep(seconds)
|
|
2288
|
+
# 记录等待操作
|
|
2289
|
+
record = {
|
|
2290
|
+
'action': 'wait',
|
|
2291
|
+
'timestamp': datetime.now().isoformat(),
|
|
2292
|
+
'seconds': seconds,
|
|
2293
|
+
}
|
|
2294
|
+
self.operation_history.append(record)
|
|
2200
2295
|
return {"success": True}
|
|
2201
2296
|
|
|
2297
|
+
async def drag_progress_bar(self, direction: str = "right", distance_percent: float = 30.0,
|
|
2298
|
+
y_percent: Optional[float] = None, y: Optional[int] = None) -> Dict:
|
|
2299
|
+
"""智能拖动进度条
|
|
2300
|
+
|
|
2301
|
+
自动检测进度条是否可见:
|
|
2302
|
+
- 如果进度条已显示,直接拖动(无需先点击播放区域)
|
|
2303
|
+
- 如果进度条未显示,先点击播放区域显示控制栏,再拖动
|
|
2304
|
+
|
|
2305
|
+
Args:
|
|
2306
|
+
direction: 拖动方向,'left'(倒退)或 'right'(前进),默认 'right'
|
|
2307
|
+
distance_percent: 拖动距离百分比 (0-100),默认 30%
|
|
2308
|
+
y_percent: 进度条的垂直位置百分比 (0-100),如果未指定则自动检测
|
|
2309
|
+
y: 进度条的垂直位置坐标(像素),如果未指定则自动检测
|
|
2310
|
+
"""
|
|
2311
|
+
try:
|
|
2312
|
+
import xml.etree.ElementTree as ET
|
|
2313
|
+
import re
|
|
2314
|
+
|
|
2315
|
+
if self._is_ios():
|
|
2316
|
+
return {"success": False, "message": "❌ iOS 暂不支持,请使用 mobile_swipe"}
|
|
2317
|
+
|
|
2318
|
+
if direction not in ['left', 'right']:
|
|
2319
|
+
return {"success": False, "message": f"❌ 拖动方向必须是 'left' 或 'right': {direction}"}
|
|
2320
|
+
|
|
2321
|
+
screen_width, screen_height = self.client.u2.window_size()
|
|
2322
|
+
|
|
2323
|
+
# 获取 XML 查找进度条
|
|
2324
|
+
xml_string = self.client.u2.dump_hierarchy(compressed=False)
|
|
2325
|
+
root = ET.fromstring(xml_string)
|
|
2326
|
+
|
|
2327
|
+
progress_bar_found = False
|
|
2328
|
+
progress_bar_y = None
|
|
2329
|
+
progress_bar_y_percent = None
|
|
2330
|
+
|
|
2331
|
+
# 查找进度条元素(SeekBar、ProgressBar)
|
|
2332
|
+
for elem in root.iter():
|
|
2333
|
+
class_name = elem.attrib.get('class', '')
|
|
2334
|
+
resource_id = elem.attrib.get('resource-id', '')
|
|
2335
|
+
bounds_str = elem.attrib.get('bounds', '')
|
|
2336
|
+
|
|
2337
|
+
# 检查是否是进度条
|
|
2338
|
+
is_progress_bar = (
|
|
2339
|
+
'SeekBar' in class_name or
|
|
2340
|
+
'ProgressBar' in class_name or
|
|
2341
|
+
'progress' in resource_id.lower() or
|
|
2342
|
+
'seek' in resource_id.lower()
|
|
2343
|
+
)
|
|
2344
|
+
|
|
2345
|
+
if is_progress_bar and bounds_str:
|
|
2346
|
+
# 解析 bounds 获取进度条位置
|
|
2347
|
+
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
2348
|
+
if match:
|
|
2349
|
+
x1, y1, x2, y2 = map(int, match.groups())
|
|
2350
|
+
center_y = (y1 + y2) // 2
|
|
2351
|
+
progress_bar_y = center_y
|
|
2352
|
+
progress_bar_y_percent = round(center_y / screen_height * 100, 1)
|
|
2353
|
+
progress_bar_found = True
|
|
2354
|
+
break
|
|
2355
|
+
|
|
2356
|
+
# 如果未找到进度条,尝试点击播放区域显示控制栏
|
|
2357
|
+
if not progress_bar_found:
|
|
2358
|
+
# 点击屏幕中心显示控制栏
|
|
2359
|
+
center_x, center_y = screen_width // 2, screen_height // 2
|
|
2360
|
+
self.client.u2.click(center_x, center_y)
|
|
2361
|
+
time.sleep(0.5)
|
|
2362
|
+
|
|
2363
|
+
# 再次查找进度条
|
|
2364
|
+
xml_string = self.client.u2.dump_hierarchy(compressed=False)
|
|
2365
|
+
root = ET.fromstring(xml_string)
|
|
2366
|
+
|
|
2367
|
+
for elem in root.iter():
|
|
2368
|
+
class_name = elem.attrib.get('class', '')
|
|
2369
|
+
resource_id = elem.attrib.get('resource-id', '')
|
|
2370
|
+
bounds_str = elem.attrib.get('bounds', '')
|
|
2371
|
+
|
|
2372
|
+
is_progress_bar = (
|
|
2373
|
+
'SeekBar' in class_name or
|
|
2374
|
+
'ProgressBar' in class_name or
|
|
2375
|
+
'progress' in resource_id.lower() or
|
|
2376
|
+
'seek' in resource_id.lower()
|
|
2377
|
+
)
|
|
2378
|
+
|
|
2379
|
+
if is_progress_bar and bounds_str:
|
|
2380
|
+
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
2381
|
+
if match:
|
|
2382
|
+
x1, y1, x2, y2 = map(int, match.groups())
|
|
2383
|
+
center_y = (y1 + y2) // 2
|
|
2384
|
+
progress_bar_y = center_y
|
|
2385
|
+
progress_bar_y_percent = round(center_y / screen_height * 100, 1)
|
|
2386
|
+
progress_bar_found = True
|
|
2387
|
+
break
|
|
2388
|
+
|
|
2389
|
+
# 确定使用的高度位置
|
|
2390
|
+
if y_percent is not None:
|
|
2391
|
+
swipe_y = int(screen_height * y_percent / 100)
|
|
2392
|
+
used_y_percent = y_percent
|
|
2393
|
+
elif y is not None:
|
|
2394
|
+
swipe_y = y
|
|
2395
|
+
used_y_percent = round(y / screen_height * 100, 1)
|
|
2396
|
+
elif progress_bar_found:
|
|
2397
|
+
swipe_y = progress_bar_y
|
|
2398
|
+
used_y_percent = progress_bar_y_percent
|
|
2399
|
+
else:
|
|
2400
|
+
# 默认使用屏幕底部附近(进度条常见位置)
|
|
2401
|
+
swipe_y = int(screen_height * 0.91)
|
|
2402
|
+
used_y_percent = 91.0
|
|
2403
|
+
|
|
2404
|
+
# 计算滑动距离
|
|
2405
|
+
swipe_distance = int(screen_width * distance_percent / 100)
|
|
2406
|
+
|
|
2407
|
+
# 计算起始和结束位置
|
|
2408
|
+
center_x = screen_width // 2
|
|
2409
|
+
if direction == 'left':
|
|
2410
|
+
start_x = min(center_x + swipe_distance // 2, screen_width - 10)
|
|
2411
|
+
end_x = start_x - swipe_distance
|
|
2412
|
+
if end_x < 10:
|
|
2413
|
+
end_x = 10
|
|
2414
|
+
start_x = min(end_x + swipe_distance, screen_width - 10)
|
|
2415
|
+
else: # right
|
|
2416
|
+
start_x = max(center_x - swipe_distance // 2, 10)
|
|
2417
|
+
end_x = start_x + swipe_distance
|
|
2418
|
+
if end_x > screen_width - 10:
|
|
2419
|
+
end_x = screen_width - 10
|
|
2420
|
+
start_x = max(end_x - swipe_distance, 10)
|
|
2421
|
+
|
|
2422
|
+
# 执行拖动
|
|
2423
|
+
self.client.u2.swipe(start_x, swipe_y, end_x, swipe_y, duration=0.5)
|
|
2424
|
+
time.sleep(0.3)
|
|
2425
|
+
|
|
2426
|
+
# 记录操作
|
|
2427
|
+
self._record_swipe(direction)
|
|
2428
|
+
|
|
2429
|
+
# 检查应用是否跳转
|
|
2430
|
+
app_check = self._check_app_switched()
|
|
2431
|
+
return_result = None
|
|
2432
|
+
if app_check['switched']:
|
|
2433
|
+
return_result = self._return_to_target_app()
|
|
2434
|
+
|
|
2435
|
+
# 构建返回消息
|
|
2436
|
+
msg = f"✅ 进度条拖动成功: {direction} (高度: {used_y_percent}%, 距离: {distance_percent}%)"
|
|
2437
|
+
if not progress_bar_found:
|
|
2438
|
+
msg += "\n💡 已自动点击播放区域显示控制栏"
|
|
2439
|
+
else:
|
|
2440
|
+
msg += "\n💡 进度条已显示,直接拖动"
|
|
2441
|
+
|
|
2442
|
+
if app_check['switched']:
|
|
2443
|
+
msg += f"\n{app_check['message']}"
|
|
2444
|
+
if return_result and return_result.get('success'):
|
|
2445
|
+
msg += f"\n{return_result['message']}"
|
|
2446
|
+
|
|
2447
|
+
return {
|
|
2448
|
+
"success": True,
|
|
2449
|
+
"message": msg,
|
|
2450
|
+
"progress_bar_found": progress_bar_found,
|
|
2451
|
+
"y_percent": used_y_percent,
|
|
2452
|
+
"distance_percent": distance_percent,
|
|
2453
|
+
"direction": direction,
|
|
2454
|
+
"app_check": app_check,
|
|
2455
|
+
"return_to_app": return_result
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
except Exception as e:
|
|
2459
|
+
return {"success": False, "message": f"❌ 拖动进度条失败: {e}"}
|
|
2460
|
+
|
|
2202
2461
|
# ==================== 应用管理 ====================
|
|
2203
2462
|
|
|
2204
2463
|
async def launch_app(self, package_name: str) -> Dict:
|
|
@@ -2353,6 +2612,35 @@ class BasicMobileToolsLite:
|
|
|
2353
2612
|
'_shadow', 'shadow_', '_divider', 'divider_', '_line', 'line_'
|
|
2354
2613
|
}
|
|
2355
2614
|
|
|
2615
|
+
# 状态栏相关关键词(这些元素对测试没有意义,直接过滤)
|
|
2616
|
+
STATUS_BAR_KEYWORDS = {
|
|
2617
|
+
'status_bar', 'statusbar', 'notification_icon', 'notificationicons',
|
|
2618
|
+
'system_icons', 'statusicons', 'battery', 'wifi_', 'wifi_combo',
|
|
2619
|
+
'wifi_group', 'wifi_signal', 'wifi_in', 'wifi_out', 'signal_',
|
|
2620
|
+
'clock', 'cutout', 'networkspeed', 'speed_container',
|
|
2621
|
+
'carrier', 'operator', 'sim_', 'mobile_signal'
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
# 系统控件关键词(厂商系统UI元素,对测试没有意义,直接过滤)
|
|
2625
|
+
SYSTEM_WIDGET_KEYWORDS = {
|
|
2626
|
+
'system_icon', 'systemicon', 'system_image', 'systemimage',
|
|
2627
|
+
'vivo_', 'vivo_superx', 'superx', 'super_x',
|
|
2628
|
+
'miui_', 'miui_system', 'huawei_', 'emui_',
|
|
2629
|
+
'oppo_', 'coloros_', 'oneplus_', 'realme_',
|
|
2630
|
+
'samsung_', 'oneui_', 'com.android.systemui',
|
|
2631
|
+
'system_ui', 'systemui', 'navigation_bar', 'navigationbar'
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
# 系统弹窗交互文本(如果元素包含这些文本,即使 resource_id 匹配系统控件,也不过滤)
|
|
2635
|
+
# 这些是系统弹窗(权限请求、系统对话框等)的常见按钮文本
|
|
2636
|
+
SYSTEM_DIALOG_INTERACTIVE_TEXTS = {
|
|
2637
|
+
'允许', '拒绝', '确定', '取消', '同意', '不同意',
|
|
2638
|
+
'允许访问', '拒绝访问', '始终允许', '仅在使用时允许',
|
|
2639
|
+
'确定', '取消', '是', '否', '好', '知道了',
|
|
2640
|
+
'Allow', 'Deny', 'OK', 'Cancel', 'Yes', 'No',
|
|
2641
|
+
'Accept', 'Reject', 'Grant', 'Deny'
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2356
2644
|
# Token 优化:构建精简元素(只返回非空字段)
|
|
2357
2645
|
def build_compact_element(resource_id, text, content_desc, bounds, likely_click, class_name):
|
|
2358
2646
|
"""只返回有值的字段,节省 token"""
|
|
@@ -2390,6 +2678,47 @@ class BasicMobileToolsLite:
|
|
|
2390
2678
|
if bounds == '[0,0][0,0]':
|
|
2391
2679
|
continue
|
|
2392
2680
|
|
|
2681
|
+
# 1.5 过滤状态栏元素(对测试没有意义)
|
|
2682
|
+
if resource_id:
|
|
2683
|
+
resource_id_lower = resource_id.lower()
|
|
2684
|
+
if any(keyword in resource_id_lower for keyword in STATUS_BAR_KEYWORDS):
|
|
2685
|
+
continue
|
|
2686
|
+
|
|
2687
|
+
# 1.6 过滤系统控件(厂商系统UI元素,对测试没有意义)
|
|
2688
|
+
# 例外:如果元素有明确的交互文本(系统弹窗按钮),不过滤
|
|
2689
|
+
if resource_id:
|
|
2690
|
+
resource_id_lower = resource_id.lower()
|
|
2691
|
+
|
|
2692
|
+
# 检查是否是系统弹窗的交互按钮(有明确的交互文本)
|
|
2693
|
+
is_system_dialog_button = (
|
|
2694
|
+
text in SYSTEM_DIALOG_INTERACTIVE_TEXTS or
|
|
2695
|
+
content_desc in SYSTEM_DIALOG_INTERACTIVE_TEXTS
|
|
2696
|
+
)
|
|
2697
|
+
|
|
2698
|
+
# 特殊处理:android:id/ 开头的元素
|
|
2699
|
+
if 'android:id/' in resource_id_lower:
|
|
2700
|
+
# android:id/button1, android:id/button2 等是系统弹窗按钮,应该保留
|
|
2701
|
+
# 只过滤特定的系统UI容器元素
|
|
2702
|
+
android_system_ids_to_filter = [
|
|
2703
|
+
'android:id/statusbarbackground',
|
|
2704
|
+
'android:id/navigationbarbackground'
|
|
2705
|
+
]
|
|
2706
|
+
# 如果是系统弹窗按钮(有交互文本)或者是按钮类ID,保留
|
|
2707
|
+
if (is_system_dialog_button or
|
|
2708
|
+
'button' in resource_id_lower or
|
|
2709
|
+
resource_id_lower not in [id.lower() for id in android_system_ids_to_filter]):
|
|
2710
|
+
# 保留,不过滤
|
|
2711
|
+
pass
|
|
2712
|
+
else:
|
|
2713
|
+
# 过滤系统UI容器
|
|
2714
|
+
continue
|
|
2715
|
+
else:
|
|
2716
|
+
# 非 android:id/ 开头的元素,检查是否匹配系统控件关键词
|
|
2717
|
+
# 如果是系统弹窗按钮(有交互文本),不过滤
|
|
2718
|
+
if not is_system_dialog_button:
|
|
2719
|
+
if any(keyword in resource_id_lower for keyword in SYSTEM_WIDGET_KEYWORDS):
|
|
2720
|
+
continue
|
|
2721
|
+
|
|
2393
2722
|
# 2. 检查是否是功能控件(直接保留)
|
|
2394
2723
|
if class_name in FUNCTIONAL_WIDGETS:
|
|
2395
2724
|
# 使用启发式判断可点击性(替代不准确的 clickable 属性)
|
|
@@ -2732,7 +3061,7 @@ class BasicMobileToolsLite:
|
|
|
2732
3061
|
except Exception as e:
|
|
2733
3062
|
return {"success": False, "msg": str(e)}
|
|
2734
3063
|
|
|
2735
|
-
def close_popup(self) -> Dict:
|
|
3064
|
+
def close_popup(self, popup_detected: bool = None, popup_bounds: tuple = None) -> Dict:
|
|
2736
3065
|
"""智能关闭弹窗(改进版)
|
|
2737
3066
|
|
|
2738
3067
|
核心改进:先检测弹窗区域,再在弹窗范围内查找关闭按钮
|
|
@@ -2746,6 +3075,10 @@ class BasicMobileToolsLite:
|
|
|
2746
3075
|
适配策略:
|
|
2747
3076
|
- X 按钮可能在任意位置(上下左右都支持)
|
|
2748
3077
|
- 使用百分比坐标记录,跨分辨率兼容
|
|
3078
|
+
|
|
3079
|
+
Args:
|
|
3080
|
+
popup_detected: 可选,AI已识别到弹窗时为True,跳过弹窗检测
|
|
3081
|
+
popup_bounds: 可选,弹窗边界 (x1, y1, x2, y2),如果AI已识别到弹窗区域可传入
|
|
2749
3082
|
"""
|
|
2750
3083
|
try:
|
|
2751
3084
|
import re
|
|
@@ -2766,25 +3099,40 @@ class BasicMobileToolsLite:
|
|
|
2766
3099
|
close_desc_keywords = ['关闭', 'close', 'dismiss', 'cancel', '跳过']
|
|
2767
3100
|
|
|
2768
3101
|
close_candidates = []
|
|
2769
|
-
|
|
3102
|
+
all_clickable_elements = [] # 所有可点击元素(用于兜底策略)
|
|
3103
|
+
popup_confidence = 0.0
|
|
2770
3104
|
|
|
2771
3105
|
# 解析 XML
|
|
2772
3106
|
try:
|
|
2773
3107
|
root = ET.fromstring(xml_string)
|
|
2774
3108
|
all_elements = list(root.iter())
|
|
2775
3109
|
|
|
2776
|
-
# =====
|
|
2777
|
-
popup_bounds
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
3110
|
+
# ===== 第一步:检测弹窗区域(如果AI未传入完整弹窗信息)=====
|
|
3111
|
+
if popup_bounds is None:
|
|
3112
|
+
# 无论popup_detected是否传入,都需要检测bounds来定位弹窗区域
|
|
3113
|
+
detected_bounds, detected_confidence = self._detect_popup_with_confidence(
|
|
3114
|
+
root, screen_width, screen_height
|
|
3115
|
+
)
|
|
3116
|
+
popup_bounds = detected_bounds
|
|
3117
|
+
popup_confidence = detected_confidence
|
|
3118
|
+
|
|
3119
|
+
# 如果AI未传入popup_detected,根据检测结果判断
|
|
3120
|
+
if popup_detected is None:
|
|
3121
|
+
popup_detected = popup_bounds is not None and popup_confidence >= 0.6
|
|
3122
|
+
# 如果AI传入了popup_detected=True,但检测不到bounds,仍然使用AI的判断
|
|
3123
|
+
elif popup_detected and popup_bounds is None:
|
|
3124
|
+
# AI说有问题但检测不到,可能是检测算法不够准确,信任AI的判断
|
|
3125
|
+
popup_detected = True
|
|
3126
|
+
popup_confidence = 0.7 # 降低置信度,因为检测不到bounds
|
|
3127
|
+
else:
|
|
3128
|
+
# AI已传入popup_bounds,直接使用
|
|
3129
|
+
if popup_detected is None:
|
|
3130
|
+
# 有bounds就认为有弹窗
|
|
3131
|
+
popup_detected = True
|
|
3132
|
+
popup_confidence = 0.8 # AI识别到的弹窗,置信度较高
|
|
2783
3133
|
|
|
2784
|
-
#
|
|
2785
|
-
#
|
|
2786
|
-
if not popup_detected:
|
|
2787
|
-
return {"success": True, "popup": False}
|
|
3134
|
+
# 【重要修复】如果没有检测到弹窗区域,只搜索有明确关闭特征的元素(文本、resource-id等)
|
|
3135
|
+
# 避免误点击普通页面的右上角图标
|
|
2788
3136
|
|
|
2789
3137
|
# ===== 第二步:在弹窗范围内查找关闭按钮 =====
|
|
2790
3138
|
for idx, elem in enumerate(all_elements):
|
|
@@ -2793,6 +3141,7 @@ class BasicMobileToolsLite:
|
|
|
2793
3141
|
bounds_str = elem.attrib.get('bounds', '')
|
|
2794
3142
|
class_name = elem.attrib.get('class', '')
|
|
2795
3143
|
clickable = elem.attrib.get('clickable', 'false') == 'true'
|
|
3144
|
+
resource_id = elem.attrib.get('resource-id', '')
|
|
2796
3145
|
|
|
2797
3146
|
if not bounds_str:
|
|
2798
3147
|
continue
|
|
@@ -2808,8 +3157,22 @@ class BasicMobileToolsLite:
|
|
|
2808
3157
|
center_x = (x1 + x2) // 2
|
|
2809
3158
|
center_y = (y1 + y2) // 2
|
|
2810
3159
|
|
|
3160
|
+
# 收集所有可点击元素(用于兜底策略:当只有一个可点击元素时点击它)
|
|
3161
|
+
if clickable:
|
|
3162
|
+
all_clickable_elements.append({
|
|
3163
|
+
'bounds': bounds_str,
|
|
3164
|
+
'center_x': center_x,
|
|
3165
|
+
'center_y': center_y,
|
|
3166
|
+
'width': width,
|
|
3167
|
+
'height': height,
|
|
3168
|
+
'text': text,
|
|
3169
|
+
'content_desc': content_desc,
|
|
3170
|
+
'resource_id': resource_id,
|
|
3171
|
+
'class_name': class_name
|
|
3172
|
+
})
|
|
3173
|
+
|
|
2811
3174
|
# 如果检测到弹窗区域,检查元素是否在弹窗范围内或附近
|
|
2812
|
-
in_popup =
|
|
3175
|
+
in_popup = False
|
|
2813
3176
|
popup_edge_bonus = 0
|
|
2814
3177
|
is_floating_close = False # 是否是浮动关闭按钮(在弹窗外部上方)
|
|
2815
3178
|
if popup_bounds:
|
|
@@ -2850,6 +3213,20 @@ class BasicMobileToolsLite:
|
|
|
2850
3213
|
# 浮动关闭按钮(在弹窗上方外侧)给予高额加分
|
|
2851
3214
|
if is_floating_close:
|
|
2852
3215
|
popup_edge_bonus += 5.0 # 大幅加分
|
|
3216
|
+
elif not popup_detected:
|
|
3217
|
+
# 没有检测到弹窗时,只处理有明确关闭特征的元素
|
|
3218
|
+
# 检查是否有明确的关闭特征(文本、resource-id、content-desc)
|
|
3219
|
+
has_explicit_close_feature = (
|
|
3220
|
+
text in close_texts or
|
|
3221
|
+
any(kw in content_desc.lower() for kw in close_desc_keywords) or
|
|
3222
|
+
'close' in resource_id.lower() or
|
|
3223
|
+
'dismiss' in resource_id.lower() or
|
|
3224
|
+
'cancel' in resource_id.lower()
|
|
3225
|
+
)
|
|
3226
|
+
if not has_explicit_close_feature:
|
|
3227
|
+
continue # 没有明确关闭特征,跳过
|
|
3228
|
+
# 有明确关闭特征时,允许处理
|
|
3229
|
+
in_popup = True
|
|
2853
3230
|
|
|
2854
3231
|
if not in_popup:
|
|
2855
3232
|
continue
|
|
@@ -2923,6 +3300,33 @@ class BasicMobileToolsLite:
|
|
|
2923
3300
|
pass
|
|
2924
3301
|
|
|
2925
3302
|
if not close_candidates:
|
|
3303
|
+
# 兜底策略:如果检测到弹窗但未找到关闭按钮,且页面元素很少(只有1个可点击元素),直接点击它
|
|
3304
|
+
if popup_detected and popup_bounds and len(all_clickable_elements) == 1:
|
|
3305
|
+
single_element = all_clickable_elements[0]
|
|
3306
|
+
self.client.u2.click(single_element['center_x'], single_element['center_y'])
|
|
3307
|
+
time.sleep(0.5)
|
|
3308
|
+
|
|
3309
|
+
# 检查应用是否跳转
|
|
3310
|
+
app_check = self._check_app_switched()
|
|
3311
|
+
return_result = None
|
|
3312
|
+
if app_check['switched']:
|
|
3313
|
+
return_result = self._return_to_target_app()
|
|
3314
|
+
|
|
3315
|
+
# 记录操作
|
|
3316
|
+
rel_x = single_element['center_x'] / screen_width
|
|
3317
|
+
rel_y = single_element['center_y'] / screen_height
|
|
3318
|
+
self._record_click('percent', f"{round(rel_x * 100, 1)}%,{round(rel_y * 100, 1)}%",
|
|
3319
|
+
round(rel_x * 100, 1), round(rel_y * 100, 1),
|
|
3320
|
+
element_desc="唯一可点击元素(弹窗兜底)")
|
|
3321
|
+
|
|
3322
|
+
result = {"success": True, "clicked": True, "method": "single_clickable_fallback"}
|
|
3323
|
+
if app_check['switched']:
|
|
3324
|
+
result["switched"] = True
|
|
3325
|
+
if return_result:
|
|
3326
|
+
result["returned"] = return_result['success']
|
|
3327
|
+
return result
|
|
3328
|
+
|
|
3329
|
+
# 如果没有找到关闭按钮,且不满足兜底条件,返回fallback
|
|
2926
3330
|
if popup_detected and popup_bounds:
|
|
2927
3331
|
return {"success": False, "fallback": "vision", "popup": True}
|
|
2928
3332
|
return {"success": True, "popup": False}
|
|
@@ -3040,6 +3444,15 @@ class BasicMobileToolsLite:
|
|
|
3040
3444
|
resource_id = elem.attrib.get('resource-id', '')
|
|
3041
3445
|
clickable = elem.attrib.get('clickable', 'false') == 'true'
|
|
3042
3446
|
|
|
3447
|
+
# 检查是否是关闭按钮
|
|
3448
|
+
is_close_button = (
|
|
3449
|
+
'close' in resource_id.lower() or
|
|
3450
|
+
'dismiss' in resource_id.lower() or
|
|
3451
|
+
'cancel' in resource_id.lower() or
|
|
3452
|
+
'×' in elem.attrib.get('text', '') or
|
|
3453
|
+
'X' in elem.attrib.get('text', '')
|
|
3454
|
+
)
|
|
3455
|
+
|
|
3043
3456
|
all_elements.append({
|
|
3044
3457
|
'idx': idx,
|
|
3045
3458
|
'bounds': (x1, y1, x2, y2),
|
|
@@ -3052,6 +3465,7 @@ class BasicMobileToolsLite:
|
|
|
3052
3465
|
'clickable': clickable,
|
|
3053
3466
|
'center_x': (x1 + x2) // 2,
|
|
3054
3467
|
'center_y': (y1 + y2) // 2,
|
|
3468
|
+
'is_close_button': is_close_button,
|
|
3055
3469
|
})
|
|
3056
3470
|
|
|
3057
3471
|
if not all_elements:
|
|
@@ -3060,6 +3474,8 @@ class BasicMobileToolsLite:
|
|
|
3060
3474
|
# 弹窗检测关键词
|
|
3061
3475
|
dialog_class_keywords = ['Dialog', 'Popup', 'Alert', 'Modal', 'BottomSheet', 'PopupWindow']
|
|
3062
3476
|
dialog_id_keywords = ['dialog', 'popup', 'alert', 'modal', 'bottom_sheet', 'overlay', 'mask']
|
|
3477
|
+
# 广告弹窗关键词(全屏广告、激励视频等)
|
|
3478
|
+
ad_popup_keywords = ['ad_close', 'ad_button', 'full_screen', 'interstitial', 'reward', 'close_icon', 'close_btn']
|
|
3063
3479
|
|
|
3064
3480
|
popup_candidates = []
|
|
3065
3481
|
has_mask_layer = False
|
|
@@ -3090,6 +3506,59 @@ class BasicMobileToolsLite:
|
|
|
3090
3506
|
if y1 < 50:
|
|
3091
3507
|
continue
|
|
3092
3508
|
|
|
3509
|
+
# 【非弹窗特征】如果元素包含底部导航栏(底部tab),则不是弹窗
|
|
3510
|
+
# 底部导航栏通常在屏幕底部,高度约100-200像素
|
|
3511
|
+
if y2 > screen_height * 0.85:
|
|
3512
|
+
# 检查是否包含tab相关的resource-id或class
|
|
3513
|
+
if 'tab' in resource_id.lower() or 'Tab' in class_name or 'navigation' in resource_id.lower():
|
|
3514
|
+
continue # 跳过底部导航栏
|
|
3515
|
+
|
|
3516
|
+
# 【非弹窗特征】如果元素包含顶部搜索栏,则不是弹窗
|
|
3517
|
+
if y1 < screen_height * 0.15:
|
|
3518
|
+
if 'search' in resource_id.lower() or 'Search' in class_name:
|
|
3519
|
+
continue # 跳过顶部搜索栏
|
|
3520
|
+
|
|
3521
|
+
# 先检查是否有强弹窗特征(用于后续判断)
|
|
3522
|
+
has_strong_popup_feature = (
|
|
3523
|
+
any(kw in class_name for kw in dialog_class_keywords) or
|
|
3524
|
+
any(kw in resource_id.lower() for kw in dialog_id_keywords) or
|
|
3525
|
+
any(kw in resource_id.lower() for kw in ad_popup_keywords) # 广告弹窗关键词
|
|
3526
|
+
)
|
|
3527
|
+
|
|
3528
|
+
# 检查是否有子元素是关闭按钮(作为弹窗特征)
|
|
3529
|
+
has_close_button_child = False
|
|
3530
|
+
elem_bounds = elem['bounds']
|
|
3531
|
+
for other_elem in all_elements:
|
|
3532
|
+
if other_elem['idx'] == elem['idx']:
|
|
3533
|
+
continue
|
|
3534
|
+
if other_elem['is_close_button']:
|
|
3535
|
+
# 检查关闭按钮是否在这个元素范围内
|
|
3536
|
+
ox1, oy1, ox2, oy2 = other_elem['bounds']
|
|
3537
|
+
ex1, ey1, ex2, ey2 = elem_bounds
|
|
3538
|
+
if ex1 <= ox1 and ey1 <= oy1 and ex2 >= ox2 and ey2 >= oy2:
|
|
3539
|
+
has_close_button_child = True
|
|
3540
|
+
break
|
|
3541
|
+
|
|
3542
|
+
# 【非弹窗特征】如果元素包含明显的页面内容特征,则不是弹窗
|
|
3543
|
+
# 检查是否包含视频播放器、内容列表等页面元素
|
|
3544
|
+
page_content_keywords = ['video', 'player', 'recycler', 'list', 'scroll', 'viewpager', 'fragment']
|
|
3545
|
+
if any(kw in resource_id.lower() or kw in class_name.lower() for kw in page_content_keywords):
|
|
3546
|
+
# 如果面积很大且没有强弹窗特征,则不是弹窗
|
|
3547
|
+
if area_ratio > 0.6 and not has_strong_popup_feature:
|
|
3548
|
+
continue
|
|
3549
|
+
|
|
3550
|
+
# 【非弹窗特征】如果元素面积过大(接近全屏),即使居中也不应该是弹窗
|
|
3551
|
+
# 真正的弹窗通常不会超过屏幕的60%
|
|
3552
|
+
# 对于面积 > 0.6 的元素,如果没有强特征,直接跳过(避免误判首页内容区域)
|
|
3553
|
+
if area_ratio > 0.6 and not has_strong_popup_feature:
|
|
3554
|
+
continue # 跳过大面积非弹窗元素(接近全屏的内容区域,如首页视频播放区域)
|
|
3555
|
+
|
|
3556
|
+
# 对于面积 > 0.7 的元素,即使有强特征也要更严格
|
|
3557
|
+
if area_ratio > 0.7:
|
|
3558
|
+
# 需要非常强的特征才认为是弹窗
|
|
3559
|
+
if not has_strong_popup_feature:
|
|
3560
|
+
continue
|
|
3561
|
+
|
|
3093
3562
|
confidence = 0.0
|
|
3094
3563
|
|
|
3095
3564
|
# 【强特征】class 名称包含弹窗关键词 (+0.5)
|
|
@@ -3100,19 +3569,46 @@ class BasicMobileToolsLite:
|
|
|
3100
3569
|
if any(kw in resource_id.lower() for kw in dialog_id_keywords):
|
|
3101
3570
|
confidence += 0.4
|
|
3102
3571
|
|
|
3572
|
+
# 【强特征】resource-id 包含广告弹窗关键词 (+0.4)
|
|
3573
|
+
if any(kw in resource_id.lower() for kw in ad_popup_keywords):
|
|
3574
|
+
confidence += 0.4
|
|
3575
|
+
|
|
3576
|
+
# 【强特征】包含关闭按钮作为子元素 (+0.3)
|
|
3577
|
+
if has_close_button_child:
|
|
3578
|
+
confidence += 0.3
|
|
3579
|
+
|
|
3103
3580
|
# 【中等特征】居中显示 (+0.2)
|
|
3581
|
+
# 但如果没有强特征,降低权重
|
|
3104
3582
|
center_x = elem['center_x']
|
|
3105
3583
|
center_y = elem['center_y']
|
|
3106
3584
|
is_centered_x = abs(center_x - screen_width / 2) < screen_width * 0.15
|
|
3107
3585
|
is_centered_y = abs(center_y - screen_height / 2) < screen_height * 0.25
|
|
3586
|
+
|
|
3587
|
+
has_strong_feature = (
|
|
3588
|
+
any(kw in class_name for kw in dialog_class_keywords) or
|
|
3589
|
+
any(kw in resource_id.lower() for kw in dialog_id_keywords) or
|
|
3590
|
+
any(kw in resource_id.lower() for kw in ad_popup_keywords) or
|
|
3591
|
+
has_close_button_child
|
|
3592
|
+
)
|
|
3593
|
+
|
|
3108
3594
|
if is_centered_x and is_centered_y:
|
|
3109
|
-
|
|
3595
|
+
if has_strong_feature:
|
|
3596
|
+
confidence += 0.2
|
|
3597
|
+
else:
|
|
3598
|
+
confidence += 0.1 # 没有强特征时降低权重
|
|
3110
3599
|
elif is_centered_x:
|
|
3111
|
-
|
|
3600
|
+
if has_strong_feature:
|
|
3601
|
+
confidence += 0.1
|
|
3602
|
+
else:
|
|
3603
|
+
confidence += 0.05 # 没有强特征时降低权重
|
|
3112
3604
|
|
|
3113
3605
|
# 【中等特征】非全屏但有一定大小 (+0.15)
|
|
3606
|
+
# 但如果没有强特征,降低权重
|
|
3114
3607
|
if 0.15 < area_ratio < 0.75:
|
|
3115
|
-
|
|
3608
|
+
if has_strong_feature:
|
|
3609
|
+
confidence += 0.15
|
|
3610
|
+
else:
|
|
3611
|
+
confidence += 0.08 # 没有强特征时降低权重
|
|
3116
3612
|
|
|
3117
3613
|
# 【弱特征】XML 顺序靠后(在视图层级上层)(+0.1)
|
|
3118
3614
|
if elem['idx'] > len(all_elements) * 0.5:
|
|
@@ -3139,8 +3635,22 @@ class BasicMobileToolsLite:
|
|
|
3139
3635
|
popup_candidates.sort(key=lambda x: (x['confidence'], x['idx']), reverse=True)
|
|
3140
3636
|
best = popup_candidates[0]
|
|
3141
3637
|
|
|
3142
|
-
#
|
|
3143
|
-
|
|
3638
|
+
# 更严格的阈值:只有置信度 >= 0.7 才返回弹窗
|
|
3639
|
+
# 如果没有强特征(class或resource-id包含弹窗关键词),需要更高的置信度
|
|
3640
|
+
has_strong_feature = (
|
|
3641
|
+
any(kw in best['class'] for kw in dialog_class_keywords) or
|
|
3642
|
+
any(kw in best['resource_id'].lower() for kw in dialog_id_keywords) or
|
|
3643
|
+
any(kw in best['resource_id'].lower() for kw in ad_popup_keywords)
|
|
3644
|
+
)
|
|
3645
|
+
|
|
3646
|
+
if has_strong_feature:
|
|
3647
|
+
# 有强特征时,阈值0.7
|
|
3648
|
+
threshold = 0.7
|
|
3649
|
+
else:
|
|
3650
|
+
# 没有强特征时,阈值0.85(更严格)
|
|
3651
|
+
threshold = 0.85
|
|
3652
|
+
|
|
3653
|
+
if best['confidence'] >= threshold:
|
|
3144
3654
|
return best['bounds'], best['confidence']
|
|
3145
3655
|
|
|
3146
3656
|
return None, best['confidence']
|
|
@@ -3379,6 +3889,15 @@ class BasicMobileToolsLite:
|
|
|
3379
3889
|
|
|
3380
3890
|
# 生成脚本
|
|
3381
3891
|
safe_name = re.sub(r'[^\w\s-]', '', test_name).strip().replace(' ', '_')
|
|
3892
|
+
# 确保 safe_name 不为空,否则使用默认名称
|
|
3893
|
+
if not safe_name:
|
|
3894
|
+
safe_name = 'generated_case'
|
|
3895
|
+
|
|
3896
|
+
# 提前处理文件名,确保文档字符串中的文件名正确
|
|
3897
|
+
if not filename.endswith('.py'):
|
|
3898
|
+
filename = f"{filename}.py"
|
|
3899
|
+
if not filename.startswith('test_'):
|
|
3900
|
+
filename = f"test_{filename}"
|
|
3382
3901
|
|
|
3383
3902
|
script_lines = [
|
|
3384
3903
|
"#!/usr/bin/env python3",
|
|
@@ -3393,8 +3912,8 @@ class BasicMobileToolsLite:
|
|
|
3393
3912
|
"3. 百分比定位 - 跨分辨率兼容(坐标自动转换)",
|
|
3394
3913
|
"",
|
|
3395
3914
|
"运行方式:",
|
|
3396
|
-
" pytest {filename} -v # 使用 pytest 运行",
|
|
3397
|
-
" python {filename} # 直接运行",
|
|
3915
|
+
f" pytest {filename} -v # 使用 pytest 运行",
|
|
3916
|
+
f" python {filename} # 直接运行",
|
|
3398
3917
|
f'"""',
|
|
3399
3918
|
"import time",
|
|
3400
3919
|
"import pytest",
|
|
@@ -3655,6 +4174,12 @@ class BasicMobileToolsLite:
|
|
|
3655
4174
|
script_lines.append(f" d.press('{key}')")
|
|
3656
4175
|
script_lines.append(" time.sleep(0.5)")
|
|
3657
4176
|
script_lines.append(" ")
|
|
4177
|
+
|
|
4178
|
+
elif action == 'wait':
|
|
4179
|
+
seconds = op.get('seconds', 1)
|
|
4180
|
+
script_lines.append(f" # 步骤{step_num}: 等待 {seconds} 秒")
|
|
4181
|
+
script_lines.append(f" time.sleep({seconds})")
|
|
4182
|
+
script_lines.append(" ")
|
|
3658
4183
|
|
|
3659
4184
|
script_lines.extend([
|
|
3660
4185
|
" print('✅ 测试完成')",
|
|
@@ -3678,12 +4203,6 @@ class BasicMobileToolsLite:
|
|
|
3678
4203
|
output_dir = Path("tests")
|
|
3679
4204
|
output_dir.mkdir(exist_ok=True)
|
|
3680
4205
|
|
|
3681
|
-
# 确保文件名符合 pytest 规范(以 test_ 开头)
|
|
3682
|
-
if not filename.endswith('.py'):
|
|
3683
|
-
filename = f"{filename}.py"
|
|
3684
|
-
if not filename.startswith('test_'):
|
|
3685
|
-
filename = f"test_{filename}"
|
|
3686
|
-
|
|
3687
4206
|
file_path = output_dir / filename
|
|
3688
4207
|
file_path.write_text(script, encoding='utf-8')
|
|
3689
4208
|
|