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.
@@ -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, "message": "iOS 客户端未初始化"}
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
- "screen_width": screen_width,
386
- "screen_height": screen_height,
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
- # 不压缩时,用截图实际尺寸(可能和 screen_width 不同)
484
+ # 返回结果(不压缩时尺寸相同)
414
485
  return {
415
486
  "success": True,
416
487
  "screenshot_path": str(final_path),
417
- "screen_width": screen_width,
418
- "screen_height": screen_height,
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 = True) -> Dict:
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, "message": "iOS 客户端未初始化"}
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["popup_detected"] = True
593
- result["popup_bounds"] = popup_info["bounds"]
594
- result["close_button_hints"] = close_positions
595
- result["message"] += f"\n🎯 检测到弹窗: {popup_info['bounds']}"
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, "message": "iOS 客户端未初始化"}
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
- "popup_bounds": f"[{popup_bounds[0]},{popup_bounds[1]}][{popup_bounds[2]},{popup_bounds[3]}]" if popup_bounds else None,
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, "message": "iOS 客户端未初始化"}
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, "message": "iOS 客户端未初始化"}
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._record_operation(
1037
- 'click',
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, "message": "iOS 客户端未初始化"}
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, "message": "无法获取屏幕尺寸"}
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._record_operation(
1138
- 'click',
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._record_operation('click', element=text, ref=text)
1179
- return {"success": True, "message": f"✅ 点击成功: '{text}'"}
1180
- return {"success": False, "message": f"❌ 文本不存在: {text}"}
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
- # 如果有位置参数,直接使用坐标点击(避免 u2 选择器匹配到错误的元素)
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
- position_info = f" ({position})" if position else ""
1197
- self._record_operation('click', element=text, x=x, y=y, ref=f"coords:{x},{y}")
1198
- return {"success": True, "message": f"✅ 点击成功(坐标定位): '{text}'{position_info} @ ({x},{y})"}
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
- position_info = f" ({position})" if position else ""
1216
- self._record_operation('click', element=text, ref=f"{attr_type}:{attr_value}")
1217
- return {"success": True, "message": f"✅ 点击成功({attr_type}): '{text}'{position_info}"}
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
- position_info = f" ({position})" if position else ""
1226
- self._record_operation('click', element=text, x=x, y=y, ref=f"coords:{x},{y}")
1227
- return {"success": True, "message": f"✅ 点击成功(坐标兜底): '{text}'{position_info} @ ({x},{y})"}
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
- return {"success": False, "message": f"❌ 文本不存在: {text}"}
1282
+ # 控件树找不到,提示用视觉识别
1283
+ return {"success": False, "fallback": "vision", "msg": f"未找到'{text}',用截图点击"}
1230
1284
  except Exception as e:
1231
- return {"success": False, "message": f"❌ 点击失败: {e}"}
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 点击(支持点击第 N 个元素)
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._record_operation('click', element=f"{resource_id}{index_desc}", ref=resource_id, index=index)
1392
- return {"success": True, "message": f"✅ 点击成功: {resource_id}{index_desc}"}
1437
+ self._record_click('id', resource_id, element_desc=resource_id)
1438
+ return {"success": True}
1393
1439
  else:
1394
- return {"success": False, "message": f"❌ 索引超出范围: 找到 {len(elements)} 个元素,但请求索引 {index}"}
1395
- return {"success": False, "message": f" 元素不存在: {resource_id}"}
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._record_operation('click', element=f"{resource_id}{index_desc}", ref=resource_id, index=index)
1405
- return {"success": True, "message": f"✅ 点击成功: {resource_id}{index_desc}" + (f" (共 {count} 个)" if count > 1 else "")}
1451
+ self._record_click('id', resource_id, element_desc=resource_id)
1452
+ return {"success": True}
1406
1453
  else:
1407
- return {"success": False, "message": f"❌ 索引超出范围: 找到 {count} 个元素,但请求索引 {index}"}
1408
- return {"success": False, "message": f" 元素不存在: {resource_id}"}
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, "message": f"❌ 点击失败: {e}"}
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, "message": "iOS 客户端未初始化"}
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._record_operation(
1493
- 'long_press',
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, "message": "iOS 客户端未初始化"}
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, "message": "无法获取屏幕尺寸"}
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._record_operation(
1578
- 'long_press',
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._record_operation('long_press', element=text, duration=duration, ref=text)
1625
- return {"success": True, "message": f"✅ 长按成功: '{text}' 持续 {duration}s"}
1626
- return {"success": False, "message": f"❌ 文本不存在: {text}"}
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._record_operation('long_press', element=text, duration=duration, ref=f"{attr_type}:{attr_value}")
1652
- return {"success": True, "message": f"✅ 长按成功({attr_type}): '{text}' 持续 {duration}s"}
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._record_operation('long_press', element=text, x=x, y=y, duration=duration, ref=f"coords:{x},{y}")
1661
- return {"success": True, "message": f"✅ 长按成功(坐标兜底): '{text}' @ ({x},{y}) 持续 {duration}s"}
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, "message": f"❌ 文本不存在: {text}"}
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._record_operation('long_press', element=resource_id, duration=duration, ref=resource_id)
1691
- return {"success": True, "message": f"✅ 长按成功: {resource_id} 持续 {duration}s"}
1692
- return {"success": False, "message": f"❌ 元素不存在: {resource_id}"}
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._record_operation('long_press', element=resource_id, duration=duration, ref=resource_id)
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, "message": f"❌ 元素不存在: {resource_id}"}
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._record_operation('input', element=resource_id, ref=resource_id, text=text)
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._record_operation('input', element=resource_id, ref=resource_id, text=text)
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._record_operation('input', element=resource_id, ref=resource_id, text=text)
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._record_operation('input', element=resource_id, ref=resource_id, text=text)
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._record_operation('input', element='EditText', ref='EditText', text=text)
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._record_operation('input', element='EditText', ref='EditText', text=text)
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
- self._record_operation(
1958
- 'input',
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, "message": "iOS 客户端未初始化"}
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
- record_info = {'direction': direction}
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, "message": f"✅ 按键成功: {key}"}
2112
- return {"success": False, "message": f"iOS 不支持: {key}"}
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._record_operation('press_key', key=key)
2118
- return {"success": True, "message": f"✅ 按键成功: {key}"}
2119
- return {"success": False, "message": f"❌ 不支持的按键: {key}"}
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, "message": f"✅ 已等待 {seconds} 秒"}
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, "message": f"✅ 已终止: {package_name}"}
2180
+ return {"success": True}
2172
2181
  except Exception as e:
2173
- return {"success": False, "message": f"❌ 终止失败: {e}"}
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
- result.append({
2305
- 'resource_id': resource_id,
2306
- 'text': text,
2307
- 'content_desc': content_desc,
2308
- 'bounds': bounds,
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
- result.append({
2325
- 'resource_id': resource_id,
2326
- 'text': text,
2327
- 'content_desc': content_desc,
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
- result.append({
2349
- 'resource_id': resource_id,
2350
- 'text': text,
2351
- 'content_desc': content_desc,
2352
- 'bounds': bounds,
2353
- 'clickable': clickable,
2354
- 'class': class_name
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, "message": "iOS 暂不支持,请使用截图+坐标点击"}
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
- "message": f"✅ 找到可能的关闭按钮",
2520
- "best_candidate": {
2521
- "reason": best['reason'],
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, "message": f"❌ 查找关闭按钮失败: {e}"}
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, "message": "iOS 暂不支持,请使用截图+坐标点击"}
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
- px1, py1, px2, py2 = popup_bounds
2728
- popup_width = px2 - px1
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
- # 点击后截图,让 AI 判断是否成功
2823
- screenshot_result = self.take_screenshot("关闭弹窗后")
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
- msg = f" 已点击关闭按钮 ({best['position']}): ({best['center_x']}, {best['center_y']})"
2843
+ # Token 优化:精简返回值
2844
+ result = {"success": True, "clicked": True}
2839
2845
  if app_check['switched']:
2840
- msg += f"\n⚠️ 应用已跳转,说明弹窗去除失败"
2846
+ result["switched"] = True
2841
2847
  if return_result:
2842
- if return_result['success']:
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
- # 返回候选按钮列表,让 AI 看截图判断
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, "message": f"❌ 关闭弹窗失败: {e}"}
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 test_main():",
3428
- " # 连接设备",
3429
- " d = u2.connect()",
3430
- " d.implicitly_wait(10) # 设置全局等待",
3431
- " ",
3432
- " # 启动应用",
3433
- f" d.app_start(PACKAGE_NAME)",
3434
- " time.sleep(LAUNCH_WAIT) # 等待启动(可调整)",
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
- " # 尝试关闭启动广告(可选,根据 App 情况调整)",
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
- ref = op.get('ref', '')
3455
- element = op.get('element', '')
3456
- has_coords = 'x' in op and 'y' in op
3457
- has_percent = 'x_percent' in op and 'y_percent' in op
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
- # 判断 ref 是否为坐标格式(coords_ 或 coords:)
3460
- is_coords_ref = ref.startswith('coords_') or ref.startswith('coords:')
3461
- is_percent_ref = ref.startswith('percent_')
3433
+ # 转义单引号
3434
+ value_escaped = locator_value.replace("'", "\\'") if locator_value else ''
3462
3435
 
3463
- # 优先级:文本 > ID > 百分比 > 坐标(兜底)
3464
- if ref and not is_coords_ref and not is_percent_ref and ':' not in ref:
3465
- # 1️⃣ 使用文本(最稳定,优先)- 排除 "text:xxx" 等带冒号的格式
3466
- script_lines.append(f" # 步骤{step_num}: 点击文本 '{ref}' (文本定位,最稳定)")
3467
- script_lines.append(f" safe_click(d, d(text='{ref}'))")
3468
- elif ref and ':' in ref and not is_coords_ref and not is_percent_ref:
3469
- # 1️⃣-b 使用文本(Android 的 text:xxx 或 description:xxx 格式)
3470
- # 提取冒号后面的实际文本值
3471
- actual_text = ref.split(':', 1)[1] if ':' in ref else ref
3472
- script_lines.append(f" # 步骤{step_num}: 点击文本 '{actual_text}' (文本定位,最稳定)")
3473
- script_lines.append(f" safe_click(d, d(text='{actual_text}'))")
3474
- elif ref and (':id/' in ref or ref.startswith('com.')):
3475
- # 2️⃣ 使用 resource-id(稳定)
3476
- script_lines.append(f" # 步骤{step_num}: 点击元素 (ID定位)")
3477
- script_lines.append(f" safe_click(d, d(resourceId='{ref}'))")
3478
- elif has_percent:
3479
- # 3️⃣ 使用百分比(跨分辨率兼容)
3480
- x_pct = op['x_percent']
3481
- y_pct = op['y_percent']
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
- continue # 无效操作,跳过
3492
-
3493
- script_lines.append(" time.sleep(0.5) # 等待响应")
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
- ref = op.get('ref', '')
3499
- has_coords = 'x' in op and 'y' in op
3500
- has_percent = 'x_percent' in op and 'y_percent' in op
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
- # 判断 ref 是否为坐标格式
3503
- is_coords_ref = ref.startswith('coords_') or ref.startswith('coords:')
3475
+ text_escaped = text.replace("'", "\\'")
3476
+ value_escaped = locator_value.replace("'", "\\'") if locator_value else ''
3504
3477
 
3505
- # 优先使用 ID,其次百分比,最后坐标
3506
- if ref and not is_coords_ref and (':id/' in ref or ref.startswith('com.')):
3507
- # 完整格式的 resource-id
3508
- script_lines.append(f" # 步骤{step_num}: 输入文本 '{text}' (ID定位)")
3509
- script_lines.append(f" d(resourceId='{ref}').set_text('{text}')")
3510
- elif ref and not is_coords_ref and not has_coords:
3511
- # 简短格式的 resource-id(不包含 com. :id/)
3512
- script_lines.append(f" # 步骤{step_num}: 输入文本 '{text}' (ID定位)")
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(f" time.sleep(0.3)")
3520
- script_lines.append(f" d.send_keys('{text}')")
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
- continue
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
- ref = op.get('ref', '')
3534
- element = op.get('element', '')
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
- has_coords = 'x' in op and 'y' in op
3537
- has_percent = 'x_percent' in op and 'y_percent' in op
3507
+ x_pct = op.get('x_percent', 0)
3508
+ y_pct = op.get('y_percent', 0)
3538
3509
 
3539
- # 判断 ref 是否为坐标格式
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
- # 优先级:文本 > ID > 百分比 > 坐标
3544
- if ref and not is_coords_ref and not is_percent_ref and ':' not in ref:
3545
- # 1️⃣ 使用文本(最稳定,优先)
3546
- script_lines.append(f" # 步骤{step_num}: 长按文本 '{ref}' (文本定位,最稳定)")
3547
- script_lines.append(f" d(text='{ref}').long_click(duration={duration})")
3548
- elif ref and ':' in ref and not is_coords_ref and not is_percent_ref:
3549
- # 1️⃣-b 使用文本(Android 的 text:xxx 或 description:xxx 格式)
3550
- actual_text = ref.split(':', 1)[1] if ':' in ref else ref
3551
- script_lines.append(f" # 步骤{step_num}: 长按文本 '{actual_text}' (文本定位,最稳定)")
3552
- script_lines.append(f" d(text='{actual_text}').long_click(duration={duration})")
3553
- elif ref and (':id/' in ref or ref.startswith('com.')):
3554
- # 2️⃣ 使用 resource-id(稳定)
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
- continue
3571
-
3572
- script_lines.append(" time.sleep(0.5) # 等待响应")
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.swipe_ext('{direction}')")
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
- " test_main()",
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
- # ========== 第1步:控件树查找关闭按钮 ==========
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
- # 点击(click_by_percent 内部已包含应用状态检查和自动返回)
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
- if return_result['success']:
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步:返回截图供 AI 分析 ==========
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["message"] = "❌ 控件树和模板匹配都未找到关闭按钮\n" \
3971
- "📸 已截图,请 AI 分析图片中的 X 按钮位置\n" \
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