mobile-mcp-ai 2.4.1__py3-none-any.whl → 2.4.2__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 +912 -20
- mobile_mcp/core/ios_client_wda.py +4 -0
- mobile_mcp/mcp_tools/mcp_server.py +247 -53
- {mobile_mcp_ai-2.4.1.dist-info → mobile_mcp_ai-2.4.2.dist-info}/METADATA +3 -2
- {mobile_mcp_ai-2.4.1.dist-info → mobile_mcp_ai-2.4.2.dist-info}/RECORD +9 -9
- {mobile_mcp_ai-2.4.1.dist-info → mobile_mcp_ai-2.4.2.dist-info}/WHEEL +0 -0
- {mobile_mcp_ai-2.4.1.dist-info → mobile_mcp_ai-2.4.2.dist-info}/entry_points.txt +0 -0
- {mobile_mcp_ai-2.4.1.dist-info → mobile_mcp_ai-2.4.2.dist-info}/licenses/LICENSE +0 -0
- {mobile_mcp_ai-2.4.1.dist-info → mobile_mcp_ai-2.4.2.dist-info}/top_level.txt +0 -0
|
@@ -53,6 +53,8 @@ class BasicMobileToolsLite:
|
|
|
53
53
|
}
|
|
54
54
|
self.operation_history.append(record)
|
|
55
55
|
|
|
56
|
+
|
|
57
|
+
|
|
56
58
|
# ==================== 截图 ====================
|
|
57
59
|
|
|
58
60
|
def take_screenshot(self, description: str = "", compress: bool = True,
|
|
@@ -277,6 +279,535 @@ class BasicMobileToolsLite:
|
|
|
277
279
|
except Exception as e:
|
|
278
280
|
return {"success": False, "message": f"❌ 截图失败: {e}"}
|
|
279
281
|
|
|
282
|
+
def take_screenshot_with_grid(self, grid_size: int = 100, show_popup_hints: bool = True) -> Dict:
|
|
283
|
+
"""截图并添加网格坐标标注(用于精确定位元素)
|
|
284
|
+
|
|
285
|
+
在截图上绘制网格线和坐标刻度,帮助快速定位元素位置。
|
|
286
|
+
如果检测到弹窗,会标注弹窗区域和可能的关闭按钮位置。
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
grid_size: 网格间距(像素),默认 100。建议值:50-200
|
|
290
|
+
show_popup_hints: 是否显示弹窗关闭按钮提示位置,默认 True
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
包含标注截图路径和弹窗信息的字典
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
297
|
+
import re
|
|
298
|
+
|
|
299
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
300
|
+
platform = "ios" if self._is_ios() else "android"
|
|
301
|
+
|
|
302
|
+
# 第1步:截图
|
|
303
|
+
temp_filename = f"temp_grid_{timestamp}.png"
|
|
304
|
+
temp_path = self.screenshot_dir / temp_filename
|
|
305
|
+
|
|
306
|
+
screen_width, screen_height = 0, 0
|
|
307
|
+
if self._is_ios():
|
|
308
|
+
ios_client = self._get_ios_client()
|
|
309
|
+
if ios_client and hasattr(ios_client, 'wda'):
|
|
310
|
+
ios_client.wda.screenshot(str(temp_path))
|
|
311
|
+
size = ios_client.wda.window_size()
|
|
312
|
+
screen_width, screen_height = size[0], size[1]
|
|
313
|
+
else:
|
|
314
|
+
return {"success": False, "message": "❌ iOS 客户端未初始化"}
|
|
315
|
+
else:
|
|
316
|
+
self.client.u2.screenshot(str(temp_path))
|
|
317
|
+
info = self.client.u2.info
|
|
318
|
+
screen_width = info.get('displayWidth', 720)
|
|
319
|
+
screen_height = info.get('displayHeight', 1280)
|
|
320
|
+
|
|
321
|
+
img = Image.open(temp_path)
|
|
322
|
+
draw = ImageDraw.Draw(img, 'RGBA')
|
|
323
|
+
|
|
324
|
+
# 尝试加载字体
|
|
325
|
+
try:
|
|
326
|
+
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 14)
|
|
327
|
+
font_small = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 11)
|
|
328
|
+
except:
|
|
329
|
+
font = ImageFont.load_default()
|
|
330
|
+
font_small = font
|
|
331
|
+
|
|
332
|
+
img_width, img_height = img.size
|
|
333
|
+
|
|
334
|
+
# 第2步:绘制网格线和坐标
|
|
335
|
+
grid_color = (255, 0, 0, 80) # 半透明红色
|
|
336
|
+
text_color = (255, 0, 0, 200) # 红色文字
|
|
337
|
+
|
|
338
|
+
# 绘制垂直网格线
|
|
339
|
+
for x in range(0, img_width, grid_size):
|
|
340
|
+
draw.line([(x, 0), (x, img_height)], fill=grid_color, width=1)
|
|
341
|
+
# 顶部标注 X 坐标
|
|
342
|
+
draw.text((x + 2, 2), str(x), fill=text_color, font=font_small)
|
|
343
|
+
|
|
344
|
+
# 绘制水平网格线
|
|
345
|
+
for y in range(0, img_height, grid_size):
|
|
346
|
+
draw.line([(0, y), (img_width, y)], fill=grid_color, width=1)
|
|
347
|
+
# 左侧标注 Y 坐标
|
|
348
|
+
draw.text((2, y + 2), str(y), fill=text_color, font=font_small)
|
|
349
|
+
|
|
350
|
+
# 第3步:检测弹窗并标注
|
|
351
|
+
popup_info = None
|
|
352
|
+
close_positions = []
|
|
353
|
+
|
|
354
|
+
if show_popup_hints and not self._is_ios():
|
|
355
|
+
try:
|
|
356
|
+
import xml.etree.ElementTree as ET
|
|
357
|
+
xml_string = self.client.u2.dump_hierarchy()
|
|
358
|
+
root = ET.fromstring(xml_string)
|
|
359
|
+
|
|
360
|
+
# 检测弹窗区域
|
|
361
|
+
popup_bounds = None
|
|
362
|
+
for elem in root.iter():
|
|
363
|
+
bounds_str = elem.attrib.get('bounds', '')
|
|
364
|
+
class_name = elem.attrib.get('class', '')
|
|
365
|
+
|
|
366
|
+
if not bounds_str:
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
370
|
+
if not match:
|
|
371
|
+
continue
|
|
372
|
+
|
|
373
|
+
x1, y1, x2, y2 = map(int, match.groups())
|
|
374
|
+
width = x2 - x1
|
|
375
|
+
height = y2 - y1
|
|
376
|
+
area = width * height
|
|
377
|
+
screen_area = screen_width * screen_height
|
|
378
|
+
|
|
379
|
+
is_container = any(kw in class_name for kw in ['Layout', 'View', 'Dialog', 'Card'])
|
|
380
|
+
area_ratio = area / screen_area if screen_area > 0 else 0
|
|
381
|
+
is_not_fullscreen = (width < screen_width * 0.98 or height < screen_height * 0.98)
|
|
382
|
+
is_reasonable_size = 0.08 < area_ratio < 0.85
|
|
383
|
+
|
|
384
|
+
if is_container and is_not_fullscreen and is_reasonable_size and y1 > 50:
|
|
385
|
+
if popup_bounds is None or area > (popup_bounds[2] - popup_bounds[0]) * (popup_bounds[3] - popup_bounds[1]):
|
|
386
|
+
popup_bounds = (x1, y1, x2, y2)
|
|
387
|
+
|
|
388
|
+
if popup_bounds:
|
|
389
|
+
px1, py1, px2, py2 = popup_bounds
|
|
390
|
+
|
|
391
|
+
# 绘制弹窗边框(蓝色)
|
|
392
|
+
draw.rectangle([px1, py1, px2, py2], outline=(0, 100, 255, 200), width=3)
|
|
393
|
+
draw.text((px1 + 5, py1 + 5), f"弹窗区域", fill=(0, 100, 255), font=font)
|
|
394
|
+
|
|
395
|
+
# 计算可能的 X 按钮位置
|
|
396
|
+
close_positions = [
|
|
397
|
+
{"name": "右上角外", "x": px2 - 20, "y": py1 - 35, "priority": 1},
|
|
398
|
+
{"name": "右上角内", "x": px2 - 35, "y": py1 + 35, "priority": 2},
|
|
399
|
+
{"name": "正上方", "x": (px1 + px2) // 2, "y": py1 - 35, "priority": 3},
|
|
400
|
+
{"name": "底部下方", "x": (px1 + px2) // 2, "y": py2 + 40, "priority": 4},
|
|
401
|
+
]
|
|
402
|
+
|
|
403
|
+
# 绘制可能的 X 按钮位置(绿色圆圈 + 数字)
|
|
404
|
+
for i, pos in enumerate(close_positions):
|
|
405
|
+
cx, cy = pos["x"], pos["y"]
|
|
406
|
+
if 0 <= cx <= img_width and 0 <= cy <= img_height:
|
|
407
|
+
# 绿色圆圈
|
|
408
|
+
draw.ellipse([cx-15, cy-15, cx+15, cy+15],
|
|
409
|
+
outline=(0, 255, 0, 200), width=2)
|
|
410
|
+
# 数字标注
|
|
411
|
+
draw.text((cx-5, cy-8), str(i+1), fill=(0, 255, 0), font=font)
|
|
412
|
+
# 坐标标注
|
|
413
|
+
draw.text((cx+18, cy-8), f"({cx},{cy})", fill=(0, 255, 0), font=font_small)
|
|
414
|
+
|
|
415
|
+
popup_info = {
|
|
416
|
+
"bounds": f"[{px1},{py1}][{px2},{py2}]",
|
|
417
|
+
"width": px2 - px1,
|
|
418
|
+
"height": py2 - py1,
|
|
419
|
+
"close_positions": close_positions
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
except Exception as e:
|
|
423
|
+
pass # 弹窗检测失败不影响主功能
|
|
424
|
+
|
|
425
|
+
# 第4步:保存标注后的截图
|
|
426
|
+
filename = f"screenshot_{platform}_grid_{timestamp}.jpg"
|
|
427
|
+
final_path = self.screenshot_dir / filename
|
|
428
|
+
|
|
429
|
+
# 转换为 RGB 并保存
|
|
430
|
+
if img.mode in ('RGBA', 'LA', 'P'):
|
|
431
|
+
background = Image.new('RGB', img.size, (255, 255, 255))
|
|
432
|
+
if img.mode == 'P':
|
|
433
|
+
img = img.convert('RGBA')
|
|
434
|
+
background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
|
|
435
|
+
img = background
|
|
436
|
+
elif img.mode != 'RGB':
|
|
437
|
+
img = img.convert("RGB")
|
|
438
|
+
|
|
439
|
+
img.save(str(final_path), "JPEG", quality=85)
|
|
440
|
+
temp_path.unlink()
|
|
441
|
+
|
|
442
|
+
result = {
|
|
443
|
+
"success": True,
|
|
444
|
+
"screenshot_path": str(final_path),
|
|
445
|
+
"screen_width": screen_width,
|
|
446
|
+
"screen_height": screen_height,
|
|
447
|
+
"image_width": img_width,
|
|
448
|
+
"image_height": img_height,
|
|
449
|
+
"grid_size": grid_size,
|
|
450
|
+
"message": f"📸 网格截图已保存: {final_path}\n"
|
|
451
|
+
f"📐 尺寸: {img_width}x{img_height}\n"
|
|
452
|
+
f"📏 网格间距: {grid_size}px"
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if popup_info:
|
|
456
|
+
result["popup_detected"] = True
|
|
457
|
+
result["popup_bounds"] = popup_info["bounds"]
|
|
458
|
+
result["close_button_hints"] = close_positions
|
|
459
|
+
result["message"] += f"\n🎯 检测到弹窗: {popup_info['bounds']}"
|
|
460
|
+
result["message"] += f"\n💡 可能的关闭按钮位置(绿色圆圈标注):"
|
|
461
|
+
for pos in close_positions:
|
|
462
|
+
result["message"] += f"\n {pos['priority']}. {pos['name']}: ({pos['x']}, {pos['y']})"
|
|
463
|
+
else:
|
|
464
|
+
result["popup_detected"] = False
|
|
465
|
+
|
|
466
|
+
return result
|
|
467
|
+
|
|
468
|
+
except ImportError:
|
|
469
|
+
return {"success": False, "message": "❌ 需要安装 Pillow: pip install Pillow"}
|
|
470
|
+
except Exception as e:
|
|
471
|
+
return {"success": False, "message": f"❌ 网格截图失败: {e}"}
|
|
472
|
+
|
|
473
|
+
def take_screenshot_with_som(self) -> Dict:
|
|
474
|
+
"""Set-of-Mark 截图:给每个可点击元素标上数字(超级好用!)
|
|
475
|
+
|
|
476
|
+
在截图上给每个可点击元素画框并标上数字编号。
|
|
477
|
+
AI 看图后直接说"点击 3 号",然后调用 click_by_som(3) 即可。
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
包含标注截图和元素列表的字典
|
|
481
|
+
"""
|
|
482
|
+
try:
|
|
483
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
484
|
+
import re
|
|
485
|
+
|
|
486
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
487
|
+
platform = "ios" if self._is_ios() else "android"
|
|
488
|
+
|
|
489
|
+
# 第1步:截图
|
|
490
|
+
temp_filename = f"temp_som_{timestamp}.png"
|
|
491
|
+
temp_path = self.screenshot_dir / temp_filename
|
|
492
|
+
|
|
493
|
+
screen_width, screen_height = 0, 0
|
|
494
|
+
if self._is_ios():
|
|
495
|
+
ios_client = self._get_ios_client()
|
|
496
|
+
if ios_client and hasattr(ios_client, 'wda'):
|
|
497
|
+
ios_client.wda.screenshot(str(temp_path))
|
|
498
|
+
size = ios_client.wda.window_size()
|
|
499
|
+
screen_width, screen_height = size[0], size[1]
|
|
500
|
+
else:
|
|
501
|
+
return {"success": False, "message": "❌ iOS 客户端未初始化"}
|
|
502
|
+
else:
|
|
503
|
+
self.client.u2.screenshot(str(temp_path))
|
|
504
|
+
info = self.client.u2.info
|
|
505
|
+
screen_width = info.get('displayWidth', 720)
|
|
506
|
+
screen_height = info.get('displayHeight', 1280)
|
|
507
|
+
|
|
508
|
+
img = Image.open(temp_path)
|
|
509
|
+
draw = ImageDraw.Draw(img, 'RGBA')
|
|
510
|
+
img_width, img_height = img.size
|
|
511
|
+
|
|
512
|
+
# 尝试加载字体
|
|
513
|
+
try:
|
|
514
|
+
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 16)
|
|
515
|
+
font_small = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 12)
|
|
516
|
+
except:
|
|
517
|
+
font = ImageFont.load_default()
|
|
518
|
+
font_small = font
|
|
519
|
+
|
|
520
|
+
# 第2步:获取所有可点击元素
|
|
521
|
+
elements = []
|
|
522
|
+
if self._is_ios():
|
|
523
|
+
# iOS 暂不支持
|
|
524
|
+
pass
|
|
525
|
+
else:
|
|
526
|
+
try:
|
|
527
|
+
import xml.etree.ElementTree as ET
|
|
528
|
+
xml_string = self.client.u2.dump_hierarchy()
|
|
529
|
+
root = ET.fromstring(xml_string)
|
|
530
|
+
|
|
531
|
+
for elem in root.iter():
|
|
532
|
+
clickable = elem.attrib.get('clickable', 'false') == 'true'
|
|
533
|
+
bounds_str = elem.attrib.get('bounds', '')
|
|
534
|
+
text = elem.attrib.get('text', '')
|
|
535
|
+
content_desc = elem.attrib.get('content-desc', '')
|
|
536
|
+
resource_id = elem.attrib.get('resource-id', '')
|
|
537
|
+
class_name = elem.attrib.get('class', '')
|
|
538
|
+
|
|
539
|
+
if not clickable or not bounds_str:
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
543
|
+
if not match:
|
|
544
|
+
continue
|
|
545
|
+
|
|
546
|
+
x1, y1, x2, y2 = map(int, match.groups())
|
|
547
|
+
width = x2 - x1
|
|
548
|
+
height = y2 - y1
|
|
549
|
+
|
|
550
|
+
# 过滤太小或太大的元素
|
|
551
|
+
if width < 20 or height < 20:
|
|
552
|
+
continue
|
|
553
|
+
if width >= screen_width * 0.98 and height >= screen_height * 0.5:
|
|
554
|
+
continue # 全屏或大面积容器
|
|
555
|
+
|
|
556
|
+
center_x = (x1 + x2) // 2
|
|
557
|
+
center_y = (y1 + y2) // 2
|
|
558
|
+
|
|
559
|
+
# 生成描述
|
|
560
|
+
desc = text or content_desc or resource_id.split('/')[-1] if resource_id else class_name.split('.')[-1]
|
|
561
|
+
if len(desc) > 20:
|
|
562
|
+
desc = desc[:17] + "..."
|
|
563
|
+
|
|
564
|
+
elements.append({
|
|
565
|
+
'bounds': (x1, y1, x2, y2),
|
|
566
|
+
'center': (center_x, center_y),
|
|
567
|
+
'text': text,
|
|
568
|
+
'desc': desc,
|
|
569
|
+
'resource_id': resource_id
|
|
570
|
+
})
|
|
571
|
+
except Exception as e:
|
|
572
|
+
pass
|
|
573
|
+
|
|
574
|
+
# 第3步:在截图上标注元素
|
|
575
|
+
# 颜色列表(循环使用)
|
|
576
|
+
colors = [
|
|
577
|
+
(255, 0, 0), # 红
|
|
578
|
+
(0, 255, 0), # 绿
|
|
579
|
+
(0, 100, 255), # 蓝
|
|
580
|
+
(255, 165, 0), # 橙
|
|
581
|
+
(255, 0, 255), # 紫
|
|
582
|
+
(0, 255, 255), # 青
|
|
583
|
+
]
|
|
584
|
+
|
|
585
|
+
som_elements = [] # 保存标注信息,供 click_by_som 使用
|
|
586
|
+
|
|
587
|
+
for i, elem in enumerate(elements):
|
|
588
|
+
x1, y1, x2, y2 = elem['bounds']
|
|
589
|
+
cx, cy = elem['center']
|
|
590
|
+
color = colors[i % len(colors)]
|
|
591
|
+
|
|
592
|
+
# 画边框
|
|
593
|
+
draw.rectangle([x1, y1, x2, y2], outline=color + (200,), width=2)
|
|
594
|
+
|
|
595
|
+
# 画编号标签背景
|
|
596
|
+
label = str(i + 1)
|
|
597
|
+
label_w, label_h = 20, 18
|
|
598
|
+
label_x = x1
|
|
599
|
+
label_y = max(0, y1 - label_h - 2)
|
|
600
|
+
draw.rectangle([label_x, label_y, label_x + label_w, label_y + label_h],
|
|
601
|
+
fill=color + (220,))
|
|
602
|
+
|
|
603
|
+
# 画编号文字
|
|
604
|
+
draw.text((label_x + 4, label_y + 1), label, fill=(255, 255, 255), font=font_small)
|
|
605
|
+
|
|
606
|
+
som_elements.append({
|
|
607
|
+
'index': i + 1,
|
|
608
|
+
'center': (cx, cy),
|
|
609
|
+
'bounds': f"[{x1},{y1}][{x2},{y2}]",
|
|
610
|
+
'desc': elem['desc']
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
# 第3.5步:检测弹窗并标注可能的 X 按钮位置(如果 X 不在元素树中)
|
|
614
|
+
popup_bounds = None
|
|
615
|
+
popup_close_hints = []
|
|
616
|
+
|
|
617
|
+
if not self._is_ios():
|
|
618
|
+
try:
|
|
619
|
+
# 检测弹窗区域
|
|
620
|
+
for elem in root.iter():
|
|
621
|
+
bounds_str = elem.attrib.get('bounds', '')
|
|
622
|
+
class_name = elem.attrib.get('class', '')
|
|
623
|
+
|
|
624
|
+
if not bounds_str:
|
|
625
|
+
continue
|
|
626
|
+
|
|
627
|
+
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
628
|
+
if not match:
|
|
629
|
+
continue
|
|
630
|
+
|
|
631
|
+
px1, py1, px2, py2 = map(int, match.groups())
|
|
632
|
+
p_width = px2 - px1
|
|
633
|
+
p_height = py2 - py1
|
|
634
|
+
p_area = p_width * p_height
|
|
635
|
+
screen_area = screen_width * screen_height
|
|
636
|
+
|
|
637
|
+
is_container = any(kw in class_name for kw in ['Layout', 'View', 'Dialog', 'Card', 'Frame'])
|
|
638
|
+
area_ratio = p_area / screen_area if screen_area > 0 else 0
|
|
639
|
+
is_not_fullscreen = (p_width < screen_width * 0.99 or p_height < screen_height * 0.95)
|
|
640
|
+
# 放宽面积范围:5% - 95%
|
|
641
|
+
is_reasonable_size = 0.05 < area_ratio < 0.95
|
|
642
|
+
|
|
643
|
+
if is_container and is_not_fullscreen and is_reasonable_size and py1 > 30:
|
|
644
|
+
if popup_bounds is None or p_area > (popup_bounds[2] - popup_bounds[0]) * (popup_bounds[3] - popup_bounds[1]):
|
|
645
|
+
popup_bounds = (px1, py1, px2, py2)
|
|
646
|
+
|
|
647
|
+
# 如果检测到弹窗,始终添加 X 按钮位置提示
|
|
648
|
+
if popup_bounds:
|
|
649
|
+
px1, py1, px2, py2 = popup_bounds
|
|
650
|
+
|
|
651
|
+
# 计算多个可能的 X 按钮位置(基于弹窗边界)
|
|
652
|
+
close_positions = [
|
|
653
|
+
{"name": "右上内", "x": px2 - 35, "y": py1 + 40},
|
|
654
|
+
{"name": "右上外", "x": px2 - 20, "y": py1 - 40},
|
|
655
|
+
{"name": "正上方", "x": (px1 + px2) // 2, "y": py1 - 40},
|
|
656
|
+
]
|
|
657
|
+
|
|
658
|
+
# 用黄色/金色标注这些可能位置(始终显示)
|
|
659
|
+
hint_color = (255, 200, 0) # 金黄色
|
|
660
|
+
next_index = len(som_elements) + 1
|
|
661
|
+
|
|
662
|
+
for pos in close_positions:
|
|
663
|
+
hx, hy = pos["x"], pos["y"]
|
|
664
|
+
if 0 <= hx <= img_width and 0 <= hy <= img_height:
|
|
665
|
+
# 画圆圈
|
|
666
|
+
draw.ellipse([hx-18, hy-18, hx+18, hy+18],
|
|
667
|
+
outline=hint_color + (255,), width=3)
|
|
668
|
+
# 画编号背景
|
|
669
|
+
draw.rectangle([hx-10, hy-22, hx+10, hy-6],
|
|
670
|
+
fill=hint_color + (220,))
|
|
671
|
+
# 画编号
|
|
672
|
+
draw.text((hx-6, hy-20), str(next_index),
|
|
673
|
+
fill=(0, 0, 0), font=font_small)
|
|
674
|
+
# 标注 "X?"
|
|
675
|
+
draw.text((hx-8, hy-5), "X?", fill=hint_color, font=font_small)
|
|
676
|
+
|
|
677
|
+
popup_close_hints.append({
|
|
678
|
+
'index': next_index,
|
|
679
|
+
'center': (hx, hy),
|
|
680
|
+
'bounds': f"[{hx-20},{hy-20}][{hx+20},{hy+20}]",
|
|
681
|
+
'desc': f"X?{pos['name']}",
|
|
682
|
+
'is_hint': True
|
|
683
|
+
})
|
|
684
|
+
next_index += 1
|
|
685
|
+
|
|
686
|
+
# 画弹窗边框(蓝色)
|
|
687
|
+
draw.rectangle([px1, py1, px2, py2], outline=(0, 150, 255, 180), width=2)
|
|
688
|
+
|
|
689
|
+
except Exception as e:
|
|
690
|
+
pass # 弹窗检测失败不影响主功能
|
|
691
|
+
|
|
692
|
+
# 合并元素列表
|
|
693
|
+
all_som_elements = som_elements + popup_close_hints
|
|
694
|
+
|
|
695
|
+
# 保存到实例变量,供 click_by_som 使用
|
|
696
|
+
self._som_elements = all_som_elements
|
|
697
|
+
|
|
698
|
+
# 第4步:保存标注后的截图
|
|
699
|
+
filename = f"screenshot_{platform}_som_{timestamp}.jpg"
|
|
700
|
+
final_path = self.screenshot_dir / filename
|
|
701
|
+
|
|
702
|
+
if img.mode in ('RGBA', 'LA', 'P'):
|
|
703
|
+
background = Image.new('RGB', img.size, (255, 255, 255))
|
|
704
|
+
if img.mode == 'P':
|
|
705
|
+
img = img.convert('RGBA')
|
|
706
|
+
background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
|
|
707
|
+
img = background
|
|
708
|
+
elif img.mode != 'RGB':
|
|
709
|
+
img = img.convert("RGB")
|
|
710
|
+
|
|
711
|
+
img.save(str(final_path), "JPEG", quality=85)
|
|
712
|
+
temp_path.unlink()
|
|
713
|
+
|
|
714
|
+
# 构建元素列表文字
|
|
715
|
+
elements_text = "\n".join([
|
|
716
|
+
f" [{e['index']}] {e['desc']} → ({e['center'][0]}, {e['center'][1]})"
|
|
717
|
+
for e in som_elements[:15] # 只显示前15个
|
|
718
|
+
])
|
|
719
|
+
if len(som_elements) > 15:
|
|
720
|
+
elements_text += f"\n ... 还有 {len(som_elements) - 15} 个元素"
|
|
721
|
+
|
|
722
|
+
# 构建弹窗提示文字
|
|
723
|
+
hints_text = ""
|
|
724
|
+
if popup_close_hints:
|
|
725
|
+
hints_text = "\n🎯 检测到弹窗,可能的 X 按钮位置(黄色圆圈):\n"
|
|
726
|
+
hints_text += "\n".join([
|
|
727
|
+
f" [{h['index']}] {h['desc']} → ({h['center'][0]}, {h['center'][1]})"
|
|
728
|
+
for h in popup_close_hints
|
|
729
|
+
])
|
|
730
|
+
|
|
731
|
+
return {
|
|
732
|
+
"success": True,
|
|
733
|
+
"screenshot_path": str(final_path),
|
|
734
|
+
"screen_width": screen_width,
|
|
735
|
+
"screen_height": screen_height,
|
|
736
|
+
"image_width": img_width,
|
|
737
|
+
"image_height": img_height,
|
|
738
|
+
"element_count": len(all_som_elements),
|
|
739
|
+
"elements": all_som_elements,
|
|
740
|
+
"popup_detected": popup_bounds is not None,
|
|
741
|
+
"popup_bounds": f"[{popup_bounds[0]},{popup_bounds[1]}][{popup_bounds[2]},{popup_bounds[3]}]" if popup_bounds else None,
|
|
742
|
+
"close_hints": popup_close_hints,
|
|
743
|
+
"message": f"📸 SoM 截图已保存: {final_path}\n"
|
|
744
|
+
f"🏷️ 已标注 {len(all_som_elements)} 个元素({len(som_elements)} 个可点击 + {len(popup_close_hints)} 个X按钮提示)\n"
|
|
745
|
+
f"📋 元素列表:\n{elements_text}{hints_text}\n\n"
|
|
746
|
+
f"💡 使用方法:看图后调用 mobile_click_by_som(编号) 点击对应元素"
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
except ImportError:
|
|
750
|
+
return {"success": False, "message": "❌ 需要安装 Pillow: pip install Pillow"}
|
|
751
|
+
except Exception as e:
|
|
752
|
+
return {"success": False, "message": f"❌ SoM 截图失败: {e}"}
|
|
753
|
+
|
|
754
|
+
def click_by_som(self, index: int) -> Dict:
|
|
755
|
+
"""根据 SoM 编号点击元素
|
|
756
|
+
|
|
757
|
+
配合 take_screenshot_with_som 使用。
|
|
758
|
+
看图后直接说"点击 3 号",调用此函数即可。
|
|
759
|
+
|
|
760
|
+
Args:
|
|
761
|
+
index: 元素编号(从 1 开始)
|
|
762
|
+
|
|
763
|
+
Returns:
|
|
764
|
+
点击结果
|
|
765
|
+
"""
|
|
766
|
+
try:
|
|
767
|
+
if not hasattr(self, '_som_elements') or not self._som_elements:
|
|
768
|
+
return {
|
|
769
|
+
"success": False,
|
|
770
|
+
"message": "❌ 请先调用 mobile_screenshot_with_som 获取元素列表"
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
# 查找对应编号的元素
|
|
774
|
+
target = None
|
|
775
|
+
for elem in self._som_elements:
|
|
776
|
+
if elem['index'] == index:
|
|
777
|
+
target = elem
|
|
778
|
+
break
|
|
779
|
+
|
|
780
|
+
if not target:
|
|
781
|
+
return {
|
|
782
|
+
"success": False,
|
|
783
|
+
"message": f"❌ 未找到编号 {index} 的元素,有效范围: 1-{len(self._som_elements)}"
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
# 点击
|
|
787
|
+
cx, cy = target['center']
|
|
788
|
+
if self._is_ios():
|
|
789
|
+
ios_client = self._get_ios_client()
|
|
790
|
+
if ios_client and hasattr(ios_client, 'wda'):
|
|
791
|
+
ios_client.wda.click(cx, cy)
|
|
792
|
+
else:
|
|
793
|
+
self.client.u2.click(cx, cy)
|
|
794
|
+
|
|
795
|
+
time.sleep(0.3)
|
|
796
|
+
|
|
797
|
+
return {
|
|
798
|
+
"success": True,
|
|
799
|
+
"message": f"✅ 已点击 [{index}] {target['desc']} → ({cx}, {cy})\n💡 建议:再次截图确认操作是否成功",
|
|
800
|
+
"clicked": {
|
|
801
|
+
"index": index,
|
|
802
|
+
"desc": target['desc'],
|
|
803
|
+
"coords": (cx, cy),
|
|
804
|
+
"bounds": target['bounds']
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
except Exception as e:
|
|
809
|
+
return {"success": False, "message": f"❌ 点击失败: {e}\n💡 如果页面已变化,请重新调用 mobile_screenshot_with_som 刷新元素列表"}
|
|
810
|
+
|
|
280
811
|
def _take_screenshot_no_compress(self, description: str = "") -> Dict:
|
|
281
812
|
"""截图(不压缩,PIL 不可用时的备用方案)"""
|
|
282
813
|
try:
|
|
@@ -649,6 +1180,298 @@ class BasicMobileToolsLite:
|
|
|
649
1180
|
except Exception as e:
|
|
650
1181
|
return {"success": False, "message": f"❌ 点击失败: {e}"}
|
|
651
1182
|
|
|
1183
|
+
# ==================== 长按操作 ====================
|
|
1184
|
+
|
|
1185
|
+
def long_press_at_coords(self, x: int, y: int, duration: float = 1.0,
|
|
1186
|
+
image_width: int = 0, image_height: int = 0,
|
|
1187
|
+
crop_offset_x: int = 0, crop_offset_y: int = 0,
|
|
1188
|
+
original_img_width: int = 0, original_img_height: int = 0) -> Dict:
|
|
1189
|
+
"""长按坐标(核心功能,支持自动坐标转换)
|
|
1190
|
+
|
|
1191
|
+
Args:
|
|
1192
|
+
x: X 坐标(来自截图分析或屏幕坐标)
|
|
1193
|
+
y: Y 坐标(来自截图分析或屏幕坐标)
|
|
1194
|
+
duration: 长按持续时间(秒),默认 1.0
|
|
1195
|
+
image_width: 压缩后图片宽度(AI 看到的图片尺寸)
|
|
1196
|
+
image_height: 压缩后图片高度(AI 看到的图片尺寸)
|
|
1197
|
+
crop_offset_x: 局部截图的 X 偏移量(局部截图时传入)
|
|
1198
|
+
crop_offset_y: 局部截图的 Y 偏移量(局部截图时传入)
|
|
1199
|
+
original_img_width: 截图原始宽度(压缩前的尺寸,用于精确转换)
|
|
1200
|
+
original_img_height: 截图原始高度(压缩前的尺寸,用于精确转换)
|
|
1201
|
+
|
|
1202
|
+
坐标转换说明:
|
|
1203
|
+
1. 全屏压缩截图:AI 坐标 → 原图坐标(基于 image/original_img 比例)
|
|
1204
|
+
2. 局部裁剪截图:AI 坐标 + 偏移量 = 屏幕坐标
|
|
1205
|
+
"""
|
|
1206
|
+
try:
|
|
1207
|
+
# 获取屏幕尺寸
|
|
1208
|
+
screen_width, screen_height = 0, 0
|
|
1209
|
+
if self._is_ios():
|
|
1210
|
+
ios_client = self._get_ios_client()
|
|
1211
|
+
if ios_client and hasattr(ios_client, 'wda'):
|
|
1212
|
+
size = ios_client.wda.window_size()
|
|
1213
|
+
screen_width, screen_height = size[0], size[1]
|
|
1214
|
+
else:
|
|
1215
|
+
return {"success": False, "message": "❌ iOS 客户端未初始化"}
|
|
1216
|
+
else:
|
|
1217
|
+
info = self.client.u2.info
|
|
1218
|
+
screen_width = info.get('displayWidth', 0)
|
|
1219
|
+
screen_height = info.get('displayHeight', 0)
|
|
1220
|
+
|
|
1221
|
+
# 🎯 坐标转换
|
|
1222
|
+
original_x, original_y = x, y
|
|
1223
|
+
converted = False
|
|
1224
|
+
conversion_type = ""
|
|
1225
|
+
|
|
1226
|
+
# 情况1:局部裁剪截图 - 加上偏移量
|
|
1227
|
+
if crop_offset_x > 0 or crop_offset_y > 0:
|
|
1228
|
+
x = x + crop_offset_x
|
|
1229
|
+
y = y + crop_offset_y
|
|
1230
|
+
converted = True
|
|
1231
|
+
conversion_type = "crop_offset"
|
|
1232
|
+
# 情况2:全屏压缩截图 - 按比例转换到原图尺寸
|
|
1233
|
+
elif image_width > 0 and image_height > 0:
|
|
1234
|
+
target_width = original_img_width if original_img_width > 0 else screen_width
|
|
1235
|
+
target_height = original_img_height if original_img_height > 0 else screen_height
|
|
1236
|
+
|
|
1237
|
+
if target_width > 0 and target_height > 0:
|
|
1238
|
+
if image_width != target_width or image_height != target_height:
|
|
1239
|
+
x = int(x * target_width / image_width)
|
|
1240
|
+
y = int(y * target_height / image_height)
|
|
1241
|
+
converted = True
|
|
1242
|
+
conversion_type = "scale"
|
|
1243
|
+
|
|
1244
|
+
# 执行长按
|
|
1245
|
+
if self._is_ios():
|
|
1246
|
+
ios_client = self._get_ios_client()
|
|
1247
|
+
# iOS 使用 tap_hold 或 swipe 原地实现长按
|
|
1248
|
+
if hasattr(ios_client.wda, 'tap_hold'):
|
|
1249
|
+
ios_client.wda.tap_hold(x, y, duration=duration)
|
|
1250
|
+
else:
|
|
1251
|
+
# 兜底:用原地 swipe 模拟长按
|
|
1252
|
+
ios_client.wda.swipe(x, y, x, y, duration=duration)
|
|
1253
|
+
else:
|
|
1254
|
+
self.client.u2.long_click(x, y, duration=duration)
|
|
1255
|
+
|
|
1256
|
+
time.sleep(0.3)
|
|
1257
|
+
|
|
1258
|
+
# 计算百分比坐标(用于跨设备兼容)
|
|
1259
|
+
x_percent = round(x / screen_width * 100, 1) if screen_width > 0 else 0
|
|
1260
|
+
y_percent = round(y / screen_height * 100, 1) if screen_height > 0 else 0
|
|
1261
|
+
|
|
1262
|
+
# 记录操作
|
|
1263
|
+
self._record_operation(
|
|
1264
|
+
'long_press',
|
|
1265
|
+
x=x,
|
|
1266
|
+
y=y,
|
|
1267
|
+
x_percent=x_percent,
|
|
1268
|
+
y_percent=y_percent,
|
|
1269
|
+
duration=duration,
|
|
1270
|
+
screen_width=screen_width,
|
|
1271
|
+
screen_height=screen_height,
|
|
1272
|
+
ref=f"coords_{x}_{y}"
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
if converted:
|
|
1276
|
+
if conversion_type == "crop_offset":
|
|
1277
|
+
return {
|
|
1278
|
+
"success": True,
|
|
1279
|
+
"message": f"✅ 长按成功: ({x}, {y}) 持续 {duration}s\n"
|
|
1280
|
+
f" 🔍 局部截图坐标转换: ({original_x},{original_y}) + 偏移({crop_offset_x},{crop_offset_y}) → ({x},{y})"
|
|
1281
|
+
}
|
|
1282
|
+
else:
|
|
1283
|
+
return {
|
|
1284
|
+
"success": True,
|
|
1285
|
+
"message": f"✅ 长按成功: ({x}, {y}) 持续 {duration}s\n"
|
|
1286
|
+
f" 📐 坐标已转换: ({original_x},{original_y}) → ({x},{y})\n"
|
|
1287
|
+
f" 🖼️ 图片尺寸: {image_width}x{image_height} → 屏幕: {screen_width}x{screen_height}"
|
|
1288
|
+
}
|
|
1289
|
+
else:
|
|
1290
|
+
return {
|
|
1291
|
+
"success": True,
|
|
1292
|
+
"message": f"✅ 长按成功: ({x}, {y}) 持续 {duration}s [相对位置: {x_percent}%, {y_percent}%]"
|
|
1293
|
+
}
|
|
1294
|
+
except Exception as e:
|
|
1295
|
+
return {"success": False, "message": f"❌ 长按失败: {e}"}
|
|
1296
|
+
|
|
1297
|
+
def long_press_by_percent(self, x_percent: float, y_percent: float, duration: float = 1.0) -> Dict:
|
|
1298
|
+
"""通过百分比坐标长按(跨设备兼容)
|
|
1299
|
+
|
|
1300
|
+
百分比坐标原理:
|
|
1301
|
+
- 屏幕左上角是 (0%, 0%),右下角是 (100%, 100%)
|
|
1302
|
+
- 屏幕正中央是 (50%, 50%)
|
|
1303
|
+
- 像素坐标 = 屏幕尺寸 × (百分比 / 100)
|
|
1304
|
+
|
|
1305
|
+
Args:
|
|
1306
|
+
x_percent: X轴百分比 (0-100),0=最左,50=中间,100=最右
|
|
1307
|
+
y_percent: Y轴百分比 (0-100),0=最上,50=中间,100=最下
|
|
1308
|
+
duration: 长按持续时间(秒),默认 1.0
|
|
1309
|
+
|
|
1310
|
+
优势:
|
|
1311
|
+
- 同样的百分比在不同分辨率设备上都能点到相同相对位置
|
|
1312
|
+
- 录制一次,多设备回放
|
|
1313
|
+
"""
|
|
1314
|
+
try:
|
|
1315
|
+
# 第1步:获取屏幕尺寸
|
|
1316
|
+
if self._is_ios():
|
|
1317
|
+
ios_client = self._get_ios_client()
|
|
1318
|
+
if ios_client and hasattr(ios_client, 'wda'):
|
|
1319
|
+
size = ios_client.wda.window_size()
|
|
1320
|
+
width, height = size[0], size[1]
|
|
1321
|
+
else:
|
|
1322
|
+
return {"success": False, "message": "❌ iOS 客户端未初始化"}
|
|
1323
|
+
else:
|
|
1324
|
+
info = self.client.u2.info
|
|
1325
|
+
width = info.get('displayWidth', 0)
|
|
1326
|
+
height = info.get('displayHeight', 0)
|
|
1327
|
+
|
|
1328
|
+
if width == 0 or height == 0:
|
|
1329
|
+
return {"success": False, "message": "❌ 无法获取屏幕尺寸"}
|
|
1330
|
+
|
|
1331
|
+
# 第2步:百分比转像素坐标
|
|
1332
|
+
x = int(width * x_percent / 100)
|
|
1333
|
+
y = int(height * y_percent / 100)
|
|
1334
|
+
|
|
1335
|
+
# 第3步:执行长按
|
|
1336
|
+
if self._is_ios():
|
|
1337
|
+
ios_client = self._get_ios_client()
|
|
1338
|
+
if hasattr(ios_client.wda, 'tap_hold'):
|
|
1339
|
+
ios_client.wda.tap_hold(x, y, duration=duration)
|
|
1340
|
+
else:
|
|
1341
|
+
ios_client.wda.swipe(x, y, x, y, duration=duration)
|
|
1342
|
+
else:
|
|
1343
|
+
self.client.u2.long_click(x, y, duration=duration)
|
|
1344
|
+
|
|
1345
|
+
time.sleep(0.3)
|
|
1346
|
+
|
|
1347
|
+
# 第4步:记录操作
|
|
1348
|
+
self._record_operation(
|
|
1349
|
+
'long_press',
|
|
1350
|
+
x=x,
|
|
1351
|
+
y=y,
|
|
1352
|
+
x_percent=x_percent,
|
|
1353
|
+
y_percent=y_percent,
|
|
1354
|
+
duration=duration,
|
|
1355
|
+
screen_width=width,
|
|
1356
|
+
screen_height=height,
|
|
1357
|
+
ref=f"percent_{x_percent}_{y_percent}"
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
return {
|
|
1361
|
+
"success": True,
|
|
1362
|
+
"message": f"✅ 百分比长按成功: ({x_percent}%, {y_percent}%) → 像素({x}, {y}) 持续 {duration}s",
|
|
1363
|
+
"screen_size": {"width": width, "height": height},
|
|
1364
|
+
"percent": {"x": x_percent, "y": y_percent},
|
|
1365
|
+
"pixel": {"x": x, "y": y},
|
|
1366
|
+
"duration": duration
|
|
1367
|
+
}
|
|
1368
|
+
except Exception as e:
|
|
1369
|
+
return {"success": False, "message": f"❌ 百分比长按失败: {e}"}
|
|
1370
|
+
|
|
1371
|
+
def long_press_by_text(self, text: str, duration: float = 1.0) -> Dict:
|
|
1372
|
+
"""通过文本长按
|
|
1373
|
+
|
|
1374
|
+
Args:
|
|
1375
|
+
text: 元素的文本内容(精确匹配)
|
|
1376
|
+
duration: 长按持续时间(秒),默认 1.0
|
|
1377
|
+
"""
|
|
1378
|
+
try:
|
|
1379
|
+
if self._is_ios():
|
|
1380
|
+
ios_client = self._get_ios_client()
|
|
1381
|
+
if ios_client and hasattr(ios_client, 'wda'):
|
|
1382
|
+
elem = ios_client.wda(name=text)
|
|
1383
|
+
if not elem.exists:
|
|
1384
|
+
elem = ios_client.wda(label=text)
|
|
1385
|
+
if elem.exists:
|
|
1386
|
+
# iOS 元素长按
|
|
1387
|
+
bounds = elem.bounds
|
|
1388
|
+
x = int((bounds.x + bounds.x + bounds.width) / 2)
|
|
1389
|
+
y = int((bounds.y + bounds.y + bounds.height) / 2)
|
|
1390
|
+
if hasattr(ios_client.wda, 'tap_hold'):
|
|
1391
|
+
ios_client.wda.tap_hold(x, y, duration=duration)
|
|
1392
|
+
else:
|
|
1393
|
+
ios_client.wda.swipe(x, y, x, y, duration=duration)
|
|
1394
|
+
time.sleep(0.3)
|
|
1395
|
+
self._record_operation('long_press', element=text, duration=duration, ref=text)
|
|
1396
|
+
return {"success": True, "message": f"✅ 长按成功: '{text}' 持续 {duration}s"}
|
|
1397
|
+
return {"success": False, "message": f"❌ 文本不存在: {text}"}
|
|
1398
|
+
else:
|
|
1399
|
+
# 先查 XML 树,找到元素
|
|
1400
|
+
found_elem = self._find_element_in_tree(text)
|
|
1401
|
+
|
|
1402
|
+
if found_elem:
|
|
1403
|
+
attr_type = found_elem['attr_type']
|
|
1404
|
+
attr_value = found_elem['attr_value']
|
|
1405
|
+
bounds = found_elem.get('bounds')
|
|
1406
|
+
|
|
1407
|
+
# 根据找到的属性类型,使用对应的选择器
|
|
1408
|
+
if attr_type == 'text':
|
|
1409
|
+
elem = self.client.u2(text=attr_value)
|
|
1410
|
+
elif attr_type == 'textContains':
|
|
1411
|
+
elem = self.client.u2(textContains=attr_value)
|
|
1412
|
+
elif attr_type == 'description':
|
|
1413
|
+
elem = self.client.u2(description=attr_value)
|
|
1414
|
+
elif attr_type == 'descriptionContains':
|
|
1415
|
+
elem = self.client.u2(descriptionContains=attr_value)
|
|
1416
|
+
else:
|
|
1417
|
+
elem = None
|
|
1418
|
+
|
|
1419
|
+
if elem and elem.exists(timeout=1):
|
|
1420
|
+
elem.long_click(duration=duration)
|
|
1421
|
+
time.sleep(0.3)
|
|
1422
|
+
self._record_operation('long_press', element=text, duration=duration, ref=f"{attr_type}:{attr_value}")
|
|
1423
|
+
return {"success": True, "message": f"✅ 长按成功({attr_type}): '{text}' 持续 {duration}s"}
|
|
1424
|
+
|
|
1425
|
+
# 如果选择器失败,用坐标兜底
|
|
1426
|
+
if bounds:
|
|
1427
|
+
x = (bounds[0] + bounds[2]) // 2
|
|
1428
|
+
y = (bounds[1] + bounds[3]) // 2
|
|
1429
|
+
self.client.u2.long_click(x, y, duration=duration)
|
|
1430
|
+
time.sleep(0.3)
|
|
1431
|
+
self._record_operation('long_press', element=text, x=x, y=y, duration=duration, ref=f"coords:{x},{y}")
|
|
1432
|
+
return {"success": True, "message": f"✅ 长按成功(坐标兜底): '{text}' @ ({x},{y}) 持续 {duration}s"}
|
|
1433
|
+
|
|
1434
|
+
return {"success": False, "message": f"❌ 文本不存在: {text}"}
|
|
1435
|
+
except Exception as e:
|
|
1436
|
+
return {"success": False, "message": f"❌ 长按失败: {e}"}
|
|
1437
|
+
|
|
1438
|
+
def long_press_by_id(self, resource_id: str, duration: float = 1.0) -> Dict:
|
|
1439
|
+
"""通过 resource-id 长按
|
|
1440
|
+
|
|
1441
|
+
Args:
|
|
1442
|
+
resource_id: 元素的 resource-id
|
|
1443
|
+
duration: 长按持续时间(秒),默认 1.0
|
|
1444
|
+
"""
|
|
1445
|
+
try:
|
|
1446
|
+
if self._is_ios():
|
|
1447
|
+
ios_client = self._get_ios_client()
|
|
1448
|
+
if ios_client and hasattr(ios_client, 'wda'):
|
|
1449
|
+
elem = ios_client.wda(id=resource_id)
|
|
1450
|
+
if not elem.exists:
|
|
1451
|
+
elem = ios_client.wda(name=resource_id)
|
|
1452
|
+
if elem.exists:
|
|
1453
|
+
bounds = elem.bounds
|
|
1454
|
+
x = int((bounds.x + bounds.x + bounds.width) / 2)
|
|
1455
|
+
y = int((bounds.y + bounds.y + bounds.height) / 2)
|
|
1456
|
+
if hasattr(ios_client.wda, 'tap_hold'):
|
|
1457
|
+
ios_client.wda.tap_hold(x, y, duration=duration)
|
|
1458
|
+
else:
|
|
1459
|
+
ios_client.wda.swipe(x, y, x, y, duration=duration)
|
|
1460
|
+
time.sleep(0.3)
|
|
1461
|
+
self._record_operation('long_press', element=resource_id, duration=duration, ref=resource_id)
|
|
1462
|
+
return {"success": True, "message": f"✅ 长按成功: {resource_id} 持续 {duration}s"}
|
|
1463
|
+
return {"success": False, "message": f"❌ 元素不存在: {resource_id}"}
|
|
1464
|
+
else:
|
|
1465
|
+
elem = self.client.u2(resourceId=resource_id)
|
|
1466
|
+
if elem.exists(timeout=0.5):
|
|
1467
|
+
elem.long_click(duration=duration)
|
|
1468
|
+
time.sleep(0.3)
|
|
1469
|
+
self._record_operation('long_press', element=resource_id, duration=duration, ref=resource_id)
|
|
1470
|
+
return {"success": True, "message": f"✅ 长按成功: {resource_id} 持续 {duration}s"}
|
|
1471
|
+
return {"success": False, "message": f"❌ 元素不存在: {resource_id}"}
|
|
1472
|
+
except Exception as e:
|
|
1473
|
+
return {"success": False, "message": f"❌ 长按失败: {e}"}
|
|
1474
|
+
|
|
652
1475
|
# ==================== 输入操作 ====================
|
|
653
1476
|
|
|
654
1477
|
def input_text_by_id(self, resource_id: str, text: str) -> Dict:
|
|
@@ -1374,21 +2197,40 @@ class BasicMobileToolsLite:
|
|
|
1374
2197
|
pass
|
|
1375
2198
|
|
|
1376
2199
|
if not close_candidates:
|
|
1377
|
-
#
|
|
1378
|
-
screenshot_result = self.take_screenshot(description="弹窗全屏", compress=True)
|
|
1379
|
-
|
|
1380
|
-
# 构建更详细的视觉分析提示
|
|
1381
|
-
visual_hint = "请仔细查看截图,找到关闭按钮(通常是 × 或 X 图标)。"
|
|
2200
|
+
# 如果检测到弹窗区域,先尝试点击常见的关闭按钮位置
|
|
1382
2201
|
if popup_bounds:
|
|
1383
2202
|
px1, py1, px2, py2 = popup_bounds
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
2203
|
+
|
|
2204
|
+
# 常见的关闭按钮位置
|
|
2205
|
+
try_positions = [
|
|
2206
|
+
(px2 - 20, py1 - 30, "弹窗正上方"),
|
|
2207
|
+
(px2 - 30, py1 + 30, "弹窗右上角内"),
|
|
2208
|
+
(px2 + 20, py1 - 20, "弹窗右上角外"),
|
|
2209
|
+
((px1 + px2) // 2, py2 + 40, "弹窗下方中间"),
|
|
2210
|
+
]
|
|
2211
|
+
|
|
2212
|
+
for try_x, try_y, position_name in try_positions:
|
|
2213
|
+
if 0 <= try_x <= screen_width and 0 <= try_y <= screen_height:
|
|
2214
|
+
self.client.u2.click(try_x, try_y)
|
|
2215
|
+
time.sleep(0.3)
|
|
2216
|
+
|
|
2217
|
+
# 尝试后截图,让 AI 判断是否成功
|
|
2218
|
+
screenshot_result = self.take_screenshot("尝试关闭后")
|
|
2219
|
+
return {
|
|
2220
|
+
"success": True,
|
|
2221
|
+
"message": f"✅ 已尝试点击常见关闭按钮位置",
|
|
2222
|
+
"tried_positions": [p[2] for p in try_positions],
|
|
2223
|
+
"screenshot": screenshot_result.get("screenshot_path", ""),
|
|
2224
|
+
"tip": "请查看截图确认弹窗是否已关闭。如果还在,可手动分析截图找到关闭按钮位置。"
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
# 没有检测到弹窗区域,截图让 AI 分析
|
|
2228
|
+
screenshot_result = self.take_screenshot(description="页面截图", compress=True)
|
|
1387
2229
|
|
|
1388
2230
|
return {
|
|
1389
2231
|
"success": False,
|
|
1390
|
-
"message": "❌
|
|
1391
|
-
"action_required":
|
|
2232
|
+
"message": "❌ 未检测到弹窗区域,已截图供 AI 分析",
|
|
2233
|
+
"action_required": "请查看截图找到关闭按钮,调用 mobile_click_at_coords 点击",
|
|
1392
2234
|
"screenshot": screenshot_result.get("screenshot_path", ""),
|
|
1393
2235
|
"screen_size": {"width": screen_width, "height": screen_height},
|
|
1394
2236
|
"image_size": {
|
|
@@ -1399,16 +2241,8 @@ class BasicMobileToolsLite:
|
|
|
1399
2241
|
"width": screenshot_result.get("original_img_width", screen_width),
|
|
1400
2242
|
"height": screenshot_result.get("original_img_height", screen_height)
|
|
1401
2243
|
},
|
|
1402
|
-
"
|
|
1403
|
-
"
|
|
1404
|
-
"search_areas": [
|
|
1405
|
-
"弹窗右上角(最常见)",
|
|
1406
|
-
"弹窗正上方外侧(浮动X按钮)",
|
|
1407
|
-
"弹窗下方中间(某些广告)",
|
|
1408
|
-
"屏幕右上角"
|
|
1409
|
-
],
|
|
1410
|
-
"button_features": "关闭按钮通常是:小圆形/方形图标、灰色或白色、带有 × 或 X 符号",
|
|
1411
|
-
"tip": "注意:不要点击广告内容区域,只点击关闭按钮"
|
|
2244
|
+
"search_areas": ["弹窗右上角", "弹窗正上方", "弹窗下方中间", "屏幕右上角"],
|
|
2245
|
+
"time_warning": "⚠️ 截图分析期间弹窗可能自动消失。如果是定时弹窗,建议等待其自动消失。"
|
|
1412
2246
|
}
|
|
1413
2247
|
|
|
1414
2248
|
# 按得分排序,取最可能的
|
|
@@ -1632,6 +2466,22 @@ class BasicMobileToolsLite:
|
|
|
1632
2466
|
" return True",
|
|
1633
2467
|
"",
|
|
1634
2468
|
"",
|
|
2469
|
+
"def long_press_by_percent(d, x_percent, y_percent, duration=1.0):",
|
|
2470
|
+
' """',
|
|
2471
|
+
' 百分比长按(跨分辨率兼容)',
|
|
2472
|
+
' ',
|
|
2473
|
+
' 原理:屏幕左上角 (0%, 0%),右下角 (100%, 100%)',
|
|
2474
|
+
' 优势:同样的百分比在不同分辨率设备上都能长按到相同相对位置',
|
|
2475
|
+
' """',
|
|
2476
|
+
" info = d.info",
|
|
2477
|
+
" width = info.get('displayWidth', 0)",
|
|
2478
|
+
" height = info.get('displayHeight', 0)",
|
|
2479
|
+
" x = int(width * x_percent / 100)",
|
|
2480
|
+
" y = int(height * y_percent / 100)",
|
|
2481
|
+
" d.long_click(x, y, duration=duration)",
|
|
2482
|
+
" return True",
|
|
2483
|
+
"",
|
|
2484
|
+
"",
|
|
1635
2485
|
"def test_main():",
|
|
1636
2486
|
" # 连接设备",
|
|
1637
2487
|
" d = u2.connect()",
|
|
@@ -1737,6 +2587,48 @@ class BasicMobileToolsLite:
|
|
|
1737
2587
|
script_lines.append(" time.sleep(0.5)")
|
|
1738
2588
|
script_lines.append(" ")
|
|
1739
2589
|
|
|
2590
|
+
elif action == 'long_press':
|
|
2591
|
+
ref = op.get('ref', '')
|
|
2592
|
+
element = op.get('element', '')
|
|
2593
|
+
duration = op.get('duration', 1.0)
|
|
2594
|
+
has_coords = 'x' in op and 'y' in op
|
|
2595
|
+
has_percent = 'x_percent' in op and 'y_percent' in op
|
|
2596
|
+
|
|
2597
|
+
# 判断 ref 是否为坐标格式
|
|
2598
|
+
is_coords_ref = ref.startswith('coords_') or ref.startswith('coords:')
|
|
2599
|
+
is_percent_ref = ref.startswith('percent_')
|
|
2600
|
+
|
|
2601
|
+
# 优先级:ID > 文本 > 百分比 > 坐标
|
|
2602
|
+
if ref and (':id/' in ref or ref.startswith('com.')):
|
|
2603
|
+
# 使用 resource-id
|
|
2604
|
+
script_lines.append(f" # 步骤{step_num}: 长按元素 (ID定位,最稳定)")
|
|
2605
|
+
script_lines.append(f" d(resourceId='{ref}').long_click(duration={duration})")
|
|
2606
|
+
elif ref and not is_coords_ref and not is_percent_ref and ':' not in ref:
|
|
2607
|
+
# 使用文本
|
|
2608
|
+
script_lines.append(f" # 步骤{step_num}: 长按文本 '{ref}' (文本定位)")
|
|
2609
|
+
script_lines.append(f" d(text='{ref}').long_click(duration={duration})")
|
|
2610
|
+
elif ref and ':' in ref and not is_coords_ref and not is_percent_ref:
|
|
2611
|
+
actual_text = ref.split(':', 1)[1] if ':' in ref else ref
|
|
2612
|
+
script_lines.append(f" # 步骤{step_num}: 长按文本 '{actual_text}' (文本定位)")
|
|
2613
|
+
script_lines.append(f" d(text='{actual_text}').long_click(duration={duration})")
|
|
2614
|
+
elif has_percent:
|
|
2615
|
+
# 使用百分比
|
|
2616
|
+
x_pct = op['x_percent']
|
|
2617
|
+
y_pct = op['y_percent']
|
|
2618
|
+
desc = f" ({element})" if element else ""
|
|
2619
|
+
script_lines.append(f" # 步骤{step_num}: 长按位置{desc} (百分比定位,跨分辨率兼容)")
|
|
2620
|
+
script_lines.append(f" long_press_by_percent(d, {x_pct}, {y_pct}, duration={duration}) # 原坐标: ({op.get('x', '?')}, {op.get('y', '?')})")
|
|
2621
|
+
elif has_coords:
|
|
2622
|
+
# 坐标兜底
|
|
2623
|
+
desc = f" ({element})" if element else ""
|
|
2624
|
+
script_lines.append(f" # 步骤{step_num}: 长按坐标{desc} (⚠️ 坐标定位,可能不兼容其他分辨率)")
|
|
2625
|
+
script_lines.append(f" d.long_click({op['x']}, {op['y']}, duration={duration})")
|
|
2626
|
+
else:
|
|
2627
|
+
continue
|
|
2628
|
+
|
|
2629
|
+
script_lines.append(" time.sleep(0.5) # 等待响应")
|
|
2630
|
+
script_lines.append(" ")
|
|
2631
|
+
|
|
1740
2632
|
elif action == 'swipe':
|
|
1741
2633
|
direction = op.get('direction', 'up')
|
|
1742
2634
|
script_lines.append(f" # 步骤{step_num}: 滑动 {direction}")
|