mobile-mcp-ai 2.3.8__py3-none-any.whl → 2.3.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mobile_mcp/core/basic_tools_lite.py +404 -57
- mobile_mcp/core/ios_client_wda.py +2 -0
- mobile_mcp/mcp_tools/mcp_server.py +37 -7
- {mobile_mcp_ai-2.3.8.dist-info → mobile_mcp_ai-2.3.9.dist-info}/METADATA +1 -1
- {mobile_mcp_ai-2.3.8.dist-info → mobile_mcp_ai-2.3.9.dist-info}/RECORD +9 -9
- {mobile_mcp_ai-2.3.8.dist-info → mobile_mcp_ai-2.3.9.dist-info}/LICENSE +0 -0
- {mobile_mcp_ai-2.3.8.dist-info → mobile_mcp_ai-2.3.9.dist-info}/WHEEL +0 -0
- {mobile_mcp_ai-2.3.8.dist-info → mobile_mcp_ai-2.3.9.dist-info}/entry_points.txt +0 -0
- {mobile_mcp_ai-2.3.8.dist-info → mobile_mcp_ai-2.3.9.dist-info}/top_level.txt +0 -0
|
@@ -1001,40 +1001,45 @@ class BasicMobileToolsLite:
|
|
|
1001
1001
|
except Exception as e:
|
|
1002
1002
|
return [{"error": f"获取元素失败: {e}"}]
|
|
1003
1003
|
|
|
1004
|
-
def
|
|
1005
|
-
"""
|
|
1004
|
+
def find_close_button(self) -> Dict:
|
|
1005
|
+
"""智能查找关闭按钮(不点击,只返回位置)
|
|
1006
|
+
|
|
1007
|
+
从元素列表中找最可能的关闭按钮,返回其坐标和百分比位置。
|
|
1008
|
+
适用于关闭弹窗广告等场景。
|
|
1006
1009
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
2. 搜索范围:右上角、左上角、正下方中间、右下角、左下角
|
|
1010
|
-
3. 如果找到,计算中心点并点击
|
|
1011
|
-
4. 如果没找到,返回需要视觉识别的提示
|
|
1010
|
+
Returns:
|
|
1011
|
+
包含关闭按钮位置信息的字典,或截图让 AI 分析
|
|
1012
1012
|
"""
|
|
1013
1013
|
try:
|
|
1014
|
-
|
|
1014
|
+
import re
|
|
1015
|
+
|
|
1015
1016
|
if self._is_ios():
|
|
1016
1017
|
return {"success": False, "message": "iOS 暂不支持,请使用截图+坐标点击"}
|
|
1017
1018
|
|
|
1019
|
+
# 获取屏幕尺寸
|
|
1018
1020
|
screen_width = self.client.u2.info.get('displayWidth', 720)
|
|
1019
1021
|
screen_height = self.client.u2.info.get('displayHeight', 1280)
|
|
1020
1022
|
|
|
1021
|
-
#
|
|
1023
|
+
# 获取元素列表
|
|
1022
1024
|
xml_string = self.client.u2.dump_hierarchy()
|
|
1023
|
-
|
|
1025
|
+
import xml.etree.ElementTree as ET
|
|
1026
|
+
root = ET.fromstring(xml_string)
|
|
1024
1027
|
|
|
1025
|
-
#
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1028
|
+
# 关闭按钮特征
|
|
1029
|
+
close_texts = ['×', 'X', 'x', '关闭', '取消', 'close', 'Close', '跳过', '知道了', '我知道了']
|
|
1030
|
+
candidates = []
|
|
1031
|
+
|
|
1032
|
+
for elem in root.iter():
|
|
1033
|
+
text = elem.attrib.get('text', '')
|
|
1034
|
+
content_desc = elem.attrib.get('content-desc', '')
|
|
1035
|
+
bounds_str = elem.attrib.get('bounds', '')
|
|
1036
|
+
class_name = elem.attrib.get('class', '')
|
|
1037
|
+
clickable = elem.attrib.get('clickable', 'false') == 'true'
|
|
1030
1038
|
|
|
1031
|
-
|
|
1032
|
-
if not bounds:
|
|
1039
|
+
if not bounds_str:
|
|
1033
1040
|
continue
|
|
1034
1041
|
|
|
1035
|
-
|
|
1036
|
-
import re
|
|
1037
|
-
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds)
|
|
1042
|
+
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
1038
1043
|
if not match:
|
|
1039
1044
|
continue
|
|
1040
1045
|
|
|
@@ -1044,62 +1049,350 @@ class BasicMobileToolsLite:
|
|
|
1044
1049
|
center_x = (x1 + x2) // 2
|
|
1045
1050
|
center_y = (y1 + y2) // 2
|
|
1046
1051
|
|
|
1047
|
-
#
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
+
# 计算百分比
|
|
1053
|
+
x_percent = round(center_x / screen_width * 100, 1)
|
|
1054
|
+
y_percent = round(center_y / screen_height * 100, 1)
|
|
1055
|
+
|
|
1056
|
+
score = 0
|
|
1057
|
+
reason = ""
|
|
1058
|
+
|
|
1059
|
+
# 策略1:关闭文本
|
|
1060
|
+
if text in close_texts:
|
|
1061
|
+
score = 100
|
|
1062
|
+
reason = f"文本='{text}'"
|
|
1063
|
+
|
|
1064
|
+
# 策略2:content-desc 包含关闭关键词
|
|
1065
|
+
elif any(kw in content_desc.lower() for kw in ['关闭', 'close', 'dismiss', '跳过']):
|
|
1066
|
+
score = 90
|
|
1067
|
+
reason = f"描述='{content_desc}'"
|
|
1068
|
+
|
|
1069
|
+
# 策略3:小尺寸的 clickable 元素(可能是 X 图标)
|
|
1070
|
+
elif clickable:
|
|
1071
|
+
min_size = max(20, int(screen_width * 0.03))
|
|
1072
|
+
max_size = max(120, int(screen_width * 0.12))
|
|
1073
|
+
if min_size <= width <= max_size and min_size <= height <= max_size:
|
|
1074
|
+
# 基于位置评分:角落位置加分
|
|
1075
|
+
rel_x = center_x / screen_width
|
|
1076
|
+
rel_y = center_y / screen_height
|
|
1077
|
+
|
|
1078
|
+
# 右上角得分最高
|
|
1079
|
+
if rel_x > 0.6 and rel_y < 0.5:
|
|
1080
|
+
score = 70 + (rel_x - 0.6) * 50 + (0.5 - rel_y) * 50
|
|
1081
|
+
reason = f"右上角小元素 {width}x{height}px"
|
|
1082
|
+
# 左上角
|
|
1083
|
+
elif rel_x < 0.4 and rel_y < 0.5:
|
|
1084
|
+
score = 60 + (0.4 - rel_x) * 50 + (0.5 - rel_y) * 50
|
|
1085
|
+
reason = f"左上角小元素 {width}x{height}px"
|
|
1086
|
+
# 其他位置的小元素
|
|
1087
|
+
elif 'Image' in class_name:
|
|
1088
|
+
score = 50
|
|
1089
|
+
reason = f"图片元素 {width}x{height}px"
|
|
1090
|
+
else:
|
|
1091
|
+
score = 40
|
|
1092
|
+
reason = f"小型可点击元素 {width}x{height}px"
|
|
1093
|
+
|
|
1094
|
+
if score > 0:
|
|
1095
|
+
candidates.append({
|
|
1096
|
+
'score': score,
|
|
1097
|
+
'reason': reason,
|
|
1098
|
+
'bounds': bounds_str,
|
|
1099
|
+
'center_x': center_x,
|
|
1100
|
+
'center_y': center_y,
|
|
1101
|
+
'x_percent': x_percent,
|
|
1102
|
+
'y_percent': y_percent,
|
|
1103
|
+
'size': f"{width}x{height}"
|
|
1104
|
+
})
|
|
1105
|
+
|
|
1106
|
+
if not candidates:
|
|
1107
|
+
# 没找到,截图让 AI 分析
|
|
1108
|
+
screenshot_result = self.take_screenshot(description="找关闭按钮", compress=True)
|
|
1109
|
+
return {
|
|
1110
|
+
"success": False,
|
|
1111
|
+
"message": "❌ 元素树未找到关闭按钮,已截图供 AI 分析",
|
|
1112
|
+
"screenshot": screenshot_result.get("screenshot_path", ""),
|
|
1113
|
+
"screen_size": {"width": screen_width, "height": screen_height},
|
|
1114
|
+
"image_size": {
|
|
1115
|
+
"width": screenshot_result.get("image_width"),
|
|
1116
|
+
"height": screenshot_result.get("image_height")
|
|
1117
|
+
},
|
|
1118
|
+
"original_size": {
|
|
1119
|
+
"width": screenshot_result.get("original_img_width"),
|
|
1120
|
+
"height": screenshot_result.get("original_img_height")
|
|
1121
|
+
},
|
|
1122
|
+
"tip": "请分析截图找到 X 关闭按钮,然后调用 mobile_click_by_percent(x_percent, y_percent)"
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
# 按得分排序
|
|
1126
|
+
candidates.sort(key=lambda x: x['score'], reverse=True)
|
|
1127
|
+
best = candidates[0]
|
|
1128
|
+
|
|
1129
|
+
return {
|
|
1130
|
+
"success": True,
|
|
1131
|
+
"message": f"✅ 找到可能的关闭按钮",
|
|
1132
|
+
"best_candidate": {
|
|
1133
|
+
"reason": best['reason'],
|
|
1134
|
+
"center": {"x": best['center_x'], "y": best['center_y']},
|
|
1135
|
+
"percent": {"x": best['x_percent'], "y": best['y_percent']},
|
|
1136
|
+
"bounds": best['bounds'],
|
|
1137
|
+
"size": best['size'],
|
|
1138
|
+
"score": best['score']
|
|
1139
|
+
},
|
|
1140
|
+
"click_command": f"mobile_click_by_percent({best['x_percent']}, {best['y_percent']})",
|
|
1141
|
+
"other_candidates": [
|
|
1142
|
+
{"reason": c['reason'], "percent": f"({c['x_percent']}%, {c['y_percent']}%)", "score": c['score']}
|
|
1143
|
+
for c in candidates[1:4]
|
|
1144
|
+
] if len(candidates) > 1 else [],
|
|
1145
|
+
"screen_size": {"width": screen_width, "height": screen_height}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
except Exception as e:
|
|
1149
|
+
return {"success": False, "message": f"❌ 查找关闭按钮失败: {e}"}
|
|
1150
|
+
|
|
1151
|
+
def close_popup(self) -> Dict:
|
|
1152
|
+
"""智能关闭弹窗(改进版)
|
|
1153
|
+
|
|
1154
|
+
核心改进:先检测弹窗区域,再在弹窗范围内查找关闭按钮
|
|
1155
|
+
|
|
1156
|
+
策略(优先级从高到低):
|
|
1157
|
+
1. 检测弹窗区域(非全屏的大面积容器)
|
|
1158
|
+
2. 在弹窗边界内查找关闭相关的文本/描述(×、X、关闭、close 等)
|
|
1159
|
+
3. 在弹窗边界内查找小尺寸的 clickable 元素(优先边角位置)
|
|
1160
|
+
4. 如果都找不到,截图让 AI 视觉识别
|
|
1161
|
+
|
|
1162
|
+
适配策略:
|
|
1163
|
+
- X 按钮可能在任意位置(上下左右都支持)
|
|
1164
|
+
- 使用百分比坐标记录,跨分辨率兼容
|
|
1165
|
+
"""
|
|
1166
|
+
try:
|
|
1167
|
+
import re
|
|
1168
|
+
import xml.etree.ElementTree as ET
|
|
1169
|
+
|
|
1170
|
+
# 获取屏幕尺寸
|
|
1171
|
+
if self._is_ios():
|
|
1172
|
+
return {"success": False, "message": "iOS 暂不支持,请使用截图+坐标点击"}
|
|
1173
|
+
|
|
1174
|
+
screen_width = self.client.u2.info.get('displayWidth', 720)
|
|
1175
|
+
screen_height = self.client.u2.info.get('displayHeight', 1280)
|
|
1176
|
+
|
|
1177
|
+
# 获取原始 XML
|
|
1178
|
+
xml_string = self.client.u2.dump_hierarchy()
|
|
1179
|
+
|
|
1180
|
+
# 关闭按钮的文本特征
|
|
1181
|
+
close_texts = ['×', 'X', 'x', '关闭', '取消', 'close', 'Close', 'CLOSE', '跳过', '知道了']
|
|
1182
|
+
close_desc_keywords = ['关闭', 'close', 'dismiss', 'cancel', '跳过']
|
|
1183
|
+
|
|
1184
|
+
close_candidates = []
|
|
1185
|
+
popup_bounds = None # 弹窗区域
|
|
1186
|
+
|
|
1187
|
+
# 解析 XML
|
|
1188
|
+
try:
|
|
1189
|
+
root = ET.fromstring(xml_string)
|
|
1190
|
+
all_elements = list(root.iter())
|
|
1191
|
+
|
|
1192
|
+
# ===== 第一步:检测弹窗区域 =====
|
|
1193
|
+
# 弹窗特征:非全屏、面积较大、通常在屏幕中央的容器
|
|
1194
|
+
popup_containers = []
|
|
1195
|
+
for idx, elem in enumerate(all_elements):
|
|
1196
|
+
bounds_str = elem.attrib.get('bounds', '')
|
|
1197
|
+
class_name = elem.attrib.get('class', '')
|
|
1198
|
+
|
|
1199
|
+
if not bounds_str:
|
|
1200
|
+
continue
|
|
1201
|
+
|
|
1202
|
+
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
1203
|
+
if not match:
|
|
1204
|
+
continue
|
|
1205
|
+
|
|
1206
|
+
x1, y1, x2, y2 = map(int, match.groups())
|
|
1207
|
+
width = x2 - x1
|
|
1208
|
+
height = y2 - y1
|
|
1209
|
+
area = width * height
|
|
1210
|
+
screen_area = screen_width * screen_height
|
|
1211
|
+
|
|
1212
|
+
# 弹窗容器特征:
|
|
1213
|
+
# 1. 面积在屏幕的 10%-90% 之间(非全屏)
|
|
1214
|
+
# 2. 宽度或高度不等于屏幕尺寸
|
|
1215
|
+
# 3. 是容器类型(Layout/View/Dialog)
|
|
1216
|
+
is_container = any(kw in class_name for kw in ['Layout', 'View', 'Dialog', 'Card', 'Container'])
|
|
1217
|
+
area_ratio = area / screen_area
|
|
1218
|
+
is_not_fullscreen = (width < screen_width * 0.98 or height < screen_height * 0.98)
|
|
1219
|
+
is_reasonable_size = 0.08 < area_ratio < 0.9
|
|
1220
|
+
|
|
1221
|
+
# 排除状态栏区域(y1 通常很小)
|
|
1222
|
+
is_below_statusbar = y1 > 50
|
|
1223
|
+
|
|
1224
|
+
if is_container and is_not_fullscreen and is_reasonable_size and is_below_statusbar:
|
|
1225
|
+
popup_containers.append({
|
|
1226
|
+
'bounds': (x1, y1, x2, y2),
|
|
1227
|
+
'bounds_str': bounds_str,
|
|
1228
|
+
'area': area,
|
|
1229
|
+
'area_ratio': area_ratio,
|
|
1230
|
+
'idx': idx, # 元素在 XML 中的顺序(越后越上层)
|
|
1231
|
+
'class': class_name
|
|
1232
|
+
})
|
|
1233
|
+
|
|
1234
|
+
# 选择最可能的弹窗容器(优先选择:XML 顺序靠后 + 面积适中)
|
|
1235
|
+
if popup_containers:
|
|
1236
|
+
# 按 XML 顺序倒序(后出现的在上层),然后按面积适中程度排序
|
|
1237
|
+
popup_containers.sort(key=lambda x: (x['idx'], -abs(x['area_ratio'] - 0.3)), reverse=True)
|
|
1238
|
+
popup_bounds = popup_containers[0]['bounds']
|
|
1239
|
+
|
|
1240
|
+
# ===== 第二步:在弹窗范围内查找关闭按钮 =====
|
|
1241
|
+
for idx, elem in enumerate(all_elements):
|
|
1242
|
+
text = elem.attrib.get('text', '')
|
|
1243
|
+
content_desc = elem.attrib.get('content-desc', '')
|
|
1244
|
+
bounds_str = elem.attrib.get('bounds', '')
|
|
1245
|
+
class_name = elem.attrib.get('class', '')
|
|
1246
|
+
clickable = elem.attrib.get('clickable', 'false') == 'true'
|
|
1247
|
+
|
|
1248
|
+
if not bounds_str:
|
|
1249
|
+
continue
|
|
1250
|
+
|
|
1251
|
+
# 解析 bounds
|
|
1252
|
+
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
1253
|
+
if not match:
|
|
1254
|
+
continue
|
|
1255
|
+
|
|
1256
|
+
x1, y1, x2, y2 = map(int, match.groups())
|
|
1257
|
+
width = x2 - x1
|
|
1258
|
+
height = y2 - y1
|
|
1259
|
+
center_x = (x1 + x2) // 2
|
|
1260
|
+
center_y = (y1 + y2) // 2
|
|
1261
|
+
|
|
1262
|
+
# 如果检测到弹窗区域,检查元素是否在弹窗范围内或附近
|
|
1263
|
+
in_popup = True
|
|
1264
|
+
popup_edge_bonus = 0
|
|
1265
|
+
is_floating_close = False # 是否是浮动关闭按钮(在弹窗外部上方)
|
|
1266
|
+
if popup_bounds:
|
|
1267
|
+
px1, py1, px2, py2 = popup_bounds
|
|
1268
|
+
|
|
1269
|
+
# 关闭按钮可能在弹窗外部(常见设计:X 按钮浮在弹窗右上角外侧)
|
|
1270
|
+
# 扩大搜索范围:弹窗上方 200 像素,右侧 50 像素
|
|
1271
|
+
margin_top = 200 # 上方扩展范围(关闭按钮常在弹窗上方)
|
|
1272
|
+
margin_side = 50 # 左右扩展范围
|
|
1273
|
+
margin_bottom = 30 # 下方扩展范围
|
|
1274
|
+
|
|
1275
|
+
in_popup = (px1 - margin_side <= center_x <= px2 + margin_side and
|
|
1276
|
+
py1 - margin_top <= center_y <= py2 + margin_bottom)
|
|
1277
|
+
|
|
1278
|
+
# 检查是否是浮动关闭按钮(在弹窗外侧:上方或下方)
|
|
1279
|
+
# 上方浮动关闭按钮(常见:右上角外侧)
|
|
1280
|
+
if center_y < py1 and center_y > py1 - margin_top:
|
|
1281
|
+
if center_x > (px1 + px2) / 2: # 在弹窗右半部分上方
|
|
1282
|
+
is_floating_close = True
|
|
1283
|
+
# 下方浮动关闭按钮(常见:底部中间外侧)
|
|
1284
|
+
elif center_y > py2 and center_y < py2 + margin_top:
|
|
1285
|
+
# 下方关闭按钮通常在中间位置
|
|
1286
|
+
if abs(center_x - (px1 + px2) / 2) < (px2 - px1) / 2:
|
|
1287
|
+
is_floating_close = True
|
|
1288
|
+
|
|
1289
|
+
if in_popup:
|
|
1290
|
+
# 计算元素是否在弹窗边缘(关闭按钮通常在边缘)
|
|
1291
|
+
dist_to_top = abs(center_y - py1)
|
|
1292
|
+
dist_to_bottom = abs(center_y - py2)
|
|
1293
|
+
dist_to_left = abs(center_x - px1)
|
|
1294
|
+
dist_to_right = abs(center_x - px2)
|
|
1295
|
+
min_dist = min(dist_to_top, dist_to_bottom, dist_to_left, dist_to_right)
|
|
1296
|
+
|
|
1297
|
+
# 在弹窗边缘 100 像素内的元素加分
|
|
1298
|
+
if min_dist < 100:
|
|
1299
|
+
popup_edge_bonus = 3.0 * (1 - min_dist / 100)
|
|
1300
|
+
|
|
1301
|
+
# 浮动关闭按钮(在弹窗上方外侧)给予高额加分
|
|
1302
|
+
if is_floating_close:
|
|
1303
|
+
popup_edge_bonus += 5.0 # 大幅加分
|
|
1304
|
+
|
|
1305
|
+
if not in_popup:
|
|
1306
|
+
continue
|
|
1052
1307
|
|
|
1053
1308
|
# 相对位置(0-1)
|
|
1054
1309
|
rel_x = center_x / screen_width
|
|
1055
1310
|
rel_y = center_y / screen_height
|
|
1056
1311
|
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
position = "右上角"
|
|
1312
|
+
score = 0
|
|
1313
|
+
match_type = ""
|
|
1314
|
+
position = self._get_position_name(rel_x, rel_y)
|
|
1061
1315
|
|
|
1062
|
-
#
|
|
1063
|
-
|
|
1064
|
-
score =
|
|
1065
|
-
|
|
1316
|
+
# ===== 策略1:精确匹配关闭文本(最高优先级)=====
|
|
1317
|
+
if text in close_texts:
|
|
1318
|
+
score = 15.0 + popup_edge_bonus
|
|
1319
|
+
match_type = f"text='{text}'"
|
|
1066
1320
|
|
|
1067
|
-
#
|
|
1068
|
-
elif
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
score = 0.8 + center_score * 0.5 + (rel_y - 0.5) * 0.5
|
|
1072
|
-
position = "正下方"
|
|
1321
|
+
# ===== 策略2:content-desc 包含关闭关键词 =====
|
|
1322
|
+
elif any(kw in content_desc.lower() for kw in close_desc_keywords):
|
|
1323
|
+
score = 12.0 + popup_edge_bonus
|
|
1324
|
+
match_type = f"desc='{content_desc}'"
|
|
1073
1325
|
|
|
1074
|
-
#
|
|
1075
|
-
elif
|
|
1076
|
-
|
|
1077
|
-
|
|
1326
|
+
# ===== 策略3:clickable 的小尺寸元素(优先于非 clickable)=====
|
|
1327
|
+
elif clickable:
|
|
1328
|
+
min_size = max(20, int(screen_width * 0.03))
|
|
1329
|
+
max_size = max(120, int(screen_width * 0.15))
|
|
1330
|
+
if min_size <= width <= max_size and min_size <= height <= max_size:
|
|
1331
|
+
# clickable 元素基础分更高
|
|
1332
|
+
base_score = 8.0
|
|
1333
|
+
# 浮动关闭按钮给予最高分
|
|
1334
|
+
if is_floating_close:
|
|
1335
|
+
base_score = 12.0
|
|
1336
|
+
match_type = "floating_close"
|
|
1337
|
+
elif 'Image' in class_name:
|
|
1338
|
+
score = base_score + 2.0
|
|
1339
|
+
match_type = "clickable_image"
|
|
1340
|
+
else:
|
|
1341
|
+
match_type = "clickable"
|
|
1342
|
+
score = base_score + self._get_position_score(rel_x, rel_y) + popup_edge_bonus
|
|
1078
1343
|
|
|
1079
|
-
#
|
|
1080
|
-
elif
|
|
1081
|
-
|
|
1082
|
-
|
|
1344
|
+
# ===== 策略4:ImageView/ImageButton 类型的小元素(非 clickable)=====
|
|
1345
|
+
elif 'Image' in class_name:
|
|
1346
|
+
min_size = max(15, int(screen_width * 0.02))
|
|
1347
|
+
max_size = max(120, int(screen_width * 0.12))
|
|
1348
|
+
if min_size <= width <= max_size and min_size <= height <= max_size:
|
|
1349
|
+
score = 5.0 + self._get_position_score(rel_x, rel_y) + popup_edge_bonus
|
|
1350
|
+
match_type = "ImageView"
|
|
1083
1351
|
|
|
1352
|
+
# XML 顺序加分(后出现的元素在上层,更可能是弹窗内的元素)
|
|
1084
1353
|
if score > 0:
|
|
1354
|
+
xml_order_bonus = idx / len(all_elements) * 2.0 # 最多加 2 分
|
|
1355
|
+
score += xml_order_bonus
|
|
1356
|
+
|
|
1085
1357
|
close_candidates.append({
|
|
1086
|
-
'bounds':
|
|
1358
|
+
'bounds': bounds_str,
|
|
1087
1359
|
'center_x': center_x,
|
|
1088
1360
|
'center_y': center_y,
|
|
1089
1361
|
'width': width,
|
|
1090
1362
|
'height': height,
|
|
1091
1363
|
'score': score,
|
|
1092
1364
|
'position': position,
|
|
1093
|
-
'
|
|
1094
|
-
'text':
|
|
1365
|
+
'match_type': match_type,
|
|
1366
|
+
'text': text,
|
|
1367
|
+
'content_desc': content_desc,
|
|
1368
|
+
'x_percent': round(rel_x * 100, 1),
|
|
1369
|
+
'y_percent': round(rel_y * 100, 1),
|
|
1370
|
+
'in_popup': popup_bounds is not None
|
|
1095
1371
|
})
|
|
1372
|
+
|
|
1373
|
+
except ET.ParseError:
|
|
1374
|
+
pass
|
|
1096
1375
|
|
|
1097
1376
|
if not close_candidates:
|
|
1377
|
+
# 控件树未找到,自动截全屏图供 AI 分析
|
|
1378
|
+
screenshot_result = self.take_screenshot(description="弹窗全屏", compress=True)
|
|
1098
1379
|
return {
|
|
1099
1380
|
"success": False,
|
|
1100
|
-
"message": "❌
|
|
1101
|
-
"
|
|
1102
|
-
"
|
|
1381
|
+
"message": "❌ 控件树未找到关闭按钮,已截全屏图供 AI 视觉分析",
|
|
1382
|
+
"action_required": "请分析截图找到 X 关闭按钮位置,然后调用 mobile_click_at_coords",
|
|
1383
|
+
"screenshot": screenshot_result.get("screenshot_path", ""),
|
|
1384
|
+
"screen_size": {"width": screen_width, "height": screen_height},
|
|
1385
|
+
"image_size": {
|
|
1386
|
+
"width": screenshot_result.get("image_width", screen_width),
|
|
1387
|
+
"height": screenshot_result.get("image_height", screen_height)
|
|
1388
|
+
},
|
|
1389
|
+
"original_size": {
|
|
1390
|
+
"width": screenshot_result.get("original_img_width", screen_width),
|
|
1391
|
+
"height": screenshot_result.get("original_img_height", screen_height)
|
|
1392
|
+
},
|
|
1393
|
+
"popup_detected": popup_bounds is not None,
|
|
1394
|
+
"popup_bounds": f"[{popup_bounds[0]},{popup_bounds[1]}][{popup_bounds[2]},{popup_bounds[3]}]" if popup_bounds else None,
|
|
1395
|
+
"tip": "找到 X 按钮后,直接调用 mobile_click_at_coords(x, y, image_width, image_height, original_img_width, original_img_height)"
|
|
1103
1396
|
}
|
|
1104
1397
|
|
|
1105
1398
|
# 按得分排序,取最可能的
|
|
@@ -1108,27 +1401,81 @@ class BasicMobileToolsLite:
|
|
|
1108
1401
|
|
|
1109
1402
|
# 点击
|
|
1110
1403
|
self.client.u2.click(best['center_x'], best['center_y'])
|
|
1404
|
+
time.sleep(0.3)
|
|
1111
1405
|
|
|
1112
|
-
#
|
|
1406
|
+
# 记录操作(使用百分比,跨设备兼容)
|
|
1113
1407
|
self._record_operation(
|
|
1114
|
-
'
|
|
1408
|
+
'click',
|
|
1115
1409
|
x=best['center_x'],
|
|
1116
1410
|
y=best['center_y'],
|
|
1117
|
-
|
|
1411
|
+
x_percent=best['x_percent'],
|
|
1412
|
+
y_percent=best['y_percent'],
|
|
1413
|
+
screen_width=screen_width,
|
|
1414
|
+
screen_height=screen_height,
|
|
1415
|
+
ref=f"close_popup_{best['position']}"
|
|
1118
1416
|
)
|
|
1119
1417
|
|
|
1120
1418
|
return {
|
|
1121
1419
|
"success": True,
|
|
1122
1420
|
"message": f"✅ 点击关闭按钮 ({best['position']}): ({best['center_x']}, {best['center_y']})",
|
|
1421
|
+
"match_type": best['match_type'],
|
|
1123
1422
|
"bounds": best['bounds'],
|
|
1124
1423
|
"position": best['position'],
|
|
1424
|
+
"percent": f"({best['x_percent']}%, {best['y_percent']}%)",
|
|
1425
|
+
"popup_detected": popup_bounds is not None,
|
|
1426
|
+
"popup_bounds": f"[{popup_bounds[0]},{popup_bounds[1]}][{popup_bounds[2]},{popup_bounds[3]}]" if popup_bounds else None,
|
|
1125
1427
|
"candidates_count": len(close_candidates),
|
|
1126
|
-
"
|
|
1428
|
+
"top_candidates": [
|
|
1429
|
+
{"position": c['position'], "type": c['match_type'], "score": round(c['score'], 1)}
|
|
1430
|
+
for c in close_candidates[:3]
|
|
1431
|
+
]
|
|
1127
1432
|
}
|
|
1128
1433
|
|
|
1129
1434
|
except Exception as e:
|
|
1130
1435
|
return {"success": False, "message": f"❌ 关闭弹窗失败: {e}"}
|
|
1131
1436
|
|
|
1437
|
+
def _get_position_name(self, rel_x: float, rel_y: float) -> str:
|
|
1438
|
+
"""根据相对坐标获取位置名称"""
|
|
1439
|
+
if rel_y < 0.4:
|
|
1440
|
+
if rel_x > 0.6:
|
|
1441
|
+
return "右上角"
|
|
1442
|
+
elif rel_x < 0.4:
|
|
1443
|
+
return "左上角"
|
|
1444
|
+
else:
|
|
1445
|
+
return "顶部中间"
|
|
1446
|
+
elif rel_y > 0.6:
|
|
1447
|
+
if rel_x > 0.6:
|
|
1448
|
+
return "右下角"
|
|
1449
|
+
elif rel_x < 0.4:
|
|
1450
|
+
return "左下角"
|
|
1451
|
+
else:
|
|
1452
|
+
return "底部中间"
|
|
1453
|
+
else:
|
|
1454
|
+
if rel_x > 0.6:
|
|
1455
|
+
return "右侧"
|
|
1456
|
+
elif rel_x < 0.4:
|
|
1457
|
+
return "左侧"
|
|
1458
|
+
else:
|
|
1459
|
+
return "中间"
|
|
1460
|
+
|
|
1461
|
+
def _get_position_score(self, rel_x: float, rel_y: float) -> float:
|
|
1462
|
+
"""根据位置计算额外得分(角落位置加分更多)"""
|
|
1463
|
+
# 弹窗关闭按钮常见位置得分:右上角 > 左上角 > 底部中间 > 其他角落
|
|
1464
|
+
if rel_y < 0.4: # 上半部分
|
|
1465
|
+
if rel_x > 0.6: # 右上角
|
|
1466
|
+
return 2.0 + (rel_x - 0.6) + (0.4 - rel_y)
|
|
1467
|
+
elif rel_x < 0.4: # 左上角
|
|
1468
|
+
return 1.5 + (0.4 - rel_x) + (0.4 - rel_y)
|
|
1469
|
+
else: # 顶部中间
|
|
1470
|
+
return 1.0
|
|
1471
|
+
elif rel_y > 0.6: # 下半部分
|
|
1472
|
+
if 0.3 < rel_x < 0.7: # 底部中间
|
|
1473
|
+
return 1.2 + (1 - abs(rel_x - 0.5) * 2)
|
|
1474
|
+
else: # 底部角落
|
|
1475
|
+
return 0.8
|
|
1476
|
+
else: # 中间区域
|
|
1477
|
+
return 0.5
|
|
1478
|
+
|
|
1132
1479
|
def assert_text(self, text: str) -> Dict:
|
|
1133
1480
|
"""检查页面是否包含文本"""
|
|
1134
1481
|
try:
|
|
@@ -387,19 +387,45 @@ class MobileMCPServer:
|
|
|
387
387
|
))
|
|
388
388
|
|
|
389
389
|
# ==================== 辅助工具 ====================
|
|
390
|
+
tools.append(Tool(
|
|
391
|
+
name="mobile_find_close_button",
|
|
392
|
+
description="""🔍 智能查找关闭按钮(只找不点,返回位置)
|
|
393
|
+
|
|
394
|
+
从元素树中找最可能的关闭按钮,返回坐标和百分比位置。
|
|
395
|
+
|
|
396
|
+
🎯 识别策略(优先级):
|
|
397
|
+
1. 文本匹配:×、X、关闭、取消、跳过 等
|
|
398
|
+
2. 描述匹配:content-desc 包含 close/关闭
|
|
399
|
+
3. 小尺寸 clickable 元素(右上角优先)
|
|
400
|
+
|
|
401
|
+
✅ 返回内容:
|
|
402
|
+
- 坐标 (x, y) 和百分比 (x%, y%)
|
|
403
|
+
- 推荐的点击命令:mobile_click_by_percent(x%, y%)
|
|
404
|
+
- 多个候选位置(供确认)
|
|
405
|
+
|
|
406
|
+
💡 使用流程:
|
|
407
|
+
1. 调用此工具找到关闭按钮位置
|
|
408
|
+
2. 确认位置正确后,用 mobile_click_by_percent 点击
|
|
409
|
+
3. 百分比点击兼容不同分辨率手机""",
|
|
410
|
+
inputSchema={"type": "object", "properties": {}, "required": []}
|
|
411
|
+
))
|
|
412
|
+
|
|
390
413
|
tools.append(Tool(
|
|
391
414
|
name="mobile_close_popup",
|
|
392
|
-
description="""🚫
|
|
415
|
+
description="""🚫 智能关闭弹窗(直接点击)
|
|
393
416
|
|
|
394
|
-
|
|
417
|
+
自动识别并点击关闭按钮,一步完成。
|
|
395
418
|
|
|
396
419
|
🎯 识别策略:
|
|
397
|
-
1.
|
|
398
|
-
2.
|
|
399
|
-
3.
|
|
420
|
+
1. 文本匹配:×、X、关闭、取消、跳过 等
|
|
421
|
+
2. 描述匹配:content-desc 包含 close/关闭
|
|
422
|
+
3. ImageView/ImageButton 小元素
|
|
423
|
+
4. clickable 的小尺寸元素(角落位置优先)
|
|
400
424
|
|
|
401
|
-
|
|
402
|
-
|
|
425
|
+
⚠️ 如果自动识别失败:
|
|
426
|
+
- 会截图供 AI 分析
|
|
427
|
+
- 用 mobile_find_close_button 先查看候选位置
|
|
428
|
+
- 或用 mobile_click_by_percent 手动点击""",
|
|
403
429
|
inputSchema={"type": "object", "properties": {}, "required": []}
|
|
404
430
|
))
|
|
405
431
|
|
|
@@ -560,6 +586,10 @@ class MobileMCPServer:
|
|
|
560
586
|
result = self.tools.list_elements()
|
|
561
587
|
return [TextContent(type="text", text=self.format_response(result))]
|
|
562
588
|
|
|
589
|
+
elif name == "mobile_find_close_button":
|
|
590
|
+
result = self.tools.find_close_button()
|
|
591
|
+
return [TextContent(type="text", text=self.format_response(result))]
|
|
592
|
+
|
|
563
593
|
elif name == "mobile_close_popup":
|
|
564
594
|
result = self.tools.close_popup()
|
|
565
595
|
return [TextContent(type="text", text=self.format_response(result))]
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
mobile_mcp/__init__.py,sha256=sQJZTL_sxQFzmcS7jOtS2AHCfUySz40vhX96N6u1qy4,816
|
|
2
2
|
mobile_mcp/config.py,sha256=yaFLAV4bc2wX0GQPtZDo7OYF9E88tXV-av41fQsJwK4,4480
|
|
3
3
|
mobile_mcp/core/__init__.py,sha256=ndMy-cLAIsQDG5op7gM_AIplycqZSZPWEkec1pEhvEY,170
|
|
4
|
-
mobile_mcp/core/basic_tools_lite.py,sha256=
|
|
4
|
+
mobile_mcp/core/basic_tools_lite.py,sha256=ki2hTa-1Liupuvf0alQSQMRvKiZM2FJ-hdLVCAVSBOA,83010
|
|
5
5
|
mobile_mcp/core/device_manager.py,sha256=PX3-B5bJFnKNt6C8fT7FSY8JwD-ngZ3toF88bcOV9qA,8766
|
|
6
6
|
mobile_mcp/core/dynamic_config.py,sha256=Ja1n1pfb0HspGByqk2_A472mYVniKmGtNEWyjUjmgK8,9811
|
|
7
|
-
mobile_mcp/core/ios_client_wda.py,sha256=
|
|
7
|
+
mobile_mcp/core/ios_client_wda.py,sha256=QUAoILP3P54YF1TtwgTNXxh8C_uHbuUjlMEdghA6-mk,18757
|
|
8
8
|
mobile_mcp/core/ios_device_manager_wda.py,sha256=A44glqI-24un7qST-E3w6BQD8mV92YVUbxy4rLlTScY,11264
|
|
9
9
|
mobile_mcp/core/mobile_client.py,sha256=bno3HvU-QSAC3G4TnoFngTxqXeu-ZP5rGlEWdWh8jOo,62570
|
|
10
10
|
mobile_mcp/core/utils/__init__.py,sha256=RhMMsPszmEn8Q8GoNufypVSHJxyM9lio9U6jjpnuoPI,378
|
|
@@ -12,14 +12,14 @@ mobile_mcp/core/utils/logger.py,sha256=XXQAHUwT1jc70pq_tYFmL6f_nKrFlYm3hcgl-5RYR
|
|
|
12
12
|
mobile_mcp/core/utils/operation_history_manager.py,sha256=gi8S8HJAMqvkUrY7_-kVbko3Xt7c4GAUziEujRd-N-Y,4792
|
|
13
13
|
mobile_mcp/core/utils/smart_wait.py,sha256=PvKXImfN9Irru3bQJUjf4FLGn8LjY2VLzUNEl-i7xLE,8601
|
|
14
14
|
mobile_mcp/mcp_tools/__init__.py,sha256=xkro8Rwqv_55YlVyhh-3DgRFSsLE3h1r31VIb3bpM6E,143
|
|
15
|
-
mobile_mcp/mcp_tools/mcp_server.py,sha256=
|
|
15
|
+
mobile_mcp/mcp_tools/mcp_server.py,sha256=Gj9rQtpcVEO1FT5Gso9WydQu_CcGjxxbh4uV6Ut5xaI,29101
|
|
16
16
|
mobile_mcp/utils/__init__.py,sha256=8EH0i7UGtx1y_j_GEgdN-cZdWn2sRtZSEOLlNF9HRnY,158
|
|
17
17
|
mobile_mcp/utils/logger.py,sha256=Sqq2Nr0Y4p03erqcrbYKVPCGiFaNGHMcE_JwCkeOfU4,3626
|
|
18
18
|
mobile_mcp/utils/xml_formatter.py,sha256=uwTRb3vLbqhT8O-udzWT7s7LsV-DyDUz2DkofD3hXOE,4556
|
|
19
19
|
mobile_mcp/utils/xml_parser.py,sha256=QhL8CWbdmNDzmBLjtx6mEnjHgMFZzJeHpCL15qfXSpI,3926
|
|
20
|
-
mobile_mcp_ai-2.3.
|
|
21
|
-
mobile_mcp_ai-2.3.
|
|
22
|
-
mobile_mcp_ai-2.3.
|
|
23
|
-
mobile_mcp_ai-2.3.
|
|
24
|
-
mobile_mcp_ai-2.3.
|
|
25
|
-
mobile_mcp_ai-2.3.
|
|
20
|
+
mobile_mcp_ai-2.3.9.dist-info/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
|
|
21
|
+
mobile_mcp_ai-2.3.9.dist-info/METADATA,sha256=t_t_XDylk8sWdLkT276_v85ExSx7Ux8UX85sxtWRP28,9423
|
|
22
|
+
mobile_mcp_ai-2.3.9.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
|
|
23
|
+
mobile_mcp_ai-2.3.9.dist-info/entry_points.txt,sha256=KB_FglozgPHBprSM1vFbIzGyheFuHFmGanscRdMJ_8A,68
|
|
24
|
+
mobile_mcp_ai-2.3.9.dist-info/top_level.txt,sha256=lLm6YpbTv855Lbh8BIA0rPxhybIrvYUzMEk9OErHT94,11
|
|
25
|
+
mobile_mcp_ai-2.3.9.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|