mobile-mcp-ai 2.6.5__py3-none-any.whl → 2.6.7__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,7 +8,6 @@
8
8
  - 核心功能精简
9
9
  - 保留 pytest 脚本生成
10
10
  - 支持操作历史记录
11
- - Token 优化模式(省钱)
12
11
  """
13
12
 
14
13
  import asyncio
@@ -18,19 +17,6 @@ from pathlib import Path
18
17
  from typing import Dict, List, Optional
19
18
  from datetime import datetime
20
19
 
21
- # Token 优化配置(只精简格式,不限制数量,确保准确度)
22
- try:
23
- from mobile_mcp.config import Config
24
- TOKEN_OPTIMIZATION = Config.TOKEN_OPTIMIZATION_ENABLED
25
- MAX_ELEMENTS = Config.MAX_ELEMENTS_RETURN
26
- MAX_SOM_ELEMENTS = Config.MAX_SOM_ELEMENTS_RETURN
27
- COMPACT_RESPONSE = Config.COMPACT_RESPONSE
28
- except ImportError:
29
- TOKEN_OPTIMIZATION = True
30
- MAX_ELEMENTS = 0 # 0 = 不限制
31
- MAX_SOM_ELEMENTS = 0 # 0 = 不限制
32
- COMPACT_RESPONSE = True
33
-
34
20
 
35
21
  class BasicMobileToolsLite:
36
22
  """精简版移动端工具"""
@@ -349,7 +335,7 @@ class BasicMobileToolsLite:
349
335
  size = ios_client.wda.window_size()
350
336
  screen_width, screen_height = size[0], size[1]
351
337
  else:
352
- return {"success": False, "msg": "iOS未初始化"}
338
+ return {"success": False, "message": "iOS 客户端未初始化"}
353
339
  else:
354
340
  self.client.u2.screenshot(str(temp_path))
355
341
  info = self.client.u2.info
@@ -400,14 +386,22 @@ class BasicMobileToolsLite:
400
386
 
401
387
  cropped_size = final_path.stat().st_size
402
388
 
403
- # 返回结果
404
389
  return {
405
390
  "success": True,
406
391
  "screenshot_path": str(final_path),
392
+ "screen_width": screen_width,
393
+ "screen_height": screen_height,
407
394
  "image_width": img.width,
408
395
  "image_height": img.height,
409
396
  "crop_offset_x": crop_offset_x,
410
- "crop_offset_y": crop_offset_y
397
+ "crop_offset_y": crop_offset_y,
398
+ "file_size": f"{cropped_size/1024:.1f}KB",
399
+ "message": f"🔍 局部截图已保存: {final_path}\n"
400
+ f"📐 裁剪区域: ({crop_offset_x}, {crop_offset_y}) 起,{img.width}x{img.height} 像素\n"
401
+ f"📦 文件大小: {cropped_size/1024:.0f}KB\n"
402
+ f"🎯 【坐标换算】AI 返回坐标 (x, y) 后:\n"
403
+ f" 实际屏幕坐标 = ({crop_offset_x} + x, {crop_offset_y} + y)\n"
404
+ f" 或直接调用 mobile_click_at_coords(x, y, crop_offset_x={crop_offset_x}, crop_offset_y={crop_offset_y})"
411
405
  }
412
406
 
413
407
  # ========== 情况2:全屏压缩截图 ==========
@@ -460,14 +454,24 @@ class BasicMobileToolsLite:
460
454
  compressed_size = final_path.stat().st_size
461
455
  saved_percent = (1 - compressed_size / original_size) * 100
462
456
 
463
- # 返回结果
464
457
  return {
465
458
  "success": True,
466
459
  "screenshot_path": str(final_path),
467
- "image_width": image_width,
468
- "image_height": image_height,
469
- "original_img_width": original_img_width,
470
- "original_img_height": original_img_height
460
+ "screen_width": screen_width,
461
+ "screen_height": screen_height,
462
+ "original_img_width": original_img_width, # 截图原始宽度
463
+ "original_img_height": original_img_height, # 截图原始高度
464
+ "image_width": image_width, # 压缩后宽度(AI 看到的)
465
+ "image_height": image_height, # 压缩后高度(AI 看到的)
466
+ "original_size": f"{original_size/1024:.1f}KB",
467
+ "compressed_size": f"{compressed_size/1024:.1f}KB",
468
+ "saved_percent": f"{saved_percent:.0f}%",
469
+ "message": f"📸 截图已保存: {final_path}\n"
470
+ f"📐 原始尺寸: {original_img_width}x{original_img_height} → 压缩后: {image_width}x{image_height}\n"
471
+ f"📦 已压缩: {original_size/1024:.0f}KB → {compressed_size/1024:.0f}KB (省 {saved_percent:.0f}%)\n"
472
+ f"⚠️ 【坐标转换】AI 返回坐标后,请传入:\n"
473
+ f" image_width={image_width}, image_height={image_height},\n"
474
+ f" original_img_width={original_img_width}, original_img_height={original_img_height}"
471
475
  }
472
476
 
473
477
  # ========== 情况3:全屏不压缩截图 ==========
@@ -481,12 +485,21 @@ class BasicMobileToolsLite:
481
485
  final_path = self.screenshot_dir / filename
482
486
  temp_path.rename(final_path)
483
487
 
484
- # 返回结果(不压缩时尺寸相同)
488
+ # 不压缩时,用截图实际尺寸(可能和 screen_width 不同)
485
489
  return {
486
490
  "success": True,
487
491
  "screenshot_path": str(final_path),
488
- "image_width": img.width,
489
- "image_height": img.height
492
+ "screen_width": screen_width,
493
+ "screen_height": screen_height,
494
+ "original_img_width": img.width, # 截图实际尺寸
495
+ "original_img_height": img.height,
496
+ "image_width": img.width, # 未压缩,和原图一样
497
+ "image_height": img.height,
498
+ "file_size": f"{original_size/1024:.1f}KB",
499
+ "message": f"📸 截图已保存: {final_path}\n"
500
+ f"📐 截图尺寸: {img.width}x{img.height}\n"
501
+ f"📦 文件大小: {original_size/1024:.0f}KB(未压缩)\n"
502
+ f"💡 未压缩,坐标可直接使用"
490
503
  }
491
504
  except ImportError:
492
505
  # 如果没有 PIL,回退到原始方式(不压缩)
@@ -526,7 +539,7 @@ class BasicMobileToolsLite:
526
539
  size = ios_client.wda.window_size()
527
540
  screen_width, screen_height = size[0], size[1]
528
541
  else:
529
- return {"success": False, "msg": "iOS未初始化"}
542
+ return {"success": False, "message": "iOS 客户端未初始化"}
530
543
  else:
531
544
  self.client.u2.screenshot(str(temp_path))
532
545
  info = self.client.u2.info
@@ -640,16 +653,26 @@ class BasicMobileToolsLite:
640
653
  result = {
641
654
  "success": True,
642
655
  "screenshot_path": str(final_path),
656
+ "screen_width": screen_width,
657
+ "screen_height": screen_height,
643
658
  "image_width": img_width,
644
659
  "image_height": img_height,
645
- "grid_size": grid_size
660
+ "grid_size": grid_size,
661
+ "message": f"📸 网格截图已保存: {final_path}\n"
662
+ f"📐 尺寸: {img_width}x{img_height}\n"
663
+ f"📏 网格间距: {grid_size}px"
646
664
  }
647
665
 
648
666
  if popup_info:
649
- result["popup"] = popup_info["bounds"]
650
- # 只返回前3个最可能的关闭按钮位置
651
- if close_positions:
652
- result["close_hints"] = [(p['x'], p['y']) for p in close_positions[:3]]
667
+ result["popup_detected"] = True
668
+ result["popup_bounds"] = popup_info["bounds"]
669
+ result["close_button_hints"] = close_positions
670
+ result["message"] += f"\n🎯 检测到弹窗: {popup_info['bounds']}"
671
+ result["message"] += f"\n💡 可能的关闭按钮位置(绿色圆圈标注):"
672
+ for pos in close_positions:
673
+ result["message"] += f"\n {pos['priority']}. {pos['name']}: ({pos['x']}, {pos['y']})"
674
+ else:
675
+ result["popup_detected"] = False
653
676
 
654
677
  return result
655
678
 
@@ -686,7 +709,7 @@ class BasicMobileToolsLite:
686
709
  size = ios_client.wda.window_size()
687
710
  screen_width, screen_height = size[0], size[1]
688
711
  else:
689
- return {"success": False, "msg": "iOS未初始化"}
712
+ return {"success": False, "message": "iOS 客户端未初始化"}
690
713
  else:
691
714
  self.client.u2.screenshot(str(temp_path))
692
715
  info = self.client.u2.info
@@ -846,15 +869,38 @@ class BasicMobileToolsLite:
846
869
  img.save(str(final_path), "JPEG", quality=85)
847
870
  temp_path.unlink()
848
871
 
849
- # 返回结果(Token 优化:不返回 elements 列表,已存储在 self._som_elements)
872
+ # 构建元素列表文字
873
+ elements_text = "\n".join([
874
+ f" [{e['index']}] {e['desc']} → ({e['center'][0]}, {e['center'][1]})"
875
+ for e in som_elements[:15] # 只显示前15个
876
+ ])
877
+ if len(som_elements) > 15:
878
+ elements_text += f"\n ... 还有 {len(som_elements) - 15} 个元素"
879
+
880
+ # 构建弹窗提示文字
881
+ hints_text = ""
882
+ if popup_bounds:
883
+ hints_text = f"\n🎯 检测到弹窗区域(蓝色边框)\n"
884
+ hints_text += f" 如需关闭弹窗,请观察图片中的 X 按钮位置\n"
885
+ hints_text += f" 然后使用 mobile_click_by_percent(x%, y%) 点击"
886
+
850
887
  return {
851
888
  "success": True,
852
889
  "screenshot_path": str(final_path),
853
890
  "screen_width": screen_width,
854
891
  "screen_height": screen_height,
892
+ "image_width": img_width,
893
+ "image_height": img_height,
855
894
  "element_count": len(som_elements),
895
+ "elements": som_elements,
856
896
  "popup_detected": popup_bounds is not None,
857
- "hint": "查看截图上的编号,用 click_by_som(编号) 点击"
897
+ "popup_bounds": f"[{popup_bounds[0]},{popup_bounds[1]}][{popup_bounds[2]},{popup_bounds[3]}]" if popup_bounds else None,
898
+ "message": f"📸 SoM 截图已保存: {final_path}\n"
899
+ f"🏷️ 已标注 {len(som_elements)} 个可点击元素\n"
900
+ f"📋 元素列表:\n{elements_text}{hints_text}\n\n"
901
+ f"💡 使用方法:\n"
902
+ f" - 点击标注元素:mobile_click_by_som(编号)\n"
903
+ f" - 点击任意位置:mobile_click_by_percent(x%, y%)"
858
904
  }
859
905
 
860
906
  except ImportError:
@@ -935,6 +981,7 @@ class BasicMobileToolsLite:
935
981
 
936
982
  return {
937
983
  "success": True,
984
+ "message": f"✅ 已点击 [{index}] {target['desc']} → ({cx}, {cy})\n💡 建议:再次截图确认操作是否成功",
938
985
  "clicked": {
939
986
  "index": index,
940
987
  "desc": target['desc'],
@@ -968,7 +1015,7 @@ class BasicMobileToolsLite:
968
1015
  size = ios_client.wda.window_size()
969
1016
  width, height = size[0], size[1]
970
1017
  else:
971
- return {"success": False, "msg": "iOS未初始化"}
1018
+ return {"success": False, "message": "iOS 客户端未初始化"}
972
1019
  else:
973
1020
  self.client.u2.screenshot(str(screenshot_path))
974
1021
  info = self.client.u2.info
@@ -1046,7 +1093,7 @@ class BasicMobileToolsLite:
1046
1093
  size = ios_client.wda.window_size()
1047
1094
  screen_width, screen_height = size[0], size[1]
1048
1095
  else:
1049
- return {"success": False, "msg": "iOS未初始化"}
1096
+ return {"success": False, "message": "iOS 客户端未初始化"}
1050
1097
  else:
1051
1098
  info = self.client.u2.info
1052
1099
  screen_width = info.get('displayWidth', 0)
@@ -1161,14 +1208,14 @@ class BasicMobileToolsLite:
1161
1208
  size = ios_client.wda.window_size()
1162
1209
  width, height = size[0], size[1]
1163
1210
  else:
1164
- return {"success": False, "msg": "iOS未初始化"}
1211
+ return {"success": False, "message": "iOS 客户端未初始化"}
1165
1212
  else:
1166
1213
  info = self.client.u2.info
1167
1214
  width = info.get('displayWidth', 0)
1168
1215
  height = info.get('displayHeight', 0)
1169
1216
 
1170
1217
  if width == 0 or height == 0:
1171
- return {"success": False, "msg": "无法获取屏幕尺寸"}
1218
+ return {"success": False, "message": "无法获取屏幕尺寸"}
1172
1219
 
1173
1220
  # 第2步:百分比转像素坐标
1174
1221
  # 公式:像素 = 屏幕尺寸 × (百分比 / 100)
@@ -1189,13 +1236,15 @@ class BasicMobileToolsLite:
1189
1236
 
1190
1237
  return {
1191
1238
  "success": True,
1239
+ "message": f"✅ 百分比点击成功: ({x_percent}%, {y_percent}%) → 像素({x}, {y})",
1240
+ "screen_size": {"width": width, "height": height},
1241
+ "percent": {"x": x_percent, "y": y_percent},
1192
1242
  "pixel": {"x": x, "y": y}
1193
1243
  }
1194
1244
  except Exception as e:
1195
1245
  return {"success": False, "message": f"❌ 百分比点击失败: {e}"}
1196
1246
 
1197
- def click_by_text(self, text: str, timeout: float = 3.0, position: Optional[str] = None,
1198
- verify: Optional[str] = None) -> Dict:
1247
+ def click_by_text(self, text: str, timeout: float = 3.0, position: Optional[str] = None) -> Dict:
1199
1248
  """通过文本点击 - 先查 XML 树,再精准匹配
1200
1249
 
1201
1250
  Args:
@@ -1204,7 +1253,6 @@ class BasicMobileToolsLite:
1204
1253
  position: 位置信息,当有多个相同文案时使用。支持:
1205
1254
  - 垂直方向: "top"/"upper"/"上", "bottom"/"lower"/"下", "middle"/"center"/"中"
1206
1255
  - 水平方向: "left"/"左", "right"/"右", "center"/"中"
1207
- verify: 可选,点击后验证的文本。如果指定,会检查该文本是否出现在页面上
1208
1256
  """
1209
1257
  try:
1210
1258
  if self._is_ios():
@@ -1216,17 +1264,10 @@ class BasicMobileToolsLite:
1216
1264
  if elem.exists:
1217
1265
  elem.click()
1218
1266
  time.sleep(0.3)
1267
+ # 使用标准记录格式
1219
1268
  self._record_click('text', text, element_desc=text, locator_attr='text')
1220
- # 验证逻辑
1221
- if verify:
1222
- return self._verify_after_click(verify, ios=True)
1223
- # 返回页面文本摘要,方便确认页面变化
1224
- page_texts = self._get_page_texts(10)
1225
- return {"success": True, "page_texts": page_texts}
1226
- # 控件树找不到,提示用视觉识别
1227
- return {"success": False, "fallback": "vision", "msg": f"未找到'{text}',用截图点击"}
1228
- else:
1229
- return {"success": False, "msg": "iOS未初始化"}
1269
+ return {"success": True, "message": f"✅ 点击成功: '{text}'"}
1270
+ return {"success": False, "message": f"❌ 文本不存在: {text}"}
1230
1271
  else:
1231
1272
  # 获取屏幕尺寸用于计算百分比
1232
1273
  screen_width, screen_height = self.client.u2.window_size()
@@ -1247,20 +1288,17 @@ class BasicMobileToolsLite:
1247
1288
  x_pct = round(cx / screen_width * 100, 1)
1248
1289
  y_pct = round(cy / screen_height * 100, 1)
1249
1290
 
1250
- # 如果有位置参数,直接使用坐标点击
1291
+ # 如果有位置参数,直接使用坐标点击(避免 u2 选择器匹配到错误的元素)
1251
1292
  if position and bounds:
1252
1293
  x = (bounds[0] + bounds[2]) // 2
1253
1294
  y = (bounds[1] + bounds[3]) // 2
1254
1295
  self.client.u2.click(x, y)
1255
1296
  time.sleep(0.3)
1297
+ position_info = f" ({position})" if position else ""
1298
+ # 虽然用坐标点击,但记录时仍使用文本定位(脚本更稳定)
1256
1299
  self._record_click('text', attr_value, x_pct, y_pct,
1257
- element_desc=f"{text}({position})", locator_attr=attr_type)
1258
- # 验证逻辑
1259
- if verify:
1260
- return self._verify_after_click(verify)
1261
- # 返回页面文本摘要
1262
- page_texts = self._get_page_texts(10)
1263
- return {"success": True, "page_texts": page_texts}
1300
+ element_desc=f"{text}{position_info}", locator_attr=attr_type)
1301
+ return {"success": True, "message": f"✅ 点击成功(坐标定位): '{text}'{position_info} @ ({x},{y})"}
1264
1302
 
1265
1303
  # 没有位置参数时,使用选择器定位
1266
1304
  if attr_type == 'text':
@@ -1277,74 +1315,27 @@ class BasicMobileToolsLite:
1277
1315
  if elem and elem.exists(timeout=1):
1278
1316
  elem.click()
1279
1317
  time.sleep(0.3)
1318
+ position_info = f" ({position})" if position else ""
1319
+ # 使用标准记录格式:文本定位
1280
1320
  self._record_click('text', attr_value, x_pct, y_pct,
1281
1321
  element_desc=text, locator_attr=attr_type)
1282
- # 验证逻辑
1283
- if verify:
1284
- return self._verify_after_click(verify)
1285
- # 返回页面文本摘要
1286
- page_texts = self._get_page_texts(10)
1287
- return {"success": True, "page_texts": page_texts}
1322
+ return {"success": True, "message": f"✅ 点击成功({attr_type}): '{text}'{position_info}"}
1288
1323
 
1289
- # 选择器失败,用坐标兜底
1324
+ # 如果选择器失败,用坐标兜底
1290
1325
  if bounds:
1291
1326
  x = (bounds[0] + bounds[2]) // 2
1292
1327
  y = (bounds[1] + bounds[3]) // 2
1293
1328
  self.client.u2.click(x, y)
1294
1329
  time.sleep(0.3)
1330
+ position_info = f" ({position})" if position else ""
1331
+ # 选择器失败,用百分比作为兜底
1295
1332
  self._record_click('percent', f"{x_pct}%,{y_pct}%", x_pct, y_pct,
1296
- element_desc=text)
1297
- # 验证逻辑
1298
- if verify:
1299
- return self._verify_after_click(verify)
1300
- # 返回页面文本摘要
1301
- page_texts = self._get_page_texts(10)
1302
- return {"success": True, "page_texts": page_texts}
1333
+ element_desc=f"{text}{position_info}")
1334
+ return {"success": True, "message": f"✅ 点击成功(坐标兜底): '{text}'{position_info} @ ({x},{y})"}
1303
1335
 
1304
- # 控件树找不到,提示用视觉识别
1305
- return {"success": False, "fallback": "vision", "msg": f"未找到'{text}',用截图点击"}
1306
- except Exception as e:
1307
- return {"success": False, "msg": str(e)}
1308
-
1309
- def _verify_after_click(self, verify_text: str, ios: bool = False, timeout: float = 2.0) -> Dict:
1310
- """点击后验证期望文本是否出现
1311
-
1312
- Args:
1313
- verify_text: 期望出现的文本
1314
- ios: 是否是 iOS 设备
1315
- timeout: 验证超时时间
1316
-
1317
- Returns:
1318
- {"success": True, "verified": True/False, "hint": "..."}
1319
- """
1320
- time.sleep(0.5) # 等待页面更新
1321
-
1322
- try:
1323
- if ios:
1324
- ios_client = self._get_ios_client()
1325
- if ios_client and hasattr(ios_client, 'wda'):
1326
- exists = ios_client.wda(name=verify_text).exists or \
1327
- ios_client.wda(label=verify_text).exists
1328
- else:
1329
- exists = False
1330
- else:
1331
- # Android: 检查文本或包含文本
1332
- exists = self.client.u2(text=verify_text).exists(timeout=timeout) or \
1333
- self.client.u2(textContains=verify_text).exists(timeout=0.5) or \
1334
- self.client.u2(description=verify_text).exists(timeout=0.5)
1335
-
1336
- if exists:
1337
- return {"success": True, "verified": True}
1338
- else:
1339
- # 验证失败,提示可以截图确认
1340
- return {
1341
- "success": True, # 点击本身成功
1342
- "verified": False,
1343
- "expect": verify_text,
1344
- "hint": "验证失败,可截图确认"
1345
- }
1336
+ return {"success": False, "message": f"❌ 文本不存在: {text}"}
1346
1337
  except Exception as e:
1347
- return {"success": True, "verified": False, "hint": f"验证异常: {e}"}
1338
+ return {"success": False, "message": f" 点击失败: {e}"}
1348
1339
 
1349
1340
  def _find_element_in_tree(self, text: str, position: Optional[str] = None) -> Optional[Dict]:
1350
1341
  """在 XML 树中查找包含指定文本的元素,优先返回可点击的元素
@@ -1483,8 +1474,15 @@ class BasicMobileToolsLite:
1483
1474
  return None
1484
1475
 
1485
1476
  def click_by_id(self, resource_id: str, index: int = 0) -> Dict:
1486
- """通过 resource-id 点击"""
1477
+ """通过 resource-id 点击(支持点击第 N 个元素)
1478
+
1479
+ Args:
1480
+ resource_id: 元素的 resource-id
1481
+ index: 第几个元素(从 0 开始),默认 0 表示第一个
1482
+ """
1487
1483
  try:
1484
+ index_desc = f"[{index}]" if index > 0 else ""
1485
+
1488
1486
  if self._is_ios():
1489
1487
  ios_client = self._get_ios_client()
1490
1488
  if ios_client and hasattr(ios_client, 'wda'):
@@ -1492,31 +1490,33 @@ class BasicMobileToolsLite:
1492
1490
  if not elem.exists:
1493
1491
  elem = ios_client.wda(name=resource_id)
1494
1492
  if elem.exists:
1493
+ # 获取所有匹配的元素
1495
1494
  elements = elem.find_elements()
1496
1495
  if index < len(elements):
1497
1496
  elements[index].click()
1498
1497
  time.sleep(0.3)
1499
- self._record_click('id', resource_id, element_desc=resource_id)
1500
- return {"success": True}
1498
+ # 使用标准记录格式
1499
+ self._record_click('id', resource_id, element_desc=f"{resource_id}{index_desc}")
1500
+ return {"success": True, "message": f"✅ 点击成功: {resource_id}{index_desc}"}
1501
1501
  else:
1502
- return {"success": False, "msg": f"索引{index}超出范围(共{len(elements)}个)"}
1503
- return {"success": False, "fallback": "vision", "msg": f"未找到ID'{resource_id}'"}
1504
- else:
1505
- return {"success": False, "msg": "iOS未初始化"}
1502
+ return {"success": False, "message": f"❌ 索引超出范围: 找到 {len(elements)} 个元素,但请求索引 {index}"}
1503
+ return {"success": False, "message": f" 元素不存在: {resource_id}"}
1506
1504
  else:
1507
1505
  elem = self.client.u2(resourceId=resource_id)
1508
1506
  if elem.exists(timeout=0.5):
1507
+ # 获取匹配元素数量
1509
1508
  count = elem.count
1510
1509
  if index < count:
1511
1510
  elem[index].click()
1512
1511
  time.sleep(0.3)
1513
- self._record_click('id', resource_id, element_desc=resource_id)
1514
- return {"success": True}
1512
+ # 使用标准记录格式
1513
+ self._record_click('id', resource_id, element_desc=f"{resource_id}{index_desc}")
1514
+ return {"success": True, "message": f"✅ 点击成功: {resource_id}{index_desc}" + (f" (共 {count} 个)" if count > 1 else "")}
1515
1515
  else:
1516
- return {"success": False, "msg": f"索引{index}超出范围(共{count}个)"}
1517
- return {"success": False, "fallback": "vision", "msg": f"未找到ID'{resource_id}'"}
1516
+ return {"success": False, "message": f"❌ 索引超出范围: 找到 {count} 个元素,但请求索引 {index}"}
1517
+ return {"success": False, "message": f" 元素不存在: {resource_id}"}
1518
1518
  except Exception as e:
1519
- return {"success": False, "msg": str(e)}
1519
+ return {"success": False, "message": f"❌ 点击失败: {e}"}
1520
1520
 
1521
1521
  # ==================== 长按操作 ====================
1522
1522
 
@@ -1550,7 +1550,7 @@ class BasicMobileToolsLite:
1550
1550
  size = ios_client.wda.window_size()
1551
1551
  screen_width, screen_height = size[0], size[1]
1552
1552
  else:
1553
- return {"success": False, "msg": "iOS未初始化"}
1553
+ return {"success": False, "message": "iOS 客户端未初始化"}
1554
1554
  else:
1555
1555
  info = self.client.u2.info
1556
1556
  screen_width = info.get('displayWidth', 0)
@@ -1603,11 +1603,23 @@ class BasicMobileToolsLite:
1603
1603
 
1604
1604
  if converted:
1605
1605
  if conversion_type == "crop_offset":
1606
- return {"success": True}
1606
+ return {
1607
+ "success": True,
1608
+ "message": f"✅ 长按成功: ({x}, {y}) 持续 {duration}s\n"
1609
+ f" 🔍 局部截图坐标转换: ({original_x},{original_y}) + 偏移({crop_offset_x},{crop_offset_y}) → ({x},{y})"
1610
+ }
1607
1611
  else:
1608
- return {"success": True}
1612
+ return {
1613
+ "success": True,
1614
+ "message": f"✅ 长按成功: ({x}, {y}) 持续 {duration}s\n"
1615
+ f" 📐 坐标已转换: ({original_x},{original_y}) → ({x},{y})\n"
1616
+ f" 🖼️ 图片尺寸: {image_width}x{image_height} → 屏幕: {screen_width}x{screen_height}"
1617
+ }
1609
1618
  else:
1610
- return {"success": True}
1619
+ return {
1620
+ "success": True,
1621
+ "message": f"✅ 长按成功: ({x}, {y}) 持续 {duration}s [相对位置: {x_percent}%, {y_percent}%]"
1622
+ }
1611
1623
  except Exception as e:
1612
1624
  return {"success": False, "message": f"❌ 长按失败: {e}"}
1613
1625
 
@@ -1636,14 +1648,14 @@ class BasicMobileToolsLite:
1636
1648
  size = ios_client.wda.window_size()
1637
1649
  width, height = size[0], size[1]
1638
1650
  else:
1639
- return {"success": False, "msg": "iOS未初始化"}
1651
+ return {"success": False, "message": "iOS 客户端未初始化"}
1640
1652
  else:
1641
1653
  info = self.client.u2.info
1642
1654
  width = info.get('displayWidth', 0)
1643
1655
  height = info.get('displayHeight', 0)
1644
1656
 
1645
1657
  if width == 0 or height == 0:
1646
- return {"success": False, "msg": "无法获取屏幕尺寸"}
1658
+ return {"success": False, "message": "无法获取屏幕尺寸"}
1647
1659
 
1648
1660
  # 第2步:百分比转像素坐标
1649
1661
  x = int(width * x_percent / 100)
@@ -1665,7 +1677,13 @@ class BasicMobileToolsLite:
1665
1677
  self._record_long_press('percent', f"{x_percent}%,{y_percent}%", duration,
1666
1678
  x_percent, y_percent, element_desc=f"百分比({x_percent}%,{y_percent}%)")
1667
1679
 
1668
- return {"success": True
1680
+ return {
1681
+ "success": True,
1682
+ "message": f"✅ 百分比长按成功: ({x_percent}%, {y_percent}%) → 像素({x}, {y}) 持续 {duration}s",
1683
+ "screen_size": {"width": width, "height": height},
1684
+ "percent": {"x": x_percent, "y": y_percent},
1685
+ "pixel": {"x": x, "y": y},
1686
+ "duration": duration
1669
1687
  }
1670
1688
  except Exception as e:
1671
1689
  return {"success": False, "message": f"❌ 百分比长按失败: {e}"}
@@ -1695,8 +1713,8 @@ class BasicMobileToolsLite:
1695
1713
  ios_client.wda.swipe(x, y, x, y, duration=duration)
1696
1714
  time.sleep(0.3)
1697
1715
  self._record_long_press('text', text, duration, element_desc=text, locator_attr='text')
1698
- return {"success": True}
1699
- return {"success": False, "msg": f"未找到'{text}'"}
1716
+ return {"success": True, "message": f"✅ 长按成功: '{text}' 持续 {duration}s"}
1717
+ return {"success": False, "message": f"❌ 文本不存在: {text}"}
1700
1718
  else:
1701
1719
  # 获取屏幕尺寸用于计算百分比
1702
1720
  screen_width, screen_height = self.client.u2.window_size()
@@ -1734,7 +1752,7 @@ class BasicMobileToolsLite:
1734
1752
  time.sleep(0.3)
1735
1753
  self._record_long_press('text', attr_value, duration, x_pct, y_pct,
1736
1754
  element_desc=text, locator_attr=attr_type)
1737
- return {"success": True}
1755
+ return {"success": True, "message": f"✅ 长按成功({attr_type}): '{text}' 持续 {duration}s"}
1738
1756
 
1739
1757
  # 如果选择器失败,用坐标兜底
1740
1758
  if bounds:
@@ -1744,9 +1762,9 @@ class BasicMobileToolsLite:
1744
1762
  time.sleep(0.3)
1745
1763
  self._record_long_press('percent', f"{x_pct}%,{y_pct}%", duration, x_pct, y_pct,
1746
1764
  element_desc=text)
1747
- return {"success": True}
1765
+ return {"success": True, "message": f"✅ 长按成功(坐标兜底): '{text}' @ ({x},{y}) 持续 {duration}s"}
1748
1766
 
1749
- return {"success": False, "msg": f"未找到'{text}'"}
1767
+ return {"success": False, "message": f"❌ 文本不存在: {text}"}
1750
1768
  except Exception as e:
1751
1769
  return {"success": False, "message": f"❌ 长按失败: {e}"}
1752
1770
 
@@ -1774,8 +1792,8 @@ class BasicMobileToolsLite:
1774
1792
  ios_client.wda.swipe(x, y, x, y, duration=duration)
1775
1793
  time.sleep(0.3)
1776
1794
  self._record_long_press('id', resource_id, duration, element_desc=resource_id)
1777
- return {"success": True}
1778
- return {"success": False, "msg": f"未找到'{resource_id}'"}
1795
+ return {"success": True, "message": f"✅ 长按成功: {resource_id} 持续 {duration}s"}
1796
+ return {"success": False, "message": f"❌ 元素不存在: {resource_id}"}
1779
1797
  else:
1780
1798
  elem = self.client.u2(resourceId=resource_id)
1781
1799
  if elem.exists(timeout=0.5):
@@ -1783,7 +1801,7 @@ class BasicMobileToolsLite:
1783
1801
  time.sleep(0.3)
1784
1802
  self._record_long_press('id', resource_id, duration, element_desc=resource_id)
1785
1803
  return {"success": True, "message": f"✅ 长按成功: {resource_id} 持续 {duration}s"}
1786
- return {"success": False, "msg": f"未找到'{resource_id}'"}
1804
+ return {"success": False, "message": f"❌ 元素不存在: {resource_id}"}
1787
1805
  except Exception as e:
1788
1806
  return {"success": False, "message": f"❌ 长按失败: {e}"}
1789
1807
 
@@ -2040,8 +2058,15 @@ class BasicMobileToolsLite:
2040
2058
  x_percent = round(x / screen_width * 100, 1) if screen_width > 0 else 0
2041
2059
  y_percent = round(y / screen_height * 100, 1) if screen_height > 0 else 0
2042
2060
 
2043
- # 使用标准记录格式
2044
- self._record_input(text, 'percent', f"{x_percent}%,{y_percent}%", x_percent, y_percent)
2061
+ self._record_operation(
2062
+ 'input',
2063
+ x=x,
2064
+ y=y,
2065
+ x_percent=x_percent,
2066
+ y_percent=y_percent,
2067
+ ref=f"coords_{x}_{y}",
2068
+ text=text
2069
+ )
2045
2070
 
2046
2071
  # 🎯 关键步骤:检查应用是否跳转,如果跳转则自动返回目标应用
2047
2072
  app_check = self._check_app_switched()
@@ -2071,13 +2096,16 @@ class BasicMobileToolsLite:
2071
2096
 
2072
2097
  # ==================== 导航操作 ====================
2073
2098
 
2074
- async def swipe(self, direction: str, y: Optional[int] = None, y_percent: Optional[float] = None) -> Dict:
2099
+ async def swipe(self, direction: str, y: Optional[int] = None, y_percent: Optional[float] = None,
2100
+ distance: Optional[int] = None, distance_percent: Optional[float] = None) -> Dict:
2075
2101
  """滑动屏幕
2076
2102
 
2077
2103
  Args:
2078
2104
  direction: 滑动方向 (up/down/left/right)
2079
2105
  y: 左右滑动时指定的高度坐标(像素)
2080
2106
  y_percent: 左右滑动时指定的高度百分比 (0-100)
2107
+ distance: 横向滑动时指定的滑动距离(像素),仅用于 left/right
2108
+ distance_percent: 横向滑动时指定的滑动距离百分比 (0-100),仅用于 left/right
2081
2109
  """
2082
2110
  try:
2083
2111
  if self._is_ios():
@@ -2086,7 +2114,7 @@ class BasicMobileToolsLite:
2086
2114
  size = ios_client.wda.window_size()
2087
2115
  width, height = size[0], size[1]
2088
2116
  else:
2089
- return {"success": False, "msg": "iOS未初始化"}
2117
+ return {"success": False, "message": "iOS 客户端未初始化"}
2090
2118
  else:
2091
2119
  width, height = self.client.u2.window_size()
2092
2120
 
@@ -2104,20 +2132,53 @@ class BasicMobileToolsLite:
2104
2132
  swipe_y = y
2105
2133
  else:
2106
2134
  swipe_y = center_y
2135
+
2136
+ # 计算横向滑动距离
2137
+ if distance_percent is not None:
2138
+ if not (0 <= distance_percent <= 100):
2139
+ return {"success": False, "message": f"❌ distance_percent 必须在 0-100 之间: {distance_percent}"}
2140
+ swipe_distance = int(width * distance_percent / 100)
2141
+ elif distance is not None:
2142
+ if distance <= 0:
2143
+ return {"success": False, "message": f"❌ distance 必须大于 0: {distance}"}
2144
+ if distance > width:
2145
+ return {"success": False, "message": f"❌ distance 不能超过屏幕宽度 ({width}): {distance}"}
2146
+ swipe_distance = distance
2147
+ else:
2148
+ # 默认滑动距离:屏幕宽度的 60%(从 0.8 到 0.2)
2149
+ swipe_distance = int(width * 0.6)
2150
+
2151
+ # 计算起始和结束位置
2152
+ if direction == 'left':
2153
+ # 从右向左滑动:起始点在右侧,结束点在左侧
2154
+ # 确保起始点不超出屏幕右边界
2155
+ start_x = min(center_x + swipe_distance // 2, width - 10)
2156
+ end_x = start_x - swipe_distance
2157
+ # 确保结束点不超出屏幕左边界
2158
+ if end_x < 10:
2159
+ end_x = 10
2160
+ start_x = min(end_x + swipe_distance, width - 10)
2161
+ else: # right
2162
+ # 从左向右滑动:起始点在左侧,结束点在右侧
2163
+ # 确保起始点不超出屏幕左边界
2164
+ start_x = max(center_x - swipe_distance // 2, 10)
2165
+ end_x = start_x + swipe_distance
2166
+ # 确保结束点不超出屏幕右边界
2167
+ if end_x > width - 10:
2168
+ end_x = width - 10
2169
+ start_x = max(end_x - swipe_distance, 10)
2170
+
2171
+ x1, y1, x2, y2 = start_x, swipe_y, end_x, swipe_y
2107
2172
  else:
2108
2173
  swipe_y = center_y
2109
-
2110
- swipe_map = {
2111
- 'up': (center_x, int(height * 0.8), center_x, int(height * 0.2)),
2112
- 'down': (center_x, int(height * 0.2), center_x, int(height * 0.8)),
2113
- 'left': (int(width * 0.8), swipe_y, int(width * 0.2), swipe_y),
2114
- 'right': (int(width * 0.2), swipe_y, int(width * 0.8), swipe_y),
2115
- }
2116
-
2117
- if direction not in swipe_map:
2118
- return {"success": False, "message": f"❌ 不支持的方向: {direction}"}
2119
-
2120
- x1, y1, x2, y2 = swipe_map[direction]
2174
+ # 纵向滑动保持原有逻辑
2175
+ swipe_map = {
2176
+ 'up': (center_x, int(height * 0.8), center_x, int(height * 0.2)),
2177
+ 'down': (center_x, int(height * 0.2), center_x, int(height * 0.8)),
2178
+ }
2179
+ if direction not in swipe_map:
2180
+ return {"success": False, "message": f"❌ 不支持的方向: {direction}"}
2181
+ x1, y1, x2, y2 = swipe_map[direction]
2121
2182
 
2122
2183
  if self._is_ios():
2123
2184
  ios_client.wda.swipe(x1, y1, x2, y2)
@@ -2138,10 +2199,21 @@ class BasicMobileToolsLite:
2138
2199
  # 构建返回消息
2139
2200
  msg = f"✅ 滑动成功: {direction}"
2140
2201
  if direction in ['left', 'right']:
2202
+ msg_parts = []
2141
2203
  if y_percent is not None:
2142
- msg += f" (高度: {y_percent}% = {swipe_y}px)"
2204
+ msg_parts.append(f"高度: {y_percent}% = {swipe_y}px")
2143
2205
  elif y is not None:
2144
- msg += f" (高度: {y}px)"
2206
+ msg_parts.append(f"高度: {y}px")
2207
+
2208
+ if distance_percent is not None:
2209
+ msg_parts.append(f"距离: {distance_percent}% = {swipe_distance}px")
2210
+ elif distance is not None:
2211
+ msg_parts.append(f"距离: {distance}px")
2212
+ else:
2213
+ msg_parts.append(f"距离: 默认 {swipe_distance}px")
2214
+
2215
+ if msg_parts:
2216
+ msg += f" ({', '.join(msg_parts)})"
2145
2217
 
2146
2218
  # 如果检测到应用跳转,添加警告和返回结果
2147
2219
  if app_check['switched']:
@@ -2182,22 +2254,22 @@ class BasicMobileToolsLite:
2182
2254
  ios_client.wda.send_keys('\n')
2183
2255
  elif ios_key == 'home':
2184
2256
  ios_client.wda.home()
2185
- return {"success": True}
2186
- return {"success": False, "msg": f"iOS不支持{key}"}
2257
+ return {"success": True, "message": f"✅ 按键成功: {key}"}
2258
+ return {"success": False, "message": f"iOS 不支持: {key}"}
2187
2259
  else:
2188
2260
  keycode = key_map.get(key.lower())
2189
2261
  if keycode:
2190
2262
  self.client.u2.shell(f'input keyevent {keycode}')
2191
2263
  self._record_key(key)
2192
- return {"success": True}
2193
- return {"success": False, "msg": f"不支持按键{key}"}
2264
+ return {"success": True, "message": f"✅ 按键成功: {key}"}
2265
+ return {"success": False, "message": f"❌ 不支持的按键: {key}"}
2194
2266
  except Exception as e:
2195
2267
  return {"success": False, "message": f"❌ 按键失败: {e}"}
2196
2268
 
2197
2269
  def wait(self, seconds: float) -> Dict:
2198
2270
  """等待指定时间"""
2199
2271
  time.sleep(seconds)
2200
- return {"success": True}
2272
+ return {"success": True, "message": f"✅ 已等待 {seconds} 秒"}
2201
2273
 
2202
2274
  # ==================== 应用管理 ====================
2203
2275
 
@@ -2226,7 +2298,10 @@ class BasicMobileToolsLite:
2226
2298
 
2227
2299
  self._record_operation('launch_app', package_name=package_name)
2228
2300
 
2229
- return {"success": True}
2301
+ return {
2302
+ "success": True,
2303
+ "message": f"✅ 已启动: {package_name}\n💡 建议等待 2-3 秒让页面加载\n📱 已设置应用状态监测"
2304
+ }
2230
2305
  except Exception as e:
2231
2306
  return {"success": False, "message": f"❌ 启动失败: {e}"}
2232
2307
 
@@ -2239,9 +2314,9 @@ class BasicMobileToolsLite:
2239
2314
  ios_client.wda.app_terminate(package_name)
2240
2315
  else:
2241
2316
  self.client.u2.app_stop(package_name)
2242
- return {"success": True}
2317
+ return {"success": True, "message": f"✅ 已终止: {package_name}"}
2243
2318
  except Exception as e:
2244
- return {"success": False, "msg": str(e)}
2319
+ return {"success": False, "message": f"❌ 终止失败: {e}"}
2245
2320
 
2246
2321
  def list_apps(self, filter_keyword: str = "") -> Dict:
2247
2322
  """列出已安装应用"""
@@ -2353,26 +2428,6 @@ class BasicMobileToolsLite:
2353
2428
  '_shadow', 'shadow_', '_divider', 'divider_', '_line', 'line_'
2354
2429
  }
2355
2430
 
2356
- # Token 优化:构建精简元素(只返回非空字段)
2357
- def build_compact_element(resource_id, text, content_desc, bounds, likely_click, class_name):
2358
- """只返回有值的字段,节省 token"""
2359
- item = {}
2360
- if resource_id:
2361
- # 精简 resource_id,只保留最后一段
2362
- item['id'] = resource_id.split('/')[-1] if '/' in resource_id else resource_id
2363
- if text:
2364
- item['text'] = text
2365
- if content_desc:
2366
- item['desc'] = content_desc
2367
- if bounds:
2368
- item['bounds'] = bounds
2369
- if likely_click:
2370
- item['click'] = True # 启发式判断可点击
2371
- # class 精简:只保留关键类型
2372
- if class_name in ('EditText', 'TextInput', 'Button', 'ImageButton', 'CheckBox', 'Switch'):
2373
- item['type'] = class_name
2374
- return item
2375
-
2376
2431
  result = []
2377
2432
  for elem in elements:
2378
2433
  # 获取元素属性
@@ -2392,11 +2447,14 @@ class BasicMobileToolsLite:
2392
2447
 
2393
2448
  # 2. 检查是否是功能控件(直接保留)
2394
2449
  if class_name in FUNCTIONAL_WIDGETS:
2395
- # 使用启发式判断可点击性(替代不准确的 clickable 属性)
2396
- likely_click = self._is_likely_clickable(class_name, resource_id, text, content_desc, clickable, bounds)
2397
- item = build_compact_element(resource_id, text, content_desc, bounds, likely_click, class_name)
2398
- if item:
2399
- result.append(item)
2450
+ result.append({
2451
+ 'resource_id': resource_id,
2452
+ 'text': text,
2453
+ 'content_desc': content_desc,
2454
+ 'bounds': bounds,
2455
+ 'clickable': clickable,
2456
+ 'class': class_name
2457
+ })
2400
2458
  continue
2401
2459
 
2402
2460
  # 3. 检查是否是容器控件
@@ -2409,10 +2467,14 @@ class BasicMobileToolsLite:
2409
2467
  # 所有属性都是默认值,过滤掉
2410
2468
  continue
2411
2469
  # 有业务ID或其他有意义属性,保留
2412
- likely_click = self._is_likely_clickable(class_name, resource_id, text, content_desc, clickable, bounds)
2413
- item = build_compact_element(resource_id, text, content_desc, bounds, likely_click, class_name)
2414
- if item:
2415
- result.append(item)
2470
+ result.append({
2471
+ 'resource_id': resource_id,
2472
+ 'text': text,
2473
+ 'content_desc': content_desc,
2474
+ 'bounds': bounds,
2475
+ 'clickable': clickable,
2476
+ 'class': class_name
2477
+ })
2416
2478
  continue
2417
2479
 
2418
2480
  # 4. 检查是否是装饰类控件
@@ -2429,72 +2491,19 @@ class BasicMobileToolsLite:
2429
2491
  continue
2430
2492
 
2431
2493
  # 6. 其他情况:有意义的元素保留
2432
- likely_click = self._is_likely_clickable(class_name, resource_id, text, content_desc, clickable, bounds)
2433
- item = build_compact_element(resource_id, text, content_desc, bounds, likely_click, class_name)
2434
- if item:
2435
- result.append(item)
2436
-
2437
- # Token 优化:可选限制返回元素数量(默认不限制,确保准确度)
2438
- if TOKEN_OPTIMIZATION and MAX_ELEMENTS > 0 and len(result) > MAX_ELEMENTS:
2439
- # 仅在用户明确设置 MAX_ELEMENTS_RETURN 时才截断
2440
- truncated = result[:MAX_ELEMENTS]
2441
- truncated.append({
2442
- '_truncated': True,
2443
- '_total': len(result),
2444
- '_shown': MAX_ELEMENTS
2494
+ result.append({
2495
+ 'resource_id': resource_id,
2496
+ 'text': text,
2497
+ 'content_desc': content_desc,
2498
+ 'bounds': bounds,
2499
+ 'clickable': clickable,
2500
+ 'class': class_name
2445
2501
  })
2446
- return truncated
2447
2502
 
2448
2503
  return result
2449
2504
  except Exception as e:
2450
2505
  return [{"error": f"获取元素失败: {e}"}]
2451
2506
 
2452
- def _get_page_texts(self, max_count: int = 15) -> List[str]:
2453
- """获取页面关键文本列表(用于点击后快速确认页面变化)
2454
-
2455
- Args:
2456
- max_count: 最多返回的文本数量
2457
-
2458
- Returns:
2459
- 页面上的关键文本列表(去重)
2460
- """
2461
- try:
2462
- if self._is_ios():
2463
- ios_client = self._get_ios_client()
2464
- if ios_client and hasattr(ios_client, 'wda'):
2465
- # iOS: 获取所有 StaticText 的文本
2466
- elements = ios_client.wda(type='XCUIElementTypeStaticText').find_elements()
2467
- texts = set()
2468
- for elem in elements[:50]: # 限制扫描数量
2469
- try:
2470
- name = elem.name or elem.label
2471
- if name and len(name) > 1 and len(name) < 50:
2472
- texts.add(name)
2473
- except:
2474
- pass
2475
- return list(texts)[:max_count]
2476
- return []
2477
- else:
2478
- # Android: 快速扫描 XML 获取文本
2479
- xml_string = self.client.u2.dump_hierarchy(compressed=True)
2480
- import xml.etree.ElementTree as ET
2481
- root = ET.fromstring(xml_string)
2482
-
2483
- texts = set()
2484
- for elem in root.iter():
2485
- text = elem.get('text', '').strip()
2486
- desc = elem.get('content-desc', '').strip()
2487
- # 只收集有意义的文本(长度2-30,非纯数字)
2488
- for t in [text, desc]:
2489
- if t and 2 <= len(t) <= 30 and not t.isdigit():
2490
- texts.add(t)
2491
- if len(texts) >= max_count * 2: # 收集足够后停止
2492
- break
2493
-
2494
- return list(texts)[:max_count]
2495
- except Exception:
2496
- return []
2497
-
2498
2507
  def _has_business_id(self, resource_id: str) -> bool:
2499
2508
  """
2500
2509
  判断resource_id是否是业务相关的ID
@@ -2526,68 +2535,6 @@ class BasicMobileToolsLite:
2526
2535
 
2527
2536
  return True
2528
2537
 
2529
- def _is_likely_clickable(self, class_name: str, resource_id: str, text: str,
2530
- content_desc: str, clickable: bool, bounds: str) -> bool:
2531
- """
2532
- 启发式判断元素是否可能可点击
2533
-
2534
- Android 的 clickable 属性经常不准确,因为:
2535
- 1. 点击事件可能设置在父容器上
2536
- 2. 使用 onTouchListener 而不是 onClick
2537
- 3. RecyclerView item 通过 ItemClickListener 处理
2538
-
2539
- 此方法通过多种规则推断元素的真实可点击性
2540
- """
2541
- # 规则1:clickable=true 肯定可点击
2542
- if clickable:
2543
- return True
2544
-
2545
- # 规则2:特定类型的控件通常可点击
2546
- TYPICALLY_CLICKABLE = {
2547
- 'Button', 'ImageButton', 'CheckBox', 'RadioButton', 'Switch',
2548
- 'ToggleButton', 'FloatingActionButton', 'Chip', 'TabView',
2549
- 'EditText', 'TextInput', # 输入框可点击获取焦点
2550
- }
2551
- if class_name in TYPICALLY_CLICKABLE:
2552
- return True
2553
-
2554
- # 规则3:resource_id 包含可点击关键词
2555
- if resource_id:
2556
- id_lower = resource_id.lower()
2557
- CLICK_KEYWORDS = [
2558
- 'btn', 'button', 'click', 'tap', 'submit', 'confirm',
2559
- 'cancel', 'close', 'back', 'next', 'prev', 'more',
2560
- 'action', 'link', 'menu', 'tab', 'item', 'cell',
2561
- 'card', 'avatar', 'icon', 'entry', 'option', 'arrow'
2562
- ]
2563
- for kw in CLICK_KEYWORDS:
2564
- if kw in id_lower:
2565
- return True
2566
-
2567
- # 规则4:content_desc 包含可点击暗示
2568
- if content_desc:
2569
- desc_lower = content_desc.lower()
2570
- CLICK_HINTS = ['点击', '按钮', '关闭', '返回', '更多', 'click', 'tap', 'button', 'close']
2571
- for hint in CLICK_HINTS:
2572
- if hint in desc_lower:
2573
- return True
2574
-
2575
- # 规则5:有 resource_id 或 content_desc 的小图标可能可点击
2576
- # (纯 ImageView 不加判断,误判率太高)
2577
- if class_name in ('ImageView', 'Image') and (resource_id or content_desc) and bounds:
2578
- match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds)
2579
- if match:
2580
- x1, y1, x2, y2 = map(int, match.groups())
2581
- w, h = x2 - x1, y2 - y1
2582
- # 小图标(20-100px)更可能是按钮
2583
- if 20 <= w <= 100 and 20 <= h <= 100:
2584
- return True
2585
-
2586
- # 规则6:移除(TextView 误判率太高,只依赖上面的规则)
2587
- # 如果有 clickable=true 或 ID/desc 中有关键词,前面的规则已经覆盖
2588
-
2589
- return False
2590
-
2591
2538
  def find_close_button(self) -> Dict:
2592
2539
  """智能查找关闭按钮(不点击,只返回位置)
2593
2540
 
@@ -2601,7 +2548,7 @@ class BasicMobileToolsLite:
2601
2548
  import re
2602
2549
 
2603
2550
  if self._is_ios():
2604
- return {"success": False, "msg": "iOS暂不支持"}
2551
+ return {"success": False, "message": "iOS 暂不支持,请使用截图+坐标点击"}
2605
2552
 
2606
2553
  # 获取屏幕尺寸
2607
2554
  screen_width = self.client.u2.info.get('displayWidth', 720)
@@ -2612,14 +2559,6 @@ class BasicMobileToolsLite:
2612
2559
  import xml.etree.ElementTree as ET
2613
2560
  root = ET.fromstring(xml_string)
2614
2561
 
2615
- # 🔴 先检测是否有弹窗,避免误识别普通页面的按钮
2616
- popup_bounds, popup_confidence = self._detect_popup_with_confidence(
2617
- root, screen_width, screen_height
2618
- )
2619
-
2620
- if popup_bounds is None or popup_confidence < 0.5:
2621
- return {"success": True, "popup": False}
2622
-
2623
2562
  # 关闭按钮特征
2624
2563
  close_texts = ['×', 'X', 'x', '关闭', '取消', 'close', 'Close', '跳过', '知道了', '我知道了']
2625
2564
  candidates = []
@@ -2721,16 +2660,27 @@ class BasicMobileToolsLite:
2721
2660
  candidates.sort(key=lambda x: x['score'], reverse=True)
2722
2661
  best = candidates[0]
2723
2662
 
2724
- # Token 优化:只返回最必要的信息
2725
2663
  return {
2726
2664
  "success": True,
2727
- "popup": True,
2728
- "close": {"x": best['x_percent'], "y": best['y_percent']},
2729
- "cmd": f"click_by_percent({best['x_percent']},{best['y_percent']})"
2665
+ "message": f"✅ 找到可能的关闭按钮",
2666
+ "best_candidate": {
2667
+ "reason": best['reason'],
2668
+ "center": {"x": best['center_x'], "y": best['center_y']},
2669
+ "percent": {"x": best['x_percent'], "y": best['y_percent']},
2670
+ "bounds": best['bounds'],
2671
+ "size": best['size'],
2672
+ "score": best['score']
2673
+ },
2674
+ "click_command": f"mobile_click_by_percent({best['x_percent']}, {best['y_percent']})",
2675
+ "other_candidates": [
2676
+ {"reason": c['reason'], "percent": f"({c['x_percent']}%, {c['y_percent']}%)", "score": c['score']}
2677
+ for c in candidates[1:4]
2678
+ ] if len(candidates) > 1 else [],
2679
+ "screen_size": {"width": screen_width, "height": screen_height}
2730
2680
  }
2731
2681
 
2732
2682
  except Exception as e:
2733
- return {"success": False, "msg": str(e)}
2683
+ return {"success": False, "message": f"❌ 查找关闭按钮失败: {e}"}
2734
2684
 
2735
2685
  def close_popup(self) -> Dict:
2736
2686
  """智能关闭弹窗(改进版)
@@ -2753,7 +2703,7 @@ class BasicMobileToolsLite:
2753
2703
 
2754
2704
  # 获取屏幕尺寸
2755
2705
  if self._is_ios():
2756
- return {"success": False, "msg": "iOS暂不支持"}
2706
+ return {"success": False, "message": "iOS 暂不支持,请使用截图+坐标点击"}
2757
2707
 
2758
2708
  screen_width = self.client.u2.info.get('displayWidth', 720)
2759
2709
  screen_height = self.client.u2.info.get('displayHeight', 1280)
@@ -2781,10 +2731,8 @@ class BasicMobileToolsLite:
2781
2731
  # 如果置信度不够高,记录但继续尝试查找关闭按钮
2782
2732
  popup_detected = popup_bounds is not None and popup_confidence >= 0.6
2783
2733
 
2784
- # 🔴 关键检查:如果没有检测到弹窗区域,直接返回"无弹窗"
2785
- # 避免误点击普通页面上的"关闭"、"取消"等按钮
2786
- if not popup_detected:
2787
- return {"success": True, "popup": False}
2734
+ # 【重要修复】如果没有检测到弹窗区域,只搜索有明确关闭特征的元素(文本、resource-id等)
2735
+ # 避免误点击普通页面的右上角图标
2788
2736
 
2789
2737
  # ===== 第二步:在弹窗范围内查找关闭按钮 =====
2790
2738
  for idx, elem in enumerate(all_elements):
@@ -2793,6 +2741,7 @@ class BasicMobileToolsLite:
2793
2741
  bounds_str = elem.attrib.get('bounds', '')
2794
2742
  class_name = elem.attrib.get('class', '')
2795
2743
  clickable = elem.attrib.get('clickable', 'false') == 'true'
2744
+ resource_id = elem.attrib.get('resource-id', '')
2796
2745
 
2797
2746
  if not bounds_str:
2798
2747
  continue
@@ -2809,7 +2758,7 @@ class BasicMobileToolsLite:
2809
2758
  center_y = (y1 + y2) // 2
2810
2759
 
2811
2760
  # 如果检测到弹窗区域,检查元素是否在弹窗范围内或附近
2812
- in_popup = True
2761
+ in_popup = False
2813
2762
  popup_edge_bonus = 0
2814
2763
  is_floating_close = False # 是否是浮动关闭按钮(在弹窗外部上方)
2815
2764
  if popup_bounds:
@@ -2850,6 +2799,20 @@ class BasicMobileToolsLite:
2850
2799
  # 浮动关闭按钮(在弹窗上方外侧)给予高额加分
2851
2800
  if is_floating_close:
2852
2801
  popup_edge_bonus += 5.0 # 大幅加分
2802
+ elif not popup_detected:
2803
+ # 没有检测到弹窗时,只处理有明确关闭特征的元素
2804
+ # 检查是否有明确的关闭特征(文本、resource-id、content-desc)
2805
+ has_explicit_close_feature = (
2806
+ text in close_texts or
2807
+ any(kw in content_desc.lower() for kw in close_desc_keywords) or
2808
+ 'close' in resource_id.lower() or
2809
+ 'dismiss' in resource_id.lower() or
2810
+ 'cancel' in resource_id.lower()
2811
+ )
2812
+ if not has_explicit_close_feature:
2813
+ continue # 没有明确关闭特征,跳过
2814
+ # 有明确关闭特征时,允许处理
2815
+ in_popup = True
2853
2816
 
2854
2817
  if not in_popup:
2855
2818
  continue
@@ -2923,9 +2886,86 @@ class BasicMobileToolsLite:
2923
2886
  pass
2924
2887
 
2925
2888
  if not close_candidates:
2889
+ # 如果检测到高置信度的弹窗区域,先尝试点击常见的关闭按钮位置
2926
2890
  if popup_detected and popup_bounds:
2927
- return {"success": False, "fallback": "vision", "popup": True}
2928
- return {"success": True, "popup": False}
2891
+ px1, py1, px2, py2 = popup_bounds
2892
+ popup_width = px2 - px1
2893
+ popup_height = py2 - py1
2894
+
2895
+ # 【优化】X按钮有三种常见位置:
2896
+ # 1. 弹窗内靠近顶部边界(内嵌X按钮)- 最常见
2897
+ # 2. 弹窗边界上方(浮动X按钮)
2898
+ # 3. 弹窗正下方(底部关闭按钮)
2899
+ offset_x = max(60, int(popup_width * 0.07)) # 宽度7%
2900
+ offset_y_above = max(35, int(popup_height * 0.025)) # 高度2.5%,在边界之上
2901
+ offset_y_near = max(45, int(popup_height * 0.03)) # 高度3%,紧贴顶边界内侧
2902
+
2903
+ try_positions = [
2904
+ # 【最高优先级】弹窗内紧贴顶部边界
2905
+ (px2 - offset_x, py1 + offset_y_near, "弹窗右上角"),
2906
+ # 弹窗边界上方(浮动X按钮)
2907
+ (px2 - offset_x, py1 - offset_y_above, "弹窗右上浮"),
2908
+ # 弹窗正下方中间(底部关闭按钮)
2909
+ ((px1 + px2) // 2, py2 + max(50, int(popup_height * 0.04)), "弹窗下方中间"),
2910
+ # 弹窗正上方中间
2911
+ ((px1 + px2) // 2, py1 - 40, "弹窗正上方"),
2912
+ ]
2913
+
2914
+ for try_x, try_y, position_name in try_positions:
2915
+ if 0 <= try_x <= screen_width and 0 <= try_y <= screen_height:
2916
+ self.client.u2.click(try_x, try_y)
2917
+ time.sleep(0.3)
2918
+
2919
+ # 🎯 关键步骤:检查应用是否跳转,如果跳转说明弹窗去除失败,需要返回目标应用
2920
+ app_check = self._check_app_switched()
2921
+ return_result = None
2922
+
2923
+ if app_check['switched']:
2924
+ # 应用已跳转,说明弹窗去除失败,尝试返回目标应用
2925
+ return_result = self._return_to_target_app()
2926
+
2927
+ # 尝试后截图,让 AI 判断是否成功
2928
+ screenshot_result = self.take_screenshot("尝试关闭后")
2929
+
2930
+ msg = f"✅ 已尝试点击常见关闭按钮位置"
2931
+ if app_check['switched']:
2932
+ msg += f"\n⚠️ 应用已跳转,说明弹窗去除失败"
2933
+ if return_result:
2934
+ if return_result['success']:
2935
+ msg += f"\n{return_result['message']}"
2936
+ else:
2937
+ msg += f"\n❌ 自动返回失败: {return_result['message']}"
2938
+
2939
+ return {
2940
+ "success": True,
2941
+ "message": msg,
2942
+ "tried_positions": [p[2] for p in try_positions],
2943
+ "screenshot": screenshot_result.get("screenshot_path", ""),
2944
+ "app_check": app_check,
2945
+ "return_to_app": return_result,
2946
+ "tip": "请查看截图确认弹窗是否已关闭。如果还在,可手动分析截图找到关闭按钮位置。"
2947
+ }
2948
+
2949
+ # 没有检测到弹窗区域,截图让 AI 分析
2950
+ screenshot_result = self.take_screenshot(description="页面截图", compress=True)
2951
+
2952
+ return {
2953
+ "success": False,
2954
+ "message": "❌ 未检测到弹窗区域,已截图供 AI 分析",
2955
+ "action_required": "请查看截图找到关闭按钮,调用 mobile_click_at_coords 点击",
2956
+ "screenshot": screenshot_result.get("screenshot_path", ""),
2957
+ "screen_size": {"width": screen_width, "height": screen_height},
2958
+ "image_size": {
2959
+ "width": screenshot_result.get("image_width", screen_width),
2960
+ "height": screenshot_result.get("image_height", screen_height)
2961
+ },
2962
+ "original_size": {
2963
+ "width": screenshot_result.get("original_img_width", screen_width),
2964
+ "height": screenshot_result.get("original_img_height", screen_height)
2965
+ },
2966
+ "search_areas": ["弹窗右上角", "弹窗正上方", "弹窗下方中间", "屏幕右上角"],
2967
+ "time_warning": "⚠️ 截图分析期间弹窗可能自动消失。如果是定时弹窗,建议等待其自动消失。"
2968
+ }
2929
2969
 
2930
2970
  # 按得分排序,取最可能的
2931
2971
  close_candidates.sort(key=lambda x: x['score'], reverse=True)
@@ -2943,22 +2983,62 @@ class BasicMobileToolsLite:
2943
2983
  # 应用已跳转,说明弹窗去除失败,尝试返回目标应用
2944
2984
  return_result = self._return_to_target_app()
2945
2985
 
2946
- # 记录操作
2947
- self._record_click('percent', f"{best['x_percent']}%,{best['y_percent']}%",
2948
- best['x_percent'], best['y_percent'],
2949
- element_desc=f"关闭按钮({best['position']})")
2986
+ # 点击后截图,让 AI 判断是否成功
2987
+ screenshot_result = self.take_screenshot("关闭弹窗后")
2988
+
2989
+ # 记录操作(使用百分比,跨设备兼容)
2990
+ self._record_operation(
2991
+ 'click',
2992
+ x=best['center_x'],
2993
+ y=best['center_y'],
2994
+ x_percent=best['x_percent'],
2995
+ y_percent=best['y_percent'],
2996
+ screen_width=screen_width,
2997
+ screen_height=screen_height,
2998
+ ref=f"close_popup_{best['position']}"
2999
+ )
2950
3000
 
2951
- # Token 优化:精简返回值
2952
- result = {"success": True, "clicked": True}
3001
+ # 构建返回消息
3002
+ msg = f"✅ 已点击关闭按钮 ({best['position']}): ({best['center_x']}, {best['center_y']})"
2953
3003
  if app_check['switched']:
2954
- result["switched"] = True
3004
+ msg += f"\n⚠️ 应用已跳转,说明弹窗去除失败"
2955
3005
  if return_result:
2956
- result["returned"] = return_result['success']
3006
+ if return_result['success']:
3007
+ msg += f"\n{return_result['message']}"
3008
+ else:
3009
+ msg += f"\n❌ 自动返回失败: {return_result['message']}"
2957
3010
 
2958
- return result
3011
+ # 返回候选按钮列表,让 AI 看截图判断
3012
+ # 如果弹窗还在,AI 可以选择点击其他候选按钮
3013
+ return {
3014
+ "success": True,
3015
+ "message": msg,
3016
+ "clicked": {
3017
+ "position": best['position'],
3018
+ "match_type": best['match_type'],
3019
+ "coords": (best['center_x'], best['center_y']),
3020
+ "percent": (best['x_percent'], best['y_percent'])
3021
+ },
3022
+ "screenshot": screenshot_result.get("screenshot_path", ""),
3023
+ "popup_detected": popup_detected,
3024
+ "popup_confidence": popup_confidence if popup_bounds else 0,
3025
+ "popup_bounds": f"[{popup_bounds[0]},{popup_bounds[1]}][{popup_bounds[2]},{popup_bounds[3]}]" if popup_detected else None,
3026
+ "app_check": app_check,
3027
+ "return_to_app": return_result,
3028
+ "other_candidates": [
3029
+ {
3030
+ "position": c['position'],
3031
+ "type": c['match_type'],
3032
+ "coords": (c['center_x'], c['center_y']),
3033
+ "percent": (c['x_percent'], c['y_percent'])
3034
+ }
3035
+ for c in close_candidates[1:4] # 返回其他3个候选,AI 可以选择
3036
+ ],
3037
+ "tip": "请查看截图判断弹窗是否已关闭。如果弹窗还在,可以尝试点击 other_candidates 中的其他位置"
3038
+ }
2959
3039
 
2960
3040
  except Exception as e:
2961
- return {"success": False, "msg": str(e)}
3041
+ return {"success": False, "message": f"❌ 关闭弹窗失败: {e}"}
2962
3042
 
2963
3043
  def _get_position_name(self, rel_x: float, rel_y: float) -> str:
2964
3044
  """根据相对坐标获取位置名称"""
@@ -3040,6 +3120,15 @@ class BasicMobileToolsLite:
3040
3120
  resource_id = elem.attrib.get('resource-id', '')
3041
3121
  clickable = elem.attrib.get('clickable', 'false') == 'true'
3042
3122
 
3123
+ # 检查是否是关闭按钮
3124
+ is_close_button = (
3125
+ 'close' in resource_id.lower() or
3126
+ 'dismiss' in resource_id.lower() or
3127
+ 'cancel' in resource_id.lower() or
3128
+ '×' in elem.attrib.get('text', '') or
3129
+ 'X' in elem.attrib.get('text', '')
3130
+ )
3131
+
3043
3132
  all_elements.append({
3044
3133
  'idx': idx,
3045
3134
  'bounds': (x1, y1, x2, y2),
@@ -3052,6 +3141,7 @@ class BasicMobileToolsLite:
3052
3141
  'clickable': clickable,
3053
3142
  'center_x': (x1 + x2) // 2,
3054
3143
  'center_y': (y1 + y2) // 2,
3144
+ 'is_close_button': is_close_button,
3055
3145
  })
3056
3146
 
3057
3147
  if not all_elements:
@@ -3060,6 +3150,8 @@ class BasicMobileToolsLite:
3060
3150
  # 弹窗检测关键词
3061
3151
  dialog_class_keywords = ['Dialog', 'Popup', 'Alert', 'Modal', 'BottomSheet', 'PopupWindow']
3062
3152
  dialog_id_keywords = ['dialog', 'popup', 'alert', 'modal', 'bottom_sheet', 'overlay', 'mask']
3153
+ # 广告弹窗关键词(全屏广告、激励视频等)
3154
+ ad_popup_keywords = ['ad_close', 'ad_button', 'full_screen', 'interstitial', 'reward', 'close_icon', 'close_btn']
3063
3155
 
3064
3156
  popup_candidates = []
3065
3157
  has_mask_layer = False
@@ -3090,6 +3182,59 @@ class BasicMobileToolsLite:
3090
3182
  if y1 < 50:
3091
3183
  continue
3092
3184
 
3185
+ # 【非弹窗特征】如果元素包含底部导航栏(底部tab),则不是弹窗
3186
+ # 底部导航栏通常在屏幕底部,高度约100-200像素
3187
+ if y2 > screen_height * 0.85:
3188
+ # 检查是否包含tab相关的resource-id或class
3189
+ if 'tab' in resource_id.lower() or 'Tab' in class_name or 'navigation' in resource_id.lower():
3190
+ continue # 跳过底部导航栏
3191
+
3192
+ # 【非弹窗特征】如果元素包含顶部搜索栏,则不是弹窗
3193
+ if y1 < screen_height * 0.15:
3194
+ if 'search' in resource_id.lower() or 'Search' in class_name:
3195
+ continue # 跳过顶部搜索栏
3196
+
3197
+ # 先检查是否有强弹窗特征(用于后续判断)
3198
+ has_strong_popup_feature = (
3199
+ any(kw in class_name for kw in dialog_class_keywords) or
3200
+ any(kw in resource_id.lower() for kw in dialog_id_keywords) or
3201
+ any(kw in resource_id.lower() for kw in ad_popup_keywords) # 广告弹窗关键词
3202
+ )
3203
+
3204
+ # 检查是否有子元素是关闭按钮(作为弹窗特征)
3205
+ has_close_button_child = False
3206
+ elem_bounds = elem['bounds']
3207
+ for other_elem in all_elements:
3208
+ if other_elem['idx'] == elem['idx']:
3209
+ continue
3210
+ if other_elem['is_close_button']:
3211
+ # 检查关闭按钮是否在这个元素范围内
3212
+ ox1, oy1, ox2, oy2 = other_elem['bounds']
3213
+ ex1, ey1, ex2, ey2 = elem_bounds
3214
+ if ex1 <= ox1 and ey1 <= oy1 and ex2 >= ox2 and ey2 >= oy2:
3215
+ has_close_button_child = True
3216
+ break
3217
+
3218
+ # 【非弹窗特征】如果元素包含明显的页面内容特征,则不是弹窗
3219
+ # 检查是否包含视频播放器、内容列表等页面元素
3220
+ page_content_keywords = ['video', 'player', 'recycler', 'list', 'scroll', 'viewpager', 'fragment']
3221
+ if any(kw in resource_id.lower() or kw in class_name.lower() for kw in page_content_keywords):
3222
+ # 如果面积很大且没有强弹窗特征,则不是弹窗
3223
+ if area_ratio > 0.6 and not has_strong_popup_feature:
3224
+ continue
3225
+
3226
+ # 【非弹窗特征】如果元素面积过大(接近全屏),即使居中也不应该是弹窗
3227
+ # 真正的弹窗通常不会超过屏幕的60%
3228
+ # 对于面积 > 0.6 的元素,如果没有强特征,直接跳过(避免误判首页内容区域)
3229
+ if area_ratio > 0.6 and not has_strong_popup_feature:
3230
+ continue # 跳过大面积非弹窗元素(接近全屏的内容区域,如首页视频播放区域)
3231
+
3232
+ # 对于面积 > 0.7 的元素,即使有强特征也要更严格
3233
+ if area_ratio > 0.7:
3234
+ # 需要非常强的特征才认为是弹窗
3235
+ if not has_strong_popup_feature:
3236
+ continue
3237
+
3093
3238
  confidence = 0.0
3094
3239
 
3095
3240
  # 【强特征】class 名称包含弹窗关键词 (+0.5)
@@ -3100,19 +3245,46 @@ class BasicMobileToolsLite:
3100
3245
  if any(kw in resource_id.lower() for kw in dialog_id_keywords):
3101
3246
  confidence += 0.4
3102
3247
 
3248
+ # 【强特征】resource-id 包含广告弹窗关键词 (+0.4)
3249
+ if any(kw in resource_id.lower() for kw in ad_popup_keywords):
3250
+ confidence += 0.4
3251
+
3252
+ # 【强特征】包含关闭按钮作为子元素 (+0.3)
3253
+ if has_close_button_child:
3254
+ confidence += 0.3
3255
+
3103
3256
  # 【中等特征】居中显示 (+0.2)
3257
+ # 但如果没有强特征,降低权重
3104
3258
  center_x = elem['center_x']
3105
3259
  center_y = elem['center_y']
3106
3260
  is_centered_x = abs(center_x - screen_width / 2) < screen_width * 0.15
3107
3261
  is_centered_y = abs(center_y - screen_height / 2) < screen_height * 0.25
3262
+
3263
+ has_strong_feature = (
3264
+ any(kw in class_name for kw in dialog_class_keywords) or
3265
+ any(kw in resource_id.lower() for kw in dialog_id_keywords) or
3266
+ any(kw in resource_id.lower() for kw in ad_popup_keywords) or
3267
+ has_close_button_child
3268
+ )
3269
+
3108
3270
  if is_centered_x and is_centered_y:
3109
- confidence += 0.2
3271
+ if has_strong_feature:
3272
+ confidence += 0.2
3273
+ else:
3274
+ confidence += 0.1 # 没有强特征时降低权重
3110
3275
  elif is_centered_x:
3111
- confidence += 0.1
3276
+ if has_strong_feature:
3277
+ confidence += 0.1
3278
+ else:
3279
+ confidence += 0.05 # 没有强特征时降低权重
3112
3280
 
3113
3281
  # 【中等特征】非全屏但有一定大小 (+0.15)
3282
+ # 但如果没有强特征,降低权重
3114
3283
  if 0.15 < area_ratio < 0.75:
3115
- confidence += 0.15
3284
+ if has_strong_feature:
3285
+ confidence += 0.15
3286
+ else:
3287
+ confidence += 0.08 # 没有强特征时降低权重
3116
3288
 
3117
3289
  # 【弱特征】XML 顺序靠后(在视图层级上层)(+0.1)
3118
3290
  if elem['idx'] > len(all_elements) * 0.5:
@@ -3139,8 +3311,22 @@ class BasicMobileToolsLite:
3139
3311
  popup_candidates.sort(key=lambda x: (x['confidence'], x['idx']), reverse=True)
3140
3312
  best = popup_candidates[0]
3141
3313
 
3142
- # 只有置信度 >= 0.6 才返回弹窗
3143
- if best['confidence'] >= 0.6:
3314
+ # 更严格的阈值:只有置信度 >= 0.7 才返回弹窗
3315
+ # 如果没有强特征(class或resource-id包含弹窗关键词),需要更高的置信度
3316
+ has_strong_feature = (
3317
+ any(kw in best['class'] for kw in dialog_class_keywords) or
3318
+ any(kw in best['resource_id'].lower() for kw in dialog_id_keywords) or
3319
+ any(kw in best['resource_id'].lower() for kw in ad_popup_keywords)
3320
+ )
3321
+
3322
+ if has_strong_feature:
3323
+ # 有强特征时,阈值0.7
3324
+ threshold = 0.7
3325
+ else:
3326
+ # 没有强特征时,阈值0.85(更严格)
3327
+ threshold = 0.85
3328
+
3329
+ if best['confidence'] >= threshold:
3144
3330
  return best['bounds'], best['confidence']
3145
3331
 
3146
3332
  return None, best['confidence']
@@ -3859,28 +4045,10 @@ class BasicMobileToolsLite:
3859
4045
  try:
3860
4046
  import xml.etree.ElementTree as ET
3861
4047
 
3862
- # ========== 第0步:先检测是否有弹窗 ==========
4048
+ # ========== 第1步:控件树查找关闭按钮 ==========
3863
4049
  xml_string = self.client.u2.dump_hierarchy(compressed=False)
3864
4050
  root = ET.fromstring(xml_string)
3865
4051
 
3866
- screen_width = self.client.u2.info.get('displayWidth', 1440)
3867
- screen_height = self.client.u2.info.get('displayHeight', 3200)
3868
-
3869
- popup_bounds, popup_confidence = self._detect_popup_with_confidence(
3870
- root, screen_width, screen_height
3871
- )
3872
-
3873
- # 如果没有检测到弹窗,直接返回"无弹窗"
3874
- if popup_bounds is None or popup_confidence < 0.5:
3875
- result["success"] = True
3876
- result["method"] = None
3877
- result["message"] = "ℹ️ 当前页面未检测到弹窗,无需关闭"
3878
- result["popup_detected"] = False
3879
- result["popup_confidence"] = popup_confidence
3880
- return result
3881
-
3882
- # ========== 第1步:控件树查找关闭按钮 ==========
3883
-
3884
4052
  # 关闭按钮的常见特征
3885
4053
  close_keywords = ['关闭', '跳过', '×', 'X', 'x', 'close', 'skip', '取消']
3886
4054
  close_content_desc = ['关闭', '跳过', 'close', 'skip', 'dismiss']
@@ -3959,6 +4127,12 @@ class BasicMobileToolsLite:
3959
4127
  cx, cy = best['center']
3960
4128
  bounds = best['bounds']
3961
4129
 
4130
+ # 点击前截图(用于自动学习)
4131
+ pre_screenshot = None
4132
+ if auto_learn:
4133
+ pre_result = self.take_screenshot(description="关闭前", compress=False)
4134
+ pre_screenshot = pre_result.get("screenshot_path")
4135
+
3962
4136
  # 点击(click_at_coords 内部已包含应用状态检查和自动返回)
3963
4137
  click_result = self.click_at_coords(cx, cy)
3964
4138
  time.sleep(0.5)
@@ -3988,11 +4162,17 @@ class BasicMobileToolsLite:
3988
4162
  result["message"] = msg
3989
4163
  result["app_check"] = app_check
3990
4164
  result["return_to_app"] = return_result
3991
- result["tip"] = "💡 建议调用 mobile_screenshot_with_som 确认弹窗是否已关闭"
4165
+
4166
+ # 自动学习:检查这个 X 是否已在模板库,不在就添加
4167
+ if auto_learn and pre_screenshot:
4168
+ learn_result = self._auto_learn_template(pre_screenshot, bounds)
4169
+ if learn_result:
4170
+ result["learned_template"] = learn_result
4171
+ result["message"] += f"\n📚 自动学习: {learn_result}"
3992
4172
 
3993
4173
  return result
3994
4174
 
3995
- # ========== 第2步:模板匹配(自动执行,不需要 AI 介入)==========
4175
+ # ========== 第2步:模板匹配 ==========
3996
4176
  screenshot_path = None
3997
4177
  try:
3998
4178
  from .template_matcher import TemplateMatcher
@@ -4011,14 +4191,16 @@ class BasicMobileToolsLite:
4011
4191
  x_pct = best["percent"]["x"]
4012
4192
  y_pct = best["percent"]["y"]
4013
4193
 
4014
- # 点击
4194
+ # 点击(click_by_percent 内部已包含应用状态检查和自动返回)
4015
4195
  click_result = self.click_by_percent(x_pct, y_pct)
4016
4196
  time.sleep(0.5)
4017
4197
 
4198
+ # 🎯 再次检查应用状态(确保弹窗去除没有导致应用跳转)
4018
4199
  app_check = self._check_app_switched()
4019
4200
  return_result = None
4020
4201
 
4021
4202
  if app_check['switched']:
4203
+ # 应用已跳转,说明弹窗去除失败,尝试返回目标应用
4022
4204
  return_result = self._return_to_target_app()
4023
4205
 
4024
4206
  result["success"] = True
@@ -4029,9 +4211,12 @@ class BasicMobileToolsLite:
4029
4211
  f" 位置: ({x_pct:.1f}%, {y_pct:.1f}%)"
4030
4212
 
4031
4213
  if app_check['switched']:
4032
- msg += f"\n⚠️ 应用已跳转"
4214
+ msg += f"\n⚠️ 应用已跳转,说明弹窗去除失败"
4033
4215
  if return_result:
4034
- msg += f"\n{return_result['message']}"
4216
+ if return_result['success']:
4217
+ msg += f"\n{return_result['message']}"
4218
+ else:
4219
+ msg += f"\n❌ 自动返回失败: {return_result['message']}"
4035
4220
 
4036
4221
  result["message"] = msg
4037
4222
  result["app_check"] = app_check
@@ -4043,12 +4228,17 @@ class BasicMobileToolsLite:
4043
4228
  except Exception:
4044
4229
  pass # 模板匹配失败,继续下一步
4045
4230
 
4046
- # ========== 第3步:控件树和模板匹配都失败,提示 AI 使用视觉识别 ==========
4231
+ # ========== 第3步:返回截图供 AI 分析 ==========
4232
+ if not screenshot_path:
4233
+ screenshot_result = self.take_screenshot(description="需要AI分析", compress=True)
4234
+
4047
4235
  result["success"] = False
4048
- result["fallback"] = "vision"
4049
4236
  result["method"] = None
4050
- result["popup_detected"] = True
4051
- result["message"] = "⚠️ 控件树和模板匹配都未找到关闭按钮,请调用 mobile_screenshot_with_som 截图后用 click_by_som 点击"
4237
+ result["message"] = "❌ 控件树和模板匹配都未找到关闭按钮\n" \
4238
+ "📸 已截图,请 AI 分析图片中的 X 按钮位置\n" \
4239
+ "💡 找到后使用 mobile_click_by_percent(x%, y%) 点击"
4240
+ result["screenshot"] = screenshot_result if not screenshot_path else {"screenshot_path": screenshot_path}
4241
+ result["need_ai_analysis"] = True
4052
4242
 
4053
4243
  return result
4054
4244