This commit is contained in:
HuangHai
2026-01-14 10:40:29 +08:00
parent d4a0065f8a
commit 068cb63274
6 changed files with 151 additions and 46 deletions

View File

@@ -15,7 +15,7 @@ WAIT_BACK_TO_LIST = 1.5
WAIT_AFTER_SCROLL = 2.5
# 坐标计算与安全防护
SAFE_EXCLUDE_RATIO = 0.55 # 大幅增加排除比例,确保从过滤器下方开始识别
SAFE_EXCLUDE_RATIO = 0.58 # 增加比例确保完全跳过顶部的“充电礼”红色Banner和“PLUS会员/我的卡券”图标区
BOTTOM_SAFE_EXCLUDE_RATIO = 0.12
MIN_CARD_HEIGHT = 250 # 增加最小高度要求确保卡片信息完整特来电卡片较长约300px
DETAIL_SCROLL_DISTANCE_RATIO = 0.9 # 详情页滑动距离比例,确保露出价格按钮

View File

@@ -63,6 +63,12 @@ class TeLaiDianCrawler(BaseCrawler):
target_x = int(close_point[0] * w / 1000)
target_y = int(close_point[1] * h / 1000)
# 安全校验:绝对不能点击微信小程序的胶囊按钮区 (右上角)
# 胶囊按钮通常在 x > 80% 宽度 且 y < 100 像素(或 8% 高度) 的区域
if target_x > w * 0.75 and target_y < 150:
logger.warning(f"⚠️ 拒绝点击疑似微信胶囊按钮的区域: ({target_x}, {target_y})")
continue
logger.info(f"{i+1} 轮发现广告: {res.get('reason')},点击关闭: ({target_x}, {target_y})")
d.click(target_x, target_y)
await asyncio.sleep(1.5)
@@ -77,8 +83,15 @@ class TeLaiDianCrawler(BaseCrawler):
# 进入列表页先清理广告
await self.clear_ads(d)
# [优化] 向下滚动以刷新/校准地理位置
logger.info("执行下拉操作以刷新校准地理位置...")
d.swipe_ext("down", scale=0.6)
logger.info("等待 2 秒让位置校准生效...")
await asyncio.sleep(2.0)
processed_count = 0
last_md5 = None
processed_station_names = set() # 用于列表级去重
while processed_count < MAX_STATIONS_COUNT:
# 1. 截图并分析
@@ -94,6 +107,14 @@ class TeLaiDianCrawler(BaseCrawler):
stations = await self.read_image_kit.analyze_station_list(screenshot_path)
if not stations:
# 检查是否意外退出了小程序
is_wrong_page = await self.check_wrong_page(d, screenshot_path)
if is_wrong_page:
logger.error("检测到已退出详情列表页(可能回到了搜索页),尝试重新进入...")
await self.open_app()
await asyncio.sleep(5)
continue
logger.info("本页未检测到场站,尝试滑动...")
d.swipe_ext("up", scale=SCROLL_DISTANCE_RATIO)
await asyncio.sleep(WAIT_AFTER_SCROLL)
@@ -109,6 +130,10 @@ class TeLaiDianCrawler(BaseCrawler):
if not name or not point:
continue
if name in processed_station_names:
logger.info(f"跳过已处理场站: {name}")
continue
logger.info(f"处理场站: {name} (坐标: {point})")
# 点击进入详情
@@ -116,21 +141,25 @@ class TeLaiDianCrawler(BaseCrawler):
logger.info(f"已点击场站 '{name}',等待 {WAIT_DETAIL_PAGE_LOAD}s 加载详情页...")
await asyncio.sleep(WAIT_DETAIL_PAGE_LOAD)
# 截图验证是否进入详情页 (可选:可以加入更复杂的页面特征判断)
# 截图验证是否进入详情页
detail_check_path = take_screenshot(d, f"tld_detail_check_{int(time.time())}.jpg")
logger.info(f"详情页快照已保存: {detail_check_path}")
# 简单验证:如果标题包含 "我的卡券"、"优惠券" 等,说明点错了
# 简单验证:如果标题包含 "我的卡券"、"优惠券"、"新人福利" 等,说明点错了
is_wrong_page = await self.check_wrong_page(d, detail_check_path)
if is_wrong_page:
logger.warning(f"检测到进入了错误页面,尝试返回列表...")
# 尝试点击左上角的返回箭头,如果没有,则执行系统 back
d.click(40, 70) # 特来电通常左上角有返回箭头
await asyncio.sleep(1.0)
d.press("back")
await asyncio.sleep(WAIT_BACK_TO_LIST)
if os.path.exists(detail_check_path): os.remove(detail_check_path)
continue
# 爬取详情
await self.crawl_detail_logic(d, station)
processed_station_names.add(name)
# 返回列表
d.press("back")
@@ -149,26 +178,11 @@ class TeLaiDianCrawler(BaseCrawler):
"""
检查是否误触进入了错误的页面(如:我的卡券、活动页等)
"""
prompt = """
分析这张截图,判断这是否是一个“充电站详情页”。
特征:详情页通常包含场站名称、电价列表、电桩状态等。
如果页面显示的是“我的卡券”、“优惠券”、“个人中心”或“活动广告”,则不是详情页。
输出格式为 JSON
{
"is_detail_page": true/false,
"page_type": "detail" / "coupons" / "ad" / "other",
"reason": "判断依据"
}
"""
try:
res_text = await self.read_image_kit.vlm.analyze_image(image_path, prompt)
json_str = self.read_image_kit.vlm.extract_json(res_text)
data = json.loads(json_str)
return not data.get("is_detail_page", True)
except Exception as e:
logger.error(f"检查页面类型失败: {e}")
return False
data = await self.read_image_kit.check_wrong_page_vlm(image_path)
is_detail = data.get("is_detail_page", True)
if not is_detail:
logger.warning(f"⚠️ 确认进入错误页面: {data.get('page_type')} ({data.get('reason')})")
return not is_detail
async def crawl_detail_logic(self, d, station_info):
"""

View File

@@ -229,36 +229,48 @@ def detect_cards_cv(image_path, top_ratio=0.40, bottom_ratio=0.12):
return cards
def draw_rectangles(image_path, points, output_path=None):
def draw_rectangles(image_path, bboxes=None, click_points=None):
"""
使用 OpenCV 在图片上绘制矩形框
使用 OpenCV 在图片上绘制矩形框和点击点,生成 _vl.jpg 和 _flag.jpg
- _vl.jpg: 仅包含矩形框,供视觉模型参考
- _flag.jpg: 包含矩形框和点击点,供人工调试
"""
try:
from Apps.TeLaiDian.Config.Setting import DRAW_DEBUG_BOXES
# 这里暂时写死颜色和粗细,或者从 Setting 读取
DEBUG_BOX_COLOR = (0, 255, 0)
DEBUG_BOX_COLOR = (0, 255, 0) # 绿色矩形
DEBUG_POINT_COLOR = (0, 0, 255) # 红色点击点
DEBUG_BOX_THICKNESS = 3
# if not DRAW_DEBUG_BOXES: # 暂时强制开启以方便调试
# return image_path
img = read_image(image_path)
if img is None:
return image_path
for p in points:
if len(p) == 4:
cv2.rectangle(img, (int(p[0]), int(p[1])), (int(p[2]), int(p[3])), DEBUG_BOX_COLOR, DEBUG_BOX_THICKNESS)
elif len(p) == 2:
center = (int(p[0]), int(p[1]))
cv2.circle(img, center, 10, DEBUG_BOX_COLOR, -1)
save_path = output_path if output_path else image_path
save_image(save_path, img)
return save_path
# 1. 先绘制矩形框 (VL 版)
vl_img = img.copy()
if bboxes:
for box in bboxes:
if len(box) == 4:
cv2.rectangle(vl_img, (int(box[0]), int(box[1])), (int(box[2]), int(box[3])), DEBUG_BOX_COLOR, DEBUG_BOX_THICKNESS)
vl_path = image_path.replace(".jpg", "_vl.jpg")
save_image(vl_path, vl_img)
# 2. 在 VL 版基础上绘制点击点 (Flag 版)
flag_img = vl_img.copy()
if click_points:
for p in click_points:
if len(p) == 2:
center = (int(p[0]), int(p[1]))
# 绘制一个实心圆和中心点
cv2.circle(flag_img, center, 12, DEBUG_POINT_COLOR, -1)
cv2.circle(flag_img, center, 2, (255, 255, 255), -1)
flag_path = image_path.replace(".jpg", "_flag.jpg")
save_image(flag_path, flag_img)
return vl_path, flag_path
except Exception as e:
logger.error(f"绘制矩形框失败: {e}")
return image_path
logger.error(f"绘制诊断图片失败: {e}")
return image_path, image_path
def clear_temp_dir(save_dir=None):
"""清空临时目录中的所有文件"""

View File

@@ -43,6 +43,20 @@ class ReadImageKit:
res_text = await self.vlm.analyze_image(image_path, prompt)
json_str = self.vlm.extract_json(res_text)
data = json.loads(json_str)
# 保存诊断图片
if data.get("found") and data.get("point"):
p = data["point"]
# 转换坐标
img = read_image(image_path)
if img is not None:
h, w = img.shape[:2]
actual_p = [int(p[0] * w / 1000), int(p[1] * h / 1000)]
# 生成虚拟 bbox
bbox = [actual_p[0]-50, actual_p[1]-30, actual_p[0]+50, actual_p[1]+30]
draw_rectangles(image_path, bboxes=[bbox], click_points=[actual_p])
logger.info(f"已生成价格入口诊断图片: {image_path.replace('.jpg', '_vl.jpg')}")
return data
except Exception as e:
logger.error(f"VLM 寻找价格入口失败: {e}")
@@ -56,6 +70,10 @@ class ReadImageKit:
分析这张截图,判断是否存在弹窗广告或遮罩层。
如果存在,请找到关闭按钮(通常是圆圈里的 X或者写着“跳过”、“关闭”的按钮
**重要警告**
1. 严禁选择屏幕最右上角的按钮(微信小程序的“胶囊按钮”,包含三个点和圆圈)。
2. 广告关闭按钮通常在弹窗的边缘,或者是页面中心大弹窗的某个角落。
输出格式为 JSON
{
"has_ad": true/false,
@@ -67,11 +85,52 @@ class ReadImageKit:
res_text = await self.vlm.analyze_image(image_path, prompt)
json_str = self.vlm.extract_json(res_text)
data = json.loads(json_str)
# 保存诊断图片
if data.get("has_ad") and data.get("close_point"):
p = data["close_point"]
# 转换坐标
img = read_image(image_path)
if img is not None:
h, w = img.shape[:2]
actual_p = [int(p[0] * w / 1000), int(p[1] * h / 1000)]
# 生成虚拟 bbox
bbox = [actual_p[0]-50, actual_p[1]-30, actual_p[0]+50, actual_p[1]+30]
draw_rectangles(image_path, bboxes=[bbox], click_points=[actual_p])
logger.info(f"已生成广告关闭诊断图片: {image_path.replace('.jpg', '_vl.jpg')}")
return data
except Exception as e:
logger.error(f"VLM 寻找关闭按钮失败: {e}")
return {"has_ad": False}
async def check_wrong_page_vlm(self, image_path):
"""
检查是否误触进入了错误的页面(如:新人福利、我的卡券、活动页等)
"""
prompt = """
分析这张截图,判断这是否是一个真实的“充电站详情页”。
**识别准则**
1. 真正的“详情页”必须包含:充电站的具体名称、电价列表、终端状态(空闲/占用)等信息。
2. 如果页面标题是“新人福利专区”、“活动规则”、“我的卡券”、“领券中心”或类似的营销活动页,则判定为错误页面。
输出格式为 JSON
{
"is_detail_page": true/false,
"page_type": "detail" / "marketing" / "coupons" / "other",
"reason": "判断依据"
}
"""
try:
res_text = await self.vlm.analyze_image(image_path, prompt)
json_str = self.vlm.extract_json(res_text)
data = json.loads(json_str)
return data
except Exception as e:
logger.error(f"VLM 检查页面类型失败: {e}")
return {"is_detail_page": True} # 默认认为是详情页,避免死循环
async def analyze_detail_price(self, image_path):
"""
分析详情页截图提取电价信息包括优惠价、PLUS价和挂牌价
@@ -175,7 +234,7 @@ class ReadImageKit:
cv_bboxes = detect_cards_cv(image_path, top_ratio=SAFE_EXCLUDE_RATIO, bottom_ratio=BOTTOM_SAFE_EXCLUDE_RATIO)
if cv_bboxes:
draw_rectangles(image_path, cv_bboxes)
draw_rectangles(image_path, bboxes=cv_bboxes)
prompt = f"""
图片中已经用绿色矩形框标记了 {len(cv_bboxes)} 个可能的充电站卡片。
请按从上到下的顺序,识别每个绿色框内的场站信息。
@@ -237,13 +296,33 @@ class ReadImageKit:
# 2. 坐标安全过滤:忽略页面上半部分的误点击(通常是 Logo 或广告)
# 即使 VLM 没听话,我们也在这里硬性过滤
y_threshold = h * 0.4 if h > 0 else 800
# 根据主屏幕截图,顶部广告区和功能图标区占用了约 55% 的空间
y_threshold = h * 0.58 if h > 0 else 1400
if actual_p[1] < y_threshold:
logger.warning(f"过滤掉可能的误点击点 (y={actual_p[1]} < {y_threshold}): {res.get('name')}")
continue
res["point"] = actual_p
final_stations.append(res)
# 保存诊断图片 (_vl.jpg, _flag.jpg)
if final_stations:
debug_bboxes = []
debug_points = []
for s in final_stations:
if s.get("bbox"):
debug_bboxes.append(s["bbox"])
if s.get("point"):
debug_points.append(s["point"])
# 如果没有 bbox根据 point 生成一个虚拟 bbox 供可视化
if not debug_bboxes and debug_points:
for p in debug_points:
debug_bboxes.append([p[0]-50, p[1]-30, p[0]+50, p[1]+30])
if debug_bboxes or debug_points:
draw_rectangles(image_path, bboxes=debug_bboxes, click_points=debug_points)
logger.info(f"已生成诊断图片: {image_path.replace('.jpg', '_vl.jpg')} 和 _flag.jpg")
return final_stations
except Exception as e: