mobile-mcp-ai 2.3.4__tar.gz → 2.4.1__tar.gz
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_ai-2.3.4/mobile_mcp_ai.egg-info → mobile_mcp_ai-2.4.1}/PKG-INFO +1 -1
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/core/basic_tools_lite.py +718 -54
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/core/ios_client_wda.py +5 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/mcp_tools/mcp_server.py +92 -20
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1/mobile_mcp_ai.egg-info}/PKG-INFO +1 -1
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/setup.py +1 -1
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/LICENSE +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/MANIFEST.in +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/README.md +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/__init__.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/config.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/core/__init__.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/core/device_manager.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/core/dynamic_config.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/core/ios_device_manager_wda.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/core/mobile_client.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/core/utils/__init__.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/core/utils/logger.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/core/utils/operation_history_manager.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/core/utils/smart_wait.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/docs/iOS_SETUP_GUIDE.md +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/mcp_tools/__init__.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/mobile_mcp_ai.egg-info/SOURCES.txt +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/mobile_mcp_ai.egg-info/dependency_links.txt +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/mobile_mcp_ai.egg-info/entry_points.txt +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/mobile_mcp_ai.egg-info/not-zip-safe +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/mobile_mcp_ai.egg-info/requires.txt +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/mobile_mcp_ai.egg-info/top_level.txt +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/requirements.txt +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/setup.cfg +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/tests/test_mind_cloud_my_space.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/tests/test_mind_correct.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/tests/test_mind_improved.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/tests/test_mind_optimized.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/tests/test_open_mind.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/tests/test_priority_demo.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/tests/test_simple.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/tests/test_/344/270/276/346/212/245.py" +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/tests/test_/345/210/207/346/215/242/350/257/255/350/250/200/345/210/260English.py" +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/tests/test_/346/265/213/350/257/225.py" +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/utils/__init__.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/utils/logger.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/utils/xml_formatter.py +0 -0
- {mobile_mcp_ai-2.3.4 → mobile_mcp_ai-2.4.1}/utils/xml_parser.py +0 -0
|
@@ -56,8 +56,9 @@ class BasicMobileToolsLite:
|
|
|
56
56
|
# ==================== 截图 ====================
|
|
57
57
|
|
|
58
58
|
def take_screenshot(self, description: str = "", compress: bool = True,
|
|
59
|
-
max_width: int = 720, quality: int = 75
|
|
60
|
-
|
|
59
|
+
max_width: int = 720, quality: int = 75,
|
|
60
|
+
crop_x: int = 0, crop_y: int = 0, crop_size: int = 0) -> Dict:
|
|
61
|
+
"""截图(支持压缩和局部裁剪)
|
|
61
62
|
|
|
62
63
|
压缩原理:
|
|
63
64
|
1. 先截取原始 PNG 图片
|
|
@@ -65,11 +66,20 @@ class BasicMobileToolsLite:
|
|
|
65
66
|
3. 转换为 JPEG 格式 + 降低质量(如 100% → 75%)
|
|
66
67
|
4. 最终文件从 2MB 压缩到约 80KB(节省 96%)
|
|
67
68
|
|
|
69
|
+
局部裁剪(用于精确识别小元素):
|
|
70
|
+
- 第一次全屏截图,AI 返回大概坐标
|
|
71
|
+
- 第二次传入 crop_x, crop_y, crop_size 截取局部区域
|
|
72
|
+
- 局部区域不压缩,保持清晰度,AI 可精确识别
|
|
73
|
+
- 返回 crop_offset_x/y 用于坐标换算
|
|
74
|
+
|
|
68
75
|
Args:
|
|
69
76
|
description: 截图描述(可选)
|
|
70
77
|
compress: 是否压缩(默认 True,推荐开启省 token)
|
|
71
78
|
max_width: 压缩后最大宽度(默认 720,对 AI 识别足够)
|
|
72
79
|
quality: JPEG 质量 1-100(默认 75,肉眼几乎看不出区别)
|
|
80
|
+
crop_x: 裁剪中心点 X 坐标(屏幕坐标,0 表示不裁剪)
|
|
81
|
+
crop_y: 裁剪中心点 Y 坐标(屏幕坐标,0 表示不裁剪)
|
|
82
|
+
crop_size: 裁剪区域大小(默认 0 不裁剪,推荐 200-400)
|
|
73
83
|
|
|
74
84
|
压缩效果示例:
|
|
75
85
|
原图 PNG: 2048KB
|
|
@@ -104,12 +114,74 @@ class BasicMobileToolsLite:
|
|
|
104
114
|
|
|
105
115
|
original_size = temp_path.stat().st_size
|
|
106
116
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
117
|
+
# 第2步:打开图片
|
|
118
|
+
img = Image.open(temp_path)
|
|
119
|
+
|
|
120
|
+
# 第2.5步:局部裁剪(如果指定了裁剪参数)
|
|
121
|
+
crop_offset_x, crop_offset_y = 0, 0
|
|
122
|
+
is_cropped = False
|
|
123
|
+
|
|
124
|
+
if crop_x > 0 and crop_y > 0 and crop_size > 0:
|
|
125
|
+
# 计算裁剪区域(以 crop_x, crop_y 为中心)
|
|
126
|
+
half_size = crop_size // 2
|
|
127
|
+
left = max(0, crop_x - half_size)
|
|
128
|
+
top = max(0, crop_y - half_size)
|
|
129
|
+
right = min(img.width, crop_x + half_size)
|
|
130
|
+
bottom = min(img.height, crop_y + half_size)
|
|
131
|
+
|
|
132
|
+
# 记录偏移量(用于坐标换算)
|
|
133
|
+
crop_offset_x = left
|
|
134
|
+
crop_offset_y = top
|
|
135
|
+
|
|
136
|
+
# 裁剪
|
|
137
|
+
img = img.crop((left, top, right, bottom))
|
|
138
|
+
is_cropped = True
|
|
139
|
+
|
|
140
|
+
# ========== 情况1:局部裁剪截图(不压缩,保持清晰度)==========
|
|
141
|
+
if is_cropped:
|
|
142
|
+
# 生成文件名
|
|
143
|
+
if description:
|
|
144
|
+
safe_desc = re.sub(r'[^\w\s-]', '', description).strip().replace(' ', '_')
|
|
145
|
+
filename = f"screenshot_{platform}_crop_{safe_desc}_{timestamp}.png"
|
|
146
|
+
else:
|
|
147
|
+
filename = f"screenshot_{platform}_crop_{timestamp}.png"
|
|
148
|
+
|
|
149
|
+
final_path = self.screenshot_dir / filename
|
|
150
|
+
|
|
151
|
+
# 保存为 PNG(保持清晰度)
|
|
152
|
+
img.save(str(final_path), "PNG")
|
|
153
|
+
|
|
154
|
+
# 删除临时文件
|
|
155
|
+
temp_path.unlink()
|
|
156
|
+
|
|
157
|
+
cropped_size = final_path.stat().st_size
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
"success": True,
|
|
161
|
+
"screenshot_path": str(final_path),
|
|
162
|
+
"screen_width": screen_width,
|
|
163
|
+
"screen_height": screen_height,
|
|
164
|
+
"image_width": img.width,
|
|
165
|
+
"image_height": img.height,
|
|
166
|
+
"crop_offset_x": crop_offset_x,
|
|
167
|
+
"crop_offset_y": crop_offset_y,
|
|
168
|
+
"file_size": f"{cropped_size/1024:.1f}KB",
|
|
169
|
+
"message": f"🔍 局部截图已保存: {final_path}\n"
|
|
170
|
+
f"📐 裁剪区域: ({crop_offset_x}, {crop_offset_y}) 起,{img.width}x{img.height} 像素\n"
|
|
171
|
+
f"📦 文件大小: {cropped_size/1024:.0f}KB\n"
|
|
172
|
+
f"🎯 【坐标换算】AI 返回坐标 (x, y) 后:\n"
|
|
173
|
+
f" 实际屏幕坐标 = ({crop_offset_x} + x, {crop_offset_y} + y)\n"
|
|
174
|
+
f" 或直接调用 mobile_click_at_coords(x, y, crop_offset_x={crop_offset_x}, crop_offset_y={crop_offset_y})"
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# ========== 情况2:全屏压缩截图 ==========
|
|
178
|
+
elif compress:
|
|
179
|
+
# 🔴 关键:记录原始图片尺寸(用于坐标转换)
|
|
180
|
+
# 注意:截图尺寸可能和 u2.info 的 displayWidth 不一致!
|
|
181
|
+
original_img_width = img.width
|
|
182
|
+
original_img_height = img.height
|
|
110
183
|
|
|
111
184
|
# 第3步:缩小尺寸(保持宽高比)
|
|
112
|
-
# 记录压缩后的图片尺寸(用于坐标转换)
|
|
113
185
|
image_width, image_height = img.width, img.height
|
|
114
186
|
|
|
115
187
|
if img.width > max_width:
|
|
@@ -118,20 +190,16 @@ class BasicMobileToolsLite:
|
|
|
118
190
|
new_h = int(img.height * ratio)
|
|
119
191
|
# 兼容不同版本的 Pillow
|
|
120
192
|
try:
|
|
121
|
-
# Pillow 10.0.0+
|
|
122
193
|
resample = Image.Resampling.LANCZOS
|
|
123
194
|
except AttributeError:
|
|
124
195
|
try:
|
|
125
|
-
# Pillow 9.x
|
|
126
196
|
resample = Image.LANCZOS
|
|
127
197
|
except AttributeError:
|
|
128
|
-
# Pillow 旧版本
|
|
129
198
|
resample = Image.ANTIALIAS
|
|
130
199
|
img = img.resize((new_w, new_h), resample)
|
|
131
|
-
# 更新为压缩后的尺寸
|
|
132
200
|
image_width, image_height = new_w, new_h
|
|
133
201
|
|
|
134
|
-
#
|
|
202
|
+
# 生成文件名(JPEG 格式)
|
|
135
203
|
if description:
|
|
136
204
|
safe_desc = re.sub(r'[^\w\s-]', '', description).strip().replace(' ', '_')
|
|
137
205
|
filename = f"screenshot_{platform}_{safe_desc}_{timestamp}.jpg"
|
|
@@ -140,10 +208,8 @@ class BasicMobileToolsLite:
|
|
|
140
208
|
|
|
141
209
|
final_path = self.screenshot_dir / filename
|
|
142
210
|
|
|
143
|
-
#
|
|
144
|
-
# 先转换为 RGB 模式,处理可能的 RGBA 或 P 模式
|
|
211
|
+
# 保存为 JPEG(处理透明通道)
|
|
145
212
|
if img.mode in ('RGBA', 'LA', 'P'):
|
|
146
|
-
# 创建白色背景
|
|
147
213
|
background = Image.new('RGB', img.size, (255, 255, 255))
|
|
148
214
|
if img.mode == 'P':
|
|
149
215
|
img = img.convert('RGBA')
|
|
@@ -153,8 +219,6 @@ class BasicMobileToolsLite:
|
|
|
153
219
|
img = img.convert("RGB")
|
|
154
220
|
|
|
155
221
|
img.save(str(final_path), "JPEG", quality=quality)
|
|
156
|
-
|
|
157
|
-
# 第6步:删除临时 PNG
|
|
158
222
|
temp_path.unlink()
|
|
159
223
|
|
|
160
224
|
compressed_size = final_path.stat().st_size
|
|
@@ -165,21 +229,23 @@ class BasicMobileToolsLite:
|
|
|
165
229
|
"screenshot_path": str(final_path),
|
|
166
230
|
"screen_width": screen_width,
|
|
167
231
|
"screen_height": screen_height,
|
|
168
|
-
"
|
|
169
|
-
"
|
|
232
|
+
"original_img_width": original_img_width, # 截图原始宽度
|
|
233
|
+
"original_img_height": original_img_height, # 截图原始高度
|
|
234
|
+
"image_width": image_width, # 压缩后宽度(AI 看到的)
|
|
235
|
+
"image_height": image_height, # 压缩后高度(AI 看到的)
|
|
170
236
|
"original_size": f"{original_size/1024:.1f}KB",
|
|
171
237
|
"compressed_size": f"{compressed_size/1024:.1f}KB",
|
|
172
238
|
"saved_percent": f"{saved_percent:.0f}%",
|
|
173
239
|
"message": f"📸 截图已保存: {final_path}\n"
|
|
174
|
-
f"📐
|
|
175
|
-
f"🖼️ 图片尺寸: {image_width}x{image_height}(AI 分析用)\n"
|
|
240
|
+
f"📐 原始尺寸: {original_img_width}x{original_img_height} → 压缩后: {image_width}x{image_height}\n"
|
|
176
241
|
f"📦 已压缩: {original_size/1024:.0f}KB → {compressed_size/1024:.0f}KB (省 {saved_percent:.0f}%)\n"
|
|
177
|
-
f"⚠️
|
|
178
|
-
f"
|
|
179
|
-
f"
|
|
242
|
+
f"⚠️ 【坐标转换】AI 返回坐标后,请传入:\n"
|
|
243
|
+
f" image_width={image_width}, image_height={image_height},\n"
|
|
244
|
+
f" original_img_width={original_img_width}, original_img_height={original_img_height}"
|
|
180
245
|
}
|
|
246
|
+
|
|
247
|
+
# ========== 情况3:全屏不压缩截图 ==========
|
|
181
248
|
else:
|
|
182
|
-
# 不压缩,直接重命名临时文件
|
|
183
249
|
if description:
|
|
184
250
|
safe_desc = re.sub(r'[^\w\s-]', '', description).strip().replace(' ', '_')
|
|
185
251
|
filename = f"screenshot_{platform}_{safe_desc}_{timestamp}.png"
|
|
@@ -189,19 +255,21 @@ class BasicMobileToolsLite:
|
|
|
189
255
|
final_path = self.screenshot_dir / filename
|
|
190
256
|
temp_path.rename(final_path)
|
|
191
257
|
|
|
192
|
-
#
|
|
258
|
+
# 不压缩时,用截图实际尺寸(可能和 screen_width 不同)
|
|
193
259
|
return {
|
|
194
260
|
"success": True,
|
|
195
261
|
"screenshot_path": str(final_path),
|
|
196
262
|
"screen_width": screen_width,
|
|
197
263
|
"screen_height": screen_height,
|
|
198
|
-
"
|
|
199
|
-
"
|
|
264
|
+
"original_img_width": img.width, # 截图实际尺寸
|
|
265
|
+
"original_img_height": img.height,
|
|
266
|
+
"image_width": img.width, # 未压缩,和原图一样
|
|
267
|
+
"image_height": img.height,
|
|
200
268
|
"file_size": f"{original_size/1024:.1f}KB",
|
|
201
269
|
"message": f"📸 截图已保存: {final_path}\n"
|
|
202
|
-
f"📐
|
|
270
|
+
f"📐 截图尺寸: {img.width}x{img.height}\n"
|
|
203
271
|
f"📦 文件大小: {original_size/1024:.0f}KB(未压缩)\n"
|
|
204
|
-
f"💡
|
|
272
|
+
f"💡 未压缩,坐标可直接使用"
|
|
205
273
|
}
|
|
206
274
|
except ImportError:
|
|
207
275
|
# 如果没有 PIL,回退到原始方式(不压缩)
|
|
@@ -281,18 +349,24 @@ class BasicMobileToolsLite:
|
|
|
281
349
|
|
|
282
350
|
# ==================== 点击操作 ====================
|
|
283
351
|
|
|
284
|
-
def click_at_coords(self, x: int, y: int, image_width: int = 0, image_height: int = 0
|
|
352
|
+
def click_at_coords(self, x: int, y: int, image_width: int = 0, image_height: int = 0,
|
|
353
|
+
crop_offset_x: int = 0, crop_offset_y: int = 0,
|
|
354
|
+
original_img_width: int = 0, original_img_height: int = 0) -> Dict:
|
|
285
355
|
"""点击坐标(核心功能,支持自动坐标转换)
|
|
286
356
|
|
|
287
357
|
Args:
|
|
288
358
|
x: X 坐标(来自截图分析或屏幕坐标)
|
|
289
359
|
y: Y 坐标(来自截图分析或屏幕坐标)
|
|
290
|
-
image_width:
|
|
291
|
-
image_height:
|
|
360
|
+
image_width: 压缩后图片宽度(AI 看到的图片尺寸)
|
|
361
|
+
image_height: 压缩后图片高度(AI 看到的图片尺寸)
|
|
362
|
+
crop_offset_x: 局部截图的 X 偏移量(局部截图时传入)
|
|
363
|
+
crop_offset_y: 局部截图的 Y 偏移量(局部截图时传入)
|
|
364
|
+
original_img_width: 截图原始宽度(压缩前的尺寸,用于精确转换)
|
|
365
|
+
original_img_height: 截图原始高度(压缩前的尺寸,用于精确转换)
|
|
292
366
|
|
|
293
367
|
坐标转换说明:
|
|
294
|
-
|
|
295
|
-
|
|
368
|
+
1. 全屏压缩截图:AI 坐标 → 原图坐标(基于 image/original_img 比例)
|
|
369
|
+
2. 局部裁剪截图:AI 坐标 + 偏移量 = 屏幕坐标
|
|
296
370
|
"""
|
|
297
371
|
try:
|
|
298
372
|
# 获取屏幕尺寸
|
|
@@ -309,15 +383,30 @@ class BasicMobileToolsLite:
|
|
|
309
383
|
screen_width = info.get('displayWidth', 0)
|
|
310
384
|
screen_height = info.get('displayHeight', 0)
|
|
311
385
|
|
|
312
|
-
# 🎯
|
|
386
|
+
# 🎯 坐标转换
|
|
313
387
|
original_x, original_y = x, y
|
|
314
388
|
converted = False
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
389
|
+
conversion_type = ""
|
|
390
|
+
|
|
391
|
+
# 情况1:局部裁剪截图 - 加上偏移量
|
|
392
|
+
if crop_offset_x > 0 or crop_offset_y > 0:
|
|
393
|
+
x = x + crop_offset_x
|
|
394
|
+
y = y + crop_offset_y
|
|
395
|
+
converted = True
|
|
396
|
+
conversion_type = "crop_offset"
|
|
397
|
+
# 情况2:全屏压缩截图 - 按比例转换到原图尺寸
|
|
398
|
+
elif image_width > 0 and image_height > 0:
|
|
399
|
+
# 优先使用 original_img_width/height(更精确)
|
|
400
|
+
# 如果没传,则用 screen_width/height(兼容旧版本)
|
|
401
|
+
target_width = original_img_width if original_img_width > 0 else screen_width
|
|
402
|
+
target_height = original_img_height if original_img_height > 0 else screen_height
|
|
403
|
+
|
|
404
|
+
if target_width > 0 and target_height > 0:
|
|
405
|
+
if image_width != target_width or image_height != target_height:
|
|
406
|
+
x = int(x * target_width / image_width)
|
|
407
|
+
y = int(y * target_height / image_height)
|
|
408
|
+
converted = True
|
|
409
|
+
conversion_type = "scale"
|
|
321
410
|
|
|
322
411
|
# 执行点击
|
|
323
412
|
if self._is_ios():
|
|
@@ -345,12 +434,19 @@ class BasicMobileToolsLite:
|
|
|
345
434
|
)
|
|
346
435
|
|
|
347
436
|
if converted:
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
437
|
+
if conversion_type == "crop_offset":
|
|
438
|
+
return {
|
|
439
|
+
"success": True,
|
|
440
|
+
"message": f"✅ 点击成功: ({x}, {y})\n"
|
|
441
|
+
f" 🔍 局部截图坐标转换: ({original_x},{original_y}) + 偏移({crop_offset_x},{crop_offset_y}) → ({x},{y})"
|
|
442
|
+
}
|
|
443
|
+
else:
|
|
444
|
+
return {
|
|
445
|
+
"success": True,
|
|
446
|
+
"message": f"✅ 点击成功: ({x}, {y})\n"
|
|
447
|
+
f" 📐 坐标已转换: ({original_x},{original_y}) → ({x},{y})\n"
|
|
448
|
+
f" 🖼️ 图片尺寸: {image_width}x{image_height} → 屏幕: {screen_width}x{screen_height}"
|
|
449
|
+
}
|
|
354
450
|
else:
|
|
355
451
|
return {
|
|
356
452
|
"success": True,
|
|
@@ -556,7 +652,14 @@ class BasicMobileToolsLite:
|
|
|
556
652
|
# ==================== 输入操作 ====================
|
|
557
653
|
|
|
558
654
|
def input_text_by_id(self, resource_id: str, text: str) -> Dict:
|
|
559
|
-
"""通过 resource-id 输入文本
|
|
655
|
+
"""通过 resource-id 输入文本
|
|
656
|
+
|
|
657
|
+
优化策略:
|
|
658
|
+
1. 先用 resourceId 定位
|
|
659
|
+
2. 如果只有 1 个元素 → 直接输入
|
|
660
|
+
3. 如果有多个相同 ID(>5个说明 ID 不可靠)→ 改用 EditText 类型定位
|
|
661
|
+
4. 多个 EditText 时选择最靠上的(搜索框通常在顶部)
|
|
662
|
+
"""
|
|
560
663
|
try:
|
|
561
664
|
if self._is_ios():
|
|
562
665
|
ios_client = self._get_ios_client()
|
|
@@ -571,13 +674,70 @@ class BasicMobileToolsLite:
|
|
|
571
674
|
return {"success": True, "message": f"✅ 输入成功: '{text}'"}
|
|
572
675
|
return {"success": False, "message": f"❌ 输入框不存在: {resource_id}"}
|
|
573
676
|
else:
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
677
|
+
elements = self.client.u2(resourceId=resource_id)
|
|
678
|
+
|
|
679
|
+
# 检查是否存在
|
|
680
|
+
if elements.exists(timeout=0.5):
|
|
681
|
+
count = elements.count
|
|
682
|
+
|
|
683
|
+
# 只有 1 个元素,直接输入
|
|
684
|
+
if count == 1:
|
|
685
|
+
elements.set_text(text)
|
|
686
|
+
time.sleep(0.3)
|
|
687
|
+
self._record_operation('input', element=resource_id, ref=resource_id, text=text)
|
|
688
|
+
return {"success": True, "message": f"✅ 输入成功: '{text}'"}
|
|
689
|
+
|
|
690
|
+
# 多个相同 ID(<=5个),尝试智能选择
|
|
691
|
+
if count <= 5:
|
|
692
|
+
for i in range(count):
|
|
693
|
+
try:
|
|
694
|
+
elem = elements[i]
|
|
695
|
+
info = elem.info
|
|
696
|
+
# 优先选择可编辑的
|
|
697
|
+
if info.get('editable') or info.get('focusable'):
|
|
698
|
+
elem.set_text(text)
|
|
699
|
+
time.sleep(0.3)
|
|
700
|
+
self._record_operation('input', element=resource_id, ref=resource_id, text=text)
|
|
701
|
+
return {"success": True, "message": f"✅ 输入成功: '{text}'"}
|
|
702
|
+
except:
|
|
703
|
+
continue
|
|
704
|
+
# 没找到可编辑的,用第一个
|
|
705
|
+
elements[0].set_text(text)
|
|
706
|
+
time.sleep(0.3)
|
|
707
|
+
self._record_operation('input', element=resource_id, ref=resource_id, text=text)
|
|
708
|
+
return {"success": True, "message": f"✅ 输入成功: '{text}'"}
|
|
709
|
+
|
|
710
|
+
# ID 不可靠(不存在或太多),改用 EditText 类型定位
|
|
711
|
+
edit_texts = self.client.u2(className='android.widget.EditText')
|
|
712
|
+
if edit_texts.exists(timeout=0.5):
|
|
713
|
+
et_count = edit_texts.count
|
|
714
|
+
if et_count == 1:
|
|
715
|
+
edit_texts.set_text(text)
|
|
716
|
+
time.sleep(0.3)
|
|
717
|
+
self._record_operation('input', element='EditText', ref='EditText', text=text)
|
|
718
|
+
return {"success": True, "message": f"✅ 输入成功: '{text}' (通过 EditText 定位)"}
|
|
719
|
+
|
|
720
|
+
# 多个 EditText,选择最靠上的
|
|
721
|
+
best_elem = None
|
|
722
|
+
min_top = 9999
|
|
723
|
+
for i in range(et_count):
|
|
724
|
+
try:
|
|
725
|
+
elem = edit_texts[i]
|
|
726
|
+
top = elem.info.get('bounds', {}).get('top', 9999)
|
|
727
|
+
if top < min_top:
|
|
728
|
+
min_top = top
|
|
729
|
+
best_elem = elem
|
|
730
|
+
except:
|
|
731
|
+
continue
|
|
732
|
+
|
|
733
|
+
if best_elem:
|
|
734
|
+
best_elem.set_text(text)
|
|
735
|
+
time.sleep(0.3)
|
|
736
|
+
self._record_operation('input', element='EditText', ref='EditText', text=text)
|
|
737
|
+
return {"success": True, "message": f"✅ 输入成功: '{text}' (通过 EditText 定位,选择最顶部的)"}
|
|
738
|
+
|
|
580
739
|
return {"success": False, "message": f"❌ 输入框不存在: {resource_id}"}
|
|
740
|
+
|
|
581
741
|
except Exception as e:
|
|
582
742
|
return {"success": False, "message": f"❌ 输入失败: {e}"}
|
|
583
743
|
|
|
@@ -841,6 +1001,510 @@ class BasicMobileToolsLite:
|
|
|
841
1001
|
except Exception as e:
|
|
842
1002
|
return [{"error": f"获取元素失败: {e}"}]
|
|
843
1003
|
|
|
1004
|
+
def find_close_button(self) -> Dict:
|
|
1005
|
+
"""智能查找关闭按钮(不点击,只返回位置)
|
|
1006
|
+
|
|
1007
|
+
从元素列表中找最可能的关闭按钮,返回其坐标和百分比位置。
|
|
1008
|
+
适用于关闭弹窗广告等场景。
|
|
1009
|
+
|
|
1010
|
+
Returns:
|
|
1011
|
+
包含关闭按钮位置信息的字典,或截图让 AI 分析
|
|
1012
|
+
"""
|
|
1013
|
+
try:
|
|
1014
|
+
import re
|
|
1015
|
+
|
|
1016
|
+
if self._is_ios():
|
|
1017
|
+
return {"success": False, "message": "iOS 暂不支持,请使用截图+坐标点击"}
|
|
1018
|
+
|
|
1019
|
+
# 获取屏幕尺寸
|
|
1020
|
+
screen_width = self.client.u2.info.get('displayWidth', 720)
|
|
1021
|
+
screen_height = self.client.u2.info.get('displayHeight', 1280)
|
|
1022
|
+
|
|
1023
|
+
# 获取元素列表
|
|
1024
|
+
xml_string = self.client.u2.dump_hierarchy()
|
|
1025
|
+
import xml.etree.ElementTree as ET
|
|
1026
|
+
root = ET.fromstring(xml_string)
|
|
1027
|
+
|
|
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'
|
|
1038
|
+
|
|
1039
|
+
if not bounds_str:
|
|
1040
|
+
continue
|
|
1041
|
+
|
|
1042
|
+
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
1043
|
+
if not match:
|
|
1044
|
+
continue
|
|
1045
|
+
|
|
1046
|
+
x1, y1, x2, y2 = map(int, match.groups())
|
|
1047
|
+
width = x2 - x1
|
|
1048
|
+
height = y2 - y1
|
|
1049
|
+
center_x = (x1 + x2) // 2
|
|
1050
|
+
center_y = (y1 + y2) // 2
|
|
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
|
|
1307
|
+
|
|
1308
|
+
# 相对位置(0-1)
|
|
1309
|
+
rel_x = center_x / screen_width
|
|
1310
|
+
rel_y = center_y / screen_height
|
|
1311
|
+
|
|
1312
|
+
score = 0
|
|
1313
|
+
match_type = ""
|
|
1314
|
+
position = self._get_position_name(rel_x, rel_y)
|
|
1315
|
+
|
|
1316
|
+
# ===== 策略1:精确匹配关闭文本(最高优先级)=====
|
|
1317
|
+
if text in close_texts:
|
|
1318
|
+
score = 15.0 + popup_edge_bonus
|
|
1319
|
+
match_type = f"text='{text}'"
|
|
1320
|
+
|
|
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}'"
|
|
1325
|
+
|
|
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
|
|
1343
|
+
|
|
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"
|
|
1351
|
+
|
|
1352
|
+
# XML 顺序加分(后出现的元素在上层,更可能是弹窗内的元素)
|
|
1353
|
+
if score > 0:
|
|
1354
|
+
xml_order_bonus = idx / len(all_elements) * 2.0 # 最多加 2 分
|
|
1355
|
+
score += xml_order_bonus
|
|
1356
|
+
|
|
1357
|
+
close_candidates.append({
|
|
1358
|
+
'bounds': bounds_str,
|
|
1359
|
+
'center_x': center_x,
|
|
1360
|
+
'center_y': center_y,
|
|
1361
|
+
'width': width,
|
|
1362
|
+
'height': height,
|
|
1363
|
+
'score': score,
|
|
1364
|
+
'position': position,
|
|
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
|
|
1371
|
+
})
|
|
1372
|
+
|
|
1373
|
+
except ET.ParseError:
|
|
1374
|
+
pass
|
|
1375
|
+
|
|
1376
|
+
if not close_candidates:
|
|
1377
|
+
# 控件树未找到,自动截全屏图供 AI 分析
|
|
1378
|
+
screenshot_result = self.take_screenshot(description="弹窗全屏", compress=True)
|
|
1379
|
+
|
|
1380
|
+
# 构建更详细的视觉分析提示
|
|
1381
|
+
visual_hint = "请仔细查看截图,找到关闭按钮(通常是 × 或 X 图标)。"
|
|
1382
|
+
if popup_bounds:
|
|
1383
|
+
px1, py1, px2, py2 = popup_bounds
|
|
1384
|
+
visual_hint += f" 弹窗区域大约在 [{px1},{py1}] 到 [{px2},{py2}],关闭按钮通常在弹窗的右上角或正上方。"
|
|
1385
|
+
else:
|
|
1386
|
+
visual_hint += " 关闭按钮通常在屏幕右上角、弹窗右上角、或弹窗下方中间位置。"
|
|
1387
|
+
|
|
1388
|
+
return {
|
|
1389
|
+
"success": False,
|
|
1390
|
+
"message": "❌ 控件树未找到关闭按钮,已截全屏图供 AI 视觉分析",
|
|
1391
|
+
"action_required": visual_hint + " 找到后调用 mobile_click_at_coords(x, y, image_width, image_height, original_img_width, original_img_height) 点击。",
|
|
1392
|
+
"screenshot": screenshot_result.get("screenshot_path", ""),
|
|
1393
|
+
"screen_size": {"width": screen_width, "height": screen_height},
|
|
1394
|
+
"image_size": {
|
|
1395
|
+
"width": screenshot_result.get("image_width", screen_width),
|
|
1396
|
+
"height": screenshot_result.get("image_height", screen_height)
|
|
1397
|
+
},
|
|
1398
|
+
"original_size": {
|
|
1399
|
+
"width": screenshot_result.get("original_img_width", screen_width),
|
|
1400
|
+
"height": screenshot_result.get("original_img_height", screen_height)
|
|
1401
|
+
},
|
|
1402
|
+
"popup_detected": popup_bounds is not None,
|
|
1403
|
+
"popup_bounds": f"[{popup_bounds[0]},{popup_bounds[1]}][{popup_bounds[2]},{popup_bounds[3]}]" if popup_bounds else None,
|
|
1404
|
+
"search_areas": [
|
|
1405
|
+
"弹窗右上角(最常见)",
|
|
1406
|
+
"弹窗正上方外侧(浮动X按钮)",
|
|
1407
|
+
"弹窗下方中间(某些广告)",
|
|
1408
|
+
"屏幕右上角"
|
|
1409
|
+
],
|
|
1410
|
+
"button_features": "关闭按钮通常是:小圆形/方形图标、灰色或白色、带有 × 或 X 符号",
|
|
1411
|
+
"tip": "注意:不要点击广告内容区域,只点击关闭按钮"
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
# 按得分排序,取最可能的
|
|
1415
|
+
close_candidates.sort(key=lambda x: x['score'], reverse=True)
|
|
1416
|
+
best = close_candidates[0]
|
|
1417
|
+
|
|
1418
|
+
# 点击
|
|
1419
|
+
self.client.u2.click(best['center_x'], best['center_y'])
|
|
1420
|
+
time.sleep(0.5)
|
|
1421
|
+
|
|
1422
|
+
# 点击后截图,让 AI 判断是否成功
|
|
1423
|
+
screenshot_result = self.take_screenshot("关闭弹窗后")
|
|
1424
|
+
|
|
1425
|
+
# 记录操作(使用百分比,跨设备兼容)
|
|
1426
|
+
self._record_operation(
|
|
1427
|
+
'click',
|
|
1428
|
+
x=best['center_x'],
|
|
1429
|
+
y=best['center_y'],
|
|
1430
|
+
x_percent=best['x_percent'],
|
|
1431
|
+
y_percent=best['y_percent'],
|
|
1432
|
+
screen_width=screen_width,
|
|
1433
|
+
screen_height=screen_height,
|
|
1434
|
+
ref=f"close_popup_{best['position']}"
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
# 返回候选按钮列表,让 AI 看截图判断
|
|
1438
|
+
# 如果弹窗还在,AI 可以选择点击其他候选按钮
|
|
1439
|
+
return {
|
|
1440
|
+
"success": True,
|
|
1441
|
+
"message": f"✅ 已点击关闭按钮 ({best['position']}): ({best['center_x']}, {best['center_y']})",
|
|
1442
|
+
"clicked": {
|
|
1443
|
+
"position": best['position'],
|
|
1444
|
+
"match_type": best['match_type'],
|
|
1445
|
+
"coords": (best['center_x'], best['center_y']),
|
|
1446
|
+
"percent": (best['x_percent'], best['y_percent'])
|
|
1447
|
+
},
|
|
1448
|
+
"screenshot": screenshot_result.get("screenshot_path", ""),
|
|
1449
|
+
"popup_detected": popup_bounds is not None,
|
|
1450
|
+
"popup_bounds": f"[{popup_bounds[0]},{popup_bounds[1]}][{popup_bounds[2]},{popup_bounds[3]}]" if popup_bounds else None,
|
|
1451
|
+
"other_candidates": [
|
|
1452
|
+
{
|
|
1453
|
+
"position": c['position'],
|
|
1454
|
+
"type": c['match_type'],
|
|
1455
|
+
"coords": (c['center_x'], c['center_y']),
|
|
1456
|
+
"percent": (c['x_percent'], c['y_percent'])
|
|
1457
|
+
}
|
|
1458
|
+
for c in close_candidates[1:4] # 返回其他3个候选,AI 可以选择
|
|
1459
|
+
],
|
|
1460
|
+
"tip": "请查看截图判断弹窗是否已关闭。如果弹窗还在,可以尝试点击 other_candidates 中的其他位置;如果误点跳转了,请按返回键"
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
except Exception as e:
|
|
1464
|
+
return {"success": False, "message": f"❌ 关闭弹窗失败: {e}"}
|
|
1465
|
+
|
|
1466
|
+
def _get_position_name(self, rel_x: float, rel_y: float) -> str:
|
|
1467
|
+
"""根据相对坐标获取位置名称"""
|
|
1468
|
+
if rel_y < 0.4:
|
|
1469
|
+
if rel_x > 0.6:
|
|
1470
|
+
return "右上角"
|
|
1471
|
+
elif rel_x < 0.4:
|
|
1472
|
+
return "左上角"
|
|
1473
|
+
else:
|
|
1474
|
+
return "顶部中间"
|
|
1475
|
+
elif rel_y > 0.6:
|
|
1476
|
+
if rel_x > 0.6:
|
|
1477
|
+
return "右下角"
|
|
1478
|
+
elif rel_x < 0.4:
|
|
1479
|
+
return "左下角"
|
|
1480
|
+
else:
|
|
1481
|
+
return "底部中间"
|
|
1482
|
+
else:
|
|
1483
|
+
if rel_x > 0.6:
|
|
1484
|
+
return "右侧"
|
|
1485
|
+
elif rel_x < 0.4:
|
|
1486
|
+
return "左侧"
|
|
1487
|
+
else:
|
|
1488
|
+
return "中间"
|
|
1489
|
+
|
|
1490
|
+
def _get_position_score(self, rel_x: float, rel_y: float) -> float:
|
|
1491
|
+
"""根据位置计算额外得分(角落位置加分更多)"""
|
|
1492
|
+
# 弹窗关闭按钮常见位置得分:右上角 > 左上角 > 底部中间 > 其他角落
|
|
1493
|
+
if rel_y < 0.4: # 上半部分
|
|
1494
|
+
if rel_x > 0.6: # 右上角
|
|
1495
|
+
return 2.0 + (rel_x - 0.6) + (0.4 - rel_y)
|
|
1496
|
+
elif rel_x < 0.4: # 左上角
|
|
1497
|
+
return 1.5 + (0.4 - rel_x) + (0.4 - rel_y)
|
|
1498
|
+
else: # 顶部中间
|
|
1499
|
+
return 1.0
|
|
1500
|
+
elif rel_y > 0.6: # 下半部分
|
|
1501
|
+
if 0.3 < rel_x < 0.7: # 底部中间
|
|
1502
|
+
return 1.2 + (1 - abs(rel_x - 0.5) * 2)
|
|
1503
|
+
else: # 底部角落
|
|
1504
|
+
return 0.8
|
|
1505
|
+
else: # 中间区域
|
|
1506
|
+
return 0.5
|
|
1507
|
+
|
|
844
1508
|
def assert_text(self, text: str) -> Dict:
|
|
845
1509
|
"""检查页面是否包含文本"""
|
|
846
1510
|
try:
|
|
@@ -99,7 +99,8 @@ class MobileMCPServer:
|
|
|
99
99
|
|
|
100
100
|
async def initialize(self):
|
|
101
101
|
"""延迟初始化设备连接"""
|
|
102
|
-
|
|
102
|
+
# 如果已成功初始化,直接返回
|
|
103
|
+
if self._initialized and self.tools is not None:
|
|
103
104
|
return
|
|
104
105
|
|
|
105
106
|
platform = self._detect_platform()
|
|
@@ -110,13 +111,13 @@ class MobileMCPServer:
|
|
|
110
111
|
|
|
111
112
|
self.client = MobileClient(platform=platform)
|
|
112
113
|
self.tools = BasicMobileToolsLite(self.client)
|
|
114
|
+
self._initialized = True # 只在成功时标记
|
|
113
115
|
print(f"📱 已连接到 {platform.upper()} 设备", file=sys.stderr)
|
|
114
116
|
except Exception as e:
|
|
115
|
-
print(f"⚠️ 设备连接失败: {e}", file=sys.stderr)
|
|
116
|
-
self.client =
|
|
117
|
+
print(f"⚠️ 设备连接失败: {e},下次调用时将重试", file=sys.stderr)
|
|
118
|
+
self.client = None
|
|
117
119
|
self.tools = None
|
|
118
|
-
|
|
119
|
-
self._initialized = True
|
|
120
|
+
# 不设置 _initialized = True,下次调用会重试
|
|
120
121
|
|
|
121
122
|
def _detect_platform(self) -> str:
|
|
122
123
|
"""自动检测设备平台"""
|
|
@@ -156,19 +157,26 @@ class MobileMCPServer:
|
|
|
156
157
|
# ==================== 截图(视觉兜底)====================
|
|
157
158
|
tools.append(Tool(
|
|
158
159
|
name="mobile_take_screenshot",
|
|
159
|
-
description="📸
|
|
160
|
+
description="📸 截图(支持全屏和局部裁剪)\n\n"
|
|
160
161
|
"🎯 使用场景:\n"
|
|
161
162
|
"- 游戏(Unity/Cocos)无法获取元素时\n"
|
|
162
163
|
"- mobile_list_elements 返回空时\n"
|
|
163
164
|
"- 需要确认页面状态时\n\n"
|
|
165
|
+
"🔍 【局部裁剪】精确识别小元素(如广告关闭按钮):\n"
|
|
166
|
+
" 1. 先全屏截图,AI 返回大概坐标 (600, 200)\n"
|
|
167
|
+
" 2. 再调用 crop_x=600, crop_y=200, crop_size=200 截取局部\n"
|
|
168
|
+
" 3. 局部图不压缩,AI 可精确识别\n"
|
|
169
|
+
" 4. 点击时传入 crop_offset_x/y 自动换算坐标\n\n"
|
|
164
170
|
"⚠️ 【重要】截图会被压缩!\n"
|
|
165
|
-
" -
|
|
166
|
-
" -
|
|
167
|
-
" - 点击时必须传入 image_width/image_height 让工具自动转换坐标!",
|
|
171
|
+
" - 全屏截图:点击时传 image_width/image_height 转换坐标\n"
|
|
172
|
+
" - 局部截图:点击时传 crop_offset_x/crop_offset_y 转换坐标",
|
|
168
173
|
inputSchema={
|
|
169
174
|
"type": "object",
|
|
170
175
|
"properties": {
|
|
171
|
-
"description": {"type": "string", "description": "截图描述(可选)"}
|
|
176
|
+
"description": {"type": "string", "description": "截图描述(可选)"},
|
|
177
|
+
"crop_x": {"type": "integer", "description": "局部裁剪中心 X 坐标(屏幕坐标,0 表示不裁剪)"},
|
|
178
|
+
"crop_y": {"type": "integer", "description": "局部裁剪中心 Y 坐标(屏幕坐标,0 表示不裁剪)"},
|
|
179
|
+
"crop_size": {"type": "integer", "description": "裁剪区域大小(推荐 200-400,0 表示不裁剪)"}
|
|
172
180
|
},
|
|
173
181
|
"required": []
|
|
174
182
|
}
|
|
@@ -218,17 +226,22 @@ class MobileMCPServer:
|
|
|
218
226
|
"- 游戏(Unity/Cocos)无法获取元素\n"
|
|
219
227
|
"- mobile_list_elements 返回空\n"
|
|
220
228
|
"- 元素没有 id 和 text\n\n"
|
|
221
|
-
"⚠️
|
|
222
|
-
"
|
|
223
|
-
"
|
|
224
|
-
"
|
|
229
|
+
"⚠️ 【坐标转换】截图返回的参数直接传入:\n"
|
|
230
|
+
" - image_width/image_height: 压缩后尺寸(AI 看到的)\n"
|
|
231
|
+
" - original_img_width/original_img_height: 原图尺寸(用于转换)\n"
|
|
232
|
+
" - crop_offset_x/crop_offset_y: 局部截图偏移\n\n"
|
|
233
|
+
"✅ 自动记录百分比坐标,生成脚本时转换为跨分辨率兼容的百分比定位",
|
|
225
234
|
inputSchema={
|
|
226
235
|
"type": "object",
|
|
227
236
|
"properties": {
|
|
228
|
-
"x": {"type": "number", "description": "X
|
|
229
|
-
"y": {"type": "number", "description": "Y
|
|
230
|
-
"image_width": {"type": "number", "description": "
|
|
231
|
-
"image_height": {"type": "number", "description": "
|
|
237
|
+
"x": {"type": "number", "description": "X 坐标(来自 AI 分析截图)"},
|
|
238
|
+
"y": {"type": "number", "description": "Y 坐标(来自 AI 分析截图)"},
|
|
239
|
+
"image_width": {"type": "number", "description": "压缩后图片宽度(截图返回的 image_width)"},
|
|
240
|
+
"image_height": {"type": "number", "description": "压缩后图片高度(截图返回的 image_height)"},
|
|
241
|
+
"original_img_width": {"type": "number", "description": "原图宽度(截图返回的 original_img_width)"},
|
|
242
|
+
"original_img_height": {"type": "number", "description": "原图高度(截图返回的 original_img_height)"},
|
|
243
|
+
"crop_offset_x": {"type": "number", "description": "局部截图 X 偏移(裁剪截图时传入)"},
|
|
244
|
+
"crop_offset_y": {"type": "number", "description": "局部截图 Y 偏移(裁剪截图时传入)"}
|
|
232
245
|
},
|
|
233
246
|
"required": ["x", "y"]
|
|
234
247
|
}
|
|
@@ -374,6 +387,48 @@ class MobileMCPServer:
|
|
|
374
387
|
))
|
|
375
388
|
|
|
376
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
|
+
|
|
413
|
+
tools.append(Tool(
|
|
414
|
+
name="mobile_close_popup",
|
|
415
|
+
description="""🚫 智能关闭弹窗(直接点击)
|
|
416
|
+
|
|
417
|
+
自动识别并点击关闭按钮,一步完成。
|
|
418
|
+
|
|
419
|
+
🎯 识别策略:
|
|
420
|
+
1. 文本匹配:×、X、关闭、取消、跳过 等
|
|
421
|
+
2. 描述匹配:content-desc 包含 close/关闭
|
|
422
|
+
3. ImageView/ImageButton 小元素
|
|
423
|
+
4. clickable 的小尺寸元素(角落位置优先)
|
|
424
|
+
|
|
425
|
+
⚠️ 如果自动识别失败:
|
|
426
|
+
- 会截图供 AI 分析
|
|
427
|
+
- 用 mobile_find_close_button 先查看候选位置
|
|
428
|
+
- 或用 mobile_click_by_percent 手动点击""",
|
|
429
|
+
inputSchema={"type": "object", "properties": {}, "required": []}
|
|
430
|
+
))
|
|
431
|
+
|
|
377
432
|
tools.append(Tool(
|
|
378
433
|
name="mobile_assert_text",
|
|
379
434
|
description="✅ 检查页面是否包含指定文本。用于验证操作结果。",
|
|
@@ -444,7 +499,12 @@ class MobileMCPServer:
|
|
|
444
499
|
try:
|
|
445
500
|
# 截图
|
|
446
501
|
if name == "mobile_take_screenshot":
|
|
447
|
-
result = self.tools.take_screenshot(
|
|
502
|
+
result = self.tools.take_screenshot(
|
|
503
|
+
description=arguments.get("description", ""),
|
|
504
|
+
crop_x=arguments.get("crop_x", 0),
|
|
505
|
+
crop_y=arguments.get("crop_y", 0),
|
|
506
|
+
crop_size=arguments.get("crop_size", 0)
|
|
507
|
+
)
|
|
448
508
|
return [TextContent(type="text", text=self.format_response(result))]
|
|
449
509
|
|
|
450
510
|
elif name == "mobile_get_screen_size":
|
|
@@ -457,7 +517,11 @@ class MobileMCPServer:
|
|
|
457
517
|
arguments["x"],
|
|
458
518
|
arguments["y"],
|
|
459
519
|
arguments.get("image_width", 0),
|
|
460
|
-
arguments.get("image_height", 0)
|
|
520
|
+
arguments.get("image_height", 0),
|
|
521
|
+
arguments.get("crop_offset_x", 0),
|
|
522
|
+
arguments.get("crop_offset_y", 0),
|
|
523
|
+
arguments.get("original_img_width", 0),
|
|
524
|
+
arguments.get("original_img_height", 0)
|
|
461
525
|
)
|
|
462
526
|
return [TextContent(type="text", text=self.format_response(result))]
|
|
463
527
|
|
|
@@ -522,6 +586,14 @@ class MobileMCPServer:
|
|
|
522
586
|
result = self.tools.list_elements()
|
|
523
587
|
return [TextContent(type="text", text=self.format_response(result))]
|
|
524
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
|
+
|
|
593
|
+
elif name == "mobile_close_popup":
|
|
594
|
+
result = self.tools.close_popup()
|
|
595
|
+
return [TextContent(type="text", text=self.format_response(result))]
|
|
596
|
+
|
|
525
597
|
elif name == "mobile_assert_text":
|
|
526
598
|
result = self.tools.assert_text(arguments["text"])
|
|
527
599
|
return [TextContent(type="text", text=self.format_response(result))]
|
|
@@ -25,7 +25,7 @@ if requirements_file.exists():
|
|
|
25
25
|
|
|
26
26
|
setup(
|
|
27
27
|
name="mobile-mcp-ai",
|
|
28
|
-
version="2.
|
|
28
|
+
version="2.4.1", # close_popup改为AI看截图判断是否成功,更智能灵活
|
|
29
29
|
author="douzi",
|
|
30
30
|
author_email="1492994674@qq.com",
|
|
31
31
|
description="移动端自动化 MCP Server - 支持 Android/iOS,AI 功能可选(基础工具不需要 AI)",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|