mobile-mcp-ai 2.4.3__py3-none-any.whl → 2.5.0__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 +555 -67
- mobile_mcp/core/ios_client_wda.py +1 -0
- mobile_mcp/core/template_matcher.py +418 -0
- mobile_mcp/mcp_tools/mcp_server.py +129 -2
- {mobile_mcp_ai-2.4.3.dist-info → mobile_mcp_ai-2.5.0.dist-info}/METADATA +1 -1
- {mobile_mcp_ai-2.4.3.dist-info → mobile_mcp_ai-2.5.0.dist-info}/RECORD +10 -9
- {mobile_mcp_ai-2.4.3.dist-info → mobile_mcp_ai-2.5.0.dist-info}/WHEEL +0 -0
- {mobile_mcp_ai-2.4.3.dist-info → mobile_mcp_ai-2.5.0.dist-info}/entry_points.txt +0 -0
- {mobile_mcp_ai-2.4.3.dist-info → mobile_mcp_ai-2.5.0.dist-info}/licenses/LICENSE +0 -0
- {mobile_mcp_ai-2.4.3.dist-info → mobile_mcp_ai-2.5.0.dist-info}/top_level.txt +0 -0
|
@@ -616,9 +616,8 @@ class BasicMobileToolsLite:
|
|
|
616
616
|
'desc': elem['desc']
|
|
617
617
|
})
|
|
618
618
|
|
|
619
|
-
# 第3.5
|
|
619
|
+
# 第3.5步:检测弹窗区域(用于标注)
|
|
620
620
|
popup_bounds = None
|
|
621
|
-
popup_close_hints = []
|
|
622
621
|
|
|
623
622
|
if not self._is_ios():
|
|
624
623
|
try:
|
|
@@ -650,69 +649,24 @@ class BasicMobileToolsLite:
|
|
|
650
649
|
if popup_bounds is None or p_area > (popup_bounds[2] - popup_bounds[0]) * (popup_bounds[3] - popup_bounds[1]):
|
|
651
650
|
popup_bounds = (px1, py1, px2, py2)
|
|
652
651
|
|
|
653
|
-
#
|
|
652
|
+
# 如果检测到弹窗,标注弹窗边界(不再猜测X按钮位置)
|
|
654
653
|
if popup_bounds:
|
|
655
654
|
px1, py1, px2, py2 = popup_bounds
|
|
656
|
-
popup_width = px2 - px1
|
|
657
|
-
popup_height = py2 - py1
|
|
658
|
-
|
|
659
|
-
# 计算多个可能的 X 按钮位置(基于弹窗尺寸动态计算)
|
|
660
|
-
# 【优化】X按钮有三种常见位置:
|
|
661
|
-
# 1. 弹窗边界上方(浮动X按钮)
|
|
662
|
-
# 2. 弹窗内靠近顶部边界(内嵌X按钮)
|
|
663
|
-
# 3. 弹窗正下方(底部关闭按钮)
|
|
664
|
-
offset_x = max(60, int(popup_width * 0.07)) # 宽度7%,距右边界
|
|
665
|
-
offset_y_above = max(35, int(popup_height * 0.025)) # 高度2.5%,在顶边界之上
|
|
666
|
-
offset_y_near = max(45, int(popup_height * 0.03)) # 高度3%,紧贴顶边界内侧
|
|
667
|
-
|
|
668
|
-
close_positions = [
|
|
669
|
-
# 【最高优先级】弹窗内紧贴顶部边界(大多数X按钮在这里)
|
|
670
|
-
{"name": "右上角", "x": px2 - offset_x, "y": py1 + offset_y_near},
|
|
671
|
-
# 弹窗边界上方(浮动X按钮)
|
|
672
|
-
{"name": "右上浮", "x": px2 - offset_x, "y": py1 - offset_y_above},
|
|
673
|
-
# 弹窗正下方中间(底部关闭按钮)
|
|
674
|
-
{"name": "正下方", "x": (px1 + px2) // 2, "y": py2 + max(50, int(popup_height * 0.04))},
|
|
675
|
-
]
|
|
676
|
-
|
|
677
|
-
# 用黄色/金色标注这些可能位置(始终显示)
|
|
678
|
-
hint_color = (255, 200, 0) # 金黄色
|
|
679
|
-
next_index = len(som_elements) + 1
|
|
680
655
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
if 0 <= hx <= img_width and 0 <= hy <= img_height:
|
|
684
|
-
# 画圆圈
|
|
685
|
-
draw.ellipse([hx-18, hy-18, hx+18, hy+18],
|
|
686
|
-
outline=hint_color + (255,), width=3)
|
|
687
|
-
# 画编号背景
|
|
688
|
-
draw.rectangle([hx-10, hy-22, hx+10, hy-6],
|
|
689
|
-
fill=hint_color + (220,))
|
|
690
|
-
# 画编号
|
|
691
|
-
draw.text((hx-6, hy-20), str(next_index),
|
|
692
|
-
fill=(0, 0, 0), font=font_small)
|
|
693
|
-
# 标注 "X?"
|
|
694
|
-
draw.text((hx-8, hy-5), "X?", fill=hint_color, font=font_small)
|
|
695
|
-
|
|
696
|
-
popup_close_hints.append({
|
|
697
|
-
'index': next_index,
|
|
698
|
-
'center': (hx, hy),
|
|
699
|
-
'bounds': f"[{hx-20},{hy-20}][{hx+20},{hy+20}]",
|
|
700
|
-
'desc': f"X?{pos['name']}",
|
|
701
|
-
'is_hint': True
|
|
702
|
-
})
|
|
703
|
-
next_index += 1
|
|
656
|
+
# 只画弹窗边框(蓝色),不再猜测X按钮位置
|
|
657
|
+
draw.rectangle([px1, py1, px2, py2], outline=(0, 150, 255, 180), width=3)
|
|
704
658
|
|
|
705
|
-
#
|
|
706
|
-
|
|
659
|
+
# 在弹窗边框上标注提示文字
|
|
660
|
+
try:
|
|
661
|
+
draw.text((px1+5, py1-25), "弹窗区域", fill=(0, 150, 255), font=font_small)
|
|
662
|
+
except:
|
|
663
|
+
pass
|
|
707
664
|
|
|
708
665
|
except Exception as e:
|
|
709
666
|
pass # 弹窗检测失败不影响主功能
|
|
710
667
|
|
|
711
|
-
# 合并元素列表
|
|
712
|
-
all_som_elements = som_elements + popup_close_hints
|
|
713
|
-
|
|
714
668
|
# 保存到实例变量,供 click_by_som 使用
|
|
715
|
-
self._som_elements =
|
|
669
|
+
self._som_elements = som_elements
|
|
716
670
|
|
|
717
671
|
# 第4步:保存标注后的截图
|
|
718
672
|
filename = f"screenshot_{platform}_som_{timestamp}.jpg"
|
|
@@ -740,12 +694,10 @@ class BasicMobileToolsLite:
|
|
|
740
694
|
|
|
741
695
|
# 构建弹窗提示文字
|
|
742
696
|
hints_text = ""
|
|
743
|
-
if
|
|
744
|
-
hints_text = "\n🎯
|
|
745
|
-
hints_text += "
|
|
746
|
-
|
|
747
|
-
for h in popup_close_hints
|
|
748
|
-
])
|
|
697
|
+
if popup_bounds:
|
|
698
|
+
hints_text = f"\n🎯 检测到弹窗区域(蓝色边框)\n"
|
|
699
|
+
hints_text += f" 如需关闭弹窗,请观察图片中的 X 按钮位置\n"
|
|
700
|
+
hints_text += f" 然后使用 mobile_click_by_percent(x%, y%) 点击"
|
|
749
701
|
|
|
750
702
|
return {
|
|
751
703
|
"success": True,
|
|
@@ -754,15 +706,16 @@ class BasicMobileToolsLite:
|
|
|
754
706
|
"screen_height": screen_height,
|
|
755
707
|
"image_width": img_width,
|
|
756
708
|
"image_height": img_height,
|
|
757
|
-
"element_count": len(
|
|
758
|
-
"elements":
|
|
709
|
+
"element_count": len(som_elements),
|
|
710
|
+
"elements": som_elements,
|
|
759
711
|
"popup_detected": popup_bounds is not None,
|
|
760
712
|
"popup_bounds": f"[{popup_bounds[0]},{popup_bounds[1]}][{popup_bounds[2]},{popup_bounds[3]}]" if popup_bounds else None,
|
|
761
|
-
"close_hints": popup_close_hints,
|
|
762
713
|
"message": f"📸 SoM 截图已保存: {final_path}\n"
|
|
763
|
-
f"🏷️ 已标注 {len(
|
|
714
|
+
f"🏷️ 已标注 {len(som_elements)} 个可点击元素\n"
|
|
764
715
|
f"📋 元素列表:\n{elements_text}{hints_text}\n\n"
|
|
765
|
-
f"💡
|
|
716
|
+
f"💡 使用方法:\n"
|
|
717
|
+
f" - 点击标注元素:mobile_click_by_som(编号)\n"
|
|
718
|
+
f" - 点击任意位置:mobile_click_by_percent(x%, y%)"
|
|
766
719
|
}
|
|
767
720
|
|
|
768
721
|
except ImportError:
|
|
@@ -2703,3 +2656,538 @@ class BasicMobileToolsLite:
|
|
|
2703
2656
|
"preview": script[:500] + "..."
|
|
2704
2657
|
}
|
|
2705
2658
|
|
|
2659
|
+
# ========== 模板匹配功能 ==========
|
|
2660
|
+
|
|
2661
|
+
def template_match_close(self, screenshot_path: Optional[str] = None, threshold: float = 0.75) -> Dict:
|
|
2662
|
+
"""使用模板匹配查找关闭按钮
|
|
2663
|
+
|
|
2664
|
+
基于 OpenCV 模板匹配,从预设的X号模板库中查找匹配项。
|
|
2665
|
+
比 AI 视觉识别更精准、更快速。
|
|
2666
|
+
|
|
2667
|
+
Args:
|
|
2668
|
+
screenshot_path: 截图路径(可选,不提供则自动截图)
|
|
2669
|
+
threshold: 匹配阈值 0-1,越高越严格,默认0.75
|
|
2670
|
+
|
|
2671
|
+
Returns:
|
|
2672
|
+
匹配结果,包含坐标和点击命令
|
|
2673
|
+
"""
|
|
2674
|
+
try:
|
|
2675
|
+
from .template_matcher import TemplateMatcher
|
|
2676
|
+
|
|
2677
|
+
# 如果没有提供截图,先截图
|
|
2678
|
+
if screenshot_path is None:
|
|
2679
|
+
screenshot_result = self.take_screenshot(description="模板匹配", compress=False)
|
|
2680
|
+
screenshot_path = screenshot_result.get("screenshot_path")
|
|
2681
|
+
if not screenshot_path:
|
|
2682
|
+
return {"success": False, "error": "截图失败"}
|
|
2683
|
+
|
|
2684
|
+
matcher = TemplateMatcher()
|
|
2685
|
+
result = matcher.find_close_buttons(screenshot_path, threshold)
|
|
2686
|
+
|
|
2687
|
+
return result
|
|
2688
|
+
|
|
2689
|
+
except ImportError:
|
|
2690
|
+
return {
|
|
2691
|
+
"success": False,
|
|
2692
|
+
"error": "需要安装 opencv-python: pip install opencv-python"
|
|
2693
|
+
}
|
|
2694
|
+
except Exception as e:
|
|
2695
|
+
return {"success": False, "error": f"模板匹配失败: {e}"}
|
|
2696
|
+
|
|
2697
|
+
def template_click_close(self, threshold: float = 0.75) -> Dict:
|
|
2698
|
+
"""模板匹配并点击关闭按钮(一步到位)
|
|
2699
|
+
|
|
2700
|
+
截图 -> 模板匹配 -> 点击最佳匹配位置
|
|
2701
|
+
|
|
2702
|
+
Args:
|
|
2703
|
+
threshold: 匹配阈值 0-1
|
|
2704
|
+
|
|
2705
|
+
Returns:
|
|
2706
|
+
操作结果
|
|
2707
|
+
"""
|
|
2708
|
+
try:
|
|
2709
|
+
# 先截图并匹配
|
|
2710
|
+
match_result = self.template_match_close(threshold=threshold)
|
|
2711
|
+
|
|
2712
|
+
if not match_result.get("success"):
|
|
2713
|
+
return match_result
|
|
2714
|
+
|
|
2715
|
+
# 获取最佳匹配的百分比坐标
|
|
2716
|
+
best = match_result.get("best_match", {})
|
|
2717
|
+
x_percent = best.get("percent", {}).get("x")
|
|
2718
|
+
y_percent = best.get("percent", {}).get("y")
|
|
2719
|
+
|
|
2720
|
+
if x_percent is None or y_percent is None:
|
|
2721
|
+
return {"success": False, "error": "无法获取匹配坐标"}
|
|
2722
|
+
|
|
2723
|
+
# 点击
|
|
2724
|
+
click_result = self.click_by_percent(x_percent, y_percent)
|
|
2725
|
+
|
|
2726
|
+
return {
|
|
2727
|
+
"success": True,
|
|
2728
|
+
"message": f"✅ 模板匹配并点击成功",
|
|
2729
|
+
"matched_template": best.get("template"),
|
|
2730
|
+
"confidence": best.get("confidence"),
|
|
2731
|
+
"clicked_position": f"({x_percent}%, {y_percent}%)",
|
|
2732
|
+
"click_result": click_result
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
except Exception as e:
|
|
2736
|
+
return {"success": False, "error": f"模板点击失败: {e}"}
|
|
2737
|
+
|
|
2738
|
+
def template_add(self, screenshot_path: str, x: int, y: int,
|
|
2739
|
+
width: int, height: int, template_name: str) -> Dict:
|
|
2740
|
+
"""从截图中裁剪并添加新模板
|
|
2741
|
+
|
|
2742
|
+
当遇到新样式的X号时,用此方法添加到模板库。
|
|
2743
|
+
|
|
2744
|
+
Args:
|
|
2745
|
+
screenshot_path: 截图路径
|
|
2746
|
+
x, y: 裁剪区域左上角坐标
|
|
2747
|
+
width, height: 裁剪区域大小
|
|
2748
|
+
template_name: 模板名称(如 x_circle_gray)
|
|
2749
|
+
|
|
2750
|
+
Returns:
|
|
2751
|
+
结果
|
|
2752
|
+
"""
|
|
2753
|
+
try:
|
|
2754
|
+
from .template_matcher import TemplateMatcher
|
|
2755
|
+
|
|
2756
|
+
matcher = TemplateMatcher()
|
|
2757
|
+
return matcher.crop_and_add_template(
|
|
2758
|
+
screenshot_path, x, y, width, height, template_name
|
|
2759
|
+
)
|
|
2760
|
+
except ImportError:
|
|
2761
|
+
return {"success": False, "error": "需要安装 opencv-python"}
|
|
2762
|
+
except Exception as e:
|
|
2763
|
+
return {"success": False, "error": f"添加模板失败: {e}"}
|
|
2764
|
+
|
|
2765
|
+
def template_list(self) -> Dict:
|
|
2766
|
+
"""列出所有关闭按钮模板"""
|
|
2767
|
+
try:
|
|
2768
|
+
from .template_matcher import TemplateMatcher
|
|
2769
|
+
|
|
2770
|
+
matcher = TemplateMatcher()
|
|
2771
|
+
return matcher.list_templates()
|
|
2772
|
+
except ImportError:
|
|
2773
|
+
return {"success": False, "error": "需要安装 opencv-python"}
|
|
2774
|
+
except Exception as e:
|
|
2775
|
+
return {"success": False, "error": f"列出模板失败: {e}"}
|
|
2776
|
+
|
|
2777
|
+
def template_delete(self, template_name: str) -> Dict:
|
|
2778
|
+
"""删除指定模板"""
|
|
2779
|
+
try:
|
|
2780
|
+
from .template_matcher import TemplateMatcher
|
|
2781
|
+
|
|
2782
|
+
matcher = TemplateMatcher()
|
|
2783
|
+
return matcher.delete_template(template_name)
|
|
2784
|
+
except ImportError:
|
|
2785
|
+
return {"success": False, "error": "需要安装 opencv-python"}
|
|
2786
|
+
except Exception as e:
|
|
2787
|
+
return {"success": False, "error": f"删除模板失败: {e}"}
|
|
2788
|
+
|
|
2789
|
+
def close_ad_popup(self, auto_learn: bool = True) -> Dict:
|
|
2790
|
+
"""智能关闭广告弹窗(专用于广告场景)
|
|
2791
|
+
|
|
2792
|
+
按优先级尝试:
|
|
2793
|
+
1. 控件树查找关闭按钮(最可靠)
|
|
2794
|
+
2. 模板匹配(需要积累模板库)
|
|
2795
|
+
3. 返回视觉信息供 AI 分析(如果前两步失败)
|
|
2796
|
+
|
|
2797
|
+
自动学习:
|
|
2798
|
+
- 点击成功后,检查这个 X 是否已在模板库
|
|
2799
|
+
- 如果是新样式,自动裁剪并添加到模板库
|
|
2800
|
+
|
|
2801
|
+
Args:
|
|
2802
|
+
auto_learn: 是否自动学习新模板(点击成功后检查并保存)
|
|
2803
|
+
|
|
2804
|
+
Returns:
|
|
2805
|
+
结果字典
|
|
2806
|
+
"""
|
|
2807
|
+
import time
|
|
2808
|
+
import re
|
|
2809
|
+
|
|
2810
|
+
result = {
|
|
2811
|
+
"success": False,
|
|
2812
|
+
"method": None,
|
|
2813
|
+
"message": "",
|
|
2814
|
+
"learned_template": None
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
if self._is_ios():
|
|
2818
|
+
return {"success": False, "error": "iOS 暂不支持此功能"}
|
|
2819
|
+
|
|
2820
|
+
try:
|
|
2821
|
+
import xml.etree.ElementTree as ET
|
|
2822
|
+
|
|
2823
|
+
# ========== 第1步:控件树查找关闭按钮 ==========
|
|
2824
|
+
xml_string = self.client.u2.dump_hierarchy()
|
|
2825
|
+
root = ET.fromstring(xml_string)
|
|
2826
|
+
|
|
2827
|
+
# 关闭按钮的常见特征
|
|
2828
|
+
close_keywords = ['关闭', '跳过', '×', 'X', 'x', 'close', 'skip', '取消']
|
|
2829
|
+
close_content_desc = ['关闭', '跳过', 'close', 'skip', 'dismiss']
|
|
2830
|
+
|
|
2831
|
+
close_candidates = []
|
|
2832
|
+
|
|
2833
|
+
for elem in root.iter():
|
|
2834
|
+
text = elem.attrib.get('text', '').strip()
|
|
2835
|
+
content_desc = elem.attrib.get('content-desc', '').strip()
|
|
2836
|
+
clickable = elem.attrib.get('clickable', 'false') == 'true'
|
|
2837
|
+
bounds_str = elem.attrib.get('bounds', '')
|
|
2838
|
+
resource_id = elem.attrib.get('resource-id', '')
|
|
2839
|
+
|
|
2840
|
+
if not bounds_str:
|
|
2841
|
+
continue
|
|
2842
|
+
|
|
2843
|
+
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
2844
|
+
if not match:
|
|
2845
|
+
continue
|
|
2846
|
+
|
|
2847
|
+
x1, y1, x2, y2 = map(int, match.groups())
|
|
2848
|
+
width = x2 - x1
|
|
2849
|
+
height = y2 - y1
|
|
2850
|
+
cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
|
|
2851
|
+
|
|
2852
|
+
score = 0
|
|
2853
|
+
reason = ""
|
|
2854
|
+
|
|
2855
|
+
# 文本匹配
|
|
2856
|
+
for kw in close_keywords:
|
|
2857
|
+
if kw in text:
|
|
2858
|
+
score += 10
|
|
2859
|
+
reason = f"文本含'{kw}'"
|
|
2860
|
+
break
|
|
2861
|
+
|
|
2862
|
+
# content-desc 匹配
|
|
2863
|
+
for kw in close_content_desc:
|
|
2864
|
+
if kw.lower() in content_desc.lower():
|
|
2865
|
+
score += 8
|
|
2866
|
+
reason = f"描述含'{kw}'"
|
|
2867
|
+
break
|
|
2868
|
+
|
|
2869
|
+
# 小尺寸可点击元素(可能是 X 按钮)
|
|
2870
|
+
if clickable and 30 < width < 200 and 30 < height < 200:
|
|
2871
|
+
screen_width = self.client.u2.info.get('displayWidth', 1440)
|
|
2872
|
+
screen_height = self.client.u2.info.get('displayHeight', 3200)
|
|
2873
|
+
|
|
2874
|
+
# 在屏幕右半边上半部分,很可能是 X
|
|
2875
|
+
if cx > screen_width * 0.6 and cy < screen_height * 0.5:
|
|
2876
|
+
score += 5
|
|
2877
|
+
reason = reason or "右上角小按钮"
|
|
2878
|
+
# 在屏幕上半部分的小按钮,也可能是 X
|
|
2879
|
+
elif cy < screen_height * 0.4:
|
|
2880
|
+
score += 2
|
|
2881
|
+
reason = reason or "上部小按钮"
|
|
2882
|
+
|
|
2883
|
+
# 只要是可点击的小按钮都考虑(即使没有文本)
|
|
2884
|
+
if score > 0 or (clickable and 30 < width < 150 and 30 < height < 150):
|
|
2885
|
+
if not reason and clickable:
|
|
2886
|
+
reason = "可点击小按钮"
|
|
2887
|
+
score = max(score, 1) # 确保有分数
|
|
2888
|
+
close_candidates.append({
|
|
2889
|
+
'score': score,
|
|
2890
|
+
'reason': reason,
|
|
2891
|
+
'bounds': (x1, y1, x2, y2),
|
|
2892
|
+
'center': (cx, cy),
|
|
2893
|
+
'resource_id': resource_id,
|
|
2894
|
+
'text': text
|
|
2895
|
+
})
|
|
2896
|
+
|
|
2897
|
+
# 按分数排序
|
|
2898
|
+
close_candidates.sort(key=lambda x: x['score'], reverse=True)
|
|
2899
|
+
|
|
2900
|
+
if close_candidates:
|
|
2901
|
+
best = close_candidates[0]
|
|
2902
|
+
cx, cy = best['center']
|
|
2903
|
+
bounds = best['bounds']
|
|
2904
|
+
|
|
2905
|
+
# 点击前截图(用于自动学习)
|
|
2906
|
+
pre_screenshot = None
|
|
2907
|
+
if auto_learn:
|
|
2908
|
+
pre_result = self.take_screenshot(description="关闭前", compress=False)
|
|
2909
|
+
pre_screenshot = pre_result.get("screenshot_path")
|
|
2910
|
+
|
|
2911
|
+
# 点击
|
|
2912
|
+
self.click_at_coords(cx, cy)
|
|
2913
|
+
time.sleep(0.5)
|
|
2914
|
+
|
|
2915
|
+
result["success"] = True
|
|
2916
|
+
result["method"] = "控件树"
|
|
2917
|
+
result["message"] = f"✅ 通过控件树找到关闭按钮并点击\n" \
|
|
2918
|
+
f" 位置: ({cx}, {cy})\n" \
|
|
2919
|
+
f" 原因: {best['reason']}"
|
|
2920
|
+
|
|
2921
|
+
# 自动学习:检查这个 X 是否已在模板库,不在就添加
|
|
2922
|
+
if auto_learn and pre_screenshot:
|
|
2923
|
+
learn_result = self._auto_learn_template(pre_screenshot, bounds)
|
|
2924
|
+
if learn_result:
|
|
2925
|
+
result["learned_template"] = learn_result
|
|
2926
|
+
result["message"] += f"\n📚 自动学习: {learn_result}"
|
|
2927
|
+
|
|
2928
|
+
return result
|
|
2929
|
+
|
|
2930
|
+
# ========== 第2步:模板匹配 ==========
|
|
2931
|
+
screenshot_path = None
|
|
2932
|
+
try:
|
|
2933
|
+
from .template_matcher import TemplateMatcher
|
|
2934
|
+
|
|
2935
|
+
# 截图用于模板匹配
|
|
2936
|
+
screenshot_result = self.take_screenshot(description="模板匹配", compress=False)
|
|
2937
|
+
screenshot_path = screenshot_result.get("screenshot_path")
|
|
2938
|
+
|
|
2939
|
+
if screenshot_path:
|
|
2940
|
+
matcher = TemplateMatcher()
|
|
2941
|
+
match_result = matcher.find_close_buttons(screenshot_path, threshold=0.75)
|
|
2942
|
+
|
|
2943
|
+
# 直接使用最佳匹配(已按置信度排序)
|
|
2944
|
+
if match_result.get("success") and match_result.get("best_match"):
|
|
2945
|
+
best = match_result["best_match"]
|
|
2946
|
+
x_pct = best["percent"]["x"]
|
|
2947
|
+
y_pct = best["percent"]["y"]
|
|
2948
|
+
|
|
2949
|
+
# 点击
|
|
2950
|
+
self.click_by_percent(x_pct, y_pct)
|
|
2951
|
+
time.sleep(0.5)
|
|
2952
|
+
|
|
2953
|
+
result["success"] = True
|
|
2954
|
+
result["method"] = "模板匹配"
|
|
2955
|
+
result["message"] = f"✅ 通过模板匹配找到关闭按钮并点击\n" \
|
|
2956
|
+
f" 模板: {best.get('template', 'unknown')}\n" \
|
|
2957
|
+
f" 置信度: {best.get('confidence', 'N/A')}%\n" \
|
|
2958
|
+
f" 位置: ({x_pct:.1f}%, {y_pct:.1f}%)"
|
|
2959
|
+
return result
|
|
2960
|
+
|
|
2961
|
+
except ImportError:
|
|
2962
|
+
pass # OpenCV 未安装,跳过模板匹配
|
|
2963
|
+
except Exception:
|
|
2964
|
+
pass # 模板匹配失败,继续下一步
|
|
2965
|
+
|
|
2966
|
+
# ========== 第3步:返回截图供 AI 分析 ==========
|
|
2967
|
+
if not screenshot_path:
|
|
2968
|
+
screenshot_result = self.take_screenshot(description="需要AI分析", compress=True)
|
|
2969
|
+
|
|
2970
|
+
result["success"] = False
|
|
2971
|
+
result["method"] = None
|
|
2972
|
+
result["message"] = "❌ 控件树和模板匹配都未找到关闭按钮\n" \
|
|
2973
|
+
"📸 已截图,请 AI 分析图片中的 X 按钮位置\n" \
|
|
2974
|
+
"💡 找到后使用 mobile_click_by_percent(x%, y%) 点击"
|
|
2975
|
+
result["screenshot"] = screenshot_result if not screenshot_path else {"screenshot_path": screenshot_path}
|
|
2976
|
+
result["need_ai_analysis"] = True
|
|
2977
|
+
|
|
2978
|
+
return result
|
|
2979
|
+
|
|
2980
|
+
except Exception as e:
|
|
2981
|
+
return {"success": False, "error": f"关闭弹窗失败: {e}"}
|
|
2982
|
+
|
|
2983
|
+
def _detect_popup_region(self, root) -> tuple:
|
|
2984
|
+
"""从控件树中检测弹窗区域
|
|
2985
|
+
|
|
2986
|
+
Args:
|
|
2987
|
+
root: 控件树根元素
|
|
2988
|
+
|
|
2989
|
+
Returns:
|
|
2990
|
+
弹窗边界 (x1, y1, x2, y2) 或 None
|
|
2991
|
+
"""
|
|
2992
|
+
import re
|
|
2993
|
+
|
|
2994
|
+
screen_width = self.client.u2.info.get('displayWidth', 1440)
|
|
2995
|
+
screen_height = self.client.u2.info.get('displayHeight', 3200)
|
|
2996
|
+
|
|
2997
|
+
popup_candidates = []
|
|
2998
|
+
|
|
2999
|
+
for elem in root.iter():
|
|
3000
|
+
bounds_str = elem.attrib.get('bounds', '')
|
|
3001
|
+
if not bounds_str:
|
|
3002
|
+
continue
|
|
3003
|
+
|
|
3004
|
+
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
3005
|
+
if not match:
|
|
3006
|
+
continue
|
|
3007
|
+
|
|
3008
|
+
x1, y1, x2, y2 = map(int, match.groups())
|
|
3009
|
+
width = x2 - x1
|
|
3010
|
+
height = y2 - y1
|
|
3011
|
+
|
|
3012
|
+
# 弹窗特征:
|
|
3013
|
+
# 1. 不是全屏
|
|
3014
|
+
# 2. 在屏幕中央
|
|
3015
|
+
# 3. 有一定大小
|
|
3016
|
+
is_fullscreen = (width >= screen_width * 0.95 and height >= screen_height * 0.9)
|
|
3017
|
+
is_centered = (x1 > screen_width * 0.05 and x2 < screen_width * 0.95)
|
|
3018
|
+
is_reasonable_size = (width > 200 and height > 200 and
|
|
3019
|
+
width < screen_width * 0.95 and
|
|
3020
|
+
height < screen_height * 0.8)
|
|
3021
|
+
|
|
3022
|
+
if not is_fullscreen and is_centered and is_reasonable_size:
|
|
3023
|
+
# 计算"弹窗感"分数
|
|
3024
|
+
area = width * height
|
|
3025
|
+
center_x = (x1 + x2) / 2
|
|
3026
|
+
center_y = (y1 + y2) / 2
|
|
3027
|
+
center_dist = abs(center_x - screen_width/2) + abs(center_y - screen_height/2)
|
|
3028
|
+
|
|
3029
|
+
score = area / 1000 - center_dist / 10
|
|
3030
|
+
popup_candidates.append({
|
|
3031
|
+
'bounds': (x1, y1, x2, y2),
|
|
3032
|
+
'score': score
|
|
3033
|
+
})
|
|
3034
|
+
|
|
3035
|
+
if popup_candidates:
|
|
3036
|
+
# 返回分数最高的弹窗
|
|
3037
|
+
popup_candidates.sort(key=lambda x: x['score'], reverse=True)
|
|
3038
|
+
return popup_candidates[0]['bounds']
|
|
3039
|
+
|
|
3040
|
+
return None
|
|
3041
|
+
|
|
3042
|
+
def _auto_learn_template(self, screenshot_path: str, bounds: tuple, threshold: float = 0.6) -> str:
|
|
3043
|
+
"""自动学习:检查 X 按钮是否已在模板库,不在就添加
|
|
3044
|
+
|
|
3045
|
+
Args:
|
|
3046
|
+
screenshot_path: 截图路径
|
|
3047
|
+
bounds: X 按钮的边界 (x1, y1, x2, y2)
|
|
3048
|
+
threshold: 判断是否已存在的阈值(高于此值认为已存在)
|
|
3049
|
+
|
|
3050
|
+
Returns:
|
|
3051
|
+
新模板名称,如果是新模板的话;已存在或失败返回 None
|
|
3052
|
+
"""
|
|
3053
|
+
try:
|
|
3054
|
+
from .template_matcher import TemplateMatcher
|
|
3055
|
+
from PIL import Image
|
|
3056
|
+
import time
|
|
3057
|
+
|
|
3058
|
+
x1, y1, x2, y2 = bounds
|
|
3059
|
+
cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
|
|
3060
|
+
width = x2 - x1
|
|
3061
|
+
height = y2 - y1
|
|
3062
|
+
|
|
3063
|
+
# 扩展一点边界,确保裁剪完整
|
|
3064
|
+
padding = max(10, int(max(width, height) * 0.2))
|
|
3065
|
+
|
|
3066
|
+
# 打开截图
|
|
3067
|
+
img = Image.open(screenshot_path)
|
|
3068
|
+
|
|
3069
|
+
# 裁剪 X 按钮区域
|
|
3070
|
+
crop_x1 = max(0, x1 - padding)
|
|
3071
|
+
crop_y1 = max(0, y1 - padding)
|
|
3072
|
+
crop_x2 = min(img.width, x2 + padding)
|
|
3073
|
+
crop_y2 = min(img.height, y2 + padding)
|
|
3074
|
+
|
|
3075
|
+
cropped = img.crop((crop_x1, crop_y1, crop_x2, crop_y2))
|
|
3076
|
+
|
|
3077
|
+
# 保存临时文件用于匹配检查
|
|
3078
|
+
temp_path = self.screenshot_dir / "temp_new_x.png"
|
|
3079
|
+
cropped.save(str(temp_path))
|
|
3080
|
+
|
|
3081
|
+
# 检查是否已在模板库中(用模板匹配检测相似度)
|
|
3082
|
+
matcher = TemplateMatcher()
|
|
3083
|
+
|
|
3084
|
+
import cv2
|
|
3085
|
+
new_img = cv2.imread(str(temp_path), cv2.IMREAD_GRAYSCALE)
|
|
3086
|
+
if new_img is None:
|
|
3087
|
+
return None
|
|
3088
|
+
|
|
3089
|
+
is_new = True
|
|
3090
|
+
for template_file in matcher.template_dir.glob("*.png"):
|
|
3091
|
+
template = cv2.imread(str(template_file), cv2.IMREAD_GRAYSCALE)
|
|
3092
|
+
if template is None:
|
|
3093
|
+
continue
|
|
3094
|
+
|
|
3095
|
+
# 将两个图都调整到合适大小,然后用小模板在大图中搜索
|
|
3096
|
+
# 这样比较更接近实际匹配场景
|
|
3097
|
+
|
|
3098
|
+
# 新图作为搜索区域(稍大一点)
|
|
3099
|
+
new_resized = cv2.resize(new_img, (100, 100))
|
|
3100
|
+
# 模板调整到较小尺寸
|
|
3101
|
+
template_resized = cv2.resize(template, (60, 60))
|
|
3102
|
+
|
|
3103
|
+
# 在新图中搜索模板
|
|
3104
|
+
result = cv2.matchTemplate(new_resized, template_resized, cv2.TM_CCOEFF_NORMED)
|
|
3105
|
+
_, max_val, _, _ = cv2.minMaxLoc(result)
|
|
3106
|
+
|
|
3107
|
+
if max_val >= threshold:
|
|
3108
|
+
is_new = False
|
|
3109
|
+
break
|
|
3110
|
+
|
|
3111
|
+
# 清理临时文件
|
|
3112
|
+
if temp_path.exists():
|
|
3113
|
+
temp_path.unlink()
|
|
3114
|
+
|
|
3115
|
+
if is_new:
|
|
3116
|
+
# 生成唯一模板名
|
|
3117
|
+
timestamp = time.strftime("%m%d_%H%M%S")
|
|
3118
|
+
template_name = f"auto_x_{timestamp}.png"
|
|
3119
|
+
template_path = matcher.template_dir / template_name
|
|
3120
|
+
|
|
3121
|
+
# 保存新模板
|
|
3122
|
+
cropped.save(str(template_path))
|
|
3123
|
+
|
|
3124
|
+
return template_name
|
|
3125
|
+
else:
|
|
3126
|
+
return None # 已存在类似模板
|
|
3127
|
+
|
|
3128
|
+
except Exception as e:
|
|
3129
|
+
return None # 学习失败,不影响主流程
|
|
3130
|
+
|
|
3131
|
+
def template_add_by_percent(self, x_percent: float, y_percent: float,
|
|
3132
|
+
size: int, template_name: str) -> Dict:
|
|
3133
|
+
"""通过百分比坐标添加模板(更方便!)
|
|
3134
|
+
|
|
3135
|
+
自动截图 → 根据百分比位置裁剪 → 保存为模板
|
|
3136
|
+
|
|
3137
|
+
Args:
|
|
3138
|
+
x_percent: X号中心的水平百分比 (0-100)
|
|
3139
|
+
y_percent: X号中心的垂直百分比 (0-100)
|
|
3140
|
+
size: 裁剪区域大小(正方形边长,像素)
|
|
3141
|
+
template_name: 模板名称
|
|
3142
|
+
|
|
3143
|
+
Returns:
|
|
3144
|
+
结果
|
|
3145
|
+
"""
|
|
3146
|
+
try:
|
|
3147
|
+
from .template_matcher import TemplateMatcher
|
|
3148
|
+
from PIL import Image
|
|
3149
|
+
|
|
3150
|
+
# 先截图(不带 SoM 标注的干净截图)
|
|
3151
|
+
screenshot_result = self.take_screenshot(description="添加模板", compress=False)
|
|
3152
|
+
screenshot_path = screenshot_result.get("screenshot_path")
|
|
3153
|
+
|
|
3154
|
+
if not screenshot_path:
|
|
3155
|
+
return {"success": False, "error": "截图失败"}
|
|
3156
|
+
|
|
3157
|
+
# 读取截图获取尺寸
|
|
3158
|
+
img = Image.open(screenshot_path)
|
|
3159
|
+
img_w, img_h = img.size
|
|
3160
|
+
|
|
3161
|
+
# 计算中心点像素坐标
|
|
3162
|
+
cx = int(img_w * x_percent / 100)
|
|
3163
|
+
cy = int(img_h * y_percent / 100)
|
|
3164
|
+
|
|
3165
|
+
# 计算裁剪区域
|
|
3166
|
+
half = size // 2
|
|
3167
|
+
x1 = max(0, cx - half)
|
|
3168
|
+
y1 = max(0, cy - half)
|
|
3169
|
+
x2 = min(img_w, cx + half)
|
|
3170
|
+
y2 = min(img_h, cy + half)
|
|
3171
|
+
|
|
3172
|
+
# 裁剪并保存
|
|
3173
|
+
cropped = img.crop((x1, y1, x2, y2))
|
|
3174
|
+
|
|
3175
|
+
matcher = TemplateMatcher()
|
|
3176
|
+
output_path = matcher.template_dir / f"{template_name}.png"
|
|
3177
|
+
cropped.save(str(output_path))
|
|
3178
|
+
|
|
3179
|
+
return {
|
|
3180
|
+
"success": True,
|
|
3181
|
+
"message": f"✅ 模板已保存: {template_name}",
|
|
3182
|
+
"template_path": str(output_path),
|
|
3183
|
+
"center_percent": f"({x_percent}%, {y_percent}%)",
|
|
3184
|
+
"center_pixel": f"({cx}, {cy})",
|
|
3185
|
+
"crop_region": f"({x1},{y1}) - ({x2},{y2})",
|
|
3186
|
+
"size": f"{cropped.size[0]}x{cropped.size[1]}"
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
except ImportError as e:
|
|
3190
|
+
return {"success": False, "error": f"需要安装依赖: {e}"}
|
|
3191
|
+
except Exception as e:
|
|
3192
|
+
return {"success": False, "error": f"添加模板失败: {e}"}
|
|
3193
|
+
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenCV 模板匹配器 - 用于精确识别广告弹窗X号
|
|
3
|
+
核心优势:
|
|
4
|
+
1. 收集常见X号样式建立模板库
|
|
5
|
+
2. 多尺度匹配解决分辨率差异
|
|
6
|
+
3. 返回精确坐标,点击准确率高
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import cv2
|
|
11
|
+
import numpy as np
|
|
12
|
+
from typing import Dict, List, Tuple, Optional
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TemplateMatcher:
|
|
17
|
+
"""OpenCV 模板匹配器"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, template_dir: Optional[str] = None):
|
|
20
|
+
"""
|
|
21
|
+
初始化模板匹配器
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
template_dir: 模板目录路径,默认为 templates/close_buttons/
|
|
25
|
+
"""
|
|
26
|
+
if template_dir is None:
|
|
27
|
+
# 默认模板目录
|
|
28
|
+
base_dir = Path(__file__).parent.parent
|
|
29
|
+
self.template_dir = base_dir / "templates" / "close_buttons"
|
|
30
|
+
else:
|
|
31
|
+
self.template_dir = Path(template_dir)
|
|
32
|
+
|
|
33
|
+
# 确保目录存在
|
|
34
|
+
self.template_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
# 多尺度匹配的缩放范围
|
|
37
|
+
self.scales = [0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.5, 1.8, 2.0]
|
|
38
|
+
|
|
39
|
+
# 匹配阈值(越高越严格)
|
|
40
|
+
self.match_threshold = 0.75
|
|
41
|
+
|
|
42
|
+
# 缓存加载的模板
|
|
43
|
+
self._template_cache: Dict[str, np.ndarray] = {}
|
|
44
|
+
|
|
45
|
+
def load_templates(self) -> List[Tuple[str, np.ndarray]]:
|
|
46
|
+
"""
|
|
47
|
+
加载所有模板图片
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
List of (template_name, template_image) tuples
|
|
51
|
+
"""
|
|
52
|
+
templates = []
|
|
53
|
+
|
|
54
|
+
if not self.template_dir.exists():
|
|
55
|
+
return templates
|
|
56
|
+
|
|
57
|
+
# 支持的图片格式
|
|
58
|
+
extensions = ['.png', '.jpg', '.jpeg', '.bmp']
|
|
59
|
+
|
|
60
|
+
for file in self.template_dir.iterdir():
|
|
61
|
+
if file.suffix.lower() in extensions:
|
|
62
|
+
template_name = file.stem
|
|
63
|
+
|
|
64
|
+
# 使用缓存
|
|
65
|
+
if template_name in self._template_cache:
|
|
66
|
+
templates.append((template_name, self._template_cache[template_name]))
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
# 读取模板(支持透明通道)
|
|
70
|
+
template = cv2.imread(str(file), cv2.IMREAD_UNCHANGED)
|
|
71
|
+
if template is not None:
|
|
72
|
+
self._template_cache[template_name] = template
|
|
73
|
+
templates.append((template_name, template))
|
|
74
|
+
|
|
75
|
+
return templates
|
|
76
|
+
|
|
77
|
+
def match_single_template(
|
|
78
|
+
self,
|
|
79
|
+
screenshot: np.ndarray,
|
|
80
|
+
template: np.ndarray,
|
|
81
|
+
threshold: Optional[float] = None
|
|
82
|
+
) -> List[Dict]:
|
|
83
|
+
"""
|
|
84
|
+
单模板多尺度匹配
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
screenshot: 截图 (BGR格式)
|
|
88
|
+
template: 模板图片
|
|
89
|
+
threshold: 匹配阈值
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
匹配结果列表
|
|
93
|
+
"""
|
|
94
|
+
if threshold is None:
|
|
95
|
+
threshold = self.match_threshold
|
|
96
|
+
|
|
97
|
+
results = []
|
|
98
|
+
|
|
99
|
+
# 转灰度图
|
|
100
|
+
if len(screenshot.shape) == 3:
|
|
101
|
+
gray_screen = cv2.cvtColor(screenshot, cv2.COLOR_BGR2GRAY)
|
|
102
|
+
else:
|
|
103
|
+
gray_screen = screenshot
|
|
104
|
+
|
|
105
|
+
# 处理模板(可能有透明通道)
|
|
106
|
+
# 注意:不使用 mask,因为 TM_CCOEFF_NORMED + mask 可能返回 INF
|
|
107
|
+
if len(template.shape) == 3:
|
|
108
|
+
if template.shape[2] == 4: # BGRA
|
|
109
|
+
template_gray = cv2.cvtColor(template[:, :, :3], cv2.COLOR_BGR2GRAY)
|
|
110
|
+
else: # BGR
|
|
111
|
+
template_gray = cv2.cvtColor(template, cv2.COLOR_BGR2GRAY)
|
|
112
|
+
else:
|
|
113
|
+
template_gray = template
|
|
114
|
+
|
|
115
|
+
template_h, template_w = template_gray.shape[:2]
|
|
116
|
+
|
|
117
|
+
# 多尺度匹配
|
|
118
|
+
for scale in self.scales:
|
|
119
|
+
# 缩放模板
|
|
120
|
+
new_w = int(template_w * scale)
|
|
121
|
+
new_h = int(template_h * scale)
|
|
122
|
+
|
|
123
|
+
# 跳过太小或太大的模板
|
|
124
|
+
if new_w < 10 or new_h < 10:
|
|
125
|
+
continue
|
|
126
|
+
if new_w > gray_screen.shape[1] or new_h > gray_screen.shape[0]:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
resized_template = cv2.resize(template_gray, (new_w, new_h))
|
|
130
|
+
|
|
131
|
+
# 模板匹配
|
|
132
|
+
try:
|
|
133
|
+
result = cv2.matchTemplate(
|
|
134
|
+
gray_screen, resized_template,
|
|
135
|
+
cv2.TM_CCOEFF_NORMED
|
|
136
|
+
)
|
|
137
|
+
except cv2.error:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# 跳过包含 INF/NAN 的结果
|
|
141
|
+
if np.isinf(result).any() or np.isnan(result).any():
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
# 找所有超过阈值的匹配点
|
|
145
|
+
locations = np.where(result >= threshold)
|
|
146
|
+
|
|
147
|
+
for pt in zip(*locations[::-1]): # (x, y)
|
|
148
|
+
confidence = float(result[pt[1], pt[0]])
|
|
149
|
+
center_x = int(pt[0] + new_w // 2)
|
|
150
|
+
center_y = int(pt[1] + new_h // 2)
|
|
151
|
+
|
|
152
|
+
results.append({
|
|
153
|
+
'x': center_x,
|
|
154
|
+
'y': center_y,
|
|
155
|
+
'width': int(new_w),
|
|
156
|
+
'height': int(new_h),
|
|
157
|
+
'scale': float(scale),
|
|
158
|
+
'confidence': confidence,
|
|
159
|
+
'top_left': (int(pt[0]), int(pt[1])),
|
|
160
|
+
'bottom_right': (int(pt[0] + new_w), int(pt[1] + new_h))
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
# 非极大值抑制(去除重叠的检测框)
|
|
164
|
+
results = self._non_max_suppression(results)
|
|
165
|
+
|
|
166
|
+
return results
|
|
167
|
+
|
|
168
|
+
def _non_max_suppression(self, results: List[Dict], overlap_thresh: float = 0.3) -> List[Dict]:
|
|
169
|
+
"""
|
|
170
|
+
非极大值抑制,去除重叠的检测框
|
|
171
|
+
"""
|
|
172
|
+
if len(results) == 0:
|
|
173
|
+
return []
|
|
174
|
+
|
|
175
|
+
# 按置信度排序
|
|
176
|
+
results = sorted(results, key=lambda x: x['confidence'], reverse=True)
|
|
177
|
+
|
|
178
|
+
kept = []
|
|
179
|
+
for result in results:
|
|
180
|
+
is_duplicate = False
|
|
181
|
+
for kept_result in kept:
|
|
182
|
+
# 计算中心点距离
|
|
183
|
+
dx = abs(result['x'] - kept_result['x'])
|
|
184
|
+
dy = abs(result['y'] - kept_result['y'])
|
|
185
|
+
|
|
186
|
+
# 如果中心点距离小于框的一半大小,认为是重复
|
|
187
|
+
avg_size = (result['width'] + result['height'] +
|
|
188
|
+
kept_result['width'] + kept_result['height']) / 4
|
|
189
|
+
|
|
190
|
+
if dx < avg_size * overlap_thresh and dy < avg_size * overlap_thresh:
|
|
191
|
+
is_duplicate = True
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
if not is_duplicate:
|
|
195
|
+
kept.append(result)
|
|
196
|
+
|
|
197
|
+
return kept
|
|
198
|
+
|
|
199
|
+
def find_close_buttons(
|
|
200
|
+
self,
|
|
201
|
+
screenshot_path: str,
|
|
202
|
+
threshold: Optional[float] = None
|
|
203
|
+
) -> Dict:
|
|
204
|
+
"""
|
|
205
|
+
在截图中查找所有关闭按钮
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
screenshot_path: 截图路径
|
|
209
|
+
threshold: 匹配阈值 (0-1)
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
匹配结果
|
|
213
|
+
"""
|
|
214
|
+
# 读取截图
|
|
215
|
+
screenshot = cv2.imread(screenshot_path)
|
|
216
|
+
if screenshot is None:
|
|
217
|
+
return {
|
|
218
|
+
"success": False,
|
|
219
|
+
"error": f"无法读取截图: {screenshot_path}"
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
img_height, img_width = screenshot.shape[:2]
|
|
223
|
+
|
|
224
|
+
# 加载模板
|
|
225
|
+
templates = self.load_templates()
|
|
226
|
+
if not templates:
|
|
227
|
+
return {
|
|
228
|
+
"success": False,
|
|
229
|
+
"error": "没有找到模板图片,请在 templates/close_buttons/ 目录添加X号模板",
|
|
230
|
+
"template_dir": str(self.template_dir),
|
|
231
|
+
"tip": "添加常见X号截图到模板目录,命名如 x_circle.png, x_white.png 等"
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
all_matches = []
|
|
235
|
+
|
|
236
|
+
for template_name, template in templates:
|
|
237
|
+
matches = self.match_single_template(screenshot, template, threshold)
|
|
238
|
+
for match in matches:
|
|
239
|
+
match['template'] = template_name
|
|
240
|
+
all_matches.append(match)
|
|
241
|
+
|
|
242
|
+
# 按置信度排序
|
|
243
|
+
all_matches = sorted(all_matches, key=lambda x: x['confidence'], reverse=True)
|
|
244
|
+
|
|
245
|
+
# 再次 NMS 去除不同模板的重复检测
|
|
246
|
+
all_matches = self._non_max_suppression(all_matches)
|
|
247
|
+
|
|
248
|
+
if not all_matches:
|
|
249
|
+
return {
|
|
250
|
+
"success": False,
|
|
251
|
+
"message": "未找到匹配的关闭按钮",
|
|
252
|
+
"templates_used": [t[0] for t in templates],
|
|
253
|
+
"threshold": threshold or self.match_threshold,
|
|
254
|
+
"tip": "可能需要添加新的X号模板,或降低匹配阈值"
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
# 计算百分比坐标
|
|
258
|
+
for match in all_matches:
|
|
259
|
+
match['x_percent'] = round(match['x'] / img_width * 100, 1)
|
|
260
|
+
match['y_percent'] = round(match['y'] / img_height * 100, 1)
|
|
261
|
+
|
|
262
|
+
best = all_matches[0]
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
"success": True,
|
|
266
|
+
"message": f"✅ 找到 {len(all_matches)} 个关闭按钮",
|
|
267
|
+
"best_match": {
|
|
268
|
+
"template": best['template'],
|
|
269
|
+
"center": {"x": int(best['x']), "y": int(best['y'])},
|
|
270
|
+
"percent": {"x": float(best['x_percent']), "y": float(best['y_percent'])},
|
|
271
|
+
"size": f"{best['width']}x{best['height']}",
|
|
272
|
+
"confidence": float(round(best['confidence'] * 100, 1))
|
|
273
|
+
},
|
|
274
|
+
"click_command": f"mobile_click_by_percent({best['x_percent']}, {best['y_percent']})",
|
|
275
|
+
"all_matches": [
|
|
276
|
+
{
|
|
277
|
+
"template": m['template'],
|
|
278
|
+
"percent": f"({m['x_percent']}%, {m['y_percent']}%)",
|
|
279
|
+
"confidence": f"{m['confidence']*100:.1f}%"
|
|
280
|
+
}
|
|
281
|
+
for m in all_matches[:5] # 最多返回5个
|
|
282
|
+
],
|
|
283
|
+
"image_size": {"width": img_width, "height": img_height}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
def add_template(self, image_path: str, template_name: str) -> Dict:
|
|
287
|
+
"""
|
|
288
|
+
添加新模板到模板库
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
image_path: 图片路径(可以是截图的一部分)
|
|
292
|
+
template_name: 模板名称
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
结果
|
|
296
|
+
"""
|
|
297
|
+
# 读取图片
|
|
298
|
+
img = cv2.imread(image_path)
|
|
299
|
+
if img is None:
|
|
300
|
+
return {"success": False, "error": f"无法读取图片: {image_path}"}
|
|
301
|
+
|
|
302
|
+
# 保存到模板目录
|
|
303
|
+
output_path = self.template_dir / f"{template_name}.png"
|
|
304
|
+
cv2.imwrite(str(output_path), img)
|
|
305
|
+
|
|
306
|
+
# 清除缓存
|
|
307
|
+
self._template_cache.clear()
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
"success": True,
|
|
311
|
+
"message": f"✅ 模板已保存: {output_path}",
|
|
312
|
+
"template_name": template_name
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
def crop_and_add_template(
|
|
316
|
+
self,
|
|
317
|
+
screenshot_path: str,
|
|
318
|
+
x: int, y: int,
|
|
319
|
+
width: int, height: int,
|
|
320
|
+
template_name: str
|
|
321
|
+
) -> Dict:
|
|
322
|
+
"""
|
|
323
|
+
从截图中裁剪区域并添加为模板
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
screenshot_path: 截图路径
|
|
327
|
+
x, y: 左上角坐标
|
|
328
|
+
width, height: 裁剪尺寸
|
|
329
|
+
template_name: 模板名称
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
结果
|
|
333
|
+
"""
|
|
334
|
+
img = cv2.imread(screenshot_path)
|
|
335
|
+
if img is None:
|
|
336
|
+
return {"success": False, "error": f"无法读取截图: {screenshot_path}"}
|
|
337
|
+
|
|
338
|
+
# 裁剪
|
|
339
|
+
cropped = img[y:y+height, x:x+width]
|
|
340
|
+
|
|
341
|
+
if cropped.size == 0:
|
|
342
|
+
return {"success": False, "error": "裁剪区域无效"}
|
|
343
|
+
|
|
344
|
+
# 保存
|
|
345
|
+
output_path = self.template_dir / f"{template_name}.png"
|
|
346
|
+
cv2.imwrite(str(output_path), cropped)
|
|
347
|
+
|
|
348
|
+
# 清除缓存
|
|
349
|
+
self._template_cache.clear()
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
"success": True,
|
|
353
|
+
"message": f"✅ 模板已保存: {output_path}",
|
|
354
|
+
"template_name": template_name,
|
|
355
|
+
"size": f"{width}x{height}"
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
def list_templates(self) -> Dict:
|
|
359
|
+
"""列出所有模板"""
|
|
360
|
+
templates = self.load_templates()
|
|
361
|
+
|
|
362
|
+
if not templates:
|
|
363
|
+
return {
|
|
364
|
+
"success": True,
|
|
365
|
+
"templates": [],
|
|
366
|
+
"message": "模板库为空",
|
|
367
|
+
"template_dir": str(self.template_dir)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
template_info = []
|
|
371
|
+
for name, img in templates:
|
|
372
|
+
h, w = img.shape[:2]
|
|
373
|
+
template_info.append({
|
|
374
|
+
"name": name,
|
|
375
|
+
"size": f"{w}x{h}",
|
|
376
|
+
"path": str(self.template_dir / f"{name}.png")
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
"success": True,
|
|
381
|
+
"templates": template_info,
|
|
382
|
+
"count": len(template_info),
|
|
383
|
+
"template_dir": str(self.template_dir)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
def delete_template(self, template_name: str) -> Dict:
|
|
387
|
+
"""删除模板"""
|
|
388
|
+
# 查找模板文件
|
|
389
|
+
for ext in ['.png', '.jpg', '.jpeg', '.bmp']:
|
|
390
|
+
path = self.template_dir / f"{template_name}{ext}"
|
|
391
|
+
if path.exists():
|
|
392
|
+
path.unlink()
|
|
393
|
+
self._template_cache.pop(template_name, None)
|
|
394
|
+
return {
|
|
395
|
+
"success": True,
|
|
396
|
+
"message": f"✅ 已删除模板: {template_name}"
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
"success": False,
|
|
401
|
+
"error": f"模板不存在: {template_name}"
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# 便捷函数
|
|
406
|
+
def match_close_button(screenshot_path: str, threshold: float = 0.75) -> Dict:
|
|
407
|
+
"""
|
|
408
|
+
快速匹配关闭按钮
|
|
409
|
+
|
|
410
|
+
用法:
|
|
411
|
+
from core.template_matcher import match_close_button
|
|
412
|
+
result = match_close_button("screenshot.png")
|
|
413
|
+
if result["success"]:
|
|
414
|
+
print(result["click_command"])
|
|
415
|
+
"""
|
|
416
|
+
matcher = TemplateMatcher()
|
|
417
|
+
return matcher.find_close_buttons(screenshot_path, threshold)
|
|
418
|
+
|
|
@@ -162,12 +162,14 @@ class MobileMCPServer:
|
|
|
162
162
|
"SoM 截图会给元素标号,AI 可以直接说'点击几号',更精准!\n\n"
|
|
163
163
|
"🎯 本工具仅用于:\n"
|
|
164
164
|
"- 快速确认页面状态(不需要点击时)\n"
|
|
165
|
-
"- 操作后确认结果\n
|
|
165
|
+
"- 操作后确认结果\n"
|
|
166
|
+
"- compress=false 时可获取原始分辨率截图(用于添加模板)\n\n"
|
|
166
167
|
"💡 如需点击元素,请用 mobile_screenshot_with_som + mobile_click_by_som",
|
|
167
168
|
inputSchema={
|
|
168
169
|
"type": "object",
|
|
169
170
|
"properties": {
|
|
170
171
|
"description": {"type": "string", "description": "截图描述(可选)"},
|
|
172
|
+
"compress": {"type": "boolean", "description": "是否压缩,默认 true。设为 false 可获取原始分辨率(用于模板添加)", "default": True},
|
|
171
173
|
"crop_x": {"type": "integer", "description": "局部裁剪中心 X 坐标(屏幕坐标,0 表示不裁剪)"},
|
|
172
174
|
"crop_y": {"type": "integer", "description": "局部裁剪中心 Y 坐标(屏幕坐标,0 表示不裁剪)"},
|
|
173
175
|
"crop_size": {"type": "integer", "description": "裁剪区域大小(推荐 200-400,0 表示不裁剪)"}
|
|
@@ -629,6 +631,90 @@ class MobileMCPServer:
|
|
|
629
631
|
}
|
|
630
632
|
))
|
|
631
633
|
|
|
634
|
+
# ==================== 广告弹窗关闭工具 ====================
|
|
635
|
+
tools.append(Tool(
|
|
636
|
+
name="mobile_close_ad",
|
|
637
|
+
description="""🚫 【推荐】智能关闭广告弹窗
|
|
638
|
+
|
|
639
|
+
专门用于关闭广告弹窗,按优先级自动尝试多种方式:
|
|
640
|
+
|
|
641
|
+
1️⃣ **控件树查找**(最可靠)
|
|
642
|
+
- 自动查找"关闭"、"跳过"、"×"等关闭按钮
|
|
643
|
+
- 找到直接点击,实时可靠
|
|
644
|
+
|
|
645
|
+
2️⃣ **模板匹配**(次优)
|
|
646
|
+
- 用 OpenCV 匹配已保存的 X 按钮模板
|
|
647
|
+
- 需要积累模板库,模板越多成功率越高
|
|
648
|
+
|
|
649
|
+
3️⃣ **返回截图供 AI 分析**(兜底)
|
|
650
|
+
- 如果前两步失败,返回截图
|
|
651
|
+
- AI 分析后用 mobile_click_by_percent 点击
|
|
652
|
+
- 点击成功后用 mobile_template_add 添加模板(自动学习)
|
|
653
|
+
|
|
654
|
+
💡 使用流程:
|
|
655
|
+
1. 遇到广告弹窗 → 调用此工具
|
|
656
|
+
2. 如果成功 → 完成
|
|
657
|
+
3. 如果失败 → 看截图找 X → 点击 → 添加模板""",
|
|
658
|
+
inputSchema={
|
|
659
|
+
"type": "object",
|
|
660
|
+
"properties": {},
|
|
661
|
+
"required": []
|
|
662
|
+
}
|
|
663
|
+
))
|
|
664
|
+
|
|
665
|
+
tools.append(Tool(
|
|
666
|
+
name="mobile_template_close",
|
|
667
|
+
description="""🎯 模板匹配关闭弹窗(仅模板匹配)
|
|
668
|
+
|
|
669
|
+
只用 OpenCV 模板匹配,不走控件树。
|
|
670
|
+
一般建议用 mobile_close_ad 代替(会自动先查控件树)。
|
|
671
|
+
|
|
672
|
+
⚙️ 参数:
|
|
673
|
+
- click: 是否点击,默认 true
|
|
674
|
+
- threshold: 匹配阈值 0-1,默认 0.75""",
|
|
675
|
+
inputSchema={
|
|
676
|
+
"type": "object",
|
|
677
|
+
"properties": {
|
|
678
|
+
"click": {"type": "boolean", "description": "是否点击,默认 true"},
|
|
679
|
+
"threshold": {"type": "number", "description": "匹配阈值 0-1,默认 0.75"}
|
|
680
|
+
},
|
|
681
|
+
"required": []
|
|
682
|
+
}
|
|
683
|
+
))
|
|
684
|
+
|
|
685
|
+
tools.append(Tool(
|
|
686
|
+
name="mobile_template_add",
|
|
687
|
+
description="""➕ 添加 X 号模板
|
|
688
|
+
|
|
689
|
+
遇到新样式 X 号时,截图并添加到模板库。
|
|
690
|
+
|
|
691
|
+
⚙️ 两种方式(二选一):
|
|
692
|
+
1. 百分比定位(推荐):提供 x_percent, y_percent, size
|
|
693
|
+
2. 像素定位:提供 screenshot_path, x, y, width, height
|
|
694
|
+
|
|
695
|
+
📋 流程:
|
|
696
|
+
1. mobile_screenshot_with_grid 查看 X 号位置
|
|
697
|
+
2. 调用此工具添加模板
|
|
698
|
+
3. 下次同样 X 号就能自动匹配
|
|
699
|
+
|
|
700
|
+
💡 百分比示例:X 在右上角 → x_percent=85, y_percent=12, size=80""",
|
|
701
|
+
inputSchema={
|
|
702
|
+
"type": "object",
|
|
703
|
+
"properties": {
|
|
704
|
+
"template_name": {"type": "string", "description": "模板名称"},
|
|
705
|
+
"x_percent": {"type": "number", "description": "X号中心水平百分比 (0-100)"},
|
|
706
|
+
"y_percent": {"type": "number", "description": "X号中心垂直百分比 (0-100)"},
|
|
707
|
+
"size": {"type": "integer", "description": "裁剪正方形边长(像素)"},
|
|
708
|
+
"screenshot_path": {"type": "string", "description": "截图路径(像素定位时用)"},
|
|
709
|
+
"x": {"type": "integer", "description": "左上角 X 坐标"},
|
|
710
|
+
"y": {"type": "integer", "description": "左上角 Y 坐标"},
|
|
711
|
+
"width": {"type": "integer", "description": "裁剪宽度"},
|
|
712
|
+
"height": {"type": "integer", "description": "裁剪高度"}
|
|
713
|
+
},
|
|
714
|
+
"required": ["template_name"]
|
|
715
|
+
}
|
|
716
|
+
))
|
|
717
|
+
|
|
632
718
|
return tools
|
|
633
719
|
|
|
634
720
|
async def handle_tool_call(self, name: str, arguments: dict):
|
|
@@ -643,6 +729,7 @@ class MobileMCPServer:
|
|
|
643
729
|
if name == "mobile_take_screenshot":
|
|
644
730
|
result = self.tools.take_screenshot(
|
|
645
731
|
description=arguments.get("description", ""),
|
|
732
|
+
compress=arguments.get("compress", True),
|
|
646
733
|
crop_x=arguments.get("crop_x", 0),
|
|
647
734
|
crop_y=arguments.get("crop_y", 0),
|
|
648
735
|
crop_size=arguments.get("crop_size", 0)
|
|
@@ -809,6 +896,46 @@ class MobileMCPServer:
|
|
|
809
896
|
)
|
|
810
897
|
return [TextContent(type="text", text=self.format_response(result))]
|
|
811
898
|
|
|
899
|
+
# 智能关闭广告弹窗
|
|
900
|
+
elif name == "mobile_close_ad":
|
|
901
|
+
result = self.tools.close_ad_popup(auto_learn=True)
|
|
902
|
+
return [TextContent(type="text", text=self.format_response(result))]
|
|
903
|
+
|
|
904
|
+
# 模板匹配(精简版)
|
|
905
|
+
elif name == "mobile_template_close":
|
|
906
|
+
click = arguments.get("click", True)
|
|
907
|
+
threshold = arguments.get("threshold", 0.75)
|
|
908
|
+
if click:
|
|
909
|
+
result = self.tools.template_click_close(threshold=threshold)
|
|
910
|
+
else:
|
|
911
|
+
result = self.tools.template_match_close(threshold=threshold)
|
|
912
|
+
return [TextContent(type="text", text=self.format_response(result))]
|
|
913
|
+
|
|
914
|
+
elif name == "mobile_template_add":
|
|
915
|
+
template_name = arguments["template_name"]
|
|
916
|
+
# 判断使用哪种方式
|
|
917
|
+
if "x_percent" in arguments and "y_percent" in arguments:
|
|
918
|
+
# 百分比方式
|
|
919
|
+
result = self.tools.template_add_by_percent(
|
|
920
|
+
arguments["x_percent"],
|
|
921
|
+
arguments["y_percent"],
|
|
922
|
+
arguments.get("size", 80),
|
|
923
|
+
template_name
|
|
924
|
+
)
|
|
925
|
+
elif "screenshot_path" in arguments:
|
|
926
|
+
# 像素方式
|
|
927
|
+
result = self.tools.template_add(
|
|
928
|
+
arguments["screenshot_path"],
|
|
929
|
+
arguments["x"],
|
|
930
|
+
arguments["y"],
|
|
931
|
+
arguments["width"],
|
|
932
|
+
arguments["height"],
|
|
933
|
+
template_name
|
|
934
|
+
)
|
|
935
|
+
else:
|
|
936
|
+
result = {"success": False, "error": "请提供 x_percent/y_percent 或 screenshot_path/x/y/width/height"}
|
|
937
|
+
return [TextContent(type="text", text=self.format_response(result))]
|
|
938
|
+
|
|
812
939
|
else:
|
|
813
940
|
return [TextContent(type="text", text=f"❌ 未知工具: {name}")]
|
|
814
941
|
|
|
@@ -831,7 +958,7 @@ async def async_main():
|
|
|
831
958
|
async def call_tool(name: str, arguments: dict):
|
|
832
959
|
return await server.handle_tool_call(name, arguments)
|
|
833
960
|
|
|
834
|
-
print("🚀 Mobile MCP Server 启动中... [
|
|
961
|
+
print("🚀 Mobile MCP Server 启动中... [26 个工具]", file=sys.stderr)
|
|
835
962
|
print("📱 支持 Android / iOS", file=sys.stderr)
|
|
836
963
|
print("👁️ 完全依赖 Cursor 视觉能力,无需 AI 密钥", file=sys.stderr)
|
|
837
964
|
|
|
@@ -1,25 +1,26 @@
|
|
|
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=BzPT180GPQjhTNHaAKl46m1jvCM5KoQIPgySGdNSD30,149836
|
|
5
5
|
mobile_mcp/core/device_manager.py,sha256=PX3-B5bJFnKNt6C8fT7FSY8JwD-ngZ3toF88bcOV9qA,8766
|
|
6
6
|
mobile_mcp/core/dynamic_config.py,sha256=Ja1n1pfb0HspGByqk2_A472mYVniKmGtNEWyjUjmgK8,9811
|
|
7
|
-
mobile_mcp/core/ios_client_wda.py,sha256=
|
|
7
|
+
mobile_mcp/core/ios_client_wda.py,sha256=Nq9WxevhTWpVpolM-Ymp-b0nUQV3tXLFszmJHbDC4wA,18770
|
|
8
8
|
mobile_mcp/core/ios_device_manager_wda.py,sha256=A44glqI-24un7qST-E3w6BQD8mV92YVUbxy4rLlTScY,11264
|
|
9
9
|
mobile_mcp/core/mobile_client.py,sha256=bno3HvU-QSAC3G4TnoFngTxqXeu-ZP5rGlEWdWh8jOo,62570
|
|
10
|
+
mobile_mcp/core/template_matcher.py,sha256=dGYrae6cAWiPhF6U4WtYFz_4o7a-LQc3FdZnFq4nHNE,14018
|
|
10
11
|
mobile_mcp/core/utils/__init__.py,sha256=RhMMsPszmEn8Q8GoNufypVSHJxyM9lio9U6jjpnuoPI,378
|
|
11
12
|
mobile_mcp/core/utils/logger.py,sha256=XXQAHUwT1jc70pq_tYFmL6f_nKrFlYm3hcgl-5RYRg0,3402
|
|
12
13
|
mobile_mcp/core/utils/operation_history_manager.py,sha256=gi8S8HJAMqvkUrY7_-kVbko3Xt7c4GAUziEujRd-N-Y,4792
|
|
13
14
|
mobile_mcp/core/utils/smart_wait.py,sha256=PvKXImfN9Irru3bQJUjf4FLGn8LjY2VLzUNEl-i7xLE,8601
|
|
14
15
|
mobile_mcp/mcp_tools/__init__.py,sha256=xkro8Rwqv_55YlVyhh-3DgRFSsLE3h1r31VIb3bpM6E,143
|
|
15
|
-
mobile_mcp/mcp_tools/mcp_server.py,sha256=
|
|
16
|
+
mobile_mcp/mcp_tools/mcp_server.py,sha256=K53DSxKGT3B_f6mqaoTID2th5luBtwlh5MjoUDGbhl0,45263
|
|
16
17
|
mobile_mcp/utils/__init__.py,sha256=8EH0i7UGtx1y_j_GEgdN-cZdWn2sRtZSEOLlNF9HRnY,158
|
|
17
18
|
mobile_mcp/utils/logger.py,sha256=Sqq2Nr0Y4p03erqcrbYKVPCGiFaNGHMcE_JwCkeOfU4,3626
|
|
18
19
|
mobile_mcp/utils/xml_formatter.py,sha256=uwTRb3vLbqhT8O-udzWT7s7LsV-DyDUz2DkofD3hXOE,4556
|
|
19
20
|
mobile_mcp/utils/xml_parser.py,sha256=QhL8CWbdmNDzmBLjtx6mEnjHgMFZzJeHpCL15qfXSpI,3926
|
|
20
|
-
mobile_mcp_ai-2.
|
|
21
|
-
mobile_mcp_ai-2.
|
|
22
|
-
mobile_mcp_ai-2.
|
|
23
|
-
mobile_mcp_ai-2.
|
|
24
|
-
mobile_mcp_ai-2.
|
|
25
|
-
mobile_mcp_ai-2.
|
|
21
|
+
mobile_mcp_ai-2.5.0.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
|
|
22
|
+
mobile_mcp_ai-2.5.0.dist-info/METADATA,sha256=nbbNqfi6O5zgrLLGP0QgZdRCUKT_4iYHmySIx5jabJk,9745
|
|
23
|
+
mobile_mcp_ai-2.5.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
24
|
+
mobile_mcp_ai-2.5.0.dist-info/entry_points.txt,sha256=KB_FglozgPHBprSM1vFbIzGyheFuHFmGanscRdMJ_8A,68
|
|
25
|
+
mobile_mcp_ai-2.5.0.dist-info/top_level.txt,sha256=lLm6YpbTv855Lbh8BIA0rPxhybIrvYUzMEk9OErHT94,11
|
|
26
|
+
mobile_mcp_ai-2.5.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|