mobile-mcp-ai 2.6.5__py3-none-any.whl → 2.6.6__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/config.py +0 -32
- mobile_mcp/core/basic_tools_lite.py +446 -380
- mobile_mcp/mcp_tools/mcp_server.py +336 -241
- {mobile_mcp_ai-2.6.5.dist-info → mobile_mcp_ai-2.6.6.dist-info}/METADATA +1 -1
- {mobile_mcp_ai-2.6.5.dist-info → mobile_mcp_ai-2.6.6.dist-info}/RECORD +9 -9
- {mobile_mcp_ai-2.6.5.dist-info → mobile_mcp_ai-2.6.6.dist-info}/WHEEL +1 -1
- {mobile_mcp_ai-2.6.5.dist-info → mobile_mcp_ai-2.6.6.dist-info}/entry_points.txt +0 -0
- {mobile_mcp_ai-2.6.5.dist-info → mobile_mcp_ai-2.6.6.dist-info}/licenses/LICENSE +0 -0
- {mobile_mcp_ai-2.6.5.dist-info → mobile_mcp_ai-2.6.6.dist-info}/top_level.txt +0 -0
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
- 核心功能精简
|
|
9
9
|
- 保留 pytest 脚本生成
|
|
10
10
|
- 支持操作历史记录
|
|
11
|
-
- Token 优化模式(省钱)
|
|
12
11
|
"""
|
|
13
12
|
|
|
14
13
|
import asyncio
|
|
@@ -18,19 +17,6 @@ from pathlib import Path
|
|
|
18
17
|
from typing import Dict, List, Optional
|
|
19
18
|
from datetime import datetime
|
|
20
19
|
|
|
21
|
-
# Token 优化配置(只精简格式,不限制数量,确保准确度)
|
|
22
|
-
try:
|
|
23
|
-
from mobile_mcp.config import Config
|
|
24
|
-
TOKEN_OPTIMIZATION = Config.TOKEN_OPTIMIZATION_ENABLED
|
|
25
|
-
MAX_ELEMENTS = Config.MAX_ELEMENTS_RETURN
|
|
26
|
-
MAX_SOM_ELEMENTS = Config.MAX_SOM_ELEMENTS_RETURN
|
|
27
|
-
COMPACT_RESPONSE = Config.COMPACT_RESPONSE
|
|
28
|
-
except ImportError:
|
|
29
|
-
TOKEN_OPTIMIZATION = True
|
|
30
|
-
MAX_ELEMENTS = 0 # 0 = 不限制
|
|
31
|
-
MAX_SOM_ELEMENTS = 0 # 0 = 不限制
|
|
32
|
-
COMPACT_RESPONSE = True
|
|
33
|
-
|
|
34
20
|
|
|
35
21
|
class BasicMobileToolsLite:
|
|
36
22
|
"""精简版移动端工具"""
|
|
@@ -349,7 +335,7 @@ class BasicMobileToolsLite:
|
|
|
349
335
|
size = ios_client.wda.window_size()
|
|
350
336
|
screen_width, screen_height = size[0], size[1]
|
|
351
337
|
else:
|
|
352
|
-
return {"success": False, "
|
|
338
|
+
return {"success": False, "message": "❌ iOS 客户端未初始化"}
|
|
353
339
|
else:
|
|
354
340
|
self.client.u2.screenshot(str(temp_path))
|
|
355
341
|
info = self.client.u2.info
|
|
@@ -400,14 +386,22 @@ class BasicMobileToolsLite:
|
|
|
400
386
|
|
|
401
387
|
cropped_size = final_path.stat().st_size
|
|
402
388
|
|
|
403
|
-
# 返回结果
|
|
404
389
|
return {
|
|
405
390
|
"success": True,
|
|
406
391
|
"screenshot_path": str(final_path),
|
|
392
|
+
"screen_width": screen_width,
|
|
393
|
+
"screen_height": screen_height,
|
|
407
394
|
"image_width": img.width,
|
|
408
395
|
"image_height": img.height,
|
|
409
396
|
"crop_offset_x": crop_offset_x,
|
|
410
|
-
"crop_offset_y": crop_offset_y
|
|
397
|
+
"crop_offset_y": crop_offset_y,
|
|
398
|
+
"file_size": f"{cropped_size/1024:.1f}KB",
|
|
399
|
+
"message": f"🔍 局部截图已保存: {final_path}\n"
|
|
400
|
+
f"📐 裁剪区域: ({crop_offset_x}, {crop_offset_y}) 起,{img.width}x{img.height} 像素\n"
|
|
401
|
+
f"📦 文件大小: {cropped_size/1024:.0f}KB\n"
|
|
402
|
+
f"🎯 【坐标换算】AI 返回坐标 (x, y) 后:\n"
|
|
403
|
+
f" 实际屏幕坐标 = ({crop_offset_x} + x, {crop_offset_y} + y)\n"
|
|
404
|
+
f" 或直接调用 mobile_click_at_coords(x, y, crop_offset_x={crop_offset_x}, crop_offset_y={crop_offset_y})"
|
|
411
405
|
}
|
|
412
406
|
|
|
413
407
|
# ========== 情况2:全屏压缩截图 ==========
|
|
@@ -460,14 +454,24 @@ class BasicMobileToolsLite:
|
|
|
460
454
|
compressed_size = final_path.stat().st_size
|
|
461
455
|
saved_percent = (1 - compressed_size / original_size) * 100
|
|
462
456
|
|
|
463
|
-
# 返回结果
|
|
464
457
|
return {
|
|
465
458
|
"success": True,
|
|
466
459
|
"screenshot_path": str(final_path),
|
|
467
|
-
"
|
|
468
|
-
"
|
|
469
|
-
"original_img_width": original_img_width,
|
|
470
|
-
"original_img_height": original_img_height
|
|
460
|
+
"screen_width": screen_width,
|
|
461
|
+
"screen_height": screen_height,
|
|
462
|
+
"original_img_width": original_img_width, # 截图原始宽度
|
|
463
|
+
"original_img_height": original_img_height, # 截图原始高度
|
|
464
|
+
"image_width": image_width, # 压缩后宽度(AI 看到的)
|
|
465
|
+
"image_height": image_height, # 压缩后高度(AI 看到的)
|
|
466
|
+
"original_size": f"{original_size/1024:.1f}KB",
|
|
467
|
+
"compressed_size": f"{compressed_size/1024:.1f}KB",
|
|
468
|
+
"saved_percent": f"{saved_percent:.0f}%",
|
|
469
|
+
"message": f"📸 截图已保存: {final_path}\n"
|
|
470
|
+
f"📐 原始尺寸: {original_img_width}x{original_img_height} → 压缩后: {image_width}x{image_height}\n"
|
|
471
|
+
f"📦 已压缩: {original_size/1024:.0f}KB → {compressed_size/1024:.0f}KB (省 {saved_percent:.0f}%)\n"
|
|
472
|
+
f"⚠️ 【坐标转换】AI 返回坐标后,请传入:\n"
|
|
473
|
+
f" image_width={image_width}, image_height={image_height},\n"
|
|
474
|
+
f" original_img_width={original_img_width}, original_img_height={original_img_height}"
|
|
471
475
|
}
|
|
472
476
|
|
|
473
477
|
# ========== 情况3:全屏不压缩截图 ==========
|
|
@@ -481,12 +485,21 @@ class BasicMobileToolsLite:
|
|
|
481
485
|
final_path = self.screenshot_dir / filename
|
|
482
486
|
temp_path.rename(final_path)
|
|
483
487
|
|
|
484
|
-
#
|
|
488
|
+
# 不压缩时,用截图实际尺寸(可能和 screen_width 不同)
|
|
485
489
|
return {
|
|
486
490
|
"success": True,
|
|
487
491
|
"screenshot_path": str(final_path),
|
|
488
|
-
"
|
|
489
|
-
"
|
|
492
|
+
"screen_width": screen_width,
|
|
493
|
+
"screen_height": screen_height,
|
|
494
|
+
"original_img_width": img.width, # 截图实际尺寸
|
|
495
|
+
"original_img_height": img.height,
|
|
496
|
+
"image_width": img.width, # 未压缩,和原图一样
|
|
497
|
+
"image_height": img.height,
|
|
498
|
+
"file_size": f"{original_size/1024:.1f}KB",
|
|
499
|
+
"message": f"📸 截图已保存: {final_path}\n"
|
|
500
|
+
f"📐 截图尺寸: {img.width}x{img.height}\n"
|
|
501
|
+
f"📦 文件大小: {original_size/1024:.0f}KB(未压缩)\n"
|
|
502
|
+
f"💡 未压缩,坐标可直接使用"
|
|
490
503
|
}
|
|
491
504
|
except ImportError:
|
|
492
505
|
# 如果没有 PIL,回退到原始方式(不压缩)
|
|
@@ -526,7 +539,7 @@ class BasicMobileToolsLite:
|
|
|
526
539
|
size = ios_client.wda.window_size()
|
|
527
540
|
screen_width, screen_height = size[0], size[1]
|
|
528
541
|
else:
|
|
529
|
-
return {"success": False, "
|
|
542
|
+
return {"success": False, "message": "❌ iOS 客户端未初始化"}
|
|
530
543
|
else:
|
|
531
544
|
self.client.u2.screenshot(str(temp_path))
|
|
532
545
|
info = self.client.u2.info
|
|
@@ -640,16 +653,26 @@ class BasicMobileToolsLite:
|
|
|
640
653
|
result = {
|
|
641
654
|
"success": True,
|
|
642
655
|
"screenshot_path": str(final_path),
|
|
656
|
+
"screen_width": screen_width,
|
|
657
|
+
"screen_height": screen_height,
|
|
643
658
|
"image_width": img_width,
|
|
644
659
|
"image_height": img_height,
|
|
645
|
-
"grid_size": grid_size
|
|
660
|
+
"grid_size": grid_size,
|
|
661
|
+
"message": f"📸 网格截图已保存: {final_path}\n"
|
|
662
|
+
f"📐 尺寸: {img_width}x{img_height}\n"
|
|
663
|
+
f"📏 网格间距: {grid_size}px"
|
|
646
664
|
}
|
|
647
665
|
|
|
648
666
|
if popup_info:
|
|
649
|
-
result["
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
667
|
+
result["popup_detected"] = True
|
|
668
|
+
result["popup_bounds"] = popup_info["bounds"]
|
|
669
|
+
result["close_button_hints"] = close_positions
|
|
670
|
+
result["message"] += f"\n🎯 检测到弹窗: {popup_info['bounds']}"
|
|
671
|
+
result["message"] += f"\n💡 可能的关闭按钮位置(绿色圆圈标注):"
|
|
672
|
+
for pos in close_positions:
|
|
673
|
+
result["message"] += f"\n {pos['priority']}. {pos['name']}: ({pos['x']}, {pos['y']})"
|
|
674
|
+
else:
|
|
675
|
+
result["popup_detected"] = False
|
|
653
676
|
|
|
654
677
|
return result
|
|
655
678
|
|
|
@@ -686,7 +709,7 @@ class BasicMobileToolsLite:
|
|
|
686
709
|
size = ios_client.wda.window_size()
|
|
687
710
|
screen_width, screen_height = size[0], size[1]
|
|
688
711
|
else:
|
|
689
|
-
return {"success": False, "
|
|
712
|
+
return {"success": False, "message": "❌ iOS 客户端未初始化"}
|
|
690
713
|
else:
|
|
691
714
|
self.client.u2.screenshot(str(temp_path))
|
|
692
715
|
info = self.client.u2.info
|
|
@@ -846,15 +869,38 @@ class BasicMobileToolsLite:
|
|
|
846
869
|
img.save(str(final_path), "JPEG", quality=85)
|
|
847
870
|
temp_path.unlink()
|
|
848
871
|
|
|
849
|
-
#
|
|
872
|
+
# 构建元素列表文字
|
|
873
|
+
elements_text = "\n".join([
|
|
874
|
+
f" [{e['index']}] {e['desc']} → ({e['center'][0]}, {e['center'][1]})"
|
|
875
|
+
for e in som_elements[:15] # 只显示前15个
|
|
876
|
+
])
|
|
877
|
+
if len(som_elements) > 15:
|
|
878
|
+
elements_text += f"\n ... 还有 {len(som_elements) - 15} 个元素"
|
|
879
|
+
|
|
880
|
+
# 构建弹窗提示文字
|
|
881
|
+
hints_text = ""
|
|
882
|
+
if popup_bounds:
|
|
883
|
+
hints_text = f"\n🎯 检测到弹窗区域(蓝色边框)\n"
|
|
884
|
+
hints_text += f" 如需关闭弹窗,请观察图片中的 X 按钮位置\n"
|
|
885
|
+
hints_text += f" 然后使用 mobile_click_by_percent(x%, y%) 点击"
|
|
886
|
+
|
|
850
887
|
return {
|
|
851
888
|
"success": True,
|
|
852
889
|
"screenshot_path": str(final_path),
|
|
853
890
|
"screen_width": screen_width,
|
|
854
891
|
"screen_height": screen_height,
|
|
892
|
+
"image_width": img_width,
|
|
893
|
+
"image_height": img_height,
|
|
855
894
|
"element_count": len(som_elements),
|
|
895
|
+
"elements": som_elements,
|
|
856
896
|
"popup_detected": popup_bounds is not None,
|
|
857
|
-
"
|
|
897
|
+
"popup_bounds": f"[{popup_bounds[0]},{popup_bounds[1]}][{popup_bounds[2]},{popup_bounds[3]}]" if popup_bounds else None,
|
|
898
|
+
"message": f"📸 SoM 截图已保存: {final_path}\n"
|
|
899
|
+
f"🏷️ 已标注 {len(som_elements)} 个可点击元素\n"
|
|
900
|
+
f"📋 元素列表:\n{elements_text}{hints_text}\n\n"
|
|
901
|
+
f"💡 使用方法:\n"
|
|
902
|
+
f" - 点击标注元素:mobile_click_by_som(编号)\n"
|
|
903
|
+
f" - 点击任意位置:mobile_click_by_percent(x%, y%)"
|
|
858
904
|
}
|
|
859
905
|
|
|
860
906
|
except ImportError:
|
|
@@ -935,6 +981,7 @@ class BasicMobileToolsLite:
|
|
|
935
981
|
|
|
936
982
|
return {
|
|
937
983
|
"success": True,
|
|
984
|
+
"message": f"✅ 已点击 [{index}] {target['desc']} → ({cx}, {cy})\n💡 建议:再次截图确认操作是否成功",
|
|
938
985
|
"clicked": {
|
|
939
986
|
"index": index,
|
|
940
987
|
"desc": target['desc'],
|
|
@@ -968,7 +1015,7 @@ class BasicMobileToolsLite:
|
|
|
968
1015
|
size = ios_client.wda.window_size()
|
|
969
1016
|
width, height = size[0], size[1]
|
|
970
1017
|
else:
|
|
971
|
-
return {"success": False, "
|
|
1018
|
+
return {"success": False, "message": "❌ iOS 客户端未初始化"}
|
|
972
1019
|
else:
|
|
973
1020
|
self.client.u2.screenshot(str(screenshot_path))
|
|
974
1021
|
info = self.client.u2.info
|
|
@@ -1046,7 +1093,7 @@ class BasicMobileToolsLite:
|
|
|
1046
1093
|
size = ios_client.wda.window_size()
|
|
1047
1094
|
screen_width, screen_height = size[0], size[1]
|
|
1048
1095
|
else:
|
|
1049
|
-
return {"success": False, "
|
|
1096
|
+
return {"success": False, "message": "❌ iOS 客户端未初始化"}
|
|
1050
1097
|
else:
|
|
1051
1098
|
info = self.client.u2.info
|
|
1052
1099
|
screen_width = info.get('displayWidth', 0)
|
|
@@ -1161,14 +1208,14 @@ class BasicMobileToolsLite:
|
|
|
1161
1208
|
size = ios_client.wda.window_size()
|
|
1162
1209
|
width, height = size[0], size[1]
|
|
1163
1210
|
else:
|
|
1164
|
-
return {"success": False, "
|
|
1211
|
+
return {"success": False, "message": "❌ iOS 客户端未初始化"}
|
|
1165
1212
|
else:
|
|
1166
1213
|
info = self.client.u2.info
|
|
1167
1214
|
width = info.get('displayWidth', 0)
|
|
1168
1215
|
height = info.get('displayHeight', 0)
|
|
1169
1216
|
|
|
1170
1217
|
if width == 0 or height == 0:
|
|
1171
|
-
return {"success": False, "
|
|
1218
|
+
return {"success": False, "message": "❌ 无法获取屏幕尺寸"}
|
|
1172
1219
|
|
|
1173
1220
|
# 第2步:百分比转像素坐标
|
|
1174
1221
|
# 公式:像素 = 屏幕尺寸 × (百分比 / 100)
|
|
@@ -1189,13 +1236,15 @@ class BasicMobileToolsLite:
|
|
|
1189
1236
|
|
|
1190
1237
|
return {
|
|
1191
1238
|
"success": True,
|
|
1239
|
+
"message": f"✅ 百分比点击成功: ({x_percent}%, {y_percent}%) → 像素({x}, {y})",
|
|
1240
|
+
"screen_size": {"width": width, "height": height},
|
|
1241
|
+
"percent": {"x": x_percent, "y": y_percent},
|
|
1192
1242
|
"pixel": {"x": x, "y": y}
|
|
1193
1243
|
}
|
|
1194
1244
|
except Exception as e:
|
|
1195
1245
|
return {"success": False, "message": f"❌ 百分比点击失败: {e}"}
|
|
1196
1246
|
|
|
1197
|
-
def click_by_text(self, text: str, timeout: float = 3.0, position: Optional[str] = None
|
|
1198
|
-
verify: Optional[str] = None) -> Dict:
|
|
1247
|
+
def click_by_text(self, text: str, timeout: float = 3.0, position: Optional[str] = None) -> Dict:
|
|
1199
1248
|
"""通过文本点击 - 先查 XML 树,再精准匹配
|
|
1200
1249
|
|
|
1201
1250
|
Args:
|
|
@@ -1204,7 +1253,6 @@ class BasicMobileToolsLite:
|
|
|
1204
1253
|
position: 位置信息,当有多个相同文案时使用。支持:
|
|
1205
1254
|
- 垂直方向: "top"/"upper"/"上", "bottom"/"lower"/"下", "middle"/"center"/"中"
|
|
1206
1255
|
- 水平方向: "left"/"左", "right"/"右", "center"/"中"
|
|
1207
|
-
verify: 可选,点击后验证的文本。如果指定,会检查该文本是否出现在页面上
|
|
1208
1256
|
"""
|
|
1209
1257
|
try:
|
|
1210
1258
|
if self._is_ios():
|
|
@@ -1216,17 +1264,10 @@ class BasicMobileToolsLite:
|
|
|
1216
1264
|
if elem.exists:
|
|
1217
1265
|
elem.click()
|
|
1218
1266
|
time.sleep(0.3)
|
|
1267
|
+
# 使用标准记录格式
|
|
1219
1268
|
self._record_click('text', text, element_desc=text, locator_attr='text')
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
return self._verify_after_click(verify, ios=True)
|
|
1223
|
-
# 返回页面文本摘要,方便确认页面变化
|
|
1224
|
-
page_texts = self._get_page_texts(10)
|
|
1225
|
-
return {"success": True, "page_texts": page_texts}
|
|
1226
|
-
# 控件树找不到,提示用视觉识别
|
|
1227
|
-
return {"success": False, "fallback": "vision", "msg": f"未找到'{text}',用截图点击"}
|
|
1228
|
-
else:
|
|
1229
|
-
return {"success": False, "msg": "iOS未初始化"}
|
|
1269
|
+
return {"success": True, "message": f"✅ 点击成功: '{text}'"}
|
|
1270
|
+
return {"success": False, "message": f"❌ 文本不存在: {text}"}
|
|
1230
1271
|
else:
|
|
1231
1272
|
# 获取屏幕尺寸用于计算百分比
|
|
1232
1273
|
screen_width, screen_height = self.client.u2.window_size()
|
|
@@ -1247,20 +1288,17 @@ class BasicMobileToolsLite:
|
|
|
1247
1288
|
x_pct = round(cx / screen_width * 100, 1)
|
|
1248
1289
|
y_pct = round(cy / screen_height * 100, 1)
|
|
1249
1290
|
|
|
1250
|
-
#
|
|
1291
|
+
# 如果有位置参数,直接使用坐标点击(避免 u2 选择器匹配到错误的元素)
|
|
1251
1292
|
if position and bounds:
|
|
1252
1293
|
x = (bounds[0] + bounds[2]) // 2
|
|
1253
1294
|
y = (bounds[1] + bounds[3]) // 2
|
|
1254
1295
|
self.client.u2.click(x, y)
|
|
1255
1296
|
time.sleep(0.3)
|
|
1297
|
+
position_info = f" ({position})" if position else ""
|
|
1298
|
+
# 虽然用坐标点击,但记录时仍使用文本定位(脚本更稳定)
|
|
1256
1299
|
self._record_click('text', attr_value, x_pct, y_pct,
|
|
1257
|
-
element_desc=f"{text}
|
|
1258
|
-
|
|
1259
|
-
if verify:
|
|
1260
|
-
return self._verify_after_click(verify)
|
|
1261
|
-
# 返回页面文本摘要
|
|
1262
|
-
page_texts = self._get_page_texts(10)
|
|
1263
|
-
return {"success": True, "page_texts": page_texts}
|
|
1300
|
+
element_desc=f"{text}{position_info}", locator_attr=attr_type)
|
|
1301
|
+
return {"success": True, "message": f"✅ 点击成功(坐标定位): '{text}'{position_info} @ ({x},{y})"}
|
|
1264
1302
|
|
|
1265
1303
|
# 没有位置参数时,使用选择器定位
|
|
1266
1304
|
if attr_type == 'text':
|
|
@@ -1277,74 +1315,27 @@ class BasicMobileToolsLite:
|
|
|
1277
1315
|
if elem and elem.exists(timeout=1):
|
|
1278
1316
|
elem.click()
|
|
1279
1317
|
time.sleep(0.3)
|
|
1318
|
+
position_info = f" ({position})" if position else ""
|
|
1319
|
+
# 使用标准记录格式:文本定位
|
|
1280
1320
|
self._record_click('text', attr_value, x_pct, y_pct,
|
|
1281
1321
|
element_desc=text, locator_attr=attr_type)
|
|
1282
|
-
|
|
1283
|
-
if verify:
|
|
1284
|
-
return self._verify_after_click(verify)
|
|
1285
|
-
# 返回页面文本摘要
|
|
1286
|
-
page_texts = self._get_page_texts(10)
|
|
1287
|
-
return {"success": True, "page_texts": page_texts}
|
|
1322
|
+
return {"success": True, "message": f"✅ 点击成功({attr_type}): '{text}'{position_info}"}
|
|
1288
1323
|
|
|
1289
|
-
#
|
|
1324
|
+
# 如果选择器失败,用坐标兜底
|
|
1290
1325
|
if bounds:
|
|
1291
1326
|
x = (bounds[0] + bounds[2]) // 2
|
|
1292
1327
|
y = (bounds[1] + bounds[3]) // 2
|
|
1293
1328
|
self.client.u2.click(x, y)
|
|
1294
1329
|
time.sleep(0.3)
|
|
1330
|
+
position_info = f" ({position})" if position else ""
|
|
1331
|
+
# 选择器失败,用百分比作为兜底
|
|
1295
1332
|
self._record_click('percent', f"{x_pct}%,{y_pct}%", x_pct, y_pct,
|
|
1296
|
-
element_desc=text)
|
|
1297
|
-
|
|
1298
|
-
if verify:
|
|
1299
|
-
return self._verify_after_click(verify)
|
|
1300
|
-
# 返回页面文本摘要
|
|
1301
|
-
page_texts = self._get_page_texts(10)
|
|
1302
|
-
return {"success": True, "page_texts": page_texts}
|
|
1333
|
+
element_desc=f"{text}{position_info}")
|
|
1334
|
+
return {"success": True, "message": f"✅ 点击成功(坐标兜底): '{text}'{position_info} @ ({x},{y})"}
|
|
1303
1335
|
|
|
1304
|
-
|
|
1305
|
-
return {"success": False, "fallback": "vision", "msg": f"未找到'{text}',用截图点击"}
|
|
1306
|
-
except Exception as e:
|
|
1307
|
-
return {"success": False, "msg": str(e)}
|
|
1308
|
-
|
|
1309
|
-
def _verify_after_click(self, verify_text: str, ios: bool = False, timeout: float = 2.0) -> Dict:
|
|
1310
|
-
"""点击后验证期望文本是否出现
|
|
1311
|
-
|
|
1312
|
-
Args:
|
|
1313
|
-
verify_text: 期望出现的文本
|
|
1314
|
-
ios: 是否是 iOS 设备
|
|
1315
|
-
timeout: 验证超时时间
|
|
1316
|
-
|
|
1317
|
-
Returns:
|
|
1318
|
-
{"success": True, "verified": True/False, "hint": "..."}
|
|
1319
|
-
"""
|
|
1320
|
-
time.sleep(0.5) # 等待页面更新
|
|
1321
|
-
|
|
1322
|
-
try:
|
|
1323
|
-
if ios:
|
|
1324
|
-
ios_client = self._get_ios_client()
|
|
1325
|
-
if ios_client and hasattr(ios_client, 'wda'):
|
|
1326
|
-
exists = ios_client.wda(name=verify_text).exists or \
|
|
1327
|
-
ios_client.wda(label=verify_text).exists
|
|
1328
|
-
else:
|
|
1329
|
-
exists = False
|
|
1330
|
-
else:
|
|
1331
|
-
# Android: 检查文本或包含文本
|
|
1332
|
-
exists = self.client.u2(text=verify_text).exists(timeout=timeout) or \
|
|
1333
|
-
self.client.u2(textContains=verify_text).exists(timeout=0.5) or \
|
|
1334
|
-
self.client.u2(description=verify_text).exists(timeout=0.5)
|
|
1335
|
-
|
|
1336
|
-
if exists:
|
|
1337
|
-
return {"success": True, "verified": True}
|
|
1338
|
-
else:
|
|
1339
|
-
# 验证失败,提示可以截图确认
|
|
1340
|
-
return {
|
|
1341
|
-
"success": True, # 点击本身成功
|
|
1342
|
-
"verified": False,
|
|
1343
|
-
"expect": verify_text,
|
|
1344
|
-
"hint": "验证失败,可截图确认"
|
|
1345
|
-
}
|
|
1336
|
+
return {"success": False, "message": f"❌ 文本不存在: {text}"}
|
|
1346
1337
|
except Exception as e:
|
|
1347
|
-
return {"success":
|
|
1338
|
+
return {"success": False, "message": f"❌ 点击失败: {e}"}
|
|
1348
1339
|
|
|
1349
1340
|
def _find_element_in_tree(self, text: str, position: Optional[str] = None) -> Optional[Dict]:
|
|
1350
1341
|
"""在 XML 树中查找包含指定文本的元素,优先返回可点击的元素
|
|
@@ -1483,8 +1474,15 @@ class BasicMobileToolsLite:
|
|
|
1483
1474
|
return None
|
|
1484
1475
|
|
|
1485
1476
|
def click_by_id(self, resource_id: str, index: int = 0) -> Dict:
|
|
1486
|
-
"""通过 resource-id
|
|
1477
|
+
"""通过 resource-id 点击(支持点击第 N 个元素)
|
|
1478
|
+
|
|
1479
|
+
Args:
|
|
1480
|
+
resource_id: 元素的 resource-id
|
|
1481
|
+
index: 第几个元素(从 0 开始),默认 0 表示第一个
|
|
1482
|
+
"""
|
|
1487
1483
|
try:
|
|
1484
|
+
index_desc = f"[{index}]" if index > 0 else ""
|
|
1485
|
+
|
|
1488
1486
|
if self._is_ios():
|
|
1489
1487
|
ios_client = self._get_ios_client()
|
|
1490
1488
|
if ios_client and hasattr(ios_client, 'wda'):
|
|
@@ -1492,31 +1490,33 @@ class BasicMobileToolsLite:
|
|
|
1492
1490
|
if not elem.exists:
|
|
1493
1491
|
elem = ios_client.wda(name=resource_id)
|
|
1494
1492
|
if elem.exists:
|
|
1493
|
+
# 获取所有匹配的元素
|
|
1495
1494
|
elements = elem.find_elements()
|
|
1496
1495
|
if index < len(elements):
|
|
1497
1496
|
elements[index].click()
|
|
1498
1497
|
time.sleep(0.3)
|
|
1499
|
-
|
|
1500
|
-
|
|
1498
|
+
# 使用标准记录格式
|
|
1499
|
+
self._record_click('id', resource_id, element_desc=f"{resource_id}{index_desc}")
|
|
1500
|
+
return {"success": True, "message": f"✅ 点击成功: {resource_id}{index_desc}"}
|
|
1501
1501
|
else:
|
|
1502
|
-
return {"success": False, "
|
|
1503
|
-
return {"success": False, "
|
|
1504
|
-
else:
|
|
1505
|
-
return {"success": False, "msg": "iOS未初始化"}
|
|
1502
|
+
return {"success": False, "message": f"❌ 索引超出范围: 找到 {len(elements)} 个元素,但请求索引 {index}"}
|
|
1503
|
+
return {"success": False, "message": f"❌ 元素不存在: {resource_id}"}
|
|
1506
1504
|
else:
|
|
1507
1505
|
elem = self.client.u2(resourceId=resource_id)
|
|
1508
1506
|
if elem.exists(timeout=0.5):
|
|
1507
|
+
# 获取匹配元素数量
|
|
1509
1508
|
count = elem.count
|
|
1510
1509
|
if index < count:
|
|
1511
1510
|
elem[index].click()
|
|
1512
1511
|
time.sleep(0.3)
|
|
1513
|
-
|
|
1514
|
-
|
|
1512
|
+
# 使用标准记录格式
|
|
1513
|
+
self._record_click('id', resource_id, element_desc=f"{resource_id}{index_desc}")
|
|
1514
|
+
return {"success": True, "message": f"✅ 点击成功: {resource_id}{index_desc}" + (f" (共 {count} 个)" if count > 1 else "")}
|
|
1515
1515
|
else:
|
|
1516
|
-
return {"success": False, "
|
|
1517
|
-
return {"success": False, "
|
|
1516
|
+
return {"success": False, "message": f"❌ 索引超出范围: 找到 {count} 个元素,但请求索引 {index}"}
|
|
1517
|
+
return {"success": False, "message": f"❌ 元素不存在: {resource_id}"}
|
|
1518
1518
|
except Exception as e:
|
|
1519
|
-
return {"success": False, "
|
|
1519
|
+
return {"success": False, "message": f"❌ 点击失败: {e}"}
|
|
1520
1520
|
|
|
1521
1521
|
# ==================== 长按操作 ====================
|
|
1522
1522
|
|
|
@@ -1550,7 +1550,7 @@ class BasicMobileToolsLite:
|
|
|
1550
1550
|
size = ios_client.wda.window_size()
|
|
1551
1551
|
screen_width, screen_height = size[0], size[1]
|
|
1552
1552
|
else:
|
|
1553
|
-
return {"success": False, "
|
|
1553
|
+
return {"success": False, "message": "❌ iOS 客户端未初始化"}
|
|
1554
1554
|
else:
|
|
1555
1555
|
info = self.client.u2.info
|
|
1556
1556
|
screen_width = info.get('displayWidth', 0)
|
|
@@ -1603,11 +1603,23 @@ class BasicMobileToolsLite:
|
|
|
1603
1603
|
|
|
1604
1604
|
if converted:
|
|
1605
1605
|
if conversion_type == "crop_offset":
|
|
1606
|
-
return {
|
|
1606
|
+
return {
|
|
1607
|
+
"success": True,
|
|
1608
|
+
"message": f"✅ 长按成功: ({x}, {y}) 持续 {duration}s\n"
|
|
1609
|
+
f" 🔍 局部截图坐标转换: ({original_x},{original_y}) + 偏移({crop_offset_x},{crop_offset_y}) → ({x},{y})"
|
|
1610
|
+
}
|
|
1607
1611
|
else:
|
|
1608
|
-
return {
|
|
1612
|
+
return {
|
|
1613
|
+
"success": True,
|
|
1614
|
+
"message": f"✅ 长按成功: ({x}, {y}) 持续 {duration}s\n"
|
|
1615
|
+
f" 📐 坐标已转换: ({original_x},{original_y}) → ({x},{y})\n"
|
|
1616
|
+
f" 🖼️ 图片尺寸: {image_width}x{image_height} → 屏幕: {screen_width}x{screen_height}"
|
|
1617
|
+
}
|
|
1609
1618
|
else:
|
|
1610
|
-
return {
|
|
1619
|
+
return {
|
|
1620
|
+
"success": True,
|
|
1621
|
+
"message": f"✅ 长按成功: ({x}, {y}) 持续 {duration}s [相对位置: {x_percent}%, {y_percent}%]"
|
|
1622
|
+
}
|
|
1611
1623
|
except Exception as e:
|
|
1612
1624
|
return {"success": False, "message": f"❌ 长按失败: {e}"}
|
|
1613
1625
|
|
|
@@ -1636,14 +1648,14 @@ class BasicMobileToolsLite:
|
|
|
1636
1648
|
size = ios_client.wda.window_size()
|
|
1637
1649
|
width, height = size[0], size[1]
|
|
1638
1650
|
else:
|
|
1639
|
-
return {"success": False, "
|
|
1651
|
+
return {"success": False, "message": "❌ iOS 客户端未初始化"}
|
|
1640
1652
|
else:
|
|
1641
1653
|
info = self.client.u2.info
|
|
1642
1654
|
width = info.get('displayWidth', 0)
|
|
1643
1655
|
height = info.get('displayHeight', 0)
|
|
1644
1656
|
|
|
1645
1657
|
if width == 0 or height == 0:
|
|
1646
|
-
return {"success": False, "
|
|
1658
|
+
return {"success": False, "message": "❌ 无法获取屏幕尺寸"}
|
|
1647
1659
|
|
|
1648
1660
|
# 第2步:百分比转像素坐标
|
|
1649
1661
|
x = int(width * x_percent / 100)
|
|
@@ -1665,7 +1677,13 @@ class BasicMobileToolsLite:
|
|
|
1665
1677
|
self._record_long_press('percent', f"{x_percent}%,{y_percent}%", duration,
|
|
1666
1678
|
x_percent, y_percent, element_desc=f"百分比({x_percent}%,{y_percent}%)")
|
|
1667
1679
|
|
|
1668
|
-
return {
|
|
1680
|
+
return {
|
|
1681
|
+
"success": True,
|
|
1682
|
+
"message": f"✅ 百分比长按成功: ({x_percent}%, {y_percent}%) → 像素({x}, {y}) 持续 {duration}s",
|
|
1683
|
+
"screen_size": {"width": width, "height": height},
|
|
1684
|
+
"percent": {"x": x_percent, "y": y_percent},
|
|
1685
|
+
"pixel": {"x": x, "y": y},
|
|
1686
|
+
"duration": duration
|
|
1669
1687
|
}
|
|
1670
1688
|
except Exception as e:
|
|
1671
1689
|
return {"success": False, "message": f"❌ 百分比长按失败: {e}"}
|
|
@@ -1695,8 +1713,8 @@ class BasicMobileToolsLite:
|
|
|
1695
1713
|
ios_client.wda.swipe(x, y, x, y, duration=duration)
|
|
1696
1714
|
time.sleep(0.3)
|
|
1697
1715
|
self._record_long_press('text', text, duration, element_desc=text, locator_attr='text')
|
|
1698
|
-
return {"success": True}
|
|
1699
|
-
return {"success": False, "
|
|
1716
|
+
return {"success": True, "message": f"✅ 长按成功: '{text}' 持续 {duration}s"}
|
|
1717
|
+
return {"success": False, "message": f"❌ 文本不存在: {text}"}
|
|
1700
1718
|
else:
|
|
1701
1719
|
# 获取屏幕尺寸用于计算百分比
|
|
1702
1720
|
screen_width, screen_height = self.client.u2.window_size()
|
|
@@ -1734,7 +1752,7 @@ class BasicMobileToolsLite:
|
|
|
1734
1752
|
time.sleep(0.3)
|
|
1735
1753
|
self._record_long_press('text', attr_value, duration, x_pct, y_pct,
|
|
1736
1754
|
element_desc=text, locator_attr=attr_type)
|
|
1737
|
-
return {"success": True}
|
|
1755
|
+
return {"success": True, "message": f"✅ 长按成功({attr_type}): '{text}' 持续 {duration}s"}
|
|
1738
1756
|
|
|
1739
1757
|
# 如果选择器失败,用坐标兜底
|
|
1740
1758
|
if bounds:
|
|
@@ -1744,9 +1762,9 @@ class BasicMobileToolsLite:
|
|
|
1744
1762
|
time.sleep(0.3)
|
|
1745
1763
|
self._record_long_press('percent', f"{x_pct}%,{y_pct}%", duration, x_pct, y_pct,
|
|
1746
1764
|
element_desc=text)
|
|
1747
|
-
return {"success": True}
|
|
1765
|
+
return {"success": True, "message": f"✅ 长按成功(坐标兜底): '{text}' @ ({x},{y}) 持续 {duration}s"}
|
|
1748
1766
|
|
|
1749
|
-
return {"success": False, "
|
|
1767
|
+
return {"success": False, "message": f"❌ 文本不存在: {text}"}
|
|
1750
1768
|
except Exception as e:
|
|
1751
1769
|
return {"success": False, "message": f"❌ 长按失败: {e}"}
|
|
1752
1770
|
|
|
@@ -1774,8 +1792,8 @@ class BasicMobileToolsLite:
|
|
|
1774
1792
|
ios_client.wda.swipe(x, y, x, y, duration=duration)
|
|
1775
1793
|
time.sleep(0.3)
|
|
1776
1794
|
self._record_long_press('id', resource_id, duration, element_desc=resource_id)
|
|
1777
|
-
return {"success": True}
|
|
1778
|
-
return {"success": False, "
|
|
1795
|
+
return {"success": True, "message": f"✅ 长按成功: {resource_id} 持续 {duration}s"}
|
|
1796
|
+
return {"success": False, "message": f"❌ 元素不存在: {resource_id}"}
|
|
1779
1797
|
else:
|
|
1780
1798
|
elem = self.client.u2(resourceId=resource_id)
|
|
1781
1799
|
if elem.exists(timeout=0.5):
|
|
@@ -1783,7 +1801,7 @@ class BasicMobileToolsLite:
|
|
|
1783
1801
|
time.sleep(0.3)
|
|
1784
1802
|
self._record_long_press('id', resource_id, duration, element_desc=resource_id)
|
|
1785
1803
|
return {"success": True, "message": f"✅ 长按成功: {resource_id} 持续 {duration}s"}
|
|
1786
|
-
return {"success": False, "
|
|
1804
|
+
return {"success": False, "message": f"❌ 元素不存在: {resource_id}"}
|
|
1787
1805
|
except Exception as e:
|
|
1788
1806
|
return {"success": False, "message": f"❌ 长按失败: {e}"}
|
|
1789
1807
|
|
|
@@ -2040,8 +2058,15 @@ class BasicMobileToolsLite:
|
|
|
2040
2058
|
x_percent = round(x / screen_width * 100, 1) if screen_width > 0 else 0
|
|
2041
2059
|
y_percent = round(y / screen_height * 100, 1) if screen_height > 0 else 0
|
|
2042
2060
|
|
|
2043
|
-
|
|
2044
|
-
|
|
2061
|
+
self._record_operation(
|
|
2062
|
+
'input',
|
|
2063
|
+
x=x,
|
|
2064
|
+
y=y,
|
|
2065
|
+
x_percent=x_percent,
|
|
2066
|
+
y_percent=y_percent,
|
|
2067
|
+
ref=f"coords_{x}_{y}",
|
|
2068
|
+
text=text
|
|
2069
|
+
)
|
|
2045
2070
|
|
|
2046
2071
|
# 🎯 关键步骤:检查应用是否跳转,如果跳转则自动返回目标应用
|
|
2047
2072
|
app_check = self._check_app_switched()
|
|
@@ -2071,13 +2096,16 @@ class BasicMobileToolsLite:
|
|
|
2071
2096
|
|
|
2072
2097
|
# ==================== 导航操作 ====================
|
|
2073
2098
|
|
|
2074
|
-
async def swipe(self, direction: str, y: Optional[int] = None, y_percent: Optional[float] = None
|
|
2099
|
+
async def swipe(self, direction: str, y: Optional[int] = None, y_percent: Optional[float] = None,
|
|
2100
|
+
distance: Optional[int] = None, distance_percent: Optional[float] = None) -> Dict:
|
|
2075
2101
|
"""滑动屏幕
|
|
2076
2102
|
|
|
2077
2103
|
Args:
|
|
2078
2104
|
direction: 滑动方向 (up/down/left/right)
|
|
2079
2105
|
y: 左右滑动时指定的高度坐标(像素)
|
|
2080
2106
|
y_percent: 左右滑动时指定的高度百分比 (0-100)
|
|
2107
|
+
distance: 横向滑动时指定的滑动距离(像素),仅用于 left/right
|
|
2108
|
+
distance_percent: 横向滑动时指定的滑动距离百分比 (0-100),仅用于 left/right
|
|
2081
2109
|
"""
|
|
2082
2110
|
try:
|
|
2083
2111
|
if self._is_ios():
|
|
@@ -2086,7 +2114,7 @@ class BasicMobileToolsLite:
|
|
|
2086
2114
|
size = ios_client.wda.window_size()
|
|
2087
2115
|
width, height = size[0], size[1]
|
|
2088
2116
|
else:
|
|
2089
|
-
return {"success": False, "
|
|
2117
|
+
return {"success": False, "message": "❌ iOS 客户端未初始化"}
|
|
2090
2118
|
else:
|
|
2091
2119
|
width, height = self.client.u2.window_size()
|
|
2092
2120
|
|
|
@@ -2104,20 +2132,53 @@ class BasicMobileToolsLite:
|
|
|
2104
2132
|
swipe_y = y
|
|
2105
2133
|
else:
|
|
2106
2134
|
swipe_y = center_y
|
|
2135
|
+
|
|
2136
|
+
# 计算横向滑动距离
|
|
2137
|
+
if distance_percent is not None:
|
|
2138
|
+
if not (0 <= distance_percent <= 100):
|
|
2139
|
+
return {"success": False, "message": f"❌ distance_percent 必须在 0-100 之间: {distance_percent}"}
|
|
2140
|
+
swipe_distance = int(width * distance_percent / 100)
|
|
2141
|
+
elif distance is not None:
|
|
2142
|
+
if distance <= 0:
|
|
2143
|
+
return {"success": False, "message": f"❌ distance 必须大于 0: {distance}"}
|
|
2144
|
+
if distance > width:
|
|
2145
|
+
return {"success": False, "message": f"❌ distance 不能超过屏幕宽度 ({width}): {distance}"}
|
|
2146
|
+
swipe_distance = distance
|
|
2147
|
+
else:
|
|
2148
|
+
# 默认滑动距离:屏幕宽度的 60%(从 0.8 到 0.2)
|
|
2149
|
+
swipe_distance = int(width * 0.6)
|
|
2150
|
+
|
|
2151
|
+
# 计算起始和结束位置
|
|
2152
|
+
if direction == 'left':
|
|
2153
|
+
# 从右向左滑动:起始点在右侧,结束点在左侧
|
|
2154
|
+
# 确保起始点不超出屏幕右边界
|
|
2155
|
+
start_x = min(center_x + swipe_distance // 2, width - 10)
|
|
2156
|
+
end_x = start_x - swipe_distance
|
|
2157
|
+
# 确保结束点不超出屏幕左边界
|
|
2158
|
+
if end_x < 10:
|
|
2159
|
+
end_x = 10
|
|
2160
|
+
start_x = min(end_x + swipe_distance, width - 10)
|
|
2161
|
+
else: # right
|
|
2162
|
+
# 从左向右滑动:起始点在左侧,结束点在右侧
|
|
2163
|
+
# 确保起始点不超出屏幕左边界
|
|
2164
|
+
start_x = max(center_x - swipe_distance // 2, 10)
|
|
2165
|
+
end_x = start_x + swipe_distance
|
|
2166
|
+
# 确保结束点不超出屏幕右边界
|
|
2167
|
+
if end_x > width - 10:
|
|
2168
|
+
end_x = width - 10
|
|
2169
|
+
start_x = max(end_x - swipe_distance, 10)
|
|
2170
|
+
|
|
2171
|
+
x1, y1, x2, y2 = start_x, swipe_y, end_x, swipe_y
|
|
2107
2172
|
else:
|
|
2108
2173
|
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]
|
|
2174
|
+
# 纵向滑动保持原有逻辑
|
|
2175
|
+
swipe_map = {
|
|
2176
|
+
'up': (center_x, int(height * 0.8), center_x, int(height * 0.2)),
|
|
2177
|
+
'down': (center_x, int(height * 0.2), center_x, int(height * 0.8)),
|
|
2178
|
+
}
|
|
2179
|
+
if direction not in swipe_map:
|
|
2180
|
+
return {"success": False, "message": f"❌ 不支持的方向: {direction}"}
|
|
2181
|
+
x1, y1, x2, y2 = swipe_map[direction]
|
|
2121
2182
|
|
|
2122
2183
|
if self._is_ios():
|
|
2123
2184
|
ios_client.wda.swipe(x1, y1, x2, y2)
|
|
@@ -2138,10 +2199,21 @@ class BasicMobileToolsLite:
|
|
|
2138
2199
|
# 构建返回消息
|
|
2139
2200
|
msg = f"✅ 滑动成功: {direction}"
|
|
2140
2201
|
if direction in ['left', 'right']:
|
|
2202
|
+
msg_parts = []
|
|
2141
2203
|
if y_percent is not None:
|
|
2142
|
-
|
|
2204
|
+
msg_parts.append(f"高度: {y_percent}% = {swipe_y}px")
|
|
2143
2205
|
elif y is not None:
|
|
2144
|
-
|
|
2206
|
+
msg_parts.append(f"高度: {y}px")
|
|
2207
|
+
|
|
2208
|
+
if distance_percent is not None:
|
|
2209
|
+
msg_parts.append(f"距离: {distance_percent}% = {swipe_distance}px")
|
|
2210
|
+
elif distance is not None:
|
|
2211
|
+
msg_parts.append(f"距离: {distance}px")
|
|
2212
|
+
else:
|
|
2213
|
+
msg_parts.append(f"距离: 默认 {swipe_distance}px")
|
|
2214
|
+
|
|
2215
|
+
if msg_parts:
|
|
2216
|
+
msg += f" ({', '.join(msg_parts)})"
|
|
2145
2217
|
|
|
2146
2218
|
# 如果检测到应用跳转,添加警告和返回结果
|
|
2147
2219
|
if app_check['switched']:
|
|
@@ -2182,22 +2254,22 @@ class BasicMobileToolsLite:
|
|
|
2182
2254
|
ios_client.wda.send_keys('\n')
|
|
2183
2255
|
elif ios_key == 'home':
|
|
2184
2256
|
ios_client.wda.home()
|
|
2185
|
-
return {"success": True}
|
|
2186
|
-
return {"success": False, "
|
|
2257
|
+
return {"success": True, "message": f"✅ 按键成功: {key}"}
|
|
2258
|
+
return {"success": False, "message": f"❌ iOS 不支持: {key}"}
|
|
2187
2259
|
else:
|
|
2188
2260
|
keycode = key_map.get(key.lower())
|
|
2189
2261
|
if keycode:
|
|
2190
2262
|
self.client.u2.shell(f'input keyevent {keycode}')
|
|
2191
2263
|
self._record_key(key)
|
|
2192
|
-
return {"success": True}
|
|
2193
|
-
return {"success": False, "
|
|
2264
|
+
return {"success": True, "message": f"✅ 按键成功: {key}"}
|
|
2265
|
+
return {"success": False, "message": f"❌ 不支持的按键: {key}"}
|
|
2194
2266
|
except Exception as e:
|
|
2195
2267
|
return {"success": False, "message": f"❌ 按键失败: {e}"}
|
|
2196
2268
|
|
|
2197
2269
|
def wait(self, seconds: float) -> Dict:
|
|
2198
2270
|
"""等待指定时间"""
|
|
2199
2271
|
time.sleep(seconds)
|
|
2200
|
-
return {"success": True}
|
|
2272
|
+
return {"success": True, "message": f"✅ 已等待 {seconds} 秒"}
|
|
2201
2273
|
|
|
2202
2274
|
# ==================== 应用管理 ====================
|
|
2203
2275
|
|
|
@@ -2226,7 +2298,10 @@ class BasicMobileToolsLite:
|
|
|
2226
2298
|
|
|
2227
2299
|
self._record_operation('launch_app', package_name=package_name)
|
|
2228
2300
|
|
|
2229
|
-
return {
|
|
2301
|
+
return {
|
|
2302
|
+
"success": True,
|
|
2303
|
+
"message": f"✅ 已启动: {package_name}\n💡 建议等待 2-3 秒让页面加载\n📱 已设置应用状态监测"
|
|
2304
|
+
}
|
|
2230
2305
|
except Exception as e:
|
|
2231
2306
|
return {"success": False, "message": f"❌ 启动失败: {e}"}
|
|
2232
2307
|
|
|
@@ -2239,9 +2314,9 @@ class BasicMobileToolsLite:
|
|
|
2239
2314
|
ios_client.wda.app_terminate(package_name)
|
|
2240
2315
|
else:
|
|
2241
2316
|
self.client.u2.app_stop(package_name)
|
|
2242
|
-
return {"success": True}
|
|
2317
|
+
return {"success": True, "message": f"✅ 已终止: {package_name}"}
|
|
2243
2318
|
except Exception as e:
|
|
2244
|
-
return {"success": False, "
|
|
2319
|
+
return {"success": False, "message": f"❌ 终止失败: {e}"}
|
|
2245
2320
|
|
|
2246
2321
|
def list_apps(self, filter_keyword: str = "") -> Dict:
|
|
2247
2322
|
"""列出已安装应用"""
|
|
@@ -2353,26 +2428,6 @@ class BasicMobileToolsLite:
|
|
|
2353
2428
|
'_shadow', 'shadow_', '_divider', 'divider_', '_line', 'line_'
|
|
2354
2429
|
}
|
|
2355
2430
|
|
|
2356
|
-
# Token 优化:构建精简元素(只返回非空字段)
|
|
2357
|
-
def build_compact_element(resource_id, text, content_desc, bounds, likely_click, class_name):
|
|
2358
|
-
"""只返回有值的字段,节省 token"""
|
|
2359
|
-
item = {}
|
|
2360
|
-
if resource_id:
|
|
2361
|
-
# 精简 resource_id,只保留最后一段
|
|
2362
|
-
item['id'] = resource_id.split('/')[-1] if '/' in resource_id else resource_id
|
|
2363
|
-
if text:
|
|
2364
|
-
item['text'] = text
|
|
2365
|
-
if content_desc:
|
|
2366
|
-
item['desc'] = content_desc
|
|
2367
|
-
if bounds:
|
|
2368
|
-
item['bounds'] = bounds
|
|
2369
|
-
if likely_click:
|
|
2370
|
-
item['click'] = True # 启发式判断可点击
|
|
2371
|
-
# class 精简:只保留关键类型
|
|
2372
|
-
if class_name in ('EditText', 'TextInput', 'Button', 'ImageButton', 'CheckBox', 'Switch'):
|
|
2373
|
-
item['type'] = class_name
|
|
2374
|
-
return item
|
|
2375
|
-
|
|
2376
2431
|
result = []
|
|
2377
2432
|
for elem in elements:
|
|
2378
2433
|
# 获取元素属性
|
|
@@ -2392,11 +2447,14 @@ class BasicMobileToolsLite:
|
|
|
2392
2447
|
|
|
2393
2448
|
# 2. 检查是否是功能控件(直接保留)
|
|
2394
2449
|
if class_name in FUNCTIONAL_WIDGETS:
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2450
|
+
result.append({
|
|
2451
|
+
'resource_id': resource_id,
|
|
2452
|
+
'text': text,
|
|
2453
|
+
'content_desc': content_desc,
|
|
2454
|
+
'bounds': bounds,
|
|
2455
|
+
'clickable': clickable,
|
|
2456
|
+
'class': class_name
|
|
2457
|
+
})
|
|
2400
2458
|
continue
|
|
2401
2459
|
|
|
2402
2460
|
# 3. 检查是否是容器控件
|
|
@@ -2409,10 +2467,14 @@ class BasicMobileToolsLite:
|
|
|
2409
2467
|
# 所有属性都是默认值,过滤掉
|
|
2410
2468
|
continue
|
|
2411
2469
|
# 有业务ID或其他有意义属性,保留
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2470
|
+
result.append({
|
|
2471
|
+
'resource_id': resource_id,
|
|
2472
|
+
'text': text,
|
|
2473
|
+
'content_desc': content_desc,
|
|
2474
|
+
'bounds': bounds,
|
|
2475
|
+
'clickable': clickable,
|
|
2476
|
+
'class': class_name
|
|
2477
|
+
})
|
|
2416
2478
|
continue
|
|
2417
2479
|
|
|
2418
2480
|
# 4. 检查是否是装饰类控件
|
|
@@ -2429,72 +2491,19 @@ class BasicMobileToolsLite:
|
|
|
2429
2491
|
continue
|
|
2430
2492
|
|
|
2431
2493
|
# 6. 其他情况:有意义的元素保留
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
# 仅在用户明确设置 MAX_ELEMENTS_RETURN 时才截断
|
|
2440
|
-
truncated = result[:MAX_ELEMENTS]
|
|
2441
|
-
truncated.append({
|
|
2442
|
-
'_truncated': True,
|
|
2443
|
-
'_total': len(result),
|
|
2444
|
-
'_shown': MAX_ELEMENTS
|
|
2494
|
+
result.append({
|
|
2495
|
+
'resource_id': resource_id,
|
|
2496
|
+
'text': text,
|
|
2497
|
+
'content_desc': content_desc,
|
|
2498
|
+
'bounds': bounds,
|
|
2499
|
+
'clickable': clickable,
|
|
2500
|
+
'class': class_name
|
|
2445
2501
|
})
|
|
2446
|
-
return truncated
|
|
2447
2502
|
|
|
2448
2503
|
return result
|
|
2449
2504
|
except Exception as e:
|
|
2450
2505
|
return [{"error": f"获取元素失败: {e}"}]
|
|
2451
2506
|
|
|
2452
|
-
def _get_page_texts(self, max_count: int = 15) -> List[str]:
|
|
2453
|
-
"""获取页面关键文本列表(用于点击后快速确认页面变化)
|
|
2454
|
-
|
|
2455
|
-
Args:
|
|
2456
|
-
max_count: 最多返回的文本数量
|
|
2457
|
-
|
|
2458
|
-
Returns:
|
|
2459
|
-
页面上的关键文本列表(去重)
|
|
2460
|
-
"""
|
|
2461
|
-
try:
|
|
2462
|
-
if self._is_ios():
|
|
2463
|
-
ios_client = self._get_ios_client()
|
|
2464
|
-
if ios_client and hasattr(ios_client, 'wda'):
|
|
2465
|
-
# iOS: 获取所有 StaticText 的文本
|
|
2466
|
-
elements = ios_client.wda(type='XCUIElementTypeStaticText').find_elements()
|
|
2467
|
-
texts = set()
|
|
2468
|
-
for elem in elements[:50]: # 限制扫描数量
|
|
2469
|
-
try:
|
|
2470
|
-
name = elem.name or elem.label
|
|
2471
|
-
if name and len(name) > 1 and len(name) < 50:
|
|
2472
|
-
texts.add(name)
|
|
2473
|
-
except:
|
|
2474
|
-
pass
|
|
2475
|
-
return list(texts)[:max_count]
|
|
2476
|
-
return []
|
|
2477
|
-
else:
|
|
2478
|
-
# Android: 快速扫描 XML 获取文本
|
|
2479
|
-
xml_string = self.client.u2.dump_hierarchy(compressed=True)
|
|
2480
|
-
import xml.etree.ElementTree as ET
|
|
2481
|
-
root = ET.fromstring(xml_string)
|
|
2482
|
-
|
|
2483
|
-
texts = set()
|
|
2484
|
-
for elem in root.iter():
|
|
2485
|
-
text = elem.get('text', '').strip()
|
|
2486
|
-
desc = elem.get('content-desc', '').strip()
|
|
2487
|
-
# 只收集有意义的文本(长度2-30,非纯数字)
|
|
2488
|
-
for t in [text, desc]:
|
|
2489
|
-
if t and 2 <= len(t) <= 30 and not t.isdigit():
|
|
2490
|
-
texts.add(t)
|
|
2491
|
-
if len(texts) >= max_count * 2: # 收集足够后停止
|
|
2492
|
-
break
|
|
2493
|
-
|
|
2494
|
-
return list(texts)[:max_count]
|
|
2495
|
-
except Exception:
|
|
2496
|
-
return []
|
|
2497
|
-
|
|
2498
2507
|
def _has_business_id(self, resource_id: str) -> bool:
|
|
2499
2508
|
"""
|
|
2500
2509
|
判断resource_id是否是业务相关的ID
|
|
@@ -2526,68 +2535,6 @@ class BasicMobileToolsLite:
|
|
|
2526
2535
|
|
|
2527
2536
|
return True
|
|
2528
2537
|
|
|
2529
|
-
def _is_likely_clickable(self, class_name: str, resource_id: str, text: str,
|
|
2530
|
-
content_desc: str, clickable: bool, bounds: str) -> bool:
|
|
2531
|
-
"""
|
|
2532
|
-
启发式判断元素是否可能可点击
|
|
2533
|
-
|
|
2534
|
-
Android 的 clickable 属性经常不准确,因为:
|
|
2535
|
-
1. 点击事件可能设置在父容器上
|
|
2536
|
-
2. 使用 onTouchListener 而不是 onClick
|
|
2537
|
-
3. RecyclerView item 通过 ItemClickListener 处理
|
|
2538
|
-
|
|
2539
|
-
此方法通过多种规则推断元素的真实可点击性
|
|
2540
|
-
"""
|
|
2541
|
-
# 规则1:clickable=true 肯定可点击
|
|
2542
|
-
if clickable:
|
|
2543
|
-
return True
|
|
2544
|
-
|
|
2545
|
-
# 规则2:特定类型的控件通常可点击
|
|
2546
|
-
TYPICALLY_CLICKABLE = {
|
|
2547
|
-
'Button', 'ImageButton', 'CheckBox', 'RadioButton', 'Switch',
|
|
2548
|
-
'ToggleButton', 'FloatingActionButton', 'Chip', 'TabView',
|
|
2549
|
-
'EditText', 'TextInput', # 输入框可点击获取焦点
|
|
2550
|
-
}
|
|
2551
|
-
if class_name in TYPICALLY_CLICKABLE:
|
|
2552
|
-
return True
|
|
2553
|
-
|
|
2554
|
-
# 规则3:resource_id 包含可点击关键词
|
|
2555
|
-
if resource_id:
|
|
2556
|
-
id_lower = resource_id.lower()
|
|
2557
|
-
CLICK_KEYWORDS = [
|
|
2558
|
-
'btn', 'button', 'click', 'tap', 'submit', 'confirm',
|
|
2559
|
-
'cancel', 'close', 'back', 'next', 'prev', 'more',
|
|
2560
|
-
'action', 'link', 'menu', 'tab', 'item', 'cell',
|
|
2561
|
-
'card', 'avatar', 'icon', 'entry', 'option', 'arrow'
|
|
2562
|
-
]
|
|
2563
|
-
for kw in CLICK_KEYWORDS:
|
|
2564
|
-
if kw in id_lower:
|
|
2565
|
-
return True
|
|
2566
|
-
|
|
2567
|
-
# 规则4:content_desc 包含可点击暗示
|
|
2568
|
-
if content_desc:
|
|
2569
|
-
desc_lower = content_desc.lower()
|
|
2570
|
-
CLICK_HINTS = ['点击', '按钮', '关闭', '返回', '更多', 'click', 'tap', 'button', 'close']
|
|
2571
|
-
for hint in CLICK_HINTS:
|
|
2572
|
-
if hint in desc_lower:
|
|
2573
|
-
return True
|
|
2574
|
-
|
|
2575
|
-
# 规则5:有 resource_id 或 content_desc 的小图标可能可点击
|
|
2576
|
-
# (纯 ImageView 不加判断,误判率太高)
|
|
2577
|
-
if class_name in ('ImageView', 'Image') and (resource_id or content_desc) and bounds:
|
|
2578
|
-
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds)
|
|
2579
|
-
if match:
|
|
2580
|
-
x1, y1, x2, y2 = map(int, match.groups())
|
|
2581
|
-
w, h = x2 - x1, y2 - y1
|
|
2582
|
-
# 小图标(20-100px)更可能是按钮
|
|
2583
|
-
if 20 <= w <= 100 and 20 <= h <= 100:
|
|
2584
|
-
return True
|
|
2585
|
-
|
|
2586
|
-
# 规则6:移除(TextView 误判率太高,只依赖上面的规则)
|
|
2587
|
-
# 如果有 clickable=true 或 ID/desc 中有关键词,前面的规则已经覆盖
|
|
2588
|
-
|
|
2589
|
-
return False
|
|
2590
|
-
|
|
2591
2538
|
def find_close_button(self) -> Dict:
|
|
2592
2539
|
"""智能查找关闭按钮(不点击,只返回位置)
|
|
2593
2540
|
|
|
@@ -2601,7 +2548,7 @@ class BasicMobileToolsLite:
|
|
|
2601
2548
|
import re
|
|
2602
2549
|
|
|
2603
2550
|
if self._is_ios():
|
|
2604
|
-
return {"success": False, "
|
|
2551
|
+
return {"success": False, "message": "iOS 暂不支持,请使用截图+坐标点击"}
|
|
2605
2552
|
|
|
2606
2553
|
# 获取屏幕尺寸
|
|
2607
2554
|
screen_width = self.client.u2.info.get('displayWidth', 720)
|
|
@@ -2612,14 +2559,6 @@ class BasicMobileToolsLite:
|
|
|
2612
2559
|
import xml.etree.ElementTree as ET
|
|
2613
2560
|
root = ET.fromstring(xml_string)
|
|
2614
2561
|
|
|
2615
|
-
# 🔴 先检测是否有弹窗,避免误识别普通页面的按钮
|
|
2616
|
-
popup_bounds, popup_confidence = self._detect_popup_with_confidence(
|
|
2617
|
-
root, screen_width, screen_height
|
|
2618
|
-
)
|
|
2619
|
-
|
|
2620
|
-
if popup_bounds is None or popup_confidence < 0.5:
|
|
2621
|
-
return {"success": True, "popup": False}
|
|
2622
|
-
|
|
2623
2562
|
# 关闭按钮特征
|
|
2624
2563
|
close_texts = ['×', 'X', 'x', '关闭', '取消', 'close', 'Close', '跳过', '知道了', '我知道了']
|
|
2625
2564
|
candidates = []
|
|
@@ -2721,16 +2660,27 @@ class BasicMobileToolsLite:
|
|
|
2721
2660
|
candidates.sort(key=lambda x: x['score'], reverse=True)
|
|
2722
2661
|
best = candidates[0]
|
|
2723
2662
|
|
|
2724
|
-
# Token 优化:只返回最必要的信息
|
|
2725
2663
|
return {
|
|
2726
2664
|
"success": True,
|
|
2727
|
-
"
|
|
2728
|
-
"
|
|
2729
|
-
|
|
2665
|
+
"message": f"✅ 找到可能的关闭按钮",
|
|
2666
|
+
"best_candidate": {
|
|
2667
|
+
"reason": best['reason'],
|
|
2668
|
+
"center": {"x": best['center_x'], "y": best['center_y']},
|
|
2669
|
+
"percent": {"x": best['x_percent'], "y": best['y_percent']},
|
|
2670
|
+
"bounds": best['bounds'],
|
|
2671
|
+
"size": best['size'],
|
|
2672
|
+
"score": best['score']
|
|
2673
|
+
},
|
|
2674
|
+
"click_command": f"mobile_click_by_percent({best['x_percent']}, {best['y_percent']})",
|
|
2675
|
+
"other_candidates": [
|
|
2676
|
+
{"reason": c['reason'], "percent": f"({c['x_percent']}%, {c['y_percent']}%)", "score": c['score']}
|
|
2677
|
+
for c in candidates[1:4]
|
|
2678
|
+
] if len(candidates) > 1 else [],
|
|
2679
|
+
"screen_size": {"width": screen_width, "height": screen_height}
|
|
2730
2680
|
}
|
|
2731
2681
|
|
|
2732
2682
|
except Exception as e:
|
|
2733
|
-
return {"success": False, "
|
|
2683
|
+
return {"success": False, "message": f"❌ 查找关闭按钮失败: {e}"}
|
|
2734
2684
|
|
|
2735
2685
|
def close_popup(self) -> Dict:
|
|
2736
2686
|
"""智能关闭弹窗(改进版)
|
|
@@ -2753,7 +2703,7 @@ class BasicMobileToolsLite:
|
|
|
2753
2703
|
|
|
2754
2704
|
# 获取屏幕尺寸
|
|
2755
2705
|
if self._is_ios():
|
|
2756
|
-
return {"success": False, "
|
|
2706
|
+
return {"success": False, "message": "iOS 暂不支持,请使用截图+坐标点击"}
|
|
2757
2707
|
|
|
2758
2708
|
screen_width = self.client.u2.info.get('displayWidth', 720)
|
|
2759
2709
|
screen_height = self.client.u2.info.get('displayHeight', 1280)
|
|
@@ -2781,11 +2731,6 @@ class BasicMobileToolsLite:
|
|
|
2781
2731
|
# 如果置信度不够高,记录但继续尝试查找关闭按钮
|
|
2782
2732
|
popup_detected = popup_bounds is not None and popup_confidence >= 0.6
|
|
2783
2733
|
|
|
2784
|
-
# 🔴 关键检查:如果没有检测到弹窗区域,直接返回"无弹窗"
|
|
2785
|
-
# 避免误点击普通页面上的"关闭"、"取消"等按钮
|
|
2786
|
-
if not popup_detected:
|
|
2787
|
-
return {"success": True, "popup": False}
|
|
2788
|
-
|
|
2789
2734
|
# ===== 第二步:在弹窗范围内查找关闭按钮 =====
|
|
2790
2735
|
for idx, elem in enumerate(all_elements):
|
|
2791
2736
|
text = elem.attrib.get('text', '')
|
|
@@ -2923,9 +2868,86 @@ class BasicMobileToolsLite:
|
|
|
2923
2868
|
pass
|
|
2924
2869
|
|
|
2925
2870
|
if not close_candidates:
|
|
2871
|
+
# 如果检测到高置信度的弹窗区域,先尝试点击常见的关闭按钮位置
|
|
2926
2872
|
if popup_detected and popup_bounds:
|
|
2927
|
-
|
|
2928
|
-
|
|
2873
|
+
px1, py1, px2, py2 = popup_bounds
|
|
2874
|
+
popup_width = px2 - px1
|
|
2875
|
+
popup_height = py2 - py1
|
|
2876
|
+
|
|
2877
|
+
# 【优化】X按钮有三种常见位置:
|
|
2878
|
+
# 1. 弹窗内靠近顶部边界(内嵌X按钮)- 最常见
|
|
2879
|
+
# 2. 弹窗边界上方(浮动X按钮)
|
|
2880
|
+
# 3. 弹窗正下方(底部关闭按钮)
|
|
2881
|
+
offset_x = max(60, int(popup_width * 0.07)) # 宽度7%
|
|
2882
|
+
offset_y_above = max(35, int(popup_height * 0.025)) # 高度2.5%,在边界之上
|
|
2883
|
+
offset_y_near = max(45, int(popup_height * 0.03)) # 高度3%,紧贴顶边界内侧
|
|
2884
|
+
|
|
2885
|
+
try_positions = [
|
|
2886
|
+
# 【最高优先级】弹窗内紧贴顶部边界
|
|
2887
|
+
(px2 - offset_x, py1 + offset_y_near, "弹窗右上角"),
|
|
2888
|
+
# 弹窗边界上方(浮动X按钮)
|
|
2889
|
+
(px2 - offset_x, py1 - offset_y_above, "弹窗右上浮"),
|
|
2890
|
+
# 弹窗正下方中间(底部关闭按钮)
|
|
2891
|
+
((px1 + px2) // 2, py2 + max(50, int(popup_height * 0.04)), "弹窗下方中间"),
|
|
2892
|
+
# 弹窗正上方中间
|
|
2893
|
+
((px1 + px2) // 2, py1 - 40, "弹窗正上方"),
|
|
2894
|
+
]
|
|
2895
|
+
|
|
2896
|
+
for try_x, try_y, position_name in try_positions:
|
|
2897
|
+
if 0 <= try_x <= screen_width and 0 <= try_y <= screen_height:
|
|
2898
|
+
self.client.u2.click(try_x, try_y)
|
|
2899
|
+
time.sleep(0.3)
|
|
2900
|
+
|
|
2901
|
+
# 🎯 关键步骤:检查应用是否跳转,如果跳转说明弹窗去除失败,需要返回目标应用
|
|
2902
|
+
app_check = self._check_app_switched()
|
|
2903
|
+
return_result = None
|
|
2904
|
+
|
|
2905
|
+
if app_check['switched']:
|
|
2906
|
+
# 应用已跳转,说明弹窗去除失败,尝试返回目标应用
|
|
2907
|
+
return_result = self._return_to_target_app()
|
|
2908
|
+
|
|
2909
|
+
# 尝试后截图,让 AI 判断是否成功
|
|
2910
|
+
screenshot_result = self.take_screenshot("尝试关闭后")
|
|
2911
|
+
|
|
2912
|
+
msg = f"✅ 已尝试点击常见关闭按钮位置"
|
|
2913
|
+
if app_check['switched']:
|
|
2914
|
+
msg += f"\n⚠️ 应用已跳转,说明弹窗去除失败"
|
|
2915
|
+
if return_result:
|
|
2916
|
+
if return_result['success']:
|
|
2917
|
+
msg += f"\n{return_result['message']}"
|
|
2918
|
+
else:
|
|
2919
|
+
msg += f"\n❌ 自动返回失败: {return_result['message']}"
|
|
2920
|
+
|
|
2921
|
+
return {
|
|
2922
|
+
"success": True,
|
|
2923
|
+
"message": msg,
|
|
2924
|
+
"tried_positions": [p[2] for p in try_positions],
|
|
2925
|
+
"screenshot": screenshot_result.get("screenshot_path", ""),
|
|
2926
|
+
"app_check": app_check,
|
|
2927
|
+
"return_to_app": return_result,
|
|
2928
|
+
"tip": "请查看截图确认弹窗是否已关闭。如果还在,可手动分析截图找到关闭按钮位置。"
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
# 没有检测到弹窗区域,截图让 AI 分析
|
|
2932
|
+
screenshot_result = self.take_screenshot(description="页面截图", compress=True)
|
|
2933
|
+
|
|
2934
|
+
return {
|
|
2935
|
+
"success": False,
|
|
2936
|
+
"message": "❌ 未检测到弹窗区域,已截图供 AI 分析",
|
|
2937
|
+
"action_required": "请查看截图找到关闭按钮,调用 mobile_click_at_coords 点击",
|
|
2938
|
+
"screenshot": screenshot_result.get("screenshot_path", ""),
|
|
2939
|
+
"screen_size": {"width": screen_width, "height": screen_height},
|
|
2940
|
+
"image_size": {
|
|
2941
|
+
"width": screenshot_result.get("image_width", screen_width),
|
|
2942
|
+
"height": screenshot_result.get("image_height", screen_height)
|
|
2943
|
+
},
|
|
2944
|
+
"original_size": {
|
|
2945
|
+
"width": screenshot_result.get("original_img_width", screen_width),
|
|
2946
|
+
"height": screenshot_result.get("original_img_height", screen_height)
|
|
2947
|
+
},
|
|
2948
|
+
"search_areas": ["弹窗右上角", "弹窗正上方", "弹窗下方中间", "屏幕右上角"],
|
|
2949
|
+
"time_warning": "⚠️ 截图分析期间弹窗可能自动消失。如果是定时弹窗,建议等待其自动消失。"
|
|
2950
|
+
}
|
|
2929
2951
|
|
|
2930
2952
|
# 按得分排序,取最可能的
|
|
2931
2953
|
close_candidates.sort(key=lambda x: x['score'], reverse=True)
|
|
@@ -2943,22 +2965,62 @@ class BasicMobileToolsLite:
|
|
|
2943
2965
|
# 应用已跳转,说明弹窗去除失败,尝试返回目标应用
|
|
2944
2966
|
return_result = self._return_to_target_app()
|
|
2945
2967
|
|
|
2946
|
-
#
|
|
2947
|
-
self.
|
|
2948
|
-
|
|
2949
|
-
|
|
2968
|
+
# 点击后截图,让 AI 判断是否成功
|
|
2969
|
+
screenshot_result = self.take_screenshot("关闭弹窗后")
|
|
2970
|
+
|
|
2971
|
+
# 记录操作(使用百分比,跨设备兼容)
|
|
2972
|
+
self._record_operation(
|
|
2973
|
+
'click',
|
|
2974
|
+
x=best['center_x'],
|
|
2975
|
+
y=best['center_y'],
|
|
2976
|
+
x_percent=best['x_percent'],
|
|
2977
|
+
y_percent=best['y_percent'],
|
|
2978
|
+
screen_width=screen_width,
|
|
2979
|
+
screen_height=screen_height,
|
|
2980
|
+
ref=f"close_popup_{best['position']}"
|
|
2981
|
+
)
|
|
2950
2982
|
|
|
2951
|
-
#
|
|
2952
|
-
|
|
2983
|
+
# 构建返回消息
|
|
2984
|
+
msg = f"✅ 已点击关闭按钮 ({best['position']}): ({best['center_x']}, {best['center_y']})"
|
|
2953
2985
|
if app_check['switched']:
|
|
2954
|
-
|
|
2986
|
+
msg += f"\n⚠️ 应用已跳转,说明弹窗去除失败"
|
|
2955
2987
|
if return_result:
|
|
2956
|
-
|
|
2988
|
+
if return_result['success']:
|
|
2989
|
+
msg += f"\n{return_result['message']}"
|
|
2990
|
+
else:
|
|
2991
|
+
msg += f"\n❌ 自动返回失败: {return_result['message']}"
|
|
2957
2992
|
|
|
2958
|
-
|
|
2993
|
+
# 返回候选按钮列表,让 AI 看截图判断
|
|
2994
|
+
# 如果弹窗还在,AI 可以选择点击其他候选按钮
|
|
2995
|
+
return {
|
|
2996
|
+
"success": True,
|
|
2997
|
+
"message": msg,
|
|
2998
|
+
"clicked": {
|
|
2999
|
+
"position": best['position'],
|
|
3000
|
+
"match_type": best['match_type'],
|
|
3001
|
+
"coords": (best['center_x'], best['center_y']),
|
|
3002
|
+
"percent": (best['x_percent'], best['y_percent'])
|
|
3003
|
+
},
|
|
3004
|
+
"screenshot": screenshot_result.get("screenshot_path", ""),
|
|
3005
|
+
"popup_detected": popup_detected,
|
|
3006
|
+
"popup_confidence": popup_confidence if popup_bounds else 0,
|
|
3007
|
+
"popup_bounds": f"[{popup_bounds[0]},{popup_bounds[1]}][{popup_bounds[2]},{popup_bounds[3]}]" if popup_detected else None,
|
|
3008
|
+
"app_check": app_check,
|
|
3009
|
+
"return_to_app": return_result,
|
|
3010
|
+
"other_candidates": [
|
|
3011
|
+
{
|
|
3012
|
+
"position": c['position'],
|
|
3013
|
+
"type": c['match_type'],
|
|
3014
|
+
"coords": (c['center_x'], c['center_y']),
|
|
3015
|
+
"percent": (c['x_percent'], c['y_percent'])
|
|
3016
|
+
}
|
|
3017
|
+
for c in close_candidates[1:4] # 返回其他3个候选,AI 可以选择
|
|
3018
|
+
],
|
|
3019
|
+
"tip": "请查看截图判断弹窗是否已关闭。如果弹窗还在,可以尝试点击 other_candidates 中的其他位置"
|
|
3020
|
+
}
|
|
2959
3021
|
|
|
2960
3022
|
except Exception as e:
|
|
2961
|
-
return {"success": False, "
|
|
3023
|
+
return {"success": False, "message": f"❌ 关闭弹窗失败: {e}"}
|
|
2962
3024
|
|
|
2963
3025
|
def _get_position_name(self, rel_x: float, rel_y: float) -> str:
|
|
2964
3026
|
"""根据相对坐标获取位置名称"""
|
|
@@ -3859,28 +3921,10 @@ class BasicMobileToolsLite:
|
|
|
3859
3921
|
try:
|
|
3860
3922
|
import xml.etree.ElementTree as ET
|
|
3861
3923
|
|
|
3862
|
-
# ========== 第
|
|
3924
|
+
# ========== 第1步:控件树查找关闭按钮 ==========
|
|
3863
3925
|
xml_string = self.client.u2.dump_hierarchy(compressed=False)
|
|
3864
3926
|
root = ET.fromstring(xml_string)
|
|
3865
3927
|
|
|
3866
|
-
screen_width = self.client.u2.info.get('displayWidth', 1440)
|
|
3867
|
-
screen_height = self.client.u2.info.get('displayHeight', 3200)
|
|
3868
|
-
|
|
3869
|
-
popup_bounds, popup_confidence = self._detect_popup_with_confidence(
|
|
3870
|
-
root, screen_width, screen_height
|
|
3871
|
-
)
|
|
3872
|
-
|
|
3873
|
-
# 如果没有检测到弹窗,直接返回"无弹窗"
|
|
3874
|
-
if popup_bounds is None or popup_confidence < 0.5:
|
|
3875
|
-
result["success"] = True
|
|
3876
|
-
result["method"] = None
|
|
3877
|
-
result["message"] = "ℹ️ 当前页面未检测到弹窗,无需关闭"
|
|
3878
|
-
result["popup_detected"] = False
|
|
3879
|
-
result["popup_confidence"] = popup_confidence
|
|
3880
|
-
return result
|
|
3881
|
-
|
|
3882
|
-
# ========== 第1步:控件树查找关闭按钮 ==========
|
|
3883
|
-
|
|
3884
3928
|
# 关闭按钮的常见特征
|
|
3885
3929
|
close_keywords = ['关闭', '跳过', '×', 'X', 'x', 'close', 'skip', '取消']
|
|
3886
3930
|
close_content_desc = ['关闭', '跳过', 'close', 'skip', 'dismiss']
|
|
@@ -3959,6 +4003,12 @@ class BasicMobileToolsLite:
|
|
|
3959
4003
|
cx, cy = best['center']
|
|
3960
4004
|
bounds = best['bounds']
|
|
3961
4005
|
|
|
4006
|
+
# 点击前截图(用于自动学习)
|
|
4007
|
+
pre_screenshot = None
|
|
4008
|
+
if auto_learn:
|
|
4009
|
+
pre_result = self.take_screenshot(description="关闭前", compress=False)
|
|
4010
|
+
pre_screenshot = pre_result.get("screenshot_path")
|
|
4011
|
+
|
|
3962
4012
|
# 点击(click_at_coords 内部已包含应用状态检查和自动返回)
|
|
3963
4013
|
click_result = self.click_at_coords(cx, cy)
|
|
3964
4014
|
time.sleep(0.5)
|
|
@@ -3988,11 +4038,17 @@ class BasicMobileToolsLite:
|
|
|
3988
4038
|
result["message"] = msg
|
|
3989
4039
|
result["app_check"] = app_check
|
|
3990
4040
|
result["return_to_app"] = return_result
|
|
3991
|
-
|
|
4041
|
+
|
|
4042
|
+
# 自动学习:检查这个 X 是否已在模板库,不在就添加
|
|
4043
|
+
if auto_learn and pre_screenshot:
|
|
4044
|
+
learn_result = self._auto_learn_template(pre_screenshot, bounds)
|
|
4045
|
+
if learn_result:
|
|
4046
|
+
result["learned_template"] = learn_result
|
|
4047
|
+
result["message"] += f"\n📚 自动学习: {learn_result}"
|
|
3992
4048
|
|
|
3993
4049
|
return result
|
|
3994
4050
|
|
|
3995
|
-
# ========== 第2
|
|
4051
|
+
# ========== 第2步:模板匹配 ==========
|
|
3996
4052
|
screenshot_path = None
|
|
3997
4053
|
try:
|
|
3998
4054
|
from .template_matcher import TemplateMatcher
|
|
@@ -4011,14 +4067,16 @@ class BasicMobileToolsLite:
|
|
|
4011
4067
|
x_pct = best["percent"]["x"]
|
|
4012
4068
|
y_pct = best["percent"]["y"]
|
|
4013
4069
|
|
|
4014
|
-
#
|
|
4070
|
+
# 点击(click_by_percent 内部已包含应用状态检查和自动返回)
|
|
4015
4071
|
click_result = self.click_by_percent(x_pct, y_pct)
|
|
4016
4072
|
time.sleep(0.5)
|
|
4017
4073
|
|
|
4074
|
+
# 🎯 再次检查应用状态(确保弹窗去除没有导致应用跳转)
|
|
4018
4075
|
app_check = self._check_app_switched()
|
|
4019
4076
|
return_result = None
|
|
4020
4077
|
|
|
4021
4078
|
if app_check['switched']:
|
|
4079
|
+
# 应用已跳转,说明弹窗去除失败,尝试返回目标应用
|
|
4022
4080
|
return_result = self._return_to_target_app()
|
|
4023
4081
|
|
|
4024
4082
|
result["success"] = True
|
|
@@ -4029,9 +4087,12 @@ class BasicMobileToolsLite:
|
|
|
4029
4087
|
f" 位置: ({x_pct:.1f}%, {y_pct:.1f}%)"
|
|
4030
4088
|
|
|
4031
4089
|
if app_check['switched']:
|
|
4032
|
-
msg += f"\n⚠️
|
|
4090
|
+
msg += f"\n⚠️ 应用已跳转,说明弹窗去除失败"
|
|
4033
4091
|
if return_result:
|
|
4034
|
-
|
|
4092
|
+
if return_result['success']:
|
|
4093
|
+
msg += f"\n{return_result['message']}"
|
|
4094
|
+
else:
|
|
4095
|
+
msg += f"\n❌ 自动返回失败: {return_result['message']}"
|
|
4035
4096
|
|
|
4036
4097
|
result["message"] = msg
|
|
4037
4098
|
result["app_check"] = app_check
|
|
@@ -4043,12 +4104,17 @@ class BasicMobileToolsLite:
|
|
|
4043
4104
|
except Exception:
|
|
4044
4105
|
pass # 模板匹配失败,继续下一步
|
|
4045
4106
|
|
|
4046
|
-
# ========== 第3
|
|
4107
|
+
# ========== 第3步:返回截图供 AI 分析 ==========
|
|
4108
|
+
if not screenshot_path:
|
|
4109
|
+
screenshot_result = self.take_screenshot(description="需要AI分析", compress=True)
|
|
4110
|
+
|
|
4047
4111
|
result["success"] = False
|
|
4048
|
-
result["fallback"] = "vision"
|
|
4049
4112
|
result["method"] = None
|
|
4050
|
-
result["
|
|
4051
|
-
|
|
4113
|
+
result["message"] = "❌ 控件树和模板匹配都未找到关闭按钮\n" \
|
|
4114
|
+
"📸 已截图,请 AI 分析图片中的 X 按钮位置\n" \
|
|
4115
|
+
"💡 找到后使用 mobile_click_by_percent(x%, y%) 点击"
|
|
4116
|
+
result["screenshot"] = screenshot_result if not screenshot_path else {"screenshot_path": screenshot_path}
|
|
4117
|
+
result["need_ai_analysis"] = True
|
|
4052
4118
|
|
|
4053
4119
|
return result
|
|
4054
4120
|
|