'commit'
@@ -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
|
||||
# 默认回退屏幕高度,当无法自动获取设备信息时使用
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
"""
|
||||
|
||||
@@ -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}")
|
||||
BIN
Output/343c20d5-7d1e-42b4-8b85-0e807c415a44.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
Output/390da4d7-d390-42d7-8202-36f336228092.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
33
Output/390da4d7-d390-42d7-8202-36f336228092.json
Normal 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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
Output/390da4d7-d390-42d7-8202-36f336228092_flag.jpg
Normal file
|
After Width: | Height: | Size: 243 KiB |
BIN
Output/390da4d7-d390-42d7-8202-36f336228092_vl.jpg
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
Output/7c852b7b2488d43fafdb09f96700cb08_price_1.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
Output/7c852b7b2488d43fafdb09f96700cb08_price_2.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
Output/7c852b7b2488d43fafdb09f96700cb08_price_3.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
Output/Screenshot_20260112_082949.jpg
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
Output/Screenshot_20260112_083650.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 100 KiB |
BIN
Output/debug_ad_close_fail.jpg
Normal file
|
After Width: | Height: | Size: 229 KiB |
BIN
Output/debug_ad_close_x.jpg
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
Output/debug_agree_btn.jpg
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
Output/debug_bottom_circle.jpg
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
Output/特来电特来电广告产业园充电站_flag_expand.jpg
Normal file
|
After Width: | Height: | Size: 238 KiB |
@@ -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:
|
||||
|
||||