mobile-mcp-ai 2.6.3__py3-none-any.whl → 2.6.4__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 +32 -0
- mobile_mcp/core/basic_tools_lite.py +570 -601
- mobile_mcp/mcp_tools/mcp_server.py +216 -292
- {mobile_mcp_ai-2.6.3.dist-info → mobile_mcp_ai-2.6.4.dist-info}/METADATA +17 -30
- {mobile_mcp_ai-2.6.3.dist-info → mobile_mcp_ai-2.6.4.dist-info}/RECORD +9 -9
- {mobile_mcp_ai-2.6.3.dist-info → mobile_mcp_ai-2.6.4.dist-info}/WHEEL +1 -1
- {mobile_mcp_ai-2.6.3.dist-info/licenses → mobile_mcp_ai-2.6.4.dist-info}/LICENSE +0 -0
- {mobile_mcp_ai-2.6.3.dist-info → mobile_mcp_ai-2.6.4.dist-info}/entry_points.txt +0 -0
- {mobile_mcp_ai-2.6.3.dist-info → mobile_mcp_ai-2.6.4.dist-info}/top_level.txt +0 -0
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
- 核心功能精简
|
|
9
9
|
- 保留 pytest 脚本生成
|
|
10
10
|
- 支持操作历史记录
|
|
11
|
+
- Token 优化模式(省钱)
|
|
11
12
|
"""
|
|
12
13
|
|
|
13
14
|
import asyncio
|
|
@@ -17,6 +18,19 @@ from pathlib import Path
|
|
|
17
18
|
from typing import Dict, List, Optional
|
|
18
19
|
from datetime import datetime
|
|
19
20
|
|
|
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
|
+
|
|
20
34
|
|
|
21
35
|
class BasicMobileToolsLite:
|
|
22
36
|
"""精简版移动端工具"""
|
|
@@ -48,7 +62,7 @@ class BasicMobileToolsLite:
|
|
|
48
62
|
return None
|
|
49
63
|
|
|
50
64
|
def _record_operation(self, action: str, **kwargs):
|
|
51
|
-
"""
|
|
65
|
+
"""记录操作到历史(旧接口,保持兼容)"""
|
|
52
66
|
record = {
|
|
53
67
|
'action': action,
|
|
54
68
|
'timestamp': datetime.now().isoformat(),
|
|
@@ -56,6 +70,81 @@ class BasicMobileToolsLite:
|
|
|
56
70
|
}
|
|
57
71
|
self.operation_history.append(record)
|
|
58
72
|
|
|
73
|
+
def _record_click(self, locator_type: str, locator_value: str,
|
|
74
|
+
x_percent: float = 0, y_percent: float = 0,
|
|
75
|
+
element_desc: str = '', locator_attr: str = ''):
|
|
76
|
+
"""记录点击操作(标准格式)
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
locator_type: 定位类型 'text' | 'id' | 'percent' | 'coords'
|
|
80
|
+
locator_value: 定位值(文本内容、resource-id、或坐标描述)
|
|
81
|
+
x_percent: 百分比 X 坐标(兜底方案)
|
|
82
|
+
y_percent: 百分比 Y 坐标(兜底方案)
|
|
83
|
+
element_desc: 元素描述(用于脚本注释)
|
|
84
|
+
locator_attr: Android 选择器属性 'text'|'textContains'|'description'|'descriptionContains'
|
|
85
|
+
"""
|
|
86
|
+
record = {
|
|
87
|
+
'action': 'click',
|
|
88
|
+
'timestamp': datetime.now().isoformat(),
|
|
89
|
+
'locator_type': locator_type,
|
|
90
|
+
'locator_value': locator_value,
|
|
91
|
+
'locator_attr': locator_attr or locator_type, # 默认与 type 相同
|
|
92
|
+
'x_percent': x_percent,
|
|
93
|
+
'y_percent': y_percent,
|
|
94
|
+
'element_desc': element_desc or locator_value,
|
|
95
|
+
}
|
|
96
|
+
self.operation_history.append(record)
|
|
97
|
+
|
|
98
|
+
def _record_long_press(self, locator_type: str, locator_value: str,
|
|
99
|
+
duration: float = 1.0,
|
|
100
|
+
x_percent: float = 0, y_percent: float = 0,
|
|
101
|
+
element_desc: str = '', locator_attr: str = ''):
|
|
102
|
+
"""记录长按操作(标准格式)"""
|
|
103
|
+
record = {
|
|
104
|
+
'action': 'long_press',
|
|
105
|
+
'timestamp': datetime.now().isoformat(),
|
|
106
|
+
'locator_type': locator_type,
|
|
107
|
+
'locator_value': locator_value,
|
|
108
|
+
'locator_attr': locator_attr or locator_type,
|
|
109
|
+
'duration': duration,
|
|
110
|
+
'x_percent': x_percent,
|
|
111
|
+
'y_percent': y_percent,
|
|
112
|
+
'element_desc': element_desc or locator_value,
|
|
113
|
+
}
|
|
114
|
+
self.operation_history.append(record)
|
|
115
|
+
|
|
116
|
+
def _record_input(self, text: str, locator_type: str = '', locator_value: str = '',
|
|
117
|
+
x_percent: float = 0, y_percent: float = 0):
|
|
118
|
+
"""记录输入操作(标准格式)"""
|
|
119
|
+
record = {
|
|
120
|
+
'action': 'input',
|
|
121
|
+
'timestamp': datetime.now().isoformat(),
|
|
122
|
+
'text': text,
|
|
123
|
+
'locator_type': locator_type,
|
|
124
|
+
'locator_value': locator_value,
|
|
125
|
+
'x_percent': x_percent,
|
|
126
|
+
'y_percent': y_percent,
|
|
127
|
+
}
|
|
128
|
+
self.operation_history.append(record)
|
|
129
|
+
|
|
130
|
+
def _record_swipe(self, direction: str):
|
|
131
|
+
"""记录滑动操作"""
|
|
132
|
+
record = {
|
|
133
|
+
'action': 'swipe',
|
|
134
|
+
'timestamp': datetime.now().isoformat(),
|
|
135
|
+
'direction': direction,
|
|
136
|
+
}
|
|
137
|
+
self.operation_history.append(record)
|
|
138
|
+
|
|
139
|
+
def _record_key(self, key: str):
|
|
140
|
+
"""记录按键操作"""
|
|
141
|
+
record = {
|
|
142
|
+
'action': 'press_key',
|
|
143
|
+
'timestamp': datetime.now().isoformat(),
|
|
144
|
+
'key': key,
|
|
145
|
+
}
|
|
146
|
+
self.operation_history.append(record)
|
|
147
|
+
|
|
59
148
|
def _get_current_package(self) -> Optional[str]:
|
|
60
149
|
"""获取当前前台应用的包名/Bundle ID"""
|
|
61
150
|
try:
|
|
@@ -260,7 +349,7 @@ class BasicMobileToolsLite:
|
|
|
260
349
|
size = ios_client.wda.window_size()
|
|
261
350
|
screen_width, screen_height = size[0], size[1]
|
|
262
351
|
else:
|
|
263
|
-
return {"success": False, "
|
|
352
|
+
return {"success": False, "msg": "iOS未初始化"}
|
|
264
353
|
else:
|
|
265
354
|
self.client.u2.screenshot(str(temp_path))
|
|
266
355
|
info = self.client.u2.info
|
|
@@ -311,22 +400,14 @@ class BasicMobileToolsLite:
|
|
|
311
400
|
|
|
312
401
|
cropped_size = final_path.stat().st_size
|
|
313
402
|
|
|
403
|
+
# 返回结果
|
|
314
404
|
return {
|
|
315
405
|
"success": True,
|
|
316
406
|
"screenshot_path": str(final_path),
|
|
317
|
-
"screen_width": screen_width,
|
|
318
|
-
"screen_height": screen_height,
|
|
319
407
|
"image_width": img.width,
|
|
320
408
|
"image_height": img.height,
|
|
321
409
|
"crop_offset_x": crop_offset_x,
|
|
322
|
-
"crop_offset_y": crop_offset_y
|
|
323
|
-
"file_size": f"{cropped_size/1024:.1f}KB",
|
|
324
|
-
"message": f"🔍 局部截图已保存: {final_path}\n"
|
|
325
|
-
f"📐 裁剪区域: ({crop_offset_x}, {crop_offset_y}) 起,{img.width}x{img.height} 像素\n"
|
|
326
|
-
f"📦 文件大小: {cropped_size/1024:.0f}KB\n"
|
|
327
|
-
f"🎯 【坐标换算】AI 返回坐标 (x, y) 后:\n"
|
|
328
|
-
f" 实际屏幕坐标 = ({crop_offset_x} + x, {crop_offset_y} + y)\n"
|
|
329
|
-
f" 或直接调用 mobile_click_at_coords(x, y, crop_offset_x={crop_offset_x}, crop_offset_y={crop_offset_y})"
|
|
410
|
+
"crop_offset_y": crop_offset_y
|
|
330
411
|
}
|
|
331
412
|
|
|
332
413
|
# ========== 情况2:全屏压缩截图 ==========
|
|
@@ -379,24 +460,14 @@ class BasicMobileToolsLite:
|
|
|
379
460
|
compressed_size = final_path.stat().st_size
|
|
380
461
|
saved_percent = (1 - compressed_size / original_size) * 100
|
|
381
462
|
|
|
463
|
+
# 返回结果
|
|
382
464
|
return {
|
|
383
465
|
"success": True,
|
|
384
466
|
"screenshot_path": str(final_path),
|
|
385
|
-
"
|
|
386
|
-
"
|
|
387
|
-
"original_img_width": original_img_width,
|
|
388
|
-
"original_img_height": original_img_height
|
|
389
|
-
"image_width": image_width, # 压缩后宽度(AI 看到的)
|
|
390
|
-
"image_height": image_height, # 压缩后高度(AI 看到的)
|
|
391
|
-
"original_size": f"{original_size/1024:.1f}KB",
|
|
392
|
-
"compressed_size": f"{compressed_size/1024:.1f}KB",
|
|
393
|
-
"saved_percent": f"{saved_percent:.0f}%",
|
|
394
|
-
"message": f"📸 截图已保存: {final_path}\n"
|
|
395
|
-
f"📐 原始尺寸: {original_img_width}x{original_img_height} → 压缩后: {image_width}x{image_height}\n"
|
|
396
|
-
f"📦 已压缩: {original_size/1024:.0f}KB → {compressed_size/1024:.0f}KB (省 {saved_percent:.0f}%)\n"
|
|
397
|
-
f"⚠️ 【坐标转换】AI 返回坐标后,请传入:\n"
|
|
398
|
-
f" image_width={image_width}, image_height={image_height},\n"
|
|
399
|
-
f" original_img_width={original_img_width}, original_img_height={original_img_height}"
|
|
467
|
+
"image_width": image_width,
|
|
468
|
+
"image_height": image_height,
|
|
469
|
+
"original_img_width": original_img_width,
|
|
470
|
+
"original_img_height": original_img_height
|
|
400
471
|
}
|
|
401
472
|
|
|
402
473
|
# ========== 情况3:全屏不压缩截图 ==========
|
|
@@ -410,21 +481,12 @@ class BasicMobileToolsLite:
|
|
|
410
481
|
final_path = self.screenshot_dir / filename
|
|
411
482
|
temp_path.rename(final_path)
|
|
412
483
|
|
|
413
|
-
#
|
|
484
|
+
# 返回结果(不压缩时尺寸相同)
|
|
414
485
|
return {
|
|
415
486
|
"success": True,
|
|
416
487
|
"screenshot_path": str(final_path),
|
|
417
|
-
"
|
|
418
|
-
"
|
|
419
|
-
"original_img_width": img.width, # 截图实际尺寸
|
|
420
|
-
"original_img_height": img.height,
|
|
421
|
-
"image_width": img.width, # 未压缩,和原图一样
|
|
422
|
-
"image_height": img.height,
|
|
423
|
-
"file_size": f"{original_size/1024:.1f}KB",
|
|
424
|
-
"message": f"📸 截图已保存: {final_path}\n"
|
|
425
|
-
f"📐 截图尺寸: {img.width}x{img.height}\n"
|
|
426
|
-
f"📦 文件大小: {original_size/1024:.0f}KB(未压缩)\n"
|
|
427
|
-
f"💡 未压缩,坐标可直接使用"
|
|
488
|
+
"image_width": img.width,
|
|
489
|
+
"image_height": img.height
|
|
428
490
|
}
|
|
429
491
|
except ImportError:
|
|
430
492
|
# 如果没有 PIL,回退到原始方式(不压缩)
|
|
@@ -432,7 +494,7 @@ class BasicMobileToolsLite:
|
|
|
432
494
|
except Exception as e:
|
|
433
495
|
return {"success": False, "message": f"❌ 截图失败: {e}"}
|
|
434
496
|
|
|
435
|
-
def take_screenshot_with_grid(self, grid_size: int = 100, show_popup_hints: bool =
|
|
497
|
+
def take_screenshot_with_grid(self, grid_size: int = 100, show_popup_hints: bool = False) -> Dict:
|
|
436
498
|
"""截图并添加网格坐标标注(用于精确定位元素)
|
|
437
499
|
|
|
438
500
|
在截图上绘制网格线和坐标刻度,帮助快速定位元素位置。
|
|
@@ -464,7 +526,7 @@ class BasicMobileToolsLite:
|
|
|
464
526
|
size = ios_client.wda.window_size()
|
|
465
527
|
screen_width, screen_height = size[0], size[1]
|
|
466
528
|
else:
|
|
467
|
-
return {"success": False, "
|
|
529
|
+
return {"success": False, "msg": "iOS未初始化"}
|
|
468
530
|
else:
|
|
469
531
|
self.client.u2.screenshot(str(temp_path))
|
|
470
532
|
info = self.client.u2.info
|
|
@@ -578,26 +640,16 @@ class BasicMobileToolsLite:
|
|
|
578
640
|
result = {
|
|
579
641
|
"success": True,
|
|
580
642
|
"screenshot_path": str(final_path),
|
|
581
|
-
"screen_width": screen_width,
|
|
582
|
-
"screen_height": screen_height,
|
|
583
643
|
"image_width": img_width,
|
|
584
644
|
"image_height": img_height,
|
|
585
|
-
"grid_size": grid_size
|
|
586
|
-
"message": f"📸 网格截图已保存: {final_path}\n"
|
|
587
|
-
f"📐 尺寸: {img_width}x{img_height}\n"
|
|
588
|
-
f"📏 网格间距: {grid_size}px"
|
|
645
|
+
"grid_size": grid_size
|
|
589
646
|
}
|
|
590
647
|
|
|
591
648
|
if popup_info:
|
|
592
|
-
result["
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
result["message"] += f"\n💡 可能的关闭按钮位置(绿色圆圈标注):"
|
|
597
|
-
for pos in close_positions:
|
|
598
|
-
result["message"] += f"\n {pos['priority']}. {pos['name']}: ({pos['x']}, {pos['y']})"
|
|
599
|
-
else:
|
|
600
|
-
result["popup_detected"] = False
|
|
649
|
+
result["popup"] = popup_info["bounds"]
|
|
650
|
+
# 只返回前3个最可能的关闭按钮位置
|
|
651
|
+
if close_positions:
|
|
652
|
+
result["close_hints"] = [(p['x'], p['y']) for p in close_positions[:3]]
|
|
601
653
|
|
|
602
654
|
return result
|
|
603
655
|
|
|
@@ -634,7 +686,7 @@ class BasicMobileToolsLite:
|
|
|
634
686
|
size = ios_client.wda.window_size()
|
|
635
687
|
screen_width, screen_height = size[0], size[1]
|
|
636
688
|
else:
|
|
637
|
-
return {"success": False, "
|
|
689
|
+
return {"success": False, "msg": "iOS未初始化"}
|
|
638
690
|
else:
|
|
639
691
|
self.client.u2.screenshot(str(temp_path))
|
|
640
692
|
info = self.client.u2.info
|
|
@@ -743,7 +795,9 @@ class BasicMobileToolsLite:
|
|
|
743
795
|
'index': i + 1,
|
|
744
796
|
'center': (cx, cy),
|
|
745
797
|
'bounds': f"[{x1},{y1}][{x2},{y2}]",
|
|
746
|
-
'desc': elem['desc']
|
|
798
|
+
'desc': elem['desc'],
|
|
799
|
+
'text': elem.get('text', ''),
|
|
800
|
+
'resource_id': elem.get('resource_id', '')
|
|
747
801
|
})
|
|
748
802
|
|
|
749
803
|
# 第3.5步:检测弹窗区域(使用严格的置信度检测,避免误识别普通页面)
|
|
@@ -792,38 +846,15 @@ class BasicMobileToolsLite:
|
|
|
792
846
|
img.save(str(final_path), "JPEG", quality=85)
|
|
793
847
|
temp_path.unlink()
|
|
794
848
|
|
|
795
|
-
#
|
|
796
|
-
elements_text = "\n".join([
|
|
797
|
-
f" [{e['index']}] {e['desc']} → ({e['center'][0]}, {e['center'][1]})"
|
|
798
|
-
for e in som_elements[:15] # 只显示前15个
|
|
799
|
-
])
|
|
800
|
-
if len(som_elements) > 15:
|
|
801
|
-
elements_text += f"\n ... 还有 {len(som_elements) - 15} 个元素"
|
|
802
|
-
|
|
803
|
-
# 构建弹窗提示文字
|
|
804
|
-
hints_text = ""
|
|
805
|
-
if popup_bounds:
|
|
806
|
-
hints_text = f"\n🎯 检测到弹窗区域(蓝色边框)\n"
|
|
807
|
-
hints_text += f" 如需关闭弹窗,请观察图片中的 X 按钮位置\n"
|
|
808
|
-
hints_text += f" 然后使用 mobile_click_by_percent(x%, y%) 点击"
|
|
809
|
-
|
|
849
|
+
# 返回结果(Token 优化:不返回 elements 列表,已存储在 self._som_elements)
|
|
810
850
|
return {
|
|
811
851
|
"success": True,
|
|
812
852
|
"screenshot_path": str(final_path),
|
|
813
853
|
"screen_width": screen_width,
|
|
814
854
|
"screen_height": screen_height,
|
|
815
|
-
"image_width": img_width,
|
|
816
|
-
"image_height": img_height,
|
|
817
855
|
"element_count": len(som_elements),
|
|
818
|
-
"elements": som_elements,
|
|
819
856
|
"popup_detected": popup_bounds is not None,
|
|
820
|
-
"
|
|
821
|
-
"message": f"📸 SoM 截图已保存: {final_path}\n"
|
|
822
|
-
f"🏷️ 已标注 {len(som_elements)} 个可点击元素\n"
|
|
823
|
-
f"📋 元素列表:\n{elements_text}{hints_text}\n\n"
|
|
824
|
-
f"💡 使用方法:\n"
|
|
825
|
-
f" - 点击标注元素:mobile_click_by_som(编号)\n"
|
|
826
|
-
f" - 点击任意位置:mobile_click_by_percent(x%, y%)"
|
|
857
|
+
"hint": "查看截图上的编号,用 click_by_som(编号) 点击"
|
|
827
858
|
}
|
|
828
859
|
|
|
829
860
|
except ImportError:
|
|
@@ -869,14 +900,41 @@ class BasicMobileToolsLite:
|
|
|
869
900
|
ios_client = self._get_ios_client()
|
|
870
901
|
if ios_client and hasattr(ios_client, 'wda'):
|
|
871
902
|
ios_client.wda.click(cx, cy)
|
|
903
|
+
size = ios_client.wda.window_size()
|
|
904
|
+
screen_width, screen_height = size[0], size[1]
|
|
872
905
|
else:
|
|
873
906
|
self.client.u2.click(cx, cy)
|
|
874
|
-
|
|
907
|
+
info = self.client.u2.info
|
|
908
|
+
screen_width = info.get('displayWidth', 0)
|
|
909
|
+
screen_height = info.get('displayHeight', 0)
|
|
910
|
+
|
|
875
911
|
time.sleep(0.3)
|
|
876
912
|
|
|
913
|
+
# 计算百分比坐标用于跨设备兼容
|
|
914
|
+
x_percent = round(cx / screen_width * 100, 1) if screen_width > 0 else 0
|
|
915
|
+
y_percent = round(cy / screen_height * 100, 1) if screen_height > 0 else 0
|
|
916
|
+
|
|
917
|
+
# 使用标准记录格式
|
|
918
|
+
# 优先使用元素的文本/描述信息,这样生成脚本时可以用文本定位
|
|
919
|
+
elem_text = target.get('text', '')
|
|
920
|
+
elem_id = target.get('resource_id', '')
|
|
921
|
+
elem_desc = target.get('desc', '')
|
|
922
|
+
|
|
923
|
+
if elem_text and not elem_text.startswith('['): # 排除类似 "[可点击]" 的描述
|
|
924
|
+
# 有文本,使用文本定位
|
|
925
|
+
self._record_click('text', elem_text, x_percent, y_percent,
|
|
926
|
+
element_desc=f"[{index}]{elem_desc}", locator_attr='text')
|
|
927
|
+
elif elem_id:
|
|
928
|
+
# 有 resource-id,使用 ID 定位
|
|
929
|
+
self._record_click('id', elem_id, x_percent, y_percent,
|
|
930
|
+
element_desc=f"[{index}]{elem_desc}")
|
|
931
|
+
else:
|
|
932
|
+
# 都没有,使用百分比定位
|
|
933
|
+
self._record_click('percent', f"{x_percent}%,{y_percent}%", x_percent, y_percent,
|
|
934
|
+
element_desc=f"[{index}]{elem_desc}")
|
|
935
|
+
|
|
877
936
|
return {
|
|
878
937
|
"success": True,
|
|
879
|
-
"message": f"✅ 已点击 [{index}] {target['desc']} → ({cx}, {cy})\n💡 建议:再次截图确认操作是否成功",
|
|
880
938
|
"clicked": {
|
|
881
939
|
"index": index,
|
|
882
940
|
"desc": target['desc'],
|
|
@@ -910,7 +968,7 @@ class BasicMobileToolsLite:
|
|
|
910
968
|
size = ios_client.wda.window_size()
|
|
911
969
|
width, height = size[0], size[1]
|
|
912
970
|
else:
|
|
913
|
-
return {"success": False, "
|
|
971
|
+
return {"success": False, "msg": "iOS未初始化"}
|
|
914
972
|
else:
|
|
915
973
|
self.client.u2.screenshot(str(screenshot_path))
|
|
916
974
|
info = self.client.u2.info
|
|
@@ -988,7 +1046,7 @@ class BasicMobileToolsLite:
|
|
|
988
1046
|
size = ios_client.wda.window_size()
|
|
989
1047
|
screen_width, screen_height = size[0], size[1]
|
|
990
1048
|
else:
|
|
991
|
-
return {"success": False, "
|
|
1049
|
+
return {"success": False, "msg": "iOS未初始化"}
|
|
992
1050
|
else:
|
|
993
1051
|
info = self.client.u2.info
|
|
994
1052
|
screen_width = info.get('displayWidth', 0)
|
|
@@ -1032,17 +1090,9 @@ class BasicMobileToolsLite:
|
|
|
1032
1090
|
x_percent = round(x / screen_width * 100, 1) if screen_width > 0 else 0
|
|
1033
1091
|
y_percent = round(y / screen_height * 100, 1) if screen_height > 0 else 0
|
|
1034
1092
|
|
|
1035
|
-
#
|
|
1036
|
-
self.
|
|
1037
|
-
|
|
1038
|
-
x=x,
|
|
1039
|
-
y=y,
|
|
1040
|
-
x_percent=x_percent,
|
|
1041
|
-
y_percent=y_percent,
|
|
1042
|
-
screen_width=screen_width,
|
|
1043
|
-
screen_height=screen_height,
|
|
1044
|
-
ref=f"coords_{x}_{y}"
|
|
1045
|
-
)
|
|
1093
|
+
# 使用标准记录格式:坐标点击用百分比作为定位方式(跨分辨率兼容)
|
|
1094
|
+
self._record_click('percent', f"{x_percent}%,{y_percent}%", x_percent, y_percent,
|
|
1095
|
+
element_desc=f"坐标({x},{y})")
|
|
1046
1096
|
|
|
1047
1097
|
# 🎯 关键步骤:检查应用是否跳转,如果跳转则自动返回目标应用
|
|
1048
1098
|
app_check = self._check_app_switched()
|
|
@@ -1111,14 +1161,14 @@ class BasicMobileToolsLite:
|
|
|
1111
1161
|
size = ios_client.wda.window_size()
|
|
1112
1162
|
width, height = size[0], size[1]
|
|
1113
1163
|
else:
|
|
1114
|
-
return {"success": False, "
|
|
1164
|
+
return {"success": False, "msg": "iOS未初始化"}
|
|
1115
1165
|
else:
|
|
1116
1166
|
info = self.client.u2.info
|
|
1117
1167
|
width = info.get('displayWidth', 0)
|
|
1118
1168
|
height = info.get('displayHeight', 0)
|
|
1119
1169
|
|
|
1120
1170
|
if width == 0 or height == 0:
|
|
1121
|
-
return {"success": False, "
|
|
1171
|
+
return {"success": False, "msg": "无法获取屏幕尺寸"}
|
|
1122
1172
|
|
|
1123
1173
|
# 第2步:百分比转像素坐标
|
|
1124
1174
|
# 公式:像素 = 屏幕尺寸 × (百分比 / 100)
|
|
@@ -1133,23 +1183,12 @@ class BasicMobileToolsLite:
|
|
|
1133
1183
|
|
|
1134
1184
|
time.sleep(0.3)
|
|
1135
1185
|
|
|
1136
|
-
# 第4
|
|
1137
|
-
self.
|
|
1138
|
-
|
|
1139
|
-
x=x,
|
|
1140
|
-
y=y,
|
|
1141
|
-
x_percent=x_percent,
|
|
1142
|
-
y_percent=y_percent,
|
|
1143
|
-
screen_width=width,
|
|
1144
|
-
screen_height=height,
|
|
1145
|
-
ref=f"percent_{x_percent}_{y_percent}"
|
|
1146
|
-
)
|
|
1186
|
+
# 第4步:使用标准记录格式
|
|
1187
|
+
self._record_click('percent', f"{x_percent}%,{y_percent}%", x_percent, y_percent,
|
|
1188
|
+
element_desc=f"百分比({x_percent}%,{y_percent}%)")
|
|
1147
1189
|
|
|
1148
1190
|
return {
|
|
1149
1191
|
"success": True,
|
|
1150
|
-
"message": f"✅ 百分比点击成功: ({x_percent}%, {y_percent}%) → 像素({x}, {y})",
|
|
1151
|
-
"screen_size": {"width": width, "height": height},
|
|
1152
|
-
"percent": {"x": x_percent, "y": y_percent},
|
|
1153
1192
|
"pixel": {"x": x, "y": y}
|
|
1154
1193
|
}
|
|
1155
1194
|
except Exception as e:
|
|
@@ -1175,10 +1214,16 @@ class BasicMobileToolsLite:
|
|
|
1175
1214
|
if elem.exists:
|
|
1176
1215
|
elem.click()
|
|
1177
1216
|
time.sleep(0.3)
|
|
1178
|
-
self.
|
|
1179
|
-
return {"success": True
|
|
1180
|
-
|
|
1217
|
+
self._record_click('text', text, element_desc=text, locator_attr='text')
|
|
1218
|
+
return {"success": True}
|
|
1219
|
+
# 控件树找不到,提示用视觉识别
|
|
1220
|
+
return {"success": False, "fallback": "vision", "msg": f"未找到'{text}',用截图点击"}
|
|
1221
|
+
else:
|
|
1222
|
+
return {"success": False, "msg": "iOS未初始化"}
|
|
1181
1223
|
else:
|
|
1224
|
+
# 获取屏幕尺寸用于计算百分比
|
|
1225
|
+
screen_width, screen_height = self.client.u2.window_size()
|
|
1226
|
+
|
|
1182
1227
|
# 🔍 先查 XML 树,找到元素及其属性
|
|
1183
1228
|
found_elem = self._find_element_in_tree(text, position=position)
|
|
1184
1229
|
|
|
@@ -1187,15 +1232,23 @@ class BasicMobileToolsLite:
|
|
|
1187
1232
|
attr_value = found_elem['attr_value']
|
|
1188
1233
|
bounds = found_elem.get('bounds')
|
|
1189
1234
|
|
|
1190
|
-
#
|
|
1235
|
+
# 计算百分比坐标作为兜底
|
|
1236
|
+
x_pct, y_pct = 0, 0
|
|
1237
|
+
if bounds:
|
|
1238
|
+
cx = (bounds[0] + bounds[2]) // 2
|
|
1239
|
+
cy = (bounds[1] + bounds[3]) // 2
|
|
1240
|
+
x_pct = round(cx / screen_width * 100, 1)
|
|
1241
|
+
y_pct = round(cy / screen_height * 100, 1)
|
|
1242
|
+
|
|
1243
|
+
# 如果有位置参数,直接使用坐标点击
|
|
1191
1244
|
if position and bounds:
|
|
1192
1245
|
x = (bounds[0] + bounds[2]) // 2
|
|
1193
1246
|
y = (bounds[1] + bounds[3]) // 2
|
|
1194
1247
|
self.client.u2.click(x, y)
|
|
1195
1248
|
time.sleep(0.3)
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
return {"success": True
|
|
1249
|
+
self._record_click('text', attr_value, x_pct, y_pct,
|
|
1250
|
+
element_desc=f"{text}({position})", locator_attr=attr_type)
|
|
1251
|
+
return {"success": True}
|
|
1199
1252
|
|
|
1200
1253
|
# 没有位置参数时,使用选择器定位
|
|
1201
1254
|
if attr_type == 'text':
|
|
@@ -1212,23 +1265,24 @@ class BasicMobileToolsLite:
|
|
|
1212
1265
|
if elem and elem.exists(timeout=1):
|
|
1213
1266
|
elem.click()
|
|
1214
1267
|
time.sleep(0.3)
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
return {"success": True
|
|
1268
|
+
self._record_click('text', attr_value, x_pct, y_pct,
|
|
1269
|
+
element_desc=text, locator_attr=attr_type)
|
|
1270
|
+
return {"success": True}
|
|
1218
1271
|
|
|
1219
|
-
#
|
|
1272
|
+
# 选择器失败,用坐标兜底
|
|
1220
1273
|
if bounds:
|
|
1221
1274
|
x = (bounds[0] + bounds[2]) // 2
|
|
1222
1275
|
y = (bounds[1] + bounds[3]) // 2
|
|
1223
1276
|
self.client.u2.click(x, y)
|
|
1224
1277
|
time.sleep(0.3)
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
return {"success": True
|
|
1278
|
+
self._record_click('percent', f"{x_pct}%,{y_pct}%", x_pct, y_pct,
|
|
1279
|
+
element_desc=text)
|
|
1280
|
+
return {"success": True}
|
|
1228
1281
|
|
|
1229
|
-
|
|
1282
|
+
# 控件树找不到,提示用视觉识别
|
|
1283
|
+
return {"success": False, "fallback": "vision", "msg": f"未找到'{text}',用截图点击"}
|
|
1230
1284
|
except Exception as e:
|
|
1231
|
-
return {"success": False, "
|
|
1285
|
+
return {"success": False, "msg": str(e)}
|
|
1232
1286
|
|
|
1233
1287
|
def _find_element_in_tree(self, text: str, position: Optional[str] = None) -> Optional[Dict]:
|
|
1234
1288
|
"""在 XML 树中查找包含指定文本的元素,优先返回可点击的元素
|
|
@@ -1367,15 +1421,8 @@ class BasicMobileToolsLite:
|
|
|
1367
1421
|
return None
|
|
1368
1422
|
|
|
1369
1423
|
def click_by_id(self, resource_id: str, index: int = 0) -> Dict:
|
|
1370
|
-
"""通过 resource-id
|
|
1371
|
-
|
|
1372
|
-
Args:
|
|
1373
|
-
resource_id: 元素的 resource-id
|
|
1374
|
-
index: 第几个元素(从 0 开始),默认 0 表示第一个
|
|
1375
|
-
"""
|
|
1424
|
+
"""通过 resource-id 点击"""
|
|
1376
1425
|
try:
|
|
1377
|
-
index_desc = f"[{index}]" if index > 0 else ""
|
|
1378
|
-
|
|
1379
1426
|
if self._is_ios():
|
|
1380
1427
|
ios_client = self._get_ios_client()
|
|
1381
1428
|
if ios_client and hasattr(ios_client, 'wda'):
|
|
@@ -1383,31 +1430,31 @@ class BasicMobileToolsLite:
|
|
|
1383
1430
|
if not elem.exists:
|
|
1384
1431
|
elem = ios_client.wda(name=resource_id)
|
|
1385
1432
|
if elem.exists:
|
|
1386
|
-
# 获取所有匹配的元素
|
|
1387
1433
|
elements = elem.find_elements()
|
|
1388
1434
|
if index < len(elements):
|
|
1389
1435
|
elements[index].click()
|
|
1390
1436
|
time.sleep(0.3)
|
|
1391
|
-
self.
|
|
1392
|
-
return {"success": True
|
|
1437
|
+
self._record_click('id', resource_id, element_desc=resource_id)
|
|
1438
|
+
return {"success": True}
|
|
1393
1439
|
else:
|
|
1394
|
-
return {"success": False, "
|
|
1395
|
-
return {"success": False, "
|
|
1440
|
+
return {"success": False, "msg": f"索引{index}超出范围(共{len(elements)}个)"}
|
|
1441
|
+
return {"success": False, "fallback": "vision", "msg": f"未找到ID'{resource_id}'"}
|
|
1442
|
+
else:
|
|
1443
|
+
return {"success": False, "msg": "iOS未初始化"}
|
|
1396
1444
|
else:
|
|
1397
1445
|
elem = self.client.u2(resourceId=resource_id)
|
|
1398
1446
|
if elem.exists(timeout=0.5):
|
|
1399
|
-
# 获取匹配元素数量
|
|
1400
1447
|
count = elem.count
|
|
1401
1448
|
if index < count:
|
|
1402
1449
|
elem[index].click()
|
|
1403
1450
|
time.sleep(0.3)
|
|
1404
|
-
self.
|
|
1405
|
-
return {"success": True
|
|
1451
|
+
self._record_click('id', resource_id, element_desc=resource_id)
|
|
1452
|
+
return {"success": True}
|
|
1406
1453
|
else:
|
|
1407
|
-
return {"success": False, "
|
|
1408
|
-
return {"success": False, "
|
|
1454
|
+
return {"success": False, "msg": f"索引{index}超出范围(共{count}个)"}
|
|
1455
|
+
return {"success": False, "fallback": "vision", "msg": f"未找到ID'{resource_id}'"}
|
|
1409
1456
|
except Exception as e:
|
|
1410
|
-
return {"success": False, "
|
|
1457
|
+
return {"success": False, "msg": str(e)}
|
|
1411
1458
|
|
|
1412
1459
|
# ==================== 长按操作 ====================
|
|
1413
1460
|
|
|
@@ -1441,7 +1488,7 @@ class BasicMobileToolsLite:
|
|
|
1441
1488
|
size = ios_client.wda.window_size()
|
|
1442
1489
|
screen_width, screen_height = size[0], size[1]
|
|
1443
1490
|
else:
|
|
1444
|
-
return {"success": False, "
|
|
1491
|
+
return {"success": False, "msg": "iOS未初始化"}
|
|
1445
1492
|
else:
|
|
1446
1493
|
info = self.client.u2.info
|
|
1447
1494
|
screen_width = info.get('displayWidth', 0)
|
|
@@ -1488,38 +1535,17 @@ class BasicMobileToolsLite:
|
|
|
1488
1535
|
x_percent = round(x / screen_width * 100, 1) if screen_width > 0 else 0
|
|
1489
1536
|
y_percent = round(y / screen_height * 100, 1) if screen_height > 0 else 0
|
|
1490
1537
|
|
|
1491
|
-
#
|
|
1492
|
-
self.
|
|
1493
|
-
|
|
1494
|
-
x=x,
|
|
1495
|
-
y=y,
|
|
1496
|
-
x_percent=x_percent,
|
|
1497
|
-
y_percent=y_percent,
|
|
1498
|
-
duration=duration,
|
|
1499
|
-
screen_width=screen_width,
|
|
1500
|
-
screen_height=screen_height,
|
|
1501
|
-
ref=f"coords_{x}_{y}"
|
|
1502
|
-
)
|
|
1538
|
+
# 使用标准记录格式
|
|
1539
|
+
self._record_long_press('percent', f"{x_percent}%,{y_percent}%", duration,
|
|
1540
|
+
x_percent, y_percent, element_desc=f"坐标({x},{y})")
|
|
1503
1541
|
|
|
1504
1542
|
if converted:
|
|
1505
1543
|
if conversion_type == "crop_offset":
|
|
1506
|
-
return {
|
|
1507
|
-
"success": True,
|
|
1508
|
-
"message": f"✅ 长按成功: ({x}, {y}) 持续 {duration}s\n"
|
|
1509
|
-
f" 🔍 局部截图坐标转换: ({original_x},{original_y}) + 偏移({crop_offset_x},{crop_offset_y}) → ({x},{y})"
|
|
1510
|
-
}
|
|
1544
|
+
return {"success": True}
|
|
1511
1545
|
else:
|
|
1512
|
-
return {
|
|
1513
|
-
"success": True,
|
|
1514
|
-
"message": f"✅ 长按成功: ({x}, {y}) 持续 {duration}s\n"
|
|
1515
|
-
f" 📐 坐标已转换: ({original_x},{original_y}) → ({x},{y})\n"
|
|
1516
|
-
f" 🖼️ 图片尺寸: {image_width}x{image_height} → 屏幕: {screen_width}x{screen_height}"
|
|
1517
|
-
}
|
|
1546
|
+
return {"success": True}
|
|
1518
1547
|
else:
|
|
1519
|
-
return {
|
|
1520
|
-
"success": True,
|
|
1521
|
-
"message": f"✅ 长按成功: ({x}, {y}) 持续 {duration}s [相对位置: {x_percent}%, {y_percent}%]"
|
|
1522
|
-
}
|
|
1548
|
+
return {"success": True}
|
|
1523
1549
|
except Exception as e:
|
|
1524
1550
|
return {"success": False, "message": f"❌ 长按失败: {e}"}
|
|
1525
1551
|
|
|
@@ -1548,14 +1574,14 @@ class BasicMobileToolsLite:
|
|
|
1548
1574
|
size = ios_client.wda.window_size()
|
|
1549
1575
|
width, height = size[0], size[1]
|
|
1550
1576
|
else:
|
|
1551
|
-
return {"success": False, "
|
|
1577
|
+
return {"success": False, "msg": "iOS未初始化"}
|
|
1552
1578
|
else:
|
|
1553
1579
|
info = self.client.u2.info
|
|
1554
1580
|
width = info.get('displayWidth', 0)
|
|
1555
1581
|
height = info.get('displayHeight', 0)
|
|
1556
1582
|
|
|
1557
1583
|
if width == 0 or height == 0:
|
|
1558
|
-
return {"success": False, "
|
|
1584
|
+
return {"success": False, "msg": "无法获取屏幕尺寸"}
|
|
1559
1585
|
|
|
1560
1586
|
# 第2步:百分比转像素坐标
|
|
1561
1587
|
x = int(width * x_percent / 100)
|
|
@@ -1573,26 +1599,11 @@ class BasicMobileToolsLite:
|
|
|
1573
1599
|
|
|
1574
1600
|
time.sleep(0.3)
|
|
1575
1601
|
|
|
1576
|
-
# 第4
|
|
1577
|
-
self.
|
|
1578
|
-
|
|
1579
|
-
x=x,
|
|
1580
|
-
y=y,
|
|
1581
|
-
x_percent=x_percent,
|
|
1582
|
-
y_percent=y_percent,
|
|
1583
|
-
duration=duration,
|
|
1584
|
-
screen_width=width,
|
|
1585
|
-
screen_height=height,
|
|
1586
|
-
ref=f"percent_{x_percent}_{y_percent}"
|
|
1587
|
-
)
|
|
1602
|
+
# 第4步:使用标准记录格式
|
|
1603
|
+
self._record_long_press('percent', f"{x_percent}%,{y_percent}%", duration,
|
|
1604
|
+
x_percent, y_percent, element_desc=f"百分比({x_percent}%,{y_percent}%)")
|
|
1588
1605
|
|
|
1589
|
-
return {
|
|
1590
|
-
"success": True,
|
|
1591
|
-
"message": f"✅ 百分比长按成功: ({x_percent}%, {y_percent}%) → 像素({x}, {y}) 持续 {duration}s",
|
|
1592
|
-
"screen_size": {"width": width, "height": height},
|
|
1593
|
-
"percent": {"x": x_percent, "y": y_percent},
|
|
1594
|
-
"pixel": {"x": x, "y": y},
|
|
1595
|
-
"duration": duration
|
|
1606
|
+
return {"success": True
|
|
1596
1607
|
}
|
|
1597
1608
|
except Exception as e:
|
|
1598
1609
|
return {"success": False, "message": f"❌ 百分比长按失败: {e}"}
|
|
@@ -1621,10 +1632,13 @@ class BasicMobileToolsLite:
|
|
|
1621
1632
|
else:
|
|
1622
1633
|
ios_client.wda.swipe(x, y, x, y, duration=duration)
|
|
1623
1634
|
time.sleep(0.3)
|
|
1624
|
-
self.
|
|
1625
|
-
return {"success": True
|
|
1626
|
-
return {"success": False, "
|
|
1635
|
+
self._record_long_press('text', text, duration, element_desc=text, locator_attr='text')
|
|
1636
|
+
return {"success": True}
|
|
1637
|
+
return {"success": False, "msg": f"未找到'{text}'"}
|
|
1627
1638
|
else:
|
|
1639
|
+
# 获取屏幕尺寸用于计算百分比
|
|
1640
|
+
screen_width, screen_height = self.client.u2.window_size()
|
|
1641
|
+
|
|
1628
1642
|
# 先查 XML 树,找到元素
|
|
1629
1643
|
found_elem = self._find_element_in_tree(text)
|
|
1630
1644
|
|
|
@@ -1633,6 +1647,14 @@ class BasicMobileToolsLite:
|
|
|
1633
1647
|
attr_value = found_elem['attr_value']
|
|
1634
1648
|
bounds = found_elem.get('bounds')
|
|
1635
1649
|
|
|
1650
|
+
# 计算百分比坐标作为兜底
|
|
1651
|
+
x_pct, y_pct = 0, 0
|
|
1652
|
+
if bounds:
|
|
1653
|
+
cx = (bounds[0] + bounds[2]) // 2
|
|
1654
|
+
cy = (bounds[1] + bounds[3]) // 2
|
|
1655
|
+
x_pct = round(cx / screen_width * 100, 1)
|
|
1656
|
+
y_pct = round(cy / screen_height * 100, 1)
|
|
1657
|
+
|
|
1636
1658
|
# 根据找到的属性类型,使用对应的选择器
|
|
1637
1659
|
if attr_type == 'text':
|
|
1638
1660
|
elem = self.client.u2(text=attr_value)
|
|
@@ -1648,8 +1670,9 @@ class BasicMobileToolsLite:
|
|
|
1648
1670
|
if elem and elem.exists(timeout=1):
|
|
1649
1671
|
elem.long_click(duration=duration)
|
|
1650
1672
|
time.sleep(0.3)
|
|
1651
|
-
self.
|
|
1652
|
-
|
|
1673
|
+
self._record_long_press('text', attr_value, duration, x_pct, y_pct,
|
|
1674
|
+
element_desc=text, locator_attr=attr_type)
|
|
1675
|
+
return {"success": True}
|
|
1653
1676
|
|
|
1654
1677
|
# 如果选择器失败,用坐标兜底
|
|
1655
1678
|
if bounds:
|
|
@@ -1657,10 +1680,11 @@ class BasicMobileToolsLite:
|
|
|
1657
1680
|
y = (bounds[1] + bounds[3]) // 2
|
|
1658
1681
|
self.client.u2.long_click(x, y, duration=duration)
|
|
1659
1682
|
time.sleep(0.3)
|
|
1660
|
-
self.
|
|
1661
|
-
|
|
1683
|
+
self._record_long_press('percent', f"{x_pct}%,{y_pct}%", duration, x_pct, y_pct,
|
|
1684
|
+
element_desc=text)
|
|
1685
|
+
return {"success": True}
|
|
1662
1686
|
|
|
1663
|
-
return {"success": False, "
|
|
1687
|
+
return {"success": False, "msg": f"未找到'{text}'"}
|
|
1664
1688
|
except Exception as e:
|
|
1665
1689
|
return {"success": False, "message": f"❌ 长按失败: {e}"}
|
|
1666
1690
|
|
|
@@ -1687,17 +1711,17 @@ class BasicMobileToolsLite:
|
|
|
1687
1711
|
else:
|
|
1688
1712
|
ios_client.wda.swipe(x, y, x, y, duration=duration)
|
|
1689
1713
|
time.sleep(0.3)
|
|
1690
|
-
self.
|
|
1691
|
-
return {"success": True
|
|
1692
|
-
return {"success": False, "
|
|
1714
|
+
self._record_long_press('id', resource_id, duration, element_desc=resource_id)
|
|
1715
|
+
return {"success": True}
|
|
1716
|
+
return {"success": False, "msg": f"未找到'{resource_id}'"}
|
|
1693
1717
|
else:
|
|
1694
1718
|
elem = self.client.u2(resourceId=resource_id)
|
|
1695
1719
|
if elem.exists(timeout=0.5):
|
|
1696
1720
|
elem.long_click(duration=duration)
|
|
1697
1721
|
time.sleep(0.3)
|
|
1698
|
-
self.
|
|
1722
|
+
self._record_long_press('id', resource_id, duration, element_desc=resource_id)
|
|
1699
1723
|
return {"success": True, "message": f"✅ 长按成功: {resource_id} 持续 {duration}s"}
|
|
1700
|
-
return {"success": False, "
|
|
1724
|
+
return {"success": False, "msg": f"未找到'{resource_id}'"}
|
|
1701
1725
|
except Exception as e:
|
|
1702
1726
|
return {"success": False, "message": f"❌ 长按失败: {e}"}
|
|
1703
1727
|
|
|
@@ -1722,7 +1746,7 @@ class BasicMobileToolsLite:
|
|
|
1722
1746
|
if elem.exists:
|
|
1723
1747
|
elem.set_text(text)
|
|
1724
1748
|
time.sleep(0.3)
|
|
1725
|
-
self.
|
|
1749
|
+
self._record_input(text, 'id', resource_id)
|
|
1726
1750
|
|
|
1727
1751
|
# 🎯 关键步骤:检查应用是否跳转,如果跳转则自动返回目标应用
|
|
1728
1752
|
app_check = self._check_app_switched()
|
|
@@ -1757,7 +1781,7 @@ class BasicMobileToolsLite:
|
|
|
1757
1781
|
if count == 1:
|
|
1758
1782
|
elements.set_text(text)
|
|
1759
1783
|
time.sleep(0.3)
|
|
1760
|
-
self.
|
|
1784
|
+
self._record_input(text, 'id', resource_id)
|
|
1761
1785
|
|
|
1762
1786
|
# 🎯 关键步骤:检查应用是否跳转,如果跳转则自动返回目标应用
|
|
1763
1787
|
app_check = self._check_app_switched()
|
|
@@ -1791,7 +1815,7 @@ class BasicMobileToolsLite:
|
|
|
1791
1815
|
if info.get('editable') or info.get('focusable'):
|
|
1792
1816
|
elem.set_text(text)
|
|
1793
1817
|
time.sleep(0.3)
|
|
1794
|
-
self.
|
|
1818
|
+
self._record_input(text, 'id', resource_id)
|
|
1795
1819
|
|
|
1796
1820
|
# 🎯 关键步骤:检查应用是否跳转,如果跳转则自动返回目标应用
|
|
1797
1821
|
app_check = self._check_app_switched()
|
|
@@ -1819,7 +1843,7 @@ class BasicMobileToolsLite:
|
|
|
1819
1843
|
# 没找到可编辑的,用第一个
|
|
1820
1844
|
elements[0].set_text(text)
|
|
1821
1845
|
time.sleep(0.3)
|
|
1822
|
-
self.
|
|
1846
|
+
self._record_input(text, 'id', resource_id)
|
|
1823
1847
|
|
|
1824
1848
|
# 🎯 关键步骤:检查应用是否跳转,如果跳转则自动返回目标应用
|
|
1825
1849
|
app_check = self._check_app_switched()
|
|
@@ -1850,7 +1874,7 @@ class BasicMobileToolsLite:
|
|
|
1850
1874
|
if et_count == 1:
|
|
1851
1875
|
edit_texts.set_text(text)
|
|
1852
1876
|
time.sleep(0.3)
|
|
1853
|
-
self.
|
|
1877
|
+
self._record_input(text, 'class', 'EditText')
|
|
1854
1878
|
|
|
1855
1879
|
# 🎯 关键步骤:检查应用是否跳转,如果跳转则自动返回目标应用
|
|
1856
1880
|
app_check = self._check_app_switched()
|
|
@@ -1890,7 +1914,7 @@ class BasicMobileToolsLite:
|
|
|
1890
1914
|
if best_elem:
|
|
1891
1915
|
best_elem.set_text(text)
|
|
1892
1916
|
time.sleep(0.3)
|
|
1893
|
-
self.
|
|
1917
|
+
self._record_input(text, 'class', 'EditText')
|
|
1894
1918
|
|
|
1895
1919
|
# 🎯 关键步骤:检查应用是否跳转,如果跳转则自动返回目标应用
|
|
1896
1920
|
app_check = self._check_app_switched()
|
|
@@ -1954,15 +1978,8 @@ class BasicMobileToolsLite:
|
|
|
1954
1978
|
x_percent = round(x / screen_width * 100, 1) if screen_width > 0 else 0
|
|
1955
1979
|
y_percent = round(y / screen_height * 100, 1) if screen_height > 0 else 0
|
|
1956
1980
|
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
x=x,
|
|
1960
|
-
y=y,
|
|
1961
|
-
x_percent=x_percent,
|
|
1962
|
-
y_percent=y_percent,
|
|
1963
|
-
ref=f"coords_{x}_{y}",
|
|
1964
|
-
text=text
|
|
1965
|
-
)
|
|
1981
|
+
# 使用标准记录格式
|
|
1982
|
+
self._record_input(text, 'percent', f"{x_percent}%,{y_percent}%", x_percent, y_percent)
|
|
1966
1983
|
|
|
1967
1984
|
# 🎯 关键步骤:检查应用是否跳转,如果跳转则自动返回目标应用
|
|
1968
1985
|
app_check = self._check_app_switched()
|
|
@@ -2007,7 +2024,7 @@ class BasicMobileToolsLite:
|
|
|
2007
2024
|
size = ios_client.wda.window_size()
|
|
2008
2025
|
width, height = size[0], size[1]
|
|
2009
2026
|
else:
|
|
2010
|
-
return {"success": False, "
|
|
2027
|
+
return {"success": False, "msg": "iOS未初始化"}
|
|
2011
2028
|
else:
|
|
2012
2029
|
width, height = self.client.u2.window_size()
|
|
2013
2030
|
|
|
@@ -2045,13 +2062,8 @@ class BasicMobileToolsLite:
|
|
|
2045
2062
|
else:
|
|
2046
2063
|
self.client.u2.swipe(x1, y1, x2, y2, duration=0.5)
|
|
2047
2064
|
|
|
2048
|
-
#
|
|
2049
|
-
|
|
2050
|
-
if y is not None:
|
|
2051
|
-
record_info['y'] = y
|
|
2052
|
-
if y_percent is not None:
|
|
2053
|
-
record_info['y_percent'] = y_percent
|
|
2054
|
-
self._record_operation('swipe', **record_info)
|
|
2065
|
+
# 使用标准记录格式
|
|
2066
|
+
self._record_swipe(direction)
|
|
2055
2067
|
|
|
2056
2068
|
# 🎯 关键步骤:检查应用是否跳转,如果跳转则自动返回目标应用
|
|
2057
2069
|
app_check = self._check_app_switched()
|
|
@@ -2108,22 +2120,22 @@ class BasicMobileToolsLite:
|
|
|
2108
2120
|
ios_client.wda.send_keys('\n')
|
|
2109
2121
|
elif ios_key == 'home':
|
|
2110
2122
|
ios_client.wda.home()
|
|
2111
|
-
return {"success": True
|
|
2112
|
-
return {"success": False, "
|
|
2123
|
+
return {"success": True}
|
|
2124
|
+
return {"success": False, "msg": f"iOS不支持{key}"}
|
|
2113
2125
|
else:
|
|
2114
2126
|
keycode = key_map.get(key.lower())
|
|
2115
2127
|
if keycode:
|
|
2116
2128
|
self.client.u2.shell(f'input keyevent {keycode}')
|
|
2117
|
-
self.
|
|
2118
|
-
return {"success": True
|
|
2119
|
-
return {"success": False, "
|
|
2129
|
+
self._record_key(key)
|
|
2130
|
+
return {"success": True}
|
|
2131
|
+
return {"success": False, "msg": f"不支持按键{key}"}
|
|
2120
2132
|
except Exception as e:
|
|
2121
2133
|
return {"success": False, "message": f"❌ 按键失败: {e}"}
|
|
2122
2134
|
|
|
2123
2135
|
def wait(self, seconds: float) -> Dict:
|
|
2124
2136
|
"""等待指定时间"""
|
|
2125
2137
|
time.sleep(seconds)
|
|
2126
|
-
return {"success": True
|
|
2138
|
+
return {"success": True}
|
|
2127
2139
|
|
|
2128
2140
|
# ==================== 应用管理 ====================
|
|
2129
2141
|
|
|
@@ -2152,10 +2164,7 @@ class BasicMobileToolsLite:
|
|
|
2152
2164
|
|
|
2153
2165
|
self._record_operation('launch_app', package_name=package_name)
|
|
2154
2166
|
|
|
2155
|
-
return {
|
|
2156
|
-
"success": True,
|
|
2157
|
-
"message": f"✅ 已启动: {package_name}\n💡 建议等待 2-3 秒让页面加载\n📱 已设置应用状态监测"
|
|
2158
|
-
}
|
|
2167
|
+
return {"success": True}
|
|
2159
2168
|
except Exception as e:
|
|
2160
2169
|
return {"success": False, "message": f"❌ 启动失败: {e}"}
|
|
2161
2170
|
|
|
@@ -2168,9 +2177,9 @@ class BasicMobileToolsLite:
|
|
|
2168
2177
|
ios_client.wda.app_terminate(package_name)
|
|
2169
2178
|
else:
|
|
2170
2179
|
self.client.u2.app_stop(package_name)
|
|
2171
|
-
return {"success": True
|
|
2180
|
+
return {"success": True}
|
|
2172
2181
|
except Exception as e:
|
|
2173
|
-
return {"success": False, "
|
|
2182
|
+
return {"success": False, "msg": str(e)}
|
|
2174
2183
|
|
|
2175
2184
|
def list_apps(self, filter_keyword: str = "") -> Dict:
|
|
2176
2185
|
"""列出已安装应用"""
|
|
@@ -2282,6 +2291,26 @@ class BasicMobileToolsLite:
|
|
|
2282
2291
|
'_shadow', 'shadow_', '_divider', 'divider_', '_line', 'line_'
|
|
2283
2292
|
}
|
|
2284
2293
|
|
|
2294
|
+
# Token 优化:构建精简元素(只返回非空字段)
|
|
2295
|
+
def build_compact_element(resource_id, text, content_desc, bounds, likely_click, class_name):
|
|
2296
|
+
"""只返回有值的字段,节省 token"""
|
|
2297
|
+
item = {}
|
|
2298
|
+
if resource_id:
|
|
2299
|
+
# 精简 resource_id,只保留最后一段
|
|
2300
|
+
item['id'] = resource_id.split('/')[-1] if '/' in resource_id else resource_id
|
|
2301
|
+
if text:
|
|
2302
|
+
item['text'] = text
|
|
2303
|
+
if content_desc:
|
|
2304
|
+
item['desc'] = content_desc
|
|
2305
|
+
if bounds:
|
|
2306
|
+
item['bounds'] = bounds
|
|
2307
|
+
if likely_click:
|
|
2308
|
+
item['click'] = True # 启发式判断可点击
|
|
2309
|
+
# class 精简:只保留关键类型
|
|
2310
|
+
if class_name in ('EditText', 'TextInput', 'Button', 'ImageButton', 'CheckBox', 'Switch'):
|
|
2311
|
+
item['type'] = class_name
|
|
2312
|
+
return item
|
|
2313
|
+
|
|
2285
2314
|
result = []
|
|
2286
2315
|
for elem in elements:
|
|
2287
2316
|
# 获取元素属性
|
|
@@ -2301,14 +2330,11 @@ class BasicMobileToolsLite:
|
|
|
2301
2330
|
|
|
2302
2331
|
# 2. 检查是否是功能控件(直接保留)
|
|
2303
2332
|
if class_name in FUNCTIONAL_WIDGETS:
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
'clickable': clickable,
|
|
2310
|
-
'class': class_name
|
|
2311
|
-
})
|
|
2333
|
+
# 使用启发式判断可点击性(替代不准确的 clickable 属性)
|
|
2334
|
+
likely_click = self._is_likely_clickable(class_name, resource_id, text, content_desc, clickable, bounds)
|
|
2335
|
+
item = build_compact_element(resource_id, text, content_desc, bounds, likely_click, class_name)
|
|
2336
|
+
if item:
|
|
2337
|
+
result.append(item)
|
|
2312
2338
|
continue
|
|
2313
2339
|
|
|
2314
2340
|
# 3. 检查是否是容器控件
|
|
@@ -2321,14 +2347,10 @@ class BasicMobileToolsLite:
|
|
|
2321
2347
|
# 所有属性都是默认值,过滤掉
|
|
2322
2348
|
continue
|
|
2323
2349
|
# 有业务ID或其他有意义属性,保留
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
'bounds': bounds,
|
|
2329
|
-
'clickable': clickable,
|
|
2330
|
-
'class': class_name
|
|
2331
|
-
})
|
|
2350
|
+
likely_click = self._is_likely_clickable(class_name, resource_id, text, content_desc, clickable, bounds)
|
|
2351
|
+
item = build_compact_element(resource_id, text, content_desc, bounds, likely_click, class_name)
|
|
2352
|
+
if item:
|
|
2353
|
+
result.append(item)
|
|
2332
2354
|
continue
|
|
2333
2355
|
|
|
2334
2356
|
# 4. 检查是否是装饰类控件
|
|
@@ -2345,14 +2367,21 @@ class BasicMobileToolsLite:
|
|
|
2345
2367
|
continue
|
|
2346
2368
|
|
|
2347
2369
|
# 6. 其他情况:有意义的元素保留
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2370
|
+
likely_click = self._is_likely_clickable(class_name, resource_id, text, content_desc, clickable, bounds)
|
|
2371
|
+
item = build_compact_element(resource_id, text, content_desc, bounds, likely_click, class_name)
|
|
2372
|
+
if item:
|
|
2373
|
+
result.append(item)
|
|
2374
|
+
|
|
2375
|
+
# Token 优化:可选限制返回元素数量(默认不限制,确保准确度)
|
|
2376
|
+
if TOKEN_OPTIMIZATION and MAX_ELEMENTS > 0 and len(result) > MAX_ELEMENTS:
|
|
2377
|
+
# 仅在用户明确设置 MAX_ELEMENTS_RETURN 时才截断
|
|
2378
|
+
truncated = result[:MAX_ELEMENTS]
|
|
2379
|
+
truncated.append({
|
|
2380
|
+
'_truncated': True,
|
|
2381
|
+
'_total': len(result),
|
|
2382
|
+
'_shown': MAX_ELEMENTS
|
|
2355
2383
|
})
|
|
2384
|
+
return truncated
|
|
2356
2385
|
|
|
2357
2386
|
return result
|
|
2358
2387
|
except Exception as e:
|
|
@@ -2389,6 +2418,68 @@ class BasicMobileToolsLite:
|
|
|
2389
2418
|
|
|
2390
2419
|
return True
|
|
2391
2420
|
|
|
2421
|
+
def _is_likely_clickable(self, class_name: str, resource_id: str, text: str,
|
|
2422
|
+
content_desc: str, clickable: bool, bounds: str) -> bool:
|
|
2423
|
+
"""
|
|
2424
|
+
启发式判断元素是否可能可点击
|
|
2425
|
+
|
|
2426
|
+
Android 的 clickable 属性经常不准确,因为:
|
|
2427
|
+
1. 点击事件可能设置在父容器上
|
|
2428
|
+
2. 使用 onTouchListener 而不是 onClick
|
|
2429
|
+
3. RecyclerView item 通过 ItemClickListener 处理
|
|
2430
|
+
|
|
2431
|
+
此方法通过多种规则推断元素的真实可点击性
|
|
2432
|
+
"""
|
|
2433
|
+
# 规则1:clickable=true 肯定可点击
|
|
2434
|
+
if clickable:
|
|
2435
|
+
return True
|
|
2436
|
+
|
|
2437
|
+
# 规则2:特定类型的控件通常可点击
|
|
2438
|
+
TYPICALLY_CLICKABLE = {
|
|
2439
|
+
'Button', 'ImageButton', 'CheckBox', 'RadioButton', 'Switch',
|
|
2440
|
+
'ToggleButton', 'FloatingActionButton', 'Chip', 'TabView',
|
|
2441
|
+
'EditText', 'TextInput', # 输入框可点击获取焦点
|
|
2442
|
+
}
|
|
2443
|
+
if class_name in TYPICALLY_CLICKABLE:
|
|
2444
|
+
return True
|
|
2445
|
+
|
|
2446
|
+
# 规则3:resource_id 包含可点击关键词
|
|
2447
|
+
if resource_id:
|
|
2448
|
+
id_lower = resource_id.lower()
|
|
2449
|
+
CLICK_KEYWORDS = [
|
|
2450
|
+
'btn', 'button', 'click', 'tap', 'submit', 'confirm',
|
|
2451
|
+
'cancel', 'close', 'back', 'next', 'prev', 'more',
|
|
2452
|
+
'action', 'link', 'menu', 'tab', 'item', 'cell',
|
|
2453
|
+
'card', 'avatar', 'icon', 'entry', 'option', 'arrow'
|
|
2454
|
+
]
|
|
2455
|
+
for kw in CLICK_KEYWORDS:
|
|
2456
|
+
if kw in id_lower:
|
|
2457
|
+
return True
|
|
2458
|
+
|
|
2459
|
+
# 规则4:content_desc 包含可点击暗示
|
|
2460
|
+
if content_desc:
|
|
2461
|
+
desc_lower = content_desc.lower()
|
|
2462
|
+
CLICK_HINTS = ['点击', '按钮', '关闭', '返回', '更多', 'click', 'tap', 'button', 'close']
|
|
2463
|
+
for hint in CLICK_HINTS:
|
|
2464
|
+
if hint in desc_lower:
|
|
2465
|
+
return True
|
|
2466
|
+
|
|
2467
|
+
# 规则5:有 resource_id 或 content_desc 的小图标可能可点击
|
|
2468
|
+
# (纯 ImageView 不加判断,误判率太高)
|
|
2469
|
+
if class_name in ('ImageView', 'Image') and (resource_id or content_desc) and bounds:
|
|
2470
|
+
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds)
|
|
2471
|
+
if match:
|
|
2472
|
+
x1, y1, x2, y2 = map(int, match.groups())
|
|
2473
|
+
w, h = x2 - x1, y2 - y1
|
|
2474
|
+
# 小图标(20-100px)更可能是按钮
|
|
2475
|
+
if 20 <= w <= 100 and 20 <= h <= 100:
|
|
2476
|
+
return True
|
|
2477
|
+
|
|
2478
|
+
# 规则6:移除(TextView 误判率太高,只依赖上面的规则)
|
|
2479
|
+
# 如果有 clickable=true 或 ID/desc 中有关键词,前面的规则已经覆盖
|
|
2480
|
+
|
|
2481
|
+
return False
|
|
2482
|
+
|
|
2392
2483
|
def find_close_button(self) -> Dict:
|
|
2393
2484
|
"""智能查找关闭按钮(不点击,只返回位置)
|
|
2394
2485
|
|
|
@@ -2402,7 +2493,7 @@ class BasicMobileToolsLite:
|
|
|
2402
2493
|
import re
|
|
2403
2494
|
|
|
2404
2495
|
if self._is_ios():
|
|
2405
|
-
return {"success": False, "
|
|
2496
|
+
return {"success": False, "msg": "iOS暂不支持"}
|
|
2406
2497
|
|
|
2407
2498
|
# 获取屏幕尺寸
|
|
2408
2499
|
screen_width = self.client.u2.info.get('displayWidth', 720)
|
|
@@ -2413,6 +2504,14 @@ class BasicMobileToolsLite:
|
|
|
2413
2504
|
import xml.etree.ElementTree as ET
|
|
2414
2505
|
root = ET.fromstring(xml_string)
|
|
2415
2506
|
|
|
2507
|
+
# 🔴 先检测是否有弹窗,避免误识别普通页面的按钮
|
|
2508
|
+
popup_bounds, popup_confidence = self._detect_popup_with_confidence(
|
|
2509
|
+
root, screen_width, screen_height
|
|
2510
|
+
)
|
|
2511
|
+
|
|
2512
|
+
if popup_bounds is None or popup_confidence < 0.5:
|
|
2513
|
+
return {"success": True, "popup": False}
|
|
2514
|
+
|
|
2416
2515
|
# 关闭按钮特征
|
|
2417
2516
|
close_texts = ['×', 'X', 'x', '关闭', '取消', 'close', 'Close', '跳过', '知道了', '我知道了']
|
|
2418
2517
|
candidates = []
|
|
@@ -2514,27 +2613,16 @@ class BasicMobileToolsLite:
|
|
|
2514
2613
|
candidates.sort(key=lambda x: x['score'], reverse=True)
|
|
2515
2614
|
best = candidates[0]
|
|
2516
2615
|
|
|
2616
|
+
# Token 优化:只返回最必要的信息
|
|
2517
2617
|
return {
|
|
2518
2618
|
"success": True,
|
|
2519
|
-
"
|
|
2520
|
-
"
|
|
2521
|
-
|
|
2522
|
-
"center": {"x": best['center_x'], "y": best['center_y']},
|
|
2523
|
-
"percent": {"x": best['x_percent'], "y": best['y_percent']},
|
|
2524
|
-
"bounds": best['bounds'],
|
|
2525
|
-
"size": best['size'],
|
|
2526
|
-
"score": best['score']
|
|
2527
|
-
},
|
|
2528
|
-
"click_command": f"mobile_click_by_percent({best['x_percent']}, {best['y_percent']})",
|
|
2529
|
-
"other_candidates": [
|
|
2530
|
-
{"reason": c['reason'], "percent": f"({c['x_percent']}%, {c['y_percent']}%)", "score": c['score']}
|
|
2531
|
-
for c in candidates[1:4]
|
|
2532
|
-
] if len(candidates) > 1 else [],
|
|
2533
|
-
"screen_size": {"width": screen_width, "height": screen_height}
|
|
2619
|
+
"popup": True,
|
|
2620
|
+
"close": {"x": best['x_percent'], "y": best['y_percent']},
|
|
2621
|
+
"cmd": f"click_by_percent({best['x_percent']},{best['y_percent']})"
|
|
2534
2622
|
}
|
|
2535
2623
|
|
|
2536
2624
|
except Exception as e:
|
|
2537
|
-
return {"success": False, "
|
|
2625
|
+
return {"success": False, "msg": str(e)}
|
|
2538
2626
|
|
|
2539
2627
|
def close_popup(self) -> Dict:
|
|
2540
2628
|
"""智能关闭弹窗(改进版)
|
|
@@ -2557,7 +2645,7 @@ class BasicMobileToolsLite:
|
|
|
2557
2645
|
|
|
2558
2646
|
# 获取屏幕尺寸
|
|
2559
2647
|
if self._is_ios():
|
|
2560
|
-
return {"success": False, "
|
|
2648
|
+
return {"success": False, "msg": "iOS暂不支持"}
|
|
2561
2649
|
|
|
2562
2650
|
screen_width = self.client.u2.info.get('displayWidth', 720)
|
|
2563
2651
|
screen_height = self.client.u2.info.get('displayHeight', 1280)
|
|
@@ -2585,6 +2673,11 @@ class BasicMobileToolsLite:
|
|
|
2585
2673
|
# 如果置信度不够高,记录但继续尝试查找关闭按钮
|
|
2586
2674
|
popup_detected = popup_bounds is not None and popup_confidence >= 0.6
|
|
2587
2675
|
|
|
2676
|
+
# 🔴 关键检查:如果没有检测到弹窗区域,直接返回"无弹窗"
|
|
2677
|
+
# 避免误点击普通页面上的"关闭"、"取消"等按钮
|
|
2678
|
+
if not popup_detected:
|
|
2679
|
+
return {"success": True, "popup": False}
|
|
2680
|
+
|
|
2588
2681
|
# ===== 第二步:在弹窗范围内查找关闭按钮 =====
|
|
2589
2682
|
for idx, elem in enumerate(all_elements):
|
|
2590
2683
|
text = elem.attrib.get('text', '')
|
|
@@ -2722,86 +2815,9 @@ class BasicMobileToolsLite:
|
|
|
2722
2815
|
pass
|
|
2723
2816
|
|
|
2724
2817
|
if not close_candidates:
|
|
2725
|
-
# 如果检测到高置信度的弹窗区域,先尝试点击常见的关闭按钮位置
|
|
2726
2818
|
if popup_detected and popup_bounds:
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
popup_height = py2 - py1
|
|
2730
|
-
|
|
2731
|
-
# 【优化】X按钮有三种常见位置:
|
|
2732
|
-
# 1. 弹窗内靠近顶部边界(内嵌X按钮)- 最常见
|
|
2733
|
-
# 2. 弹窗边界上方(浮动X按钮)
|
|
2734
|
-
# 3. 弹窗正下方(底部关闭按钮)
|
|
2735
|
-
offset_x = max(60, int(popup_width * 0.07)) # 宽度7%
|
|
2736
|
-
offset_y_above = max(35, int(popup_height * 0.025)) # 高度2.5%,在边界之上
|
|
2737
|
-
offset_y_near = max(45, int(popup_height * 0.03)) # 高度3%,紧贴顶边界内侧
|
|
2738
|
-
|
|
2739
|
-
try_positions = [
|
|
2740
|
-
# 【最高优先级】弹窗内紧贴顶部边界
|
|
2741
|
-
(px2 - offset_x, py1 + offset_y_near, "弹窗右上角"),
|
|
2742
|
-
# 弹窗边界上方(浮动X按钮)
|
|
2743
|
-
(px2 - offset_x, py1 - offset_y_above, "弹窗右上浮"),
|
|
2744
|
-
# 弹窗正下方中间(底部关闭按钮)
|
|
2745
|
-
((px1 + px2) // 2, py2 + max(50, int(popup_height * 0.04)), "弹窗下方中间"),
|
|
2746
|
-
# 弹窗正上方中间
|
|
2747
|
-
((px1 + px2) // 2, py1 - 40, "弹窗正上方"),
|
|
2748
|
-
]
|
|
2749
|
-
|
|
2750
|
-
for try_x, try_y, position_name in try_positions:
|
|
2751
|
-
if 0 <= try_x <= screen_width and 0 <= try_y <= screen_height:
|
|
2752
|
-
self.client.u2.click(try_x, try_y)
|
|
2753
|
-
time.sleep(0.3)
|
|
2754
|
-
|
|
2755
|
-
# 🎯 关键步骤:检查应用是否跳转,如果跳转说明弹窗去除失败,需要返回目标应用
|
|
2756
|
-
app_check = self._check_app_switched()
|
|
2757
|
-
return_result = None
|
|
2758
|
-
|
|
2759
|
-
if app_check['switched']:
|
|
2760
|
-
# 应用已跳转,说明弹窗去除失败,尝试返回目标应用
|
|
2761
|
-
return_result = self._return_to_target_app()
|
|
2762
|
-
|
|
2763
|
-
# 尝试后截图,让 AI 判断是否成功
|
|
2764
|
-
screenshot_result = self.take_screenshot("尝试关闭后")
|
|
2765
|
-
|
|
2766
|
-
msg = f"✅ 已尝试点击常见关闭按钮位置"
|
|
2767
|
-
if app_check['switched']:
|
|
2768
|
-
msg += f"\n⚠️ 应用已跳转,说明弹窗去除失败"
|
|
2769
|
-
if return_result:
|
|
2770
|
-
if return_result['success']:
|
|
2771
|
-
msg += f"\n{return_result['message']}"
|
|
2772
|
-
else:
|
|
2773
|
-
msg += f"\n❌ 自动返回失败: {return_result['message']}"
|
|
2774
|
-
|
|
2775
|
-
return {
|
|
2776
|
-
"success": True,
|
|
2777
|
-
"message": msg,
|
|
2778
|
-
"tried_positions": [p[2] for p in try_positions],
|
|
2779
|
-
"screenshot": screenshot_result.get("screenshot_path", ""),
|
|
2780
|
-
"app_check": app_check,
|
|
2781
|
-
"return_to_app": return_result,
|
|
2782
|
-
"tip": "请查看截图确认弹窗是否已关闭。如果还在,可手动分析截图找到关闭按钮位置。"
|
|
2783
|
-
}
|
|
2784
|
-
|
|
2785
|
-
# 没有检测到弹窗区域,截图让 AI 分析
|
|
2786
|
-
screenshot_result = self.take_screenshot(description="页面截图", compress=True)
|
|
2787
|
-
|
|
2788
|
-
return {
|
|
2789
|
-
"success": False,
|
|
2790
|
-
"message": "❌ 未检测到弹窗区域,已截图供 AI 分析",
|
|
2791
|
-
"action_required": "请查看截图找到关闭按钮,调用 mobile_click_at_coords 点击",
|
|
2792
|
-
"screenshot": screenshot_result.get("screenshot_path", ""),
|
|
2793
|
-
"screen_size": {"width": screen_width, "height": screen_height},
|
|
2794
|
-
"image_size": {
|
|
2795
|
-
"width": screenshot_result.get("image_width", screen_width),
|
|
2796
|
-
"height": screenshot_result.get("image_height", screen_height)
|
|
2797
|
-
},
|
|
2798
|
-
"original_size": {
|
|
2799
|
-
"width": screenshot_result.get("original_img_width", screen_width),
|
|
2800
|
-
"height": screenshot_result.get("original_img_height", screen_height)
|
|
2801
|
-
},
|
|
2802
|
-
"search_areas": ["弹窗右上角", "弹窗正上方", "弹窗下方中间", "屏幕右上角"],
|
|
2803
|
-
"time_warning": "⚠️ 截图分析期间弹窗可能自动消失。如果是定时弹窗,建议等待其自动消失。"
|
|
2804
|
-
}
|
|
2819
|
+
return {"success": False, "fallback": "vision", "popup": True}
|
|
2820
|
+
return {"success": True, "popup": False}
|
|
2805
2821
|
|
|
2806
2822
|
# 按得分排序,取最可能的
|
|
2807
2823
|
close_candidates.sort(key=lambda x: x['score'], reverse=True)
|
|
@@ -2819,62 +2835,22 @@ class BasicMobileToolsLite:
|
|
|
2819
2835
|
# 应用已跳转,说明弹窗去除失败,尝试返回目标应用
|
|
2820
2836
|
return_result = self._return_to_target_app()
|
|
2821
2837
|
|
|
2822
|
-
#
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
self._record_operation(
|
|
2827
|
-
'click',
|
|
2828
|
-
x=best['center_x'],
|
|
2829
|
-
y=best['center_y'],
|
|
2830
|
-
x_percent=best['x_percent'],
|
|
2831
|
-
y_percent=best['y_percent'],
|
|
2832
|
-
screen_width=screen_width,
|
|
2833
|
-
screen_height=screen_height,
|
|
2834
|
-
ref=f"close_popup_{best['position']}"
|
|
2835
|
-
)
|
|
2838
|
+
# 记录操作
|
|
2839
|
+
self._record_click('percent', f"{best['x_percent']}%,{best['y_percent']}%",
|
|
2840
|
+
best['x_percent'], best['y_percent'],
|
|
2841
|
+
element_desc=f"关闭按钮({best['position']})")
|
|
2836
2842
|
|
|
2837
|
-
#
|
|
2838
|
-
|
|
2843
|
+
# Token 优化:精简返回值
|
|
2844
|
+
result = {"success": True, "clicked": True}
|
|
2839
2845
|
if app_check['switched']:
|
|
2840
|
-
|
|
2846
|
+
result["switched"] = True
|
|
2841
2847
|
if return_result:
|
|
2842
|
-
|
|
2843
|
-
msg += f"\n{return_result['message']}"
|
|
2844
|
-
else:
|
|
2845
|
-
msg += f"\n❌ 自动返回失败: {return_result['message']}"
|
|
2848
|
+
result["returned"] = return_result['success']
|
|
2846
2849
|
|
|
2847
|
-
|
|
2848
|
-
# 如果弹窗还在,AI 可以选择点击其他候选按钮
|
|
2849
|
-
return {
|
|
2850
|
-
"success": True,
|
|
2851
|
-
"message": msg,
|
|
2852
|
-
"clicked": {
|
|
2853
|
-
"position": best['position'],
|
|
2854
|
-
"match_type": best['match_type'],
|
|
2855
|
-
"coords": (best['center_x'], best['center_y']),
|
|
2856
|
-
"percent": (best['x_percent'], best['y_percent'])
|
|
2857
|
-
},
|
|
2858
|
-
"screenshot": screenshot_result.get("screenshot_path", ""),
|
|
2859
|
-
"popup_detected": popup_detected,
|
|
2860
|
-
"popup_confidence": popup_confidence if popup_bounds else 0,
|
|
2861
|
-
"popup_bounds": f"[{popup_bounds[0]},{popup_bounds[1]}][{popup_bounds[2]},{popup_bounds[3]}]" if popup_detected else None,
|
|
2862
|
-
"app_check": app_check,
|
|
2863
|
-
"return_to_app": return_result,
|
|
2864
|
-
"other_candidates": [
|
|
2865
|
-
{
|
|
2866
|
-
"position": c['position'],
|
|
2867
|
-
"type": c['match_type'],
|
|
2868
|
-
"coords": (c['center_x'], c['center_y']),
|
|
2869
|
-
"percent": (c['x_percent'], c['y_percent'])
|
|
2870
|
-
}
|
|
2871
|
-
for c in close_candidates[1:4] # 返回其他3个候选,AI 可以选择
|
|
2872
|
-
],
|
|
2873
|
-
"tip": "请查看截图判断弹窗是否已关闭。如果弹窗还在,可以尝试点击 other_candidates 中的其他位置"
|
|
2874
|
-
}
|
|
2850
|
+
return result
|
|
2875
2851
|
|
|
2876
2852
|
except Exception as e:
|
|
2877
|
-
return {"success": False, "
|
|
2853
|
+
return {"success": False, "msg": str(e)}
|
|
2878
2854
|
|
|
2879
2855
|
def _get_position_name(self, rel_x: float, rel_y: float) -> str:
|
|
2880
2856
|
"""根据相对坐标获取位置名称"""
|
|
@@ -2981,33 +2957,6 @@ class BasicMobileToolsLite:
|
|
|
2981
2957
|
has_mask_layer = False
|
|
2982
2958
|
mask_idx = -1
|
|
2983
2959
|
|
|
2984
|
-
# 【新增】检测浮动关闭按钮(小尺寸 clickable ImageView,位于屏幕中央偏上)
|
|
2985
|
-
floating_close_buttons = []
|
|
2986
|
-
for elem in all_elements:
|
|
2987
|
-
x1, y1, x2, y2 = elem['bounds']
|
|
2988
|
-
class_name = elem['class']
|
|
2989
|
-
width = elem['width']
|
|
2990
|
-
height = elem['height']
|
|
2991
|
-
|
|
2992
|
-
# 浮动关闭按钮特征:
|
|
2993
|
-
# 1. 小尺寸(50-200px)
|
|
2994
|
-
# 2. clickable 或 ImageView
|
|
2995
|
-
# 3. 位于屏幕中央区域的上半部分
|
|
2996
|
-
# 4. 接近正方形
|
|
2997
|
-
is_small = 50 < width < 200 and 50 < height < 200
|
|
2998
|
-
is_square_like = 0.5 < (width / height if height > 0 else 0) < 2.0
|
|
2999
|
-
is_clickable_image = elem['clickable'] or 'Image' in class_name
|
|
3000
|
-
is_upper_center = (screen_width * 0.2 < x1 < screen_width * 0.8 and
|
|
3001
|
-
y1 < screen_height * 0.5)
|
|
3002
|
-
|
|
3003
|
-
if is_small and is_square_like and is_clickable_image and is_upper_center:
|
|
3004
|
-
floating_close_buttons.append({
|
|
3005
|
-
'bounds': elem['bounds'],
|
|
3006
|
-
'center_x': elem['center_x'],
|
|
3007
|
-
'center_y': elem['center_y'],
|
|
3008
|
-
'idx': elem['idx']
|
|
3009
|
-
})
|
|
3010
|
-
|
|
3011
2960
|
for elem in all_elements:
|
|
3012
2961
|
x1, y1, x2, y2 = elem['bounds']
|
|
3013
2962
|
class_name = elem['class']
|
|
@@ -3065,19 +3014,6 @@ class BasicMobileToolsLite:
|
|
|
3065
3014
|
if has_mask_layer and elem['idx'] > mask_idx:
|
|
3066
3015
|
confidence += 0.15
|
|
3067
3016
|
|
|
3068
|
-
# 【新增强特征】有浮动关闭按钮在此容器上方附近 (+0.4)
|
|
3069
|
-
# 这是很多 App 弹窗的典型设计:内容区域 + 上方的 X 按钮
|
|
3070
|
-
for close_btn in floating_close_buttons:
|
|
3071
|
-
btn_x, btn_y = close_btn['center_x'], close_btn['center_y']
|
|
3072
|
-
# 检查关闭按钮是否在容器的上方(扩大范围到 400px)
|
|
3073
|
-
is_above_container = (
|
|
3074
|
-
x1 - 100 < btn_x < x2 + 100 and # 在容器水平范围内
|
|
3075
|
-
y1 - 400 < btn_y < y1 + 100 # 在容器上方 400px 范围内
|
|
3076
|
-
)
|
|
3077
|
-
if is_above_container:
|
|
3078
|
-
confidence += 0.4
|
|
3079
|
-
break # 只加一次分
|
|
3080
|
-
|
|
3081
3017
|
# 只有达到阈值才加入候选
|
|
3082
3018
|
if confidence >= 0.3:
|
|
3083
3019
|
popup_candidates.append({
|
|
@@ -3347,8 +3283,13 @@ class BasicMobileToolsLite:
|
|
|
3347
3283
|
"1. 文本定位 - 最稳定,跨设备兼容",
|
|
3348
3284
|
"2. ID 定位 - 稳定,跨设备兼容",
|
|
3349
3285
|
"3. 百分比定位 - 跨分辨率兼容(坐标自动转换)",
|
|
3286
|
+
"",
|
|
3287
|
+
"运行方式:",
|
|
3288
|
+
" pytest {filename} -v # 使用 pytest 运行",
|
|
3289
|
+
" python {filename} # 直接运行",
|
|
3350
3290
|
f'"""',
|
|
3351
3291
|
"import time",
|
|
3292
|
+
"import pytest",
|
|
3352
3293
|
"import uiautomator2 as u2",
|
|
3353
3294
|
"",
|
|
3354
3295
|
f'PACKAGE_NAME = "{package_name}"',
|
|
@@ -3424,22 +3365,52 @@ class BasicMobileToolsLite:
|
|
|
3424
3365
|
" return True",
|
|
3425
3366
|
"",
|
|
3426
3367
|
"",
|
|
3427
|
-
"def
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3368
|
+
"def swipe_direction(d, direction):",
|
|
3369
|
+
' """',
|
|
3370
|
+
' 通用滑动方法(兼容所有 uiautomator2 版本)',
|
|
3371
|
+
' ',
|
|
3372
|
+
' Args:',
|
|
3373
|
+
' d: uiautomator2 设备对象',
|
|
3374
|
+
' direction: 滑动方向 (up/down/left/right)',
|
|
3375
|
+
' """',
|
|
3376
|
+
" info = d.info",
|
|
3377
|
+
" width = info.get('displayWidth', 0)",
|
|
3378
|
+
" height = info.get('displayHeight', 0)",
|
|
3379
|
+
" cx, cy = width // 2, height // 2",
|
|
3435
3380
|
" ",
|
|
3436
|
-
"
|
|
3381
|
+
" if direction == 'up':",
|
|
3382
|
+
" d.swipe(cx, int(height * 0.8), cx, int(height * 0.3))",
|
|
3383
|
+
" elif direction == 'down':",
|
|
3384
|
+
" d.swipe(cx, int(height * 0.3), cx, int(height * 0.8))",
|
|
3385
|
+
" elif direction == 'left':",
|
|
3386
|
+
" d.swipe(int(width * 0.8), cy, int(width * 0.2), cy)",
|
|
3387
|
+
" elif direction == 'right':",
|
|
3388
|
+
" d.swipe(int(width * 0.2), cy, int(width * 0.8), cy)",
|
|
3389
|
+
" return True",
|
|
3390
|
+
"",
|
|
3391
|
+
"",
|
|
3392
|
+
"# ========== pytest fixture ==========",
|
|
3393
|
+
"@pytest.fixture(scope='function')",
|
|
3394
|
+
"def device():",
|
|
3395
|
+
' """pytest fixture: 连接设备并启动应用"""',
|
|
3396
|
+
" d = u2.connect()",
|
|
3397
|
+
" d.implicitly_wait(10)",
|
|
3398
|
+
" d.app_start(PACKAGE_NAME)",
|
|
3399
|
+
" time.sleep(LAUNCH_WAIT)",
|
|
3437
3400
|
" if CLOSE_AD_ON_LAUNCH:",
|
|
3438
3401
|
" close_ad_if_exists(d)",
|
|
3402
|
+
" yield d",
|
|
3403
|
+
" # 测试结束后可选择关闭应用",
|
|
3404
|
+
" # d.app_stop(PACKAGE_NAME)",
|
|
3405
|
+
"",
|
|
3406
|
+
"",
|
|
3407
|
+
f"def test_{safe_name}(device):",
|
|
3408
|
+
' """测试用例主函数"""',
|
|
3409
|
+
" d = device",
|
|
3439
3410
|
" ",
|
|
3440
3411
|
]
|
|
3441
3412
|
|
|
3442
|
-
#
|
|
3413
|
+
# 生成操作代码(使用标准记录格式,逻辑更简洁)
|
|
3443
3414
|
step_num = 0
|
|
3444
3415
|
for op in self.operation_history:
|
|
3445
3416
|
action = op.get('action')
|
|
@@ -3451,131 +3422,122 @@ class BasicMobileToolsLite:
|
|
|
3451
3422
|
step_num += 1
|
|
3452
3423
|
|
|
3453
3424
|
if action == 'click':
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3425
|
+
# 新格式:使用 locator_type 和 locator_value
|
|
3426
|
+
locator_type = op.get('locator_type', '')
|
|
3427
|
+
locator_value = op.get('locator_value', '')
|
|
3428
|
+
locator_attr = op.get('locator_attr', 'text')
|
|
3429
|
+
element_desc = op.get('element_desc', '')
|
|
3430
|
+
x_pct = op.get('x_percent', 0)
|
|
3431
|
+
y_pct = op.get('y_percent', 0)
|
|
3458
3432
|
|
|
3459
|
-
#
|
|
3460
|
-
|
|
3461
|
-
is_percent_ref = ref.startswith('percent_')
|
|
3433
|
+
# 转义单引号
|
|
3434
|
+
value_escaped = locator_value.replace("'", "\\'") if locator_value else ''
|
|
3462
3435
|
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
#
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
elif
|
|
3475
|
-
#
|
|
3476
|
-
script_lines.append(f" # 步骤{step_num}:
|
|
3477
|
-
script_lines.append(f" safe_click(d, d(resourceId='{
|
|
3478
|
-
elif
|
|
3479
|
-
#
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
desc = f" ({element})" if element else ""
|
|
3483
|
-
script_lines.append(f" # 步骤{step_num}: 点击位置{desc} (百分比定位,跨分辨率兼容)")
|
|
3484
|
-
script_lines.append(f" click_by_percent(d, {x_pct}, {y_pct}) # 原坐标: ({op.get('x', '?')}, {op.get('y', '?')})")
|
|
3485
|
-
elif has_coords:
|
|
3486
|
-
# 4️⃣ 坐标兜底(不推荐,仅用于无法获取百分比的情况)
|
|
3487
|
-
desc = f" ({element})" if element else ""
|
|
3488
|
-
script_lines.append(f" # 步骤{step_num}: 点击坐标{desc} (⚠️ 坐标定位,可能不兼容其他分辨率)")
|
|
3489
|
-
script_lines.append(f" d.click({op['x']}, {op['y']})")
|
|
3436
|
+
if locator_type == 'text':
|
|
3437
|
+
# 文本定位(最稳定)
|
|
3438
|
+
script_lines.append(f" # 步骤{step_num}: 点击 '{element_desc}' (文本定位)")
|
|
3439
|
+
if locator_attr == 'description':
|
|
3440
|
+
script_lines.append(f" safe_click(d, d(description='{value_escaped}'))")
|
|
3441
|
+
elif locator_attr == 'descriptionContains':
|
|
3442
|
+
script_lines.append(f" safe_click(d, d(descriptionContains='{value_escaped}'))")
|
|
3443
|
+
elif locator_attr == 'textContains':
|
|
3444
|
+
script_lines.append(f" safe_click(d, d(textContains='{value_escaped}'))")
|
|
3445
|
+
else:
|
|
3446
|
+
script_lines.append(f" safe_click(d, d(text='{value_escaped}'))")
|
|
3447
|
+
elif locator_type == 'id':
|
|
3448
|
+
# ID 定位(稳定)
|
|
3449
|
+
script_lines.append(f" # 步骤{step_num}: 点击 '{element_desc}' (ID定位)")
|
|
3450
|
+
script_lines.append(f" safe_click(d, d(resourceId='{value_escaped}'))")
|
|
3451
|
+
elif locator_type == 'percent':
|
|
3452
|
+
# 百分比定位(跨分辨率兼容)
|
|
3453
|
+
script_lines.append(f" # 步骤{step_num}: 点击 '{element_desc}' (百分比定位)")
|
|
3454
|
+
script_lines.append(f" click_by_percent(d, {x_pct}, {y_pct})")
|
|
3490
3455
|
else:
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3456
|
+
# 兼容旧格式
|
|
3457
|
+
ref = op.get('ref', '')
|
|
3458
|
+
if ref:
|
|
3459
|
+
ref_escaped = ref.replace("'", "\\'")
|
|
3460
|
+
script_lines.append(f" # 步骤{step_num}: 点击 '{ref}'")
|
|
3461
|
+
script_lines.append(f" safe_click(d, d(text='{ref_escaped}'))")
|
|
3462
|
+
else:
|
|
3463
|
+
continue
|
|
3464
|
+
|
|
3465
|
+
script_lines.append(" time.sleep(0.5)")
|
|
3494
3466
|
script_lines.append(" ")
|
|
3495
3467
|
|
|
3496
3468
|
elif action == 'input':
|
|
3497
3469
|
text = op.get('text', '')
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3470
|
+
locator_type = op.get('locator_type', '')
|
|
3471
|
+
locator_value = op.get('locator_value', '')
|
|
3472
|
+
x_pct = op.get('x_percent', 0)
|
|
3473
|
+
y_pct = op.get('y_percent', 0)
|
|
3501
3474
|
|
|
3502
|
-
|
|
3503
|
-
|
|
3475
|
+
text_escaped = text.replace("'", "\\'")
|
|
3476
|
+
value_escaped = locator_value.replace("'", "\\'") if locator_value else ''
|
|
3504
3477
|
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
script_lines.append(f"
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
script_lines.append(f" # 步骤{step_num}:
|
|
3513
|
-
script_lines.append(f" d(resourceId='{ref}').set_text('{text}')")
|
|
3514
|
-
elif has_percent:
|
|
3515
|
-
x_pct = op['x_percent']
|
|
3516
|
-
y_pct = op['y_percent']
|
|
3517
|
-
script_lines.append(f" # 步骤{step_num}: 点击后输入 (百分比定位)")
|
|
3478
|
+
if locator_type == 'id':
|
|
3479
|
+
script_lines.append(f" # 步骤{step_num}: 输入 '{text}' (ID定位)")
|
|
3480
|
+
script_lines.append(f" d(resourceId='{value_escaped}').set_text('{text_escaped}')")
|
|
3481
|
+
elif locator_type == 'class':
|
|
3482
|
+
script_lines.append(f" # 步骤{step_num}: 输入 '{text}' (类名定位)")
|
|
3483
|
+
script_lines.append(f" d(className='android.widget.EditText').set_text('{text_escaped}')")
|
|
3484
|
+
elif x_pct > 0 and y_pct > 0:
|
|
3485
|
+
script_lines.append(f" # 步骤{step_num}: 点击后输入 '{text}'")
|
|
3518
3486
|
script_lines.append(f" click_by_percent(d, {x_pct}, {y_pct})")
|
|
3519
|
-
script_lines.append(
|
|
3520
|
-
script_lines.append(f" d.send_keys('{
|
|
3521
|
-
elif has_coords:
|
|
3522
|
-
script_lines.append(f" # 步骤{step_num}: 点击坐标后输入 (⚠️ 可能不兼容其他分辨率)")
|
|
3523
|
-
script_lines.append(f" d.click({op['x']}, {op['y']})")
|
|
3524
|
-
script_lines.append(f" time.sleep(0.3)")
|
|
3525
|
-
script_lines.append(f" d.send_keys('{text}')")
|
|
3487
|
+
script_lines.append(" time.sleep(0.3)")
|
|
3488
|
+
script_lines.append(f" d.send_keys('{text_escaped}')")
|
|
3526
3489
|
else:
|
|
3527
|
-
#
|
|
3528
|
-
|
|
3490
|
+
# 兼容旧格式
|
|
3491
|
+
ref = op.get('ref', '')
|
|
3492
|
+
if ref:
|
|
3493
|
+
script_lines.append(f" # 步骤{step_num}: 输入 '{text}'")
|
|
3494
|
+
script_lines.append(f" d(resourceId='{ref}').set_text('{text_escaped}')")
|
|
3495
|
+
else:
|
|
3496
|
+
continue
|
|
3497
|
+
|
|
3529
3498
|
script_lines.append(" time.sleep(0.5)")
|
|
3530
3499
|
script_lines.append(" ")
|
|
3531
3500
|
|
|
3532
3501
|
elif action == 'long_press':
|
|
3533
|
-
|
|
3534
|
-
|
|
3502
|
+
locator_type = op.get('locator_type', '')
|
|
3503
|
+
locator_value = op.get('locator_value', '')
|
|
3504
|
+
locator_attr = op.get('locator_attr', 'text')
|
|
3505
|
+
element_desc = op.get('element_desc', '')
|
|
3535
3506
|
duration = op.get('duration', 1.0)
|
|
3536
|
-
|
|
3537
|
-
|
|
3507
|
+
x_pct = op.get('x_percent', 0)
|
|
3508
|
+
y_pct = op.get('y_percent', 0)
|
|
3538
3509
|
|
|
3539
|
-
|
|
3540
|
-
is_coords_ref = ref.startswith('coords_') or ref.startswith('coords:')
|
|
3541
|
-
is_percent_ref = ref.startswith('percent_')
|
|
3510
|
+
value_escaped = locator_value.replace("'", "\\'") if locator_value else ''
|
|
3542
3511
|
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
script_lines.append(f"
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
script_lines.append(f" # 步骤{step_num}: 长按元素 (ID定位)")
|
|
3556
|
-
script_lines.append(f" d(resourceId='{ref}').long_click(duration={duration})")
|
|
3557
|
-
elif has_percent:
|
|
3558
|
-
# 使用百分比
|
|
3559
|
-
x_pct = op['x_percent']
|
|
3560
|
-
y_pct = op['y_percent']
|
|
3561
|
-
desc = f" ({element})" if element else ""
|
|
3562
|
-
script_lines.append(f" # 步骤{step_num}: 长按位置{desc} (百分比定位,跨分辨率兼容)")
|
|
3563
|
-
script_lines.append(f" long_press_by_percent(d, {x_pct}, {y_pct}, duration={duration}) # 原坐标: ({op.get('x', '?')}, {op.get('y', '?')})")
|
|
3564
|
-
elif has_coords:
|
|
3565
|
-
# 坐标兜底
|
|
3566
|
-
desc = f" ({element})" if element else ""
|
|
3567
|
-
script_lines.append(f" # 步骤{step_num}: 长按坐标{desc} (⚠️ 坐标定位,可能不兼容其他分辨率)")
|
|
3568
|
-
script_lines.append(f" d.long_click({op['x']}, {op['y']}, duration={duration})")
|
|
3512
|
+
if locator_type == 'text':
|
|
3513
|
+
script_lines.append(f" # 步骤{step_num}: 长按 '{element_desc}'")
|
|
3514
|
+
if locator_attr == 'description':
|
|
3515
|
+
script_lines.append(f" d(description='{value_escaped}').long_click(duration={duration})")
|
|
3516
|
+
else:
|
|
3517
|
+
script_lines.append(f" d(text='{value_escaped}').long_click(duration={duration})")
|
|
3518
|
+
elif locator_type == 'id':
|
|
3519
|
+
script_lines.append(f" # 步骤{step_num}: 长按 '{element_desc}'")
|
|
3520
|
+
script_lines.append(f" d(resourceId='{value_escaped}').long_click(duration={duration})")
|
|
3521
|
+
elif locator_type == 'percent':
|
|
3522
|
+
script_lines.append(f" # 步骤{step_num}: 长按 '{element_desc}'")
|
|
3523
|
+
script_lines.append(f" long_press_by_percent(d, {x_pct}, {y_pct}, duration={duration})")
|
|
3569
3524
|
else:
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3525
|
+
# 兼容旧格式
|
|
3526
|
+
ref = op.get('ref', '')
|
|
3527
|
+
if ref:
|
|
3528
|
+
ref_escaped = ref.replace("'", "\\'")
|
|
3529
|
+
script_lines.append(f" # 步骤{step_num}: 长按 '{ref}'")
|
|
3530
|
+
script_lines.append(f" d(text='{ref_escaped}').long_click(duration={duration})")
|
|
3531
|
+
else:
|
|
3532
|
+
continue
|
|
3533
|
+
|
|
3534
|
+
script_lines.append(" time.sleep(0.5)")
|
|
3573
3535
|
script_lines.append(" ")
|
|
3574
3536
|
|
|
3575
3537
|
elif action == 'swipe':
|
|
3576
3538
|
direction = op.get('direction', 'up')
|
|
3577
3539
|
script_lines.append(f" # 步骤{step_num}: 滑动 {direction}")
|
|
3578
|
-
script_lines.append(f" d
|
|
3540
|
+
script_lines.append(f" swipe_direction(d, '{direction}')")
|
|
3579
3541
|
script_lines.append(" time.sleep(0.5)")
|
|
3580
3542
|
script_lines.append(" ")
|
|
3581
3543
|
|
|
@@ -3590,8 +3552,16 @@ class BasicMobileToolsLite:
|
|
|
3590
3552
|
" print('✅ 测试完成')",
|
|
3591
3553
|
"",
|
|
3592
3554
|
"",
|
|
3555
|
+
"# ========== 直接运行入口 ==========",
|
|
3593
3556
|
"if __name__ == '__main__':",
|
|
3594
|
-
"
|
|
3557
|
+
" # 直接运行时,手动创建设备连接",
|
|
3558
|
+
" _d = u2.connect()",
|
|
3559
|
+
" _d.implicitly_wait(10)",
|
|
3560
|
+
" _d.app_start(PACKAGE_NAME)",
|
|
3561
|
+
" time.sleep(LAUNCH_WAIT)",
|
|
3562
|
+
" if CLOSE_AD_ON_LAUNCH:",
|
|
3563
|
+
" close_ad_if_exists(_d)",
|
|
3564
|
+
f" test_{safe_name}(_d)",
|
|
3595
3565
|
])
|
|
3596
3566
|
|
|
3597
3567
|
script = '\n'.join(script_lines)
|
|
@@ -3600,8 +3570,11 @@ class BasicMobileToolsLite:
|
|
|
3600
3570
|
output_dir = Path("tests")
|
|
3601
3571
|
output_dir.mkdir(exist_ok=True)
|
|
3602
3572
|
|
|
3573
|
+
# 确保文件名符合 pytest 规范(以 test_ 开头)
|
|
3603
3574
|
if not filename.endswith('.py'):
|
|
3604
3575
|
filename = f"{filename}.py"
|
|
3576
|
+
if not filename.startswith('test_'):
|
|
3577
|
+
filename = f"test_{filename}"
|
|
3605
3578
|
|
|
3606
3579
|
file_path = output_dir / filename
|
|
3607
3580
|
file_path.write_text(script, encoding='utf-8')
|
|
@@ -3609,7 +3582,7 @@ class BasicMobileToolsLite:
|
|
|
3609
3582
|
return {
|
|
3610
3583
|
"success": True,
|
|
3611
3584
|
"file_path": str(file_path),
|
|
3612
|
-
"message": f"✅ 脚本已生成: {file_path}",
|
|
3585
|
+
"message": f"✅ 脚本已生成: {file_path}\n💡 运行方式: pytest {file_path} -v 或 python {file_path}",
|
|
3613
3586
|
"operations_count": len(self.operation_history),
|
|
3614
3587
|
"preview": script[:500] + "..."
|
|
3615
3588
|
}
|
|
@@ -3778,10 +3751,28 @@ class BasicMobileToolsLite:
|
|
|
3778
3751
|
try:
|
|
3779
3752
|
import xml.etree.ElementTree as ET
|
|
3780
3753
|
|
|
3781
|
-
# ========== 第
|
|
3754
|
+
# ========== 第0步:先检测是否有弹窗 ==========
|
|
3782
3755
|
xml_string = self.client.u2.dump_hierarchy(compressed=False)
|
|
3783
3756
|
root = ET.fromstring(xml_string)
|
|
3784
3757
|
|
|
3758
|
+
screen_width = self.client.u2.info.get('displayWidth', 1440)
|
|
3759
|
+
screen_height = self.client.u2.info.get('displayHeight', 3200)
|
|
3760
|
+
|
|
3761
|
+
popup_bounds, popup_confidence = self._detect_popup_with_confidence(
|
|
3762
|
+
root, screen_width, screen_height
|
|
3763
|
+
)
|
|
3764
|
+
|
|
3765
|
+
# 如果没有检测到弹窗,直接返回"无弹窗"
|
|
3766
|
+
if popup_bounds is None or popup_confidence < 0.5:
|
|
3767
|
+
result["success"] = True
|
|
3768
|
+
result["method"] = None
|
|
3769
|
+
result["message"] = "ℹ️ 当前页面未检测到弹窗,无需关闭"
|
|
3770
|
+
result["popup_detected"] = False
|
|
3771
|
+
result["popup_confidence"] = popup_confidence
|
|
3772
|
+
return result
|
|
3773
|
+
|
|
3774
|
+
# ========== 第1步:控件树查找关闭按钮 ==========
|
|
3775
|
+
|
|
3785
3776
|
# 关闭按钮的常见特征
|
|
3786
3777
|
close_keywords = ['关闭', '跳过', '×', 'X', 'x', 'close', 'skip', '取消']
|
|
3787
3778
|
close_content_desc = ['关闭', '跳过', 'close', 'skip', 'dismiss']
|
|
@@ -3860,12 +3851,6 @@ class BasicMobileToolsLite:
|
|
|
3860
3851
|
cx, cy = best['center']
|
|
3861
3852
|
bounds = best['bounds']
|
|
3862
3853
|
|
|
3863
|
-
# 点击前截图(用于自动学习)
|
|
3864
|
-
pre_screenshot = None
|
|
3865
|
-
if auto_learn:
|
|
3866
|
-
pre_result = self.take_screenshot(description="关闭前", compress=False)
|
|
3867
|
-
pre_screenshot = pre_result.get("screenshot_path")
|
|
3868
|
-
|
|
3869
3854
|
# 点击(click_at_coords 内部已包含应用状态检查和自动返回)
|
|
3870
3855
|
click_result = self.click_at_coords(cx, cy)
|
|
3871
3856
|
time.sleep(0.5)
|
|
@@ -3895,17 +3880,11 @@ class BasicMobileToolsLite:
|
|
|
3895
3880
|
result["message"] = msg
|
|
3896
3881
|
result["app_check"] = app_check
|
|
3897
3882
|
result["return_to_app"] = return_result
|
|
3898
|
-
|
|
3899
|
-
# 自动学习:检查这个 X 是否已在模板库,不在就添加
|
|
3900
|
-
if auto_learn and pre_screenshot:
|
|
3901
|
-
learn_result = self._auto_learn_template(pre_screenshot, bounds)
|
|
3902
|
-
if learn_result:
|
|
3903
|
-
result["learned_template"] = learn_result
|
|
3904
|
-
result["message"] += f"\n📚 自动学习: {learn_result}"
|
|
3883
|
+
result["tip"] = "💡 建议调用 mobile_screenshot_with_som 确认弹窗是否已关闭"
|
|
3905
3884
|
|
|
3906
3885
|
return result
|
|
3907
3886
|
|
|
3908
|
-
# ========== 第2
|
|
3887
|
+
# ========== 第2步:模板匹配(自动执行,不需要 AI 介入)==========
|
|
3909
3888
|
screenshot_path = None
|
|
3910
3889
|
try:
|
|
3911
3890
|
from .template_matcher import TemplateMatcher
|
|
@@ -3924,16 +3903,14 @@ class BasicMobileToolsLite:
|
|
|
3924
3903
|
x_pct = best["percent"]["x"]
|
|
3925
3904
|
y_pct = best["percent"]["y"]
|
|
3926
3905
|
|
|
3927
|
-
#
|
|
3906
|
+
# 点击
|
|
3928
3907
|
click_result = self.click_by_percent(x_pct, y_pct)
|
|
3929
3908
|
time.sleep(0.5)
|
|
3930
3909
|
|
|
3931
|
-
# 🎯 再次检查应用状态(确保弹窗去除没有导致应用跳转)
|
|
3932
3910
|
app_check = self._check_app_switched()
|
|
3933
3911
|
return_result = None
|
|
3934
3912
|
|
|
3935
3913
|
if app_check['switched']:
|
|
3936
|
-
# 应用已跳转,说明弹窗去除失败,尝试返回目标应用
|
|
3937
3914
|
return_result = self._return_to_target_app()
|
|
3938
3915
|
|
|
3939
3916
|
result["success"] = True
|
|
@@ -3944,12 +3921,9 @@ class BasicMobileToolsLite:
|
|
|
3944
3921
|
f" 位置: ({x_pct:.1f}%, {y_pct:.1f}%)"
|
|
3945
3922
|
|
|
3946
3923
|
if app_check['switched']:
|
|
3947
|
-
msg += f"\n⚠️
|
|
3924
|
+
msg += f"\n⚠️ 应用已跳转"
|
|
3948
3925
|
if return_result:
|
|
3949
|
-
|
|
3950
|
-
msg += f"\n{return_result['message']}"
|
|
3951
|
-
else:
|
|
3952
|
-
msg += f"\n❌ 自动返回失败: {return_result['message']}"
|
|
3926
|
+
msg += f"\n{return_result['message']}"
|
|
3953
3927
|
|
|
3954
3928
|
result["message"] = msg
|
|
3955
3929
|
result["app_check"] = app_check
|
|
@@ -3961,17 +3935,12 @@ class BasicMobileToolsLite:
|
|
|
3961
3935
|
except Exception:
|
|
3962
3936
|
pass # 模板匹配失败,继续下一步
|
|
3963
3937
|
|
|
3964
|
-
# ========== 第3
|
|
3965
|
-
if not screenshot_path:
|
|
3966
|
-
screenshot_result = self.take_screenshot(description="需要AI分析", compress=True)
|
|
3967
|
-
|
|
3938
|
+
# ========== 第3步:控件树和模板匹配都失败,提示 AI 使用视觉识别 ==========
|
|
3968
3939
|
result["success"] = False
|
|
3940
|
+
result["fallback"] = "vision"
|
|
3969
3941
|
result["method"] = None
|
|
3970
|
-
result["
|
|
3971
|
-
|
|
3972
|
-
"💡 找到后使用 mobile_click_by_percent(x%, y%) 点击"
|
|
3973
|
-
result["screenshot"] = screenshot_result if not screenshot_path else {"screenshot_path": screenshot_path}
|
|
3974
|
-
result["need_ai_analysis"] = True
|
|
3942
|
+
result["popup_detected"] = True
|
|
3943
|
+
result["message"] = "⚠️ 控件树和模板匹配都未找到关闭按钮,请调用 mobile_screenshot_with_som 截图后用 click_by_som 点击"
|
|
3975
3944
|
|
|
3976
3945
|
return result
|
|
3977
3946
|
|