This commit is contained in:
HuangHai
2026-01-12 08:48:06 +08:00
parent 7b4548c12c
commit cf3e23d9b1
24 changed files with 150 additions and 31 deletions

View File

@@ -21,10 +21,10 @@ WAIT_BACK_TO_LIST = 1.5
WAIT_AFTER_SCROLL = 3.0
# 坐标计算与安全防护
# 屏幕顶部安全排除比例 (0.0~1.0),此比例区域内不进行点击(避开状态栏、筛选栏等)
SAFE_EXCLUDE_RATIO = 0.20
# 屏幕顶部安全排除比例 (0.0~1.0),此比例区域内不进行点击(避开状态栏、顶部菜单、横幅广告等)
SAFE_EXCLUDE_RATIO = 0.40
# 屏幕底部安全排除比例 (0.0~1.0),此比例区域内不进行点击(避开底部导航栏、功能按钮等)
BOTTOM_SAFE_EXCLUDE_RATIO = 0.1
BOTTOM_SAFE_EXCLUDE_RATIO = 0.12
# 默认回退屏幕宽度,当无法自动获取设备信息时使用
FALLBACK_WIDTH = 1080
# 默认回退屏幕高度,当无法自动获取设备信息时使用

View File

@@ -40,6 +40,23 @@ def take_screenshot(d, image_uuid, save_dir=TEMP_IMAGE_DIR):
return path
def clear_temp_dir(save_dir=TEMP_IMAGE_DIR):
"""清空临时目录中的所有文件"""
if not os.path.exists(save_dir):
return
logger.info(f"正在清空临时目录: {save_dir}")
for file in os.listdir(save_dir):
file_path = os.path.join(save_dir, file)
try:
if os.path.isfile(file_path):
os.remove(file_path)
elif os.path.isdir(file_path):
import shutil
shutil.rmtree(file_path)
except Exception as e:
logger.error(f"无法删除文件 {file_path}: {e}")
def click_image_template(d, template_path, timeout=5.0, threshold=0.8):
"""
使用 OpenCV 模板匹配查找并点击图片
@@ -118,6 +135,44 @@ def click_image_template(d, template_path, timeout=5.0, threshold=0.8):
return False
def is_background_dimmed(image_path, threshold=80):
"""
检测背景是否被暗色蒙板覆盖 (Image Mask Detection)
原理:计算屏幕四周边缘区域的平均亮度。如果边缘区域普遍较暗(蒙板效果),则返回 True。
:param image_path: 截图路径
:param threshold: 亮度阈值,低于此值认为是蒙板 (0-255)
:return: bool
"""
if not os.path.exists(image_path):
return False
img = read_image(image_path)
if img is None:
return False
h, w = img.shape[:2]
# 转换为灰度图计算亮度
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 定义边缘区域 (四周各取 5% 的宽度/高度)
edge_h = int(h * 0.05)
edge_w = int(w * 0.05)
# 提取四个角落/边缘块
top_edge = gray[0:edge_h, :]
bottom_edge = gray[h-edge_h:h, :]
left_edge = gray[:, 0:edge_w]
right_edge = gray[:, w-edge_w:w]
# 计算平均亮度
avg_brightness = (np.mean(top_edge) + np.mean(bottom_edge) + np.mean(left_edge) + np.mean(right_edge)) / 4
logger.info(f"Background Dimmed Check: Avg Brightness = {avg_brightness:.2f} (Threshold: {threshold})")
# 如果平均亮度低于阈值,说明背景被压暗了
return avg_brightness < threshold
def detect_black_agree_button(image_path, debug_dir=None):
"""
通过计算机图形学检测黑色的"同意"按钮 (Image 1 场景)
@@ -275,6 +330,13 @@ def detect_ad_close_x(image_path, template_path, debug_dir=None, threshold=0.7):
f"Ad Close Button Match: Confidence={max_val:.4f}, Scale={best_scale:.2f}, Pos=({center_x}, {center_y}), Rel=({rel_x:.2f}, {rel_y:.2f})")
is_valid_pos = True
# 针对底部中心的特殊逻辑:如果是底部中心位置,降低置信度阈值要求
is_in_bottom_center = (0.4 < rel_x < 0.6) and (rel_y > 0.65)
effective_threshold = threshold
if is_in_bottom_center:
effective_threshold = min(threshold, 0.6) # 底部中心位置允许 0.6 的置信度
logger.info(f"Bottom Center detected, lowering threshold to {effective_threshold}")
# 规则1: 过滤掉屏幕正中央偏上的区域 (通常是广告标题、图标或内容)
# 范围: X在 [0.3, 0.7] 且 Y在 [0.15, 0.5]
@@ -282,20 +344,23 @@ def detect_ad_close_x(image_path, template_path, debug_dir=None, threshold=0.7):
logger.warning(f"Ignored match at ({center_x}, {center_y}) - likely Ad Content/Title (Center-Top area).")
is_valid_pos = False
# 规则2: 如果置信度不是特别高 (>0.9), 强制要求在典型区域 (右上角 或 底部中间)
# 规则2: 如果置信度不是特别高 (>0.98), 强制要求在典型区域 (右上角 或 底部中间)
# 右上角: X > 0.7, Y < 0.5
# 底部中间: Y > 0.6
if is_valid_pos and max_val < 0.98: # 如果置信度非常高(0.98+)可能是极其标准的X暂时放过(除非在禁区)
if is_valid_pos and max_val < 0.98:
if not ((rel_x > 0.7 and rel_y < 0.5) or (rel_y > 0.6)):
logger.warning(
f"Ignored match at ({center_x}, {center_y}) - not in typical Close Button regions (Top-Right or Bottom).")
is_valid_pos = False
if max_val >= threshold:
if max_val >= effective_threshold:
if is_valid_pos:
# [Safety Check] 底部安全区排除
# 如果检测到的关闭按钮位于屏幕底部 BOTTOM_SAFE_EXCLUDE_RATIO 区域内,认为是误判(如误触底部功能按钮)
if center_y > (target_h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)):
# 特例:如果按钮在水平中心附近 (0.4 < rel_x < 0.6),且垂直方向在 0.96 以内,则允许点击 (针对 2025 年度账单等插屏广告)
is_in_bottom_center_safe = (0.4 < rel_x < 0.6) and (rel_y < 0.96)
if center_y > (target_h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)) and not is_in_bottom_center_safe:
logger.warning(
f"Ignored Ad Close Button at ({center_x}, {center_y}) - in Bottom Safety Zone ({int(BOTTOM_SAFE_EXCLUDE_RATIO * 100)}%).")
# 也可以保存一下调试图
@@ -380,10 +445,11 @@ def detect_bottom_close_circle(image_path, debug_dir=None):
h, w = img.shape[:2]
# 1. 提取感兴趣区域 (ROI): 屏幕底部 25% (缩小范围,避开列表内容区)
roi_top = int(h * 0.75)
roi_h = h - roi_top
roi = img[roi_top:h, :]
# 1. 提取感兴趣区域 (ROI): 屏幕中下部 (避开顶部的搜索栏和底部的导航栏)
roi_top = int(h * 0.60)
roi_bottom = int(h * 0.90)
roi_h = roi_bottom - roi_top
roi = img[roi_top:roi_bottom, :]
# 转灰度
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
@@ -392,7 +458,7 @@ def detect_bottom_close_circle(image_path, debug_dir=None):
# 2. 霍夫圆变换检测圆形
# dp=1.2 (累加器分辨率), minDist=w/5 (圆心最小距离), param1=100 (Canny高阈值), param2=30 (圆心累加阈值), minR=w*0.04, maxR=w*0.1
# 缩小最大半径范围,防止识别到过大的按钮(如扫码充电按钮)
# 缩小最大半径范围,防止识别到过大的按钮
circles = cv2.HoughCircles(gray_blurred, cv2.HOUGH_GRADIENT, dp=1.2, minDist=w / 5,
param1=100, param2=30, minRadius=int(w * 0.04), maxRadius=int(w * 0.09))
@@ -407,14 +473,14 @@ def detect_bottom_close_circle(image_path, debug_dir=None):
global_cy = roi_top + cy
global_cx = cx
# 过滤1: 必须在水平中心附近 (容差 15%,收紧限制)
# 过滤1: 必须在水平中心附近 (容差 15%)
if abs(global_cx - w // 2) > (w * 0.15):
continue
# 过滤2: 垂直方向限制 (必须在屏幕 80% - 95% 之间)
# 这样可以避开位于 60%-80% 区域的列表项卡片
# 过滤2: 垂直方向限制 (必须在屏幕 65% - 88% 之间)
# 这样可以避开位于底部的导航栏 (0.9+) 和顶部的卡片
rel_y = global_cy / h
if rel_y < 0.80 or rel_y > 0.95:
if rel_y < 0.65 or rel_y > 0.88:
continue
dist = abs(global_cx - w // 2)
@@ -429,7 +495,12 @@ def detect_bottom_close_circle(image_path, debug_dir=None):
# [Safety Check] 底部安全区排除
# 如果检测到的圆形按钮位于屏幕底部 BOTTOM_SAFE_EXCLUDE_RATIO 区域内,认为是误判(如误触底部扫码充电等)
if cy > (h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)):
# 特例:如果按钮在水平中心附近 (0.4 < global_cx/w < 0.6),且垂直方向在 0.96 以内,则允许点击 (针对插屏广告)
rel_x = cx / w
rel_y = cy / h
is_in_bottom_center = (0.4 < rel_x < 0.6) and (rel_y < 0.96)
if cy > (h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)) and not is_in_bottom_center:
logger.warning(
f"Ignored Bottom Circle at ({cx}, {cy}) - in Bottom Safety Zone ({int(BOTTOM_SAFE_EXCLUDE_RATIO * 100)}%).")
return None

View File

@@ -8,7 +8,7 @@ import uuid
import uiautomator2 as u2
from Apps.XinDianTu.Kit import take_screenshot, detect_black_agree_button, detect_any_ad_close, \
detect_bottom_close_circle
detect_bottom_close_circle, is_background_dimmed
from Config.Config import TEMP_IMAGE_DIR
# pip install adbutils
@@ -33,6 +33,11 @@ async def check_and_close_ad(d):
save_dir = TEMP_IMAGE_DIR
screenshot_path = take_screenshot(d, image_uuid, save_dir=save_dir)
logger.info(f"Step [广告检测截图] 耗时: {time.time() - t1:.4f}s")
# 1.1 特征预检:检测背景蒙板
is_dimmed = is_background_dimmed(screenshot_path)
if is_dimmed:
logger.info("检测到背景变暗(蒙板),极大概率存在广告弹窗。")
# 2. 图形学检测方案 (Local CV)
t_cv = time.time()
@@ -55,7 +60,9 @@ async def check_and_close_ad(d):
if os.path.exists(template_dir):
logger.info(f"正在尝试多模板匹配关闭按钮: {template_dir}")
close_pos = detect_any_ad_close(screenshot_path, template_dir, debug_dir=save_dir)
# 如果检测到蒙板,可以适当调低匹配阈值
match_threshold = 0.6 if is_dimmed else 0.7
close_pos = detect_ad_close_x_with_threshold(screenshot_path, template_dir, save_dir, match_threshold)
if close_pos:
x, y = close_pos
@@ -78,17 +85,27 @@ async def check_and_close_ad(d):
os.remove(screenshot_path)
return True
logger.info(f"本地图形学检测完成,未发现已知广告。耗时: {time.time() - t_cv:.4f}s")
logger.info(f"本地图形学检测完成,未发现已知广告。")
# [优化] 用户指示:回退到大模型检查不靠谱且浪费。
# 如果本地图形学方案黑按钮、X关闭都未命中则直接认为无广告或已进入正常页面。
# 清理本地截图
if os.path.exists(screenshot_path):
os.remove(screenshot_path)
return False
def detect_ad_close_x_with_threshold(screenshot_path, template_dir, debug_dir, threshold):
"""
带自定义阈值的多模板匹配
"""
for filename in os.listdir(template_dir):
if filename.startswith("ad_close") and filename.endswith(".jpg"):
template_path = os.path.join(template_dir, filename)
from Apps.XinDianTu.Kit import detect_ad_close_x
pos = detect_ad_close_x(screenshot_path, template_path, debug_dir=debug_dir, threshold=threshold)
if pos:
return pos
return None
async def open_mini_program():
"""

View File

@@ -56,9 +56,9 @@ class XinDianTuReadImageKit:
"11. station_name_bounds_norm: 场站名称文字归一化坐标(0-1)。\n"
"\n"
"重要约束(违反者不予识别):\n"
"A. 严禁识别广告位和筛选标签。如“夜间免停”、“洗手间”、“不限车长”、“不限车高”、“组团”、“综合排序”等均不是场站。\n"
"A. 严禁识别广告位和筛选标签。如“畅充会员”、“组团充电”、“微信社群”、“申卡有礼”、“夜间免停”、“洗手间”、“不限车长”、“不限车高”、“组团”、“综合排序”等均不是场站。\n"
"B. 真正的场站卡片必须是一个横跨屏幕的大卡片包含场站名称大号加粗、金额¥开头、距离km结尾、充电枪状态闲x/x\n"
"C. 严禁将屏幕中间的筛选标签误认为场站卡片\n"
"C. 严禁将屏幕顶部的功能图标(如畅充会员、签到等)和中间的筛选标签误认为场站卡片。区域太小或缺少要素的一律跳过\n"
"\n"
"严格返回纯JSON格式。"
)
@@ -407,11 +407,6 @@ class XinDianTuReadImageKit:
data[i]["uia_center_x"] = r.get("uia_center_x")
data[i]["uia_center_y"] = r.get("uia_center_y")
for item in data:
if isinstance(item, dict):
item["uia_center_x"] = 100
item.setdefault("uia_center_y", None)
return data
except Exception as e:
logger.error(f"Error parsing JSON: {e}")

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

View File

@@ -0,0 +1,33 @@
{
"image": "390da4d7-d390-42d7-8202-36f336228092.jpg",
"width": 720,
"height": 249,
"cards": [
{
"id": 1,
"rect": [
19,
772,
712,
1061
],
"click_point": [
122,
829
]
},
{
"id": 2,
"rect": [
19,
1075,
712,
1324
],
"click_point": [
122,
1124
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

BIN
Output/debug_ad_close_x.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

BIN
Output/debug_agree_btn.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

View File

@@ -10,7 +10,10 @@ def main():
print(f"🚀 正在启动新电途小程序爬虫...")
try:
from Apps.XinDianTu import Run
from Apps.XinDianTu import Run, Kit
# 启动前清空临时目录
Kit.clear_temp_dir()
asyncio.run(Run.main())
except ImportError as e: