mobile-mcp-ai 2.6.2__py3-none-any.whl → 2.6.3__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 +367 -122
- mobile_mcp/mcp_tools/mcp_server.py +85 -0
- {mobile_mcp_ai-2.6.2.dist-info → mobile_mcp_ai-2.6.3.dist-info}/METADATA +1 -1
- {mobile_mcp_ai-2.6.2.dist-info → mobile_mcp_ai-2.6.3.dist-info}/RECORD +8 -8
- {mobile_mcp_ai-2.6.2.dist-info → mobile_mcp_ai-2.6.3.dist-info}/WHEEL +0 -0
- {mobile_mcp_ai-2.6.2.dist-info → mobile_mcp_ai-2.6.3.dist-info}/entry_points.txt +0 -0
- {mobile_mcp_ai-2.6.2.dist-info → mobile_mcp_ai-2.6.3.dist-info}/licenses/LICENSE +0 -0
- {mobile_mcp_ai-2.6.2.dist-info → mobile_mcp_ai-2.6.3.dist-info}/top_level.txt +0 -0
|
@@ -500,7 +500,7 @@ class BasicMobileToolsLite:
|
|
|
500
500
|
# 左侧标注 Y 坐标
|
|
501
501
|
draw.text((2, y + 2), str(y), fill=text_color, font=font_small)
|
|
502
502
|
|
|
503
|
-
# 第3
|
|
503
|
+
# 第3步:检测弹窗并标注(使用严格的置信度检测,避免误识别)
|
|
504
504
|
popup_info = None
|
|
505
505
|
close_positions = []
|
|
506
506
|
|
|
@@ -510,35 +510,12 @@ class BasicMobileToolsLite:
|
|
|
510
510
|
xml_string = self.client.u2.dump_hierarchy(compressed=False)
|
|
511
511
|
root = ET.fromstring(xml_string)
|
|
512
512
|
|
|
513
|
-
#
|
|
514
|
-
popup_bounds =
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
class_name = elem.attrib.get('class', '')
|
|
518
|
-
|
|
519
|
-
if not bounds_str:
|
|
520
|
-
continue
|
|
521
|
-
|
|
522
|
-
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
523
|
-
if not match:
|
|
524
|
-
continue
|
|
525
|
-
|
|
526
|
-
x1, y1, x2, y2 = map(int, match.groups())
|
|
527
|
-
width = x2 - x1
|
|
528
|
-
height = y2 - y1
|
|
529
|
-
area = width * height
|
|
530
|
-
screen_area = screen_width * screen_height
|
|
531
|
-
|
|
532
|
-
is_container = any(kw in class_name for kw in ['Layout', 'View', 'Dialog', 'Card'])
|
|
533
|
-
area_ratio = area / screen_area if screen_area > 0 else 0
|
|
534
|
-
is_not_fullscreen = (width < screen_width * 0.98 or height < screen_height * 0.98)
|
|
535
|
-
is_reasonable_size = 0.08 < area_ratio < 0.85
|
|
536
|
-
|
|
537
|
-
if is_container and is_not_fullscreen and is_reasonable_size and y1 > 50:
|
|
538
|
-
if popup_bounds is None or area > (popup_bounds[2] - popup_bounds[0]) * (popup_bounds[3] - popup_bounds[1]):
|
|
539
|
-
popup_bounds = (x1, y1, x2, y2)
|
|
513
|
+
# 使用严格的弹窗检测(置信度 >= 0.6 才认为是弹窗)
|
|
514
|
+
popup_bounds, popup_confidence = self._detect_popup_with_confidence(
|
|
515
|
+
root, screen_width, screen_height
|
|
516
|
+
)
|
|
540
517
|
|
|
541
|
-
if popup_bounds:
|
|
518
|
+
if popup_bounds and popup_confidence >= 0.6:
|
|
542
519
|
px1, py1, px2, py2 = popup_bounds
|
|
543
520
|
popup_width = px2 - px1
|
|
544
521
|
popup_height = py2 - py1
|
|
@@ -769,49 +746,19 @@ class BasicMobileToolsLite:
|
|
|
769
746
|
'desc': elem['desc']
|
|
770
747
|
})
|
|
771
748
|
|
|
772
|
-
# 第3.5
|
|
749
|
+
# 第3.5步:检测弹窗区域(使用严格的置信度检测,避免误识别普通页面)
|
|
773
750
|
popup_bounds = None
|
|
751
|
+
popup_confidence = 0
|
|
774
752
|
|
|
775
753
|
if not self._is_ios():
|
|
776
754
|
try:
|
|
777
|
-
#
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
if not bounds_str:
|
|
783
|
-
continue
|
|
784
|
-
|
|
785
|
-
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
786
|
-
if not match:
|
|
787
|
-
continue
|
|
788
|
-
|
|
789
|
-
px1, py1, px2, py2 = map(int, match.groups())
|
|
790
|
-
p_width = px2 - px1
|
|
791
|
-
p_height = py2 - py1
|
|
792
|
-
p_area = p_width * p_height
|
|
793
|
-
screen_area = screen_width * screen_height
|
|
794
|
-
|
|
795
|
-
is_container = any(kw in class_name for kw in ['Layout', 'View', 'Dialog', 'Card', 'Frame'])
|
|
796
|
-
area_ratio = p_area / screen_area if screen_area > 0 else 0
|
|
797
|
-
|
|
798
|
-
# 弹窗特征判断(更严格,排除主要内容区域):
|
|
799
|
-
# 1. 不是全屏(宽度和高度都要小于屏幕的95%)
|
|
800
|
-
is_not_fullscreen = (p_width < screen_width * 0.95 and p_height < screen_height * 0.95)
|
|
801
|
-
# 2. 面积范围:10% - 70%(排除主要内容区域,通常占80%+)
|
|
802
|
-
is_reasonable_size = 0.10 < area_ratio < 0.70
|
|
803
|
-
# 3. 不在屏幕左边缘(排除从x=0开始的主要内容容器)
|
|
804
|
-
is_not_at_left_edge = px1 > screen_width * 0.05
|
|
805
|
-
# 4. 高度不能占据屏幕的大部分(排除主要内容区域)
|
|
806
|
-
height_ratio = p_height / screen_height if screen_height > 0 else 0
|
|
807
|
-
is_not_main_content = height_ratio < 0.85
|
|
808
|
-
|
|
809
|
-
if is_container and is_not_fullscreen and is_reasonable_size and is_not_at_left_edge and is_not_main_content and py1 > 30:
|
|
810
|
-
if popup_bounds is None or p_area > (popup_bounds[2] - popup_bounds[0]) * (popup_bounds[3] - popup_bounds[1]):
|
|
811
|
-
popup_bounds = (px1, py1, px2, py2)
|
|
755
|
+
# 使用严格的弹窗检测(置信度 >= 0.6 才认为是弹窗)
|
|
756
|
+
popup_bounds, popup_confidence = self._detect_popup_with_confidence(
|
|
757
|
+
root, screen_width, screen_height
|
|
758
|
+
)
|
|
812
759
|
|
|
813
760
|
# 如果检测到弹窗,标注弹窗边界(不再猜测X按钮位置)
|
|
814
|
-
if popup_bounds:
|
|
761
|
+
if popup_bounds and popup_confidence >= 0.6:
|
|
815
762
|
px1, py1, px2, py2 = popup_bounds
|
|
816
763
|
|
|
817
764
|
# 只画弹窗边框(蓝色),不再猜测X按钮位置
|
|
@@ -2630,58 +2577,13 @@ class BasicMobileToolsLite:
|
|
|
2630
2577
|
root = ET.fromstring(xml_string)
|
|
2631
2578
|
all_elements = list(root.iter())
|
|
2632
2579
|
|
|
2633
|
-
# =====
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
bounds_str = elem.attrib.get('bounds', '')
|
|
2638
|
-
class_name = elem.attrib.get('class', '')
|
|
2639
|
-
|
|
2640
|
-
if not bounds_str:
|
|
2641
|
-
continue
|
|
2642
|
-
|
|
2643
|
-
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
2644
|
-
if not match:
|
|
2645
|
-
continue
|
|
2646
|
-
|
|
2647
|
-
x1, y1, x2, y2 = map(int, match.groups())
|
|
2648
|
-
width = x2 - x1
|
|
2649
|
-
height = y2 - y1
|
|
2650
|
-
area = width * height
|
|
2651
|
-
screen_area = screen_width * screen_height
|
|
2652
|
-
|
|
2653
|
-
# 弹窗容器特征(更严格,排除主要内容区域):
|
|
2654
|
-
# 1. 面积在屏幕的 10%-70% 之间(排除主要内容区域,通常占80%+)
|
|
2655
|
-
# 2. 宽度和高度都要小于屏幕的95%(不是全屏)
|
|
2656
|
-
# 3. 是容器类型(Layout/View/Dialog)
|
|
2657
|
-
# 4. 不在屏幕左边缘(排除从x=0开始的主要内容容器)
|
|
2658
|
-
# 5. 高度不能占据屏幕的大部分(排除主要内容区域)
|
|
2659
|
-
is_container = any(kw in class_name for kw in ['Layout', 'View', 'Dialog', 'Card', 'Container'])
|
|
2660
|
-
area_ratio = area / screen_area
|
|
2661
|
-
is_not_fullscreen = (width < screen_width * 0.95 and height < screen_height * 0.95)
|
|
2662
|
-
is_reasonable_size = 0.10 < area_ratio < 0.70
|
|
2663
|
-
is_not_at_left_edge = x1 > screen_width * 0.05
|
|
2664
|
-
height_ratio = height / screen_height if screen_height > 0 else 0
|
|
2665
|
-
is_not_main_content = height_ratio < 0.85
|
|
2666
|
-
|
|
2667
|
-
# 排除状态栏区域(y1 通常很小)
|
|
2668
|
-
is_below_statusbar = y1 > 50
|
|
2669
|
-
|
|
2670
|
-
if is_container and is_not_fullscreen and is_reasonable_size and is_not_at_left_edge and is_not_main_content and is_below_statusbar:
|
|
2671
|
-
popup_containers.append({
|
|
2672
|
-
'bounds': (x1, y1, x2, y2),
|
|
2673
|
-
'bounds_str': bounds_str,
|
|
2674
|
-
'area': area,
|
|
2675
|
-
'area_ratio': area_ratio,
|
|
2676
|
-
'idx': idx, # 元素在 XML 中的顺序(越后越上层)
|
|
2677
|
-
'class': class_name
|
|
2678
|
-
})
|
|
2580
|
+
# ===== 第一步:使用严格的置信度检测弹窗区域 =====
|
|
2581
|
+
popup_bounds, popup_confidence = self._detect_popup_with_confidence(
|
|
2582
|
+
root, screen_width, screen_height
|
|
2583
|
+
)
|
|
2679
2584
|
|
|
2680
|
-
#
|
|
2681
|
-
|
|
2682
|
-
# 按 XML 顺序倒序(后出现的在上层),然后按面积适中程度排序
|
|
2683
|
-
popup_containers.sort(key=lambda x: (x['idx'], -abs(x['area_ratio'] - 0.3)), reverse=True)
|
|
2684
|
-
popup_bounds = popup_containers[0]['bounds']
|
|
2585
|
+
# 如果置信度不够高,记录但继续尝试查找关闭按钮
|
|
2586
|
+
popup_detected = popup_bounds is not None and popup_confidence >= 0.6
|
|
2685
2587
|
|
|
2686
2588
|
# ===== 第二步:在弹窗范围内查找关闭按钮 =====
|
|
2687
2589
|
for idx, elem in enumerate(all_elements):
|
|
@@ -2813,15 +2715,15 @@ class BasicMobileToolsLite:
|
|
|
2813
2715
|
'content_desc': content_desc,
|
|
2814
2716
|
'x_percent': round(rel_x * 100, 1),
|
|
2815
2717
|
'y_percent': round(rel_y * 100, 1),
|
|
2816
|
-
'in_popup':
|
|
2718
|
+
'in_popup': popup_detected
|
|
2817
2719
|
})
|
|
2818
2720
|
|
|
2819
2721
|
except ET.ParseError:
|
|
2820
2722
|
pass
|
|
2821
2723
|
|
|
2822
2724
|
if not close_candidates:
|
|
2823
|
-
#
|
|
2824
|
-
if popup_bounds:
|
|
2725
|
+
# 如果检测到高置信度的弹窗区域,先尝试点击常见的关闭按钮位置
|
|
2726
|
+
if popup_detected and popup_bounds:
|
|
2825
2727
|
px1, py1, px2, py2 = popup_bounds
|
|
2826
2728
|
popup_width = px2 - px1
|
|
2827
2729
|
popup_height = py2 - py1
|
|
@@ -2954,8 +2856,9 @@ class BasicMobileToolsLite:
|
|
|
2954
2856
|
"percent": (best['x_percent'], best['y_percent'])
|
|
2955
2857
|
},
|
|
2956
2858
|
"screenshot": screenshot_result.get("screenshot_path", ""),
|
|
2957
|
-
"popup_detected":
|
|
2958
|
-
"
|
|
2859
|
+
"popup_detected": popup_detected,
|
|
2860
|
+
"popup_confidence": popup_confidence if popup_bounds else 0,
|
|
2861
|
+
"popup_bounds": f"[{popup_bounds[0]},{popup_bounds[1]}][{popup_bounds[2]},{popup_bounds[3]}]" if popup_detected else None,
|
|
2959
2862
|
"app_check": app_check,
|
|
2960
2863
|
"return_to_app": return_result,
|
|
2961
2864
|
"other_candidates": [
|
|
@@ -3014,6 +2917,348 @@ class BasicMobileToolsLite:
|
|
|
3014
2917
|
return 0.8
|
|
3015
2918
|
else: # 中间区域
|
|
3016
2919
|
return 0.5
|
|
2920
|
+
|
|
2921
|
+
def _detect_popup_with_confidence(self, root, screen_width: int, screen_height: int) -> tuple:
|
|
2922
|
+
"""严格的弹窗检测 - 使用置信度评分,避免误识别普通页面
|
|
2923
|
+
|
|
2924
|
+
真正的弹窗特征:
|
|
2925
|
+
1. class 名称包含 Dialog/Popup/Alert/Modal/BottomSheet(强特征)
|
|
2926
|
+
2. resource-id 包含 dialog/popup/alert/modal(强特征)
|
|
2927
|
+
3. 有遮罩层(大面积半透明 View 在弹窗之前)
|
|
2928
|
+
4. 居中显示且非全屏
|
|
2929
|
+
5. XML 层级靠后且包含可交互元素
|
|
2930
|
+
|
|
2931
|
+
Returns:
|
|
2932
|
+
(popup_bounds, confidence) 或 (None, 0)
|
|
2933
|
+
confidence >= 0.6 才认为是弹窗
|
|
2934
|
+
"""
|
|
2935
|
+
import re
|
|
2936
|
+
|
|
2937
|
+
screen_area = screen_width * screen_height
|
|
2938
|
+
|
|
2939
|
+
# 收集所有元素信息
|
|
2940
|
+
all_elements = []
|
|
2941
|
+
for idx, elem in enumerate(root.iter()):
|
|
2942
|
+
bounds_str = elem.attrib.get('bounds', '')
|
|
2943
|
+
if not bounds_str:
|
|
2944
|
+
continue
|
|
2945
|
+
|
|
2946
|
+
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
2947
|
+
if not match:
|
|
2948
|
+
continue
|
|
2949
|
+
|
|
2950
|
+
x1, y1, x2, y2 = map(int, match.groups())
|
|
2951
|
+
width = x2 - x1
|
|
2952
|
+
height = y2 - y1
|
|
2953
|
+
area = width * height
|
|
2954
|
+
|
|
2955
|
+
class_name = elem.attrib.get('class', '')
|
|
2956
|
+
resource_id = elem.attrib.get('resource-id', '')
|
|
2957
|
+
clickable = elem.attrib.get('clickable', 'false') == 'true'
|
|
2958
|
+
|
|
2959
|
+
all_elements.append({
|
|
2960
|
+
'idx': idx,
|
|
2961
|
+
'bounds': (x1, y1, x2, y2),
|
|
2962
|
+
'width': width,
|
|
2963
|
+
'height': height,
|
|
2964
|
+
'area': area,
|
|
2965
|
+
'area_ratio': area / screen_area if screen_area > 0 else 0,
|
|
2966
|
+
'class': class_name,
|
|
2967
|
+
'resource_id': resource_id,
|
|
2968
|
+
'clickable': clickable,
|
|
2969
|
+
'center_x': (x1 + x2) // 2,
|
|
2970
|
+
'center_y': (y1 + y2) // 2,
|
|
2971
|
+
})
|
|
2972
|
+
|
|
2973
|
+
if not all_elements:
|
|
2974
|
+
return None, 0
|
|
2975
|
+
|
|
2976
|
+
# 弹窗检测关键词
|
|
2977
|
+
dialog_class_keywords = ['Dialog', 'Popup', 'Alert', 'Modal', 'BottomSheet', 'PopupWindow']
|
|
2978
|
+
dialog_id_keywords = ['dialog', 'popup', 'alert', 'modal', 'bottom_sheet', 'overlay', 'mask']
|
|
2979
|
+
|
|
2980
|
+
popup_candidates = []
|
|
2981
|
+
has_mask_layer = False
|
|
2982
|
+
mask_idx = -1
|
|
2983
|
+
|
|
2984
|
+
# 【新增】检测浮动关闭按钮(小尺寸 clickable ImageView,位于屏幕中央偏上)
|
|
2985
|
+
floating_close_buttons = []
|
|
2986
|
+
for elem in all_elements:
|
|
2987
|
+
x1, y1, x2, y2 = elem['bounds']
|
|
2988
|
+
class_name = elem['class']
|
|
2989
|
+
width = elem['width']
|
|
2990
|
+
height = elem['height']
|
|
2991
|
+
|
|
2992
|
+
# 浮动关闭按钮特征:
|
|
2993
|
+
# 1. 小尺寸(50-200px)
|
|
2994
|
+
# 2. clickable 或 ImageView
|
|
2995
|
+
# 3. 位于屏幕中央区域的上半部分
|
|
2996
|
+
# 4. 接近正方形
|
|
2997
|
+
is_small = 50 < width < 200 and 50 < height < 200
|
|
2998
|
+
is_square_like = 0.5 < (width / height if height > 0 else 0) < 2.0
|
|
2999
|
+
is_clickable_image = elem['clickable'] or 'Image' in class_name
|
|
3000
|
+
is_upper_center = (screen_width * 0.2 < x1 < screen_width * 0.8 and
|
|
3001
|
+
y1 < screen_height * 0.5)
|
|
3002
|
+
|
|
3003
|
+
if is_small and is_square_like and is_clickable_image and is_upper_center:
|
|
3004
|
+
floating_close_buttons.append({
|
|
3005
|
+
'bounds': elem['bounds'],
|
|
3006
|
+
'center_x': elem['center_x'],
|
|
3007
|
+
'center_y': elem['center_y'],
|
|
3008
|
+
'idx': elem['idx']
|
|
3009
|
+
})
|
|
3010
|
+
|
|
3011
|
+
for elem in all_elements:
|
|
3012
|
+
x1, y1, x2, y2 = elem['bounds']
|
|
3013
|
+
class_name = elem['class']
|
|
3014
|
+
resource_id = elem['resource_id']
|
|
3015
|
+
area_ratio = elem['area_ratio']
|
|
3016
|
+
|
|
3017
|
+
# 检测遮罩层(大面积、几乎全屏、通常是 FrameLayout/View)
|
|
3018
|
+
if area_ratio > 0.85 and elem['width'] >= screen_width * 0.95:
|
|
3019
|
+
# 可能是遮罩层,记录位置
|
|
3020
|
+
if 'FrameLayout' in class_name or 'View' in class_name:
|
|
3021
|
+
has_mask_layer = True
|
|
3022
|
+
mask_idx = elem['idx']
|
|
3023
|
+
|
|
3024
|
+
# 跳过全屏元素
|
|
3025
|
+
if area_ratio > 0.9:
|
|
3026
|
+
continue
|
|
3027
|
+
|
|
3028
|
+
# 跳过太小的元素
|
|
3029
|
+
if area_ratio < 0.05:
|
|
3030
|
+
continue
|
|
3031
|
+
|
|
3032
|
+
# 跳过状态栏区域
|
|
3033
|
+
if y1 < 50:
|
|
3034
|
+
continue
|
|
3035
|
+
|
|
3036
|
+
confidence = 0.0
|
|
3037
|
+
|
|
3038
|
+
# 【强特征】class 名称包含弹窗关键词 (+0.5)
|
|
3039
|
+
if any(kw in class_name for kw in dialog_class_keywords):
|
|
3040
|
+
confidence += 0.5
|
|
3041
|
+
|
|
3042
|
+
# 【强特征】resource-id 包含弹窗关键词 (+0.4)
|
|
3043
|
+
if any(kw in resource_id.lower() for kw in dialog_id_keywords):
|
|
3044
|
+
confidence += 0.4
|
|
3045
|
+
|
|
3046
|
+
# 【中等特征】居中显示 (+0.2)
|
|
3047
|
+
center_x = elem['center_x']
|
|
3048
|
+
center_y = elem['center_y']
|
|
3049
|
+
is_centered_x = abs(center_x - screen_width / 2) < screen_width * 0.15
|
|
3050
|
+
is_centered_y = abs(center_y - screen_height / 2) < screen_height * 0.25
|
|
3051
|
+
if is_centered_x and is_centered_y:
|
|
3052
|
+
confidence += 0.2
|
|
3053
|
+
elif is_centered_x:
|
|
3054
|
+
confidence += 0.1
|
|
3055
|
+
|
|
3056
|
+
# 【中等特征】非全屏但有一定大小 (+0.15)
|
|
3057
|
+
if 0.15 < area_ratio < 0.75:
|
|
3058
|
+
confidence += 0.15
|
|
3059
|
+
|
|
3060
|
+
# 【弱特征】XML 顺序靠后(在视图层级上层)(+0.1)
|
|
3061
|
+
if elem['idx'] > len(all_elements) * 0.5:
|
|
3062
|
+
confidence += 0.1
|
|
3063
|
+
|
|
3064
|
+
# 【弱特征】有遮罩层且在遮罩层之后 (+0.15)
|
|
3065
|
+
if has_mask_layer and elem['idx'] > mask_idx:
|
|
3066
|
+
confidence += 0.15
|
|
3067
|
+
|
|
3068
|
+
# 【新增强特征】有浮动关闭按钮在此容器上方附近 (+0.4)
|
|
3069
|
+
# 这是很多 App 弹窗的典型设计:内容区域 + 上方的 X 按钮
|
|
3070
|
+
for close_btn in floating_close_buttons:
|
|
3071
|
+
btn_x, btn_y = close_btn['center_x'], close_btn['center_y']
|
|
3072
|
+
# 检查关闭按钮是否在容器的上方(扩大范围到 400px)
|
|
3073
|
+
is_above_container = (
|
|
3074
|
+
x1 - 100 < btn_x < x2 + 100 and # 在容器水平范围内
|
|
3075
|
+
y1 - 400 < btn_y < y1 + 100 # 在容器上方 400px 范围内
|
|
3076
|
+
)
|
|
3077
|
+
if is_above_container:
|
|
3078
|
+
confidence += 0.4
|
|
3079
|
+
break # 只加一次分
|
|
3080
|
+
|
|
3081
|
+
# 只有达到阈值才加入候选
|
|
3082
|
+
if confidence >= 0.3:
|
|
3083
|
+
popup_candidates.append({
|
|
3084
|
+
'bounds': elem['bounds'],
|
|
3085
|
+
'confidence': confidence,
|
|
3086
|
+
'class': class_name,
|
|
3087
|
+
'resource_id': resource_id,
|
|
3088
|
+
'idx': elem['idx']
|
|
3089
|
+
})
|
|
3090
|
+
|
|
3091
|
+
if not popup_candidates:
|
|
3092
|
+
return None, 0
|
|
3093
|
+
|
|
3094
|
+
# 选择置信度最高的
|
|
3095
|
+
popup_candidates.sort(key=lambda x: (x['confidence'], x['idx']), reverse=True)
|
|
3096
|
+
best = popup_candidates[0]
|
|
3097
|
+
|
|
3098
|
+
# 只有置信度 >= 0.6 才返回弹窗
|
|
3099
|
+
if best['confidence'] >= 0.6:
|
|
3100
|
+
return best['bounds'], best['confidence']
|
|
3101
|
+
|
|
3102
|
+
return None, best['confidence']
|
|
3103
|
+
|
|
3104
|
+
def start_toast_watch(self) -> Dict:
|
|
3105
|
+
"""开始监听 Toast(仅 Android)
|
|
3106
|
+
|
|
3107
|
+
⚠️ 必须在执行操作之前调用!
|
|
3108
|
+
|
|
3109
|
+
正确流程:
|
|
3110
|
+
1. 调用 mobile_start_toast_watch() 开始监听
|
|
3111
|
+
2. 执行操作(如点击提交按钮)
|
|
3112
|
+
3. 调用 mobile_get_toast() 获取 Toast 内容
|
|
3113
|
+
|
|
3114
|
+
Returns:
|
|
3115
|
+
监听状态
|
|
3116
|
+
"""
|
|
3117
|
+
if self._is_ios():
|
|
3118
|
+
return {
|
|
3119
|
+
"success": False,
|
|
3120
|
+
"message": "❌ iOS 不支持 Toast 检测,Toast 是 Android 特有功能"
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
try:
|
|
3124
|
+
# 清除缓存并开始监听
|
|
3125
|
+
self.client.u2.toast.reset()
|
|
3126
|
+
return {
|
|
3127
|
+
"success": True,
|
|
3128
|
+
"message": "✅ Toast 监听已开启,请立即执行操作,然后调用 mobile_get_toast 获取结果"
|
|
3129
|
+
}
|
|
3130
|
+
except Exception as e:
|
|
3131
|
+
return {
|
|
3132
|
+
"success": False,
|
|
3133
|
+
"message": f"❌ 开启 Toast 监听失败: {e}"
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
def get_toast(self, timeout: float = 5.0, reset_first: bool = False) -> Dict:
|
|
3137
|
+
"""获取 Toast 消息(仅 Android)
|
|
3138
|
+
|
|
3139
|
+
Toast 是 Android 系统级的短暂提示消息,常用于显示操作结果。
|
|
3140
|
+
|
|
3141
|
+
⚠️ 推荐用法(两步走):
|
|
3142
|
+
1. 先调用 mobile_start_toast_watch() 开始监听
|
|
3143
|
+
2. 执行操作(如点击提交按钮)
|
|
3144
|
+
3. 调用 mobile_get_toast() 获取 Toast
|
|
3145
|
+
|
|
3146
|
+
或者设置 reset_first=True,会自动 reset 后等待(适合操作已自动触发的场景)
|
|
3147
|
+
|
|
3148
|
+
Args:
|
|
3149
|
+
timeout: 等待 Toast 出现的超时时间(秒),默认 5 秒
|
|
3150
|
+
reset_first: 是否先 reset(清除旧缓存),默认 False
|
|
3151
|
+
|
|
3152
|
+
Returns:
|
|
3153
|
+
包含 Toast 消息的字典
|
|
3154
|
+
"""
|
|
3155
|
+
if self._is_ios():
|
|
3156
|
+
return {
|
|
3157
|
+
"success": False,
|
|
3158
|
+
"message": "❌ iOS 不支持 Toast 检测,Toast 是 Android 特有功能"
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
try:
|
|
3162
|
+
if reset_first:
|
|
3163
|
+
# 清除旧缓存,适合等待即将出现的 Toast
|
|
3164
|
+
self.client.u2.toast.reset()
|
|
3165
|
+
|
|
3166
|
+
# 等待并获取 Toast 消息
|
|
3167
|
+
toast_message = self.client.u2.toast.get_message(
|
|
3168
|
+
wait_timeout=timeout,
|
|
3169
|
+
default=None
|
|
3170
|
+
)
|
|
3171
|
+
|
|
3172
|
+
if toast_message:
|
|
3173
|
+
return {
|
|
3174
|
+
"success": True,
|
|
3175
|
+
"toast_found": True,
|
|
3176
|
+
"message": toast_message,
|
|
3177
|
+
"tip": "Toast 消息获取成功"
|
|
3178
|
+
}
|
|
3179
|
+
else:
|
|
3180
|
+
return {
|
|
3181
|
+
"success": True,
|
|
3182
|
+
"toast_found": False,
|
|
3183
|
+
"message": None,
|
|
3184
|
+
"tip": f"在 {timeout} 秒内未检测到 Toast。提示:先调用 mobile_start_toast_watch,再执行操作,最后调用此工具"
|
|
3185
|
+
}
|
|
3186
|
+
except Exception as e:
|
|
3187
|
+
return {
|
|
3188
|
+
"success": False,
|
|
3189
|
+
"message": f"❌ 获取 Toast 失败: {e}"
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
def assert_toast(self, expected_text: str, timeout: float = 5.0, contains: bool = True) -> Dict:
|
|
3193
|
+
"""断言 Toast 消息(仅 Android)
|
|
3194
|
+
|
|
3195
|
+
等待 Toast 出现并验证内容是否符合预期。
|
|
3196
|
+
|
|
3197
|
+
⚠️ 推荐用法:先调用 mobile_start_toast_watch,再执行操作,最后调用此工具
|
|
3198
|
+
|
|
3199
|
+
Args:
|
|
3200
|
+
expected_text: 期望的 Toast 文本
|
|
3201
|
+
timeout: 等待超时时间(秒)
|
|
3202
|
+
contains: True 表示包含匹配,False 表示精确匹配
|
|
3203
|
+
|
|
3204
|
+
Returns:
|
|
3205
|
+
断言结果
|
|
3206
|
+
"""
|
|
3207
|
+
if self._is_ios():
|
|
3208
|
+
return {
|
|
3209
|
+
"success": False,
|
|
3210
|
+
"passed": False,
|
|
3211
|
+
"message": "❌ iOS 不支持 Toast 检测"
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
try:
|
|
3215
|
+
# 获取 Toast(不 reset,假设之前已经调用过 start_toast_watch)
|
|
3216
|
+
toast_message = self.client.u2.toast.get_message(
|
|
3217
|
+
wait_timeout=timeout,
|
|
3218
|
+
default=None
|
|
3219
|
+
)
|
|
3220
|
+
|
|
3221
|
+
if toast_message is None:
|
|
3222
|
+
return {
|
|
3223
|
+
"success": True,
|
|
3224
|
+
"passed": False,
|
|
3225
|
+
"expected": expected_text,
|
|
3226
|
+
"actual": None,
|
|
3227
|
+
"message": f"❌ 断言失败:未检测到 Toast 消息"
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
# 匹配检查
|
|
3231
|
+
if contains:
|
|
3232
|
+
passed = expected_text in toast_message
|
|
3233
|
+
match_type = "包含"
|
|
3234
|
+
else:
|
|
3235
|
+
passed = expected_text == toast_message
|
|
3236
|
+
match_type = "精确"
|
|
3237
|
+
|
|
3238
|
+
if passed:
|
|
3239
|
+
return {
|
|
3240
|
+
"success": True,
|
|
3241
|
+
"passed": True,
|
|
3242
|
+
"expected": expected_text,
|
|
3243
|
+
"actual": toast_message,
|
|
3244
|
+
"match_type": match_type,
|
|
3245
|
+
"message": f"✅ Toast 断言通过:'{toast_message}'"
|
|
3246
|
+
}
|
|
3247
|
+
else:
|
|
3248
|
+
return {
|
|
3249
|
+
"success": True,
|
|
3250
|
+
"passed": False,
|
|
3251
|
+
"expected": expected_text,
|
|
3252
|
+
"actual": toast_message,
|
|
3253
|
+
"match_type": match_type,
|
|
3254
|
+
"message": f"❌ Toast 断言失败:期望 '{expected_text}',实际 '{toast_message}'"
|
|
3255
|
+
}
|
|
3256
|
+
except Exception as e:
|
|
3257
|
+
return {
|
|
3258
|
+
"success": False,
|
|
3259
|
+
"passed": False,
|
|
3260
|
+
"message": f"❌ Toast 断言异常: {e}"
|
|
3261
|
+
}
|
|
3017
3262
|
|
|
3018
3263
|
def assert_text(self, text: str) -> Dict:
|
|
3019
3264
|
"""检查页面是否包含文本(支持精确匹配和包含匹配)"""
|
|
@@ -671,6 +671,72 @@ class MobileMCPServer:
|
|
|
671
671
|
}
|
|
672
672
|
))
|
|
673
673
|
|
|
674
|
+
# ==================== Toast 检测工具(仅 Android)====================
|
|
675
|
+
tools.append(Tool(
|
|
676
|
+
name="mobile_start_toast_watch",
|
|
677
|
+
description="""🔔 开始监听 Toast(仅 Android)
|
|
678
|
+
|
|
679
|
+
⚠️ 【重要】必须在执行操作之前调用!
|
|
680
|
+
|
|
681
|
+
📋 正确流程(三步走):
|
|
682
|
+
1️⃣ 调用 mobile_start_toast_watch() 开始监听
|
|
683
|
+
2️⃣ 执行操作(如点击提交按钮)
|
|
684
|
+
3️⃣ 调用 mobile_get_toast() 或 mobile_assert_toast() 获取结果
|
|
685
|
+
|
|
686
|
+
❌ 错误用法:先点击按钮,再调用此工具(Toast 可能已消失)""",
|
|
687
|
+
inputSchema={
|
|
688
|
+
"type": "object",
|
|
689
|
+
"properties": {},
|
|
690
|
+
"required": []
|
|
691
|
+
}
|
|
692
|
+
))
|
|
693
|
+
|
|
694
|
+
tools.append(Tool(
|
|
695
|
+
name="mobile_get_toast",
|
|
696
|
+
description="""🍞 获取 Toast 消息(仅 Android)
|
|
697
|
+
|
|
698
|
+
Toast 是 Android 系统级的短暂提示消息,常用于显示操作结果。
|
|
699
|
+
⚠️ Toast 不在控件树中,无法通过 mobile_list_elements 获取。
|
|
700
|
+
|
|
701
|
+
📋 推荐用法(三步走):
|
|
702
|
+
1️⃣ mobile_start_toast_watch() - 开始监听
|
|
703
|
+
2️⃣ 执行操作(点击按钮等)
|
|
704
|
+
3️⃣ mobile_get_toast() - 获取 Toast
|
|
705
|
+
|
|
706
|
+
⏱️ timeout 设置等待时间,默认 5 秒。""",
|
|
707
|
+
inputSchema={
|
|
708
|
+
"type": "object",
|
|
709
|
+
"properties": {
|
|
710
|
+
"timeout": {"type": "number", "description": "等待 Toast 出现的超时时间(秒),默认 5"},
|
|
711
|
+
"reset_first": {"type": "boolean", "description": "是否先清除旧缓存,默认 False"}
|
|
712
|
+
},
|
|
713
|
+
"required": []
|
|
714
|
+
}
|
|
715
|
+
))
|
|
716
|
+
|
|
717
|
+
tools.append(Tool(
|
|
718
|
+
name="mobile_assert_toast",
|
|
719
|
+
description="""✅ 断言 Toast 消息(仅 Android)
|
|
720
|
+
|
|
721
|
+
等待 Toast 出现并验证内容是否符合预期。
|
|
722
|
+
|
|
723
|
+
📋 推荐用法(三步走):
|
|
724
|
+
1️⃣ mobile_start_toast_watch() - 开始监听
|
|
725
|
+
2️⃣ 执行操作(点击按钮等)
|
|
726
|
+
3️⃣ mobile_assert_toast(expected_text="成功") - 断言
|
|
727
|
+
|
|
728
|
+
💡 支持包含匹配(默认)和精确匹配。""",
|
|
729
|
+
inputSchema={
|
|
730
|
+
"type": "object",
|
|
731
|
+
"properties": {
|
|
732
|
+
"expected_text": {"type": "string", "description": "期望的 Toast 文本"},
|
|
733
|
+
"timeout": {"type": "number", "description": "等待超时时间(秒),默认 5"},
|
|
734
|
+
"contains": {"type": "boolean", "description": "True=包含匹配(默认),False=精确匹配"}
|
|
735
|
+
},
|
|
736
|
+
"required": ["expected_text"]
|
|
737
|
+
}
|
|
738
|
+
))
|
|
739
|
+
|
|
674
740
|
# ==================== pytest 脚本生成 ====================
|
|
675
741
|
tools.append(Tool(
|
|
676
742
|
name="mobile_get_operation_history",
|
|
@@ -990,6 +1056,25 @@ class MobileMCPServer:
|
|
|
990
1056
|
result = self.tools.assert_text(arguments["text"])
|
|
991
1057
|
return [TextContent(type="text", text=self.format_response(result))]
|
|
992
1058
|
|
|
1059
|
+
# Toast 检测(仅 Android)
|
|
1060
|
+
elif name == "mobile_start_toast_watch":
|
|
1061
|
+
result = self.tools.start_toast_watch()
|
|
1062
|
+
return [TextContent(type="text", text=self.format_response(result))]
|
|
1063
|
+
|
|
1064
|
+
elif name == "mobile_get_toast":
|
|
1065
|
+
timeout = arguments.get("timeout", 5.0)
|
|
1066
|
+
reset_first = arguments.get("reset_first", False)
|
|
1067
|
+
result = self.tools.get_toast(timeout=timeout, reset_first=reset_first)
|
|
1068
|
+
return [TextContent(type="text", text=self.format_response(result))]
|
|
1069
|
+
|
|
1070
|
+
elif name == "mobile_assert_toast":
|
|
1071
|
+
result = self.tools.assert_toast(
|
|
1072
|
+
expected_text=arguments["expected_text"],
|
|
1073
|
+
timeout=arguments.get("timeout", 5.0),
|
|
1074
|
+
contains=arguments.get("contains", True)
|
|
1075
|
+
)
|
|
1076
|
+
return [TextContent(type="text", text=self.format_response(result))]
|
|
1077
|
+
|
|
993
1078
|
# 脚本生成
|
|
994
1079
|
elif name == "mobile_get_operation_history":
|
|
995
1080
|
result = self.tools.get_operation_history(arguments.get("limit"))
|
|
@@ -1,7 +1,7 @@
|
|
|
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=_hN1gsno7fa3pQYOIkzquGYwRvgIOLp1h8bYpZnwaNE,194014
|
|
5
5
|
mobile_mcp/core/device_manager.py,sha256=xG5DoeNFs45pl-FTEhEWblqVwxtFK-FmVEGlNL6EqRI,8798
|
|
6
6
|
mobile_mcp/core/dynamic_config.py,sha256=Ja1n1pfb0HspGByqk2_A472mYVniKmGtNEWyjUjmgK8,9811
|
|
7
7
|
mobile_mcp/core/ios_client_wda.py,sha256=Nq9WxevhTWpVpolM-Ymp-b0nUQV3tXLFszmJHbDC4wA,18770
|
|
@@ -19,14 +19,14 @@ mobile_mcp/core/utils/logger.py,sha256=XXQAHUwT1jc70pq_tYFmL6f_nKrFlYm3hcgl-5RYR
|
|
|
19
19
|
mobile_mcp/core/utils/operation_history_manager.py,sha256=gi8S8HJAMqvkUrY7_-kVbko3Xt7c4GAUziEujRd-N-Y,4792
|
|
20
20
|
mobile_mcp/core/utils/smart_wait.py,sha256=N5wKTUYrNWPruBILqrAjpvtso8Z3GRWCfMIR_aZxPLg,8649
|
|
21
21
|
mobile_mcp/mcp_tools/__init__.py,sha256=xkro8Rwqv_55YlVyhh-3DgRFSsLE3h1r31VIb3bpM6E,143
|
|
22
|
-
mobile_mcp/mcp_tools/mcp_server.py,sha256=
|
|
22
|
+
mobile_mcp/mcp_tools/mcp_server.py,sha256=x_qvP94FjTeFkk_Tc3rwXtN0Fmp4yblm8b5eoeTOR8w,54744
|
|
23
23
|
mobile_mcp/utils/__init__.py,sha256=8EH0i7UGtx1y_j_GEgdN-cZdWn2sRtZSEOLlNF9HRnY,158
|
|
24
24
|
mobile_mcp/utils/logger.py,sha256=Sqq2Nr0Y4p03erqcrbYKVPCGiFaNGHMcE_JwCkeOfU4,3626
|
|
25
25
|
mobile_mcp/utils/xml_formatter.py,sha256=uwTRb3vLbqhT8O-udzWT7s7LsV-DyDUz2DkofD3hXOE,4556
|
|
26
26
|
mobile_mcp/utils/xml_parser.py,sha256=QhL8CWbdmNDzmBLjtx6mEnjHgMFZzJeHpCL15qfXSpI,3926
|
|
27
|
-
mobile_mcp_ai-2.6.
|
|
28
|
-
mobile_mcp_ai-2.6.
|
|
29
|
-
mobile_mcp_ai-2.6.
|
|
30
|
-
mobile_mcp_ai-2.6.
|
|
31
|
-
mobile_mcp_ai-2.6.
|
|
32
|
-
mobile_mcp_ai-2.6.
|
|
27
|
+
mobile_mcp_ai-2.6.3.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
|
|
28
|
+
mobile_mcp_ai-2.6.3.dist-info/METADATA,sha256=j4pJ-FkSVtIy9YEW4HbmVa0u-WSc5LqKqUj7_Sd27cU,10495
|
|
29
|
+
mobile_mcp_ai-2.6.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
30
|
+
mobile_mcp_ai-2.6.3.dist-info/entry_points.txt,sha256=KB_FglozgPHBprSM1vFbIzGyheFuHFmGanscRdMJ_8A,68
|
|
31
|
+
mobile_mcp_ai-2.6.3.dist-info/top_level.txt,sha256=lLm6YpbTv855Lbh8BIA0rPxhybIrvYUzMEk9OErHT94,11
|
|
32
|
+
mobile_mcp_ai-2.6.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|