This commit is contained in:
HuangHai
2026-01-15 09:33:38 +08:00
parent b49dd9bc1b
commit fe27bc6884
6 changed files with 147 additions and 126 deletions

View File

@@ -1,9 +1,11 @@
# coding=utf-8
# 采集配置
SCROLL_DISTANCE_RATIO = 0.5
SCROLL_DISTANCE_RATIO = 0.3
MAX_STATIONS_COUNT = 3
FIRST_RUN_ONLY_ONE_STATION = False
# 场站去重过期时间(秒),在此时间内重复出现的场站不会再次点击进入详情页
REDIS_STATION_EXPIRE = 120
# 调试绘图配置
DRAW_DEBUG_BOXES = True
@@ -16,7 +18,7 @@ WAIT_BACK_TO_LIST = 1.5
WAIT_AFTER_SCROLL = 5.0
# 坐标计算与安全防护
SAFE_EXCLUDE_RATIO = 0.58
SAFE_EXCLUDE_RATIO = 0.4
BOTTOM_SAFE_EXCLUDE_RATIO = 0.12
MIN_CARD_HEIGHT = 250
DETAIL_SCROLL_DISTANCE_RATIO = 0.9

View File

@@ -14,9 +14,11 @@ from Apps.TeLaiDian.Service import TeLaiDianService
from Apps.TeLaiDian.Config.Setting import (
SCROLL_DISTANCE_RATIO, WAIT_AFTER_SCROLL, MAX_STATIONS_COUNT,
SAFE_EXCLUDE_RATIO, BOTTOM_SAFE_EXCLUDE_RATIO, WAIT_DETAIL_PAGE_LOAD,
WAIT_BACK_TO_LIST, DETAIL_SCROLL_DISTANCE_RATIO, FIRST_RUN_ONLY_ONE_STATION
WAIT_BACK_TO_LIST, DETAIL_SCROLL_DISTANCE_RATIO, FIRST_RUN_ONLY_ONE_STATION,
REDIS_STATION_EXPIRE
)
from Core.BaseCrawler import BaseCrawler
from Util.RedisKit import RedisKit
import uiautomator2 as u2
# 项目根目录处理
@@ -36,6 +38,7 @@ class TeLaiDianCrawler(BaseCrawler):
def __init__(self, service=None):
super().__init__(service or TeLaiDianService())
self.read_image_kit = ReadImageKit()
self.redis_kit = RedisKit()
async def start(self):
"""
@@ -166,7 +169,6 @@ class TeLaiDianCrawler(BaseCrawler):
processed_count = 0
last_md5 = None
processed_station_names = set() # 用于列表级去重
while processed_count < MAX_STATIONS_COUNT:
# 1. 截图并分析
@@ -205,8 +207,11 @@ class TeLaiDianCrawler(BaseCrawler):
if not name or not point:
continue
if name in processed_station_names:
logger.info(f"跳过已处理场站: {name}")
# [优化] 使用 Redis 进行跨运行去重
cleaned_name = clean_station_name(name)
redis_key = f"crawled:tld:{cleaned_name}"
if await self.redis_kit.get_data(redis_key):
logger.info(f"跳过已处理场站 (Redis): {name}")
continue
logger.info(f"处理场站: {name} (坐标: {point})")
@@ -234,7 +239,9 @@ class TeLaiDianCrawler(BaseCrawler):
# 爬取详情
await self.crawl_detail_logic(d, station)
processed_station_names.add(name)
# 标记为已处理
await self.redis_kit.set_data(redis_key, "1", ex=REDIS_STATION_EXPIRE)
d.press("back")
await asyncio.sleep(WAIT_BACK_TO_LIST)
@@ -270,19 +277,29 @@ class TeLaiDianCrawler(BaseCrawler):
first_screen_path = take_screenshot(d, f"tld_detail_basic_{int(time.time())}.jpg")
station_name = station_info.get("name")
address = station_info.get("address")
total_piles = None
free_piles = None
piles_detail = None
parking_info = None
logger.info(f"[详情页] 进入 crawl_detail_logic场站: {station_name} | 地址: {address}")
logger.info(f"[详情页] 已截取首屏截图,准备识别基础信息: {first_screen_path}")
try:
basic_info = await self.read_image_kit.analyze_detail_basic_info(first_screen_path)
if isinstance(basic_info, dict):
name2 = basic_info.get("name")
addr2 = basic_info.get("address")
if name2:
station_name = name2
if addr2:
address = addr2
logger.info(f"[详情页] 同步基础信息识别结果用于写库: {station_name} | {address}")
if basic_info.get("name"):
station_name = basic_info.get("name")
if basic_info.get("address"):
address = basic_info.get("address")
# 提取电桩信息
total_piles = basic_info.get("total_piles")
free_piles = basic_info.get("free_piles")
piles_detail = basic_info.get("piles_detail")
parking_info = basic_info.get("parking_info")
logger.info(f"[详情页] 基础信息识别结果: {station_name} | {address} | 桩数: {total_piles}/{free_piles} | 停车费: {parking_info}")
except Exception as ex:
logger.error(f"[详情页] 同步分析详情页基础信息失败: {ex}")
finally:
@@ -298,81 +315,94 @@ class TeLaiDianCrawler(BaseCrawler):
w, h = d.window_size()
# 1. 增加等待时间,确保页面加载完成
logger.info(f"[详情页] 等待 {WAIT_DETAIL_PAGE_LOAD + 1}s 确保页面稳定...")
await asyncio.sleep(WAIT_DETAIL_PAGE_LOAD + 1)
logger.info(f"[详情页] 等待 {WAIT_DETAIL_PAGE_LOAD}s 确保页面稳定...")
await asyncio.sleep(WAIT_DETAIL_PAGE_LOAD)
# 2. 向上滑动大一些,确保“全部时段”露出来
logger.info("[详情页] 执行大范围向上滑动 (y: 90% -> 10%),确保显示全部时段")
d.swipe(w // 2, int(h * 0.9), w // 2, int(h * 0.1), duration=1.0)
await asyncio.sleep(2.5) # 滑动后等待稳定
# --- [优化] 小步快跑滚动查找逻辑 ---
# 目标:通过多次小幅度滚动 + OCR 实时探测,精准捕获“全部时段”入口,避免滑过头
max_scroll_attempts = 6 # 最大滚动尝试次数
scroll_step_ratio = 0.3 # 每次滚动的步长(屏幕高度的 30%
found_entry = None
# 3. 截图并识别 (纯 OCR 流程)
final_screen_path = take_screenshot(d, f"tld_detail_ocr_input_{int(time.time())}.jpg")
logger.info(f"[详情页] 已截取 OCR 识别用图: {final_screen_path}")
logger.info(f"[详情页] 开始“小步快跑”滚动查找‘全部时段’入口 (最多 {max_scroll_attempts} 次)...")
for i in range(max_scroll_attempts):
# 1. 截图识别当前屏
curr_screen = take_screenshot(d, f"tld_scroll_ocr_{i}_{int(time.time())}.jpg")
# 2. 尝试 OCR 识别“全部时段”
entry_data = await self.read_image_kit.find_price_entrance_ocr(curr_screen)
if entry_data.get("found"):
logger.info(f"[详情页] 第 {i+1} 次尝试:成功探测到‘全部时段’入口!")
found_entry = {
"screen": curr_screen,
"point": entry_data["point"]
}
break
# 3. 如果没找到,小幅向上滚动一段距离
if i < max_scroll_attempts - 1:
logger.info(f"[详情页] 第 {i+1} 次尝试未找到,小幅向上滚动 (步长: {scroll_step_ratio*100}%)...")
d.swipe(w // 2, int(h * 0.7), w // 2, int(h * (0.7 - scroll_step_ratio)), duration=0.5)
await asyncio.sleep(1.5) # 滚动后短暂停留
# 清理过程截图
if os.path.exists(curr_screen):
try: os.remove(curr_screen)
except: pass
entrance_clicked = False
try:
# 1. 使用 OCR 寻找顶部“价格”标签
tab_data = await self.read_image_kit.find_price_tab_ocr(final_screen_path)
tab_x, tab_y = None, None
if tab_data.get("found") and tab_data.get("point"):
p = tab_data["point"]
tab_x = int(p[0] * w / 1000)
tab_y = int(p[1] * h / 1000)
logger.info(f"[详情页] OCR 成功找到价格标签: 归一化{p} -> 像素({tab_x}, {tab_y})")
else:
tab_x = int(PRICE_TAB_X_NORM * w / 1000)
tab_y = int(PRICE_TAB_Y_NORM * h / 1000)
logger.warning(f"[详情页] OCR 未找到价格标签,使用固定坐标兜底: 像素({tab_x}, {tab_y})")
logger.info(f"[详情页] 正在点击价格标签...")
d.click(tab_x, tab_y)
await asyncio.sleep(2.5) # 点击标签后等待
price_tab_screen = take_screenshot(d, f"tld_detail_after_price_tab_{int(time.time())}.jpg")
logger.info(f"[详情页] 点击价格标签后的界面截图已保存: {price_tab_screen}")
# 2. 寻找并点击“全部时段”入口 (纯 OCR 识别)
entry_data = await self.read_image_kit.find_price_entrance_ocr(price_tab_screen)
entry_x, entry_y = None, None
if entry_data.get("found") and entry_data.get("point"):
p = entry_data["point"]
# 安全校验:严禁点击 y > 900 的区域,那是底部浮动条
if p[1] > 900:
logger.warning(f"[详情页] OCR 返回坐标 {p} 偏低 (疑似底部浮动条),尝试使用固定坐标兜底")
entry_x = int(PRICE_ENTRY_X_NORM * w / 1000)
entry_y = int(PRICE_ENTRY_Y_NORM * h / 1000)
if found_entry:
price_tab_screen = found_entry["screen"]
p = found_entry["point"]
# 1. 先点击顶部“价格”标签 (确保切到价格页,虽然滚动前可能已经点击,但这里做双保险)
# 先 OCR 找一次标签
tab_data = await self.read_image_kit.find_price_tab_ocr(price_tab_screen)
tab_x, tab_y = None, None
if tab_data.get("found"):
p_tab = tab_data["point"]
tab_x, tab_y = int(p_tab[0] * w / 1000), int(p_tab[1] * h / 1000)
logger.info(f"[详情页] 点击顶部价格标签: ({tab_x}, {tab_y})")
d.click(tab_x, tab_y)
await asyncio.sleep(1.0)
else:
entry_x = int(p[0] * w / 1000)
entry_y = int(p[1] * h / 1000)
logger.info(f"[详情页] OCR 成功找到价格入口: 归一化{p} -> 像素({entry_x}, {entry_y})")
else:
entry_x = int(PRICE_ENTRY_X_NORM * w / 1000)
entry_y = int(PRICE_ENTRY_Y_NORM * h / 1000)
logger.warning(f"[详情页] OCR 未找到价格入口,使用固定坐标兜底: 像素({entry_x}, {entry_y})")
# 按照用户要求:找不到文字时输出日志、截图并停止程序
fail_screen = take_screenshot(d, f"tld_ocr_tab_fail_{int(time.time())}.jpg")
logger.error(f"❌ [OCR失败] 在页面中未找到价格’标签文字!")
logger.error(f"❌ [OCR失败] 最终截图已保存至: {fail_screen}")
logger.error("❌ [OCR失败] 程序将停止运行,请检查页面内容或识别逻辑。")
sys.exit(1)
click_x = max(5, min(w - 5, entry_x))
click_y = max(5, min(h - 5, entry_y))
# 绘制诊断图
debug_click_path = price_tab_screen.replace(".jpg", f"_click_diag.jpg")
try:
# 2. 点击“全部时段”入口
entry_x = int(p[0] * w / 1000)
entry_y = int(p[1] * h / 1000)
# 安全校验
if entry_y > h * 0.9:
logger.warning(f"[详情页] 入口坐标偏低 ({entry_y}),可能在底部遮罩,尝试微调。")
# 绘制最终点击诊断图
debug_click_path = price_tab_screen.replace(".jpg", "_final_click.jpg")
img = read_image(price_tab_screen)
if img is not None:
# 绘制两个点击位置
cv2.circle(img, (tab_x, tab_y), 20, (0, 0, 255), -1)
cv2.circle(img, (click_x, click_y), 20, (0, 255, 0), -1)
cv2.circle(img, (entry_x, entry_y), 25, (0, 255, 0), -1)
save_image(debug_click_path, img)
logger.info(f"[详情页] 已生成 OCR 点击诊断图 (红点:价格标签, 绿点:入口): {debug_click_path}")
except Exception as e:
logger.error(f"[详情页] 生成诊断图片失败: {e}")
logger.info(f"[详情页] 已生成最终点击诊断图: {debug_click_path}")
logger.info(f"[详情页] 正在点击电价入口...")
d.click(click_x, click_y)
entrance_clicked = True
await asyncio.sleep(WAIT_DETAIL_PAGE_LOAD + 1)
logger.info(f"[详情页] 正在点击电价入口: ({entry_x}, {entry_y})")
d.click(entry_x, entry_y)
entrance_clicked = True
await asyncio.sleep(WAIT_DETAIL_PAGE_LOAD)
else:
# 按照用户要求:找不到文字时输出日志、截图并停止程序
fail_screen = take_screenshot(d, f"tld_ocr_fail_{int(time.time())}.jpg")
logger.error(f"❌ [OCR失败] 经过 {max_scroll_attempts} 次滚动仍未在页面中找到‘全部时段’文字!")
logger.error(f"❌ [OCR失败] 最终截图已保存至: {fail_screen}")
logger.error("❌ [OCR失败] 程序将停止运行,请检查页面内容或识别逻辑。")
sys.exit(1) # 停止程序
except Exception as e:
logger.error(f"[详情页] 识别或点击价格入口失败: {e}")
@@ -475,7 +505,15 @@ class TeLaiDianCrawler(BaseCrawler):
except:
pass
logger.info(f"✅ 场站 {station_name_clean} 共提取到 {len(all_prices)} 条价格信息,准备保存...")
await self.service.save_station_data(station_name_clean, address, all_prices)
await self.service.save_station_data(
station_name_clean,
address,
all_prices,
total_piles=total_piles,
free_piles=free_piles,
piles_detail=piles_detail,
parking_info=parking_info
)
else:
logger.warning(f"❌ 未能提取到任何价格信息,请检查页面识别逻辑")
if address:

View File

@@ -179,37 +179,6 @@ class ReadImageKit:
logger.warning(f"[OCR] 未能在顶部区域定位到‘价格’标签")
return {"found": False}
async def find_price_tab_ocr(self, image_path):
"""
使用 OCR 在详情页寻找顶部“价格”标签
"""
img = read_image(image_path)
if img is None:
return {"found": False}
h, w = img.shape[:2]
from Util.EasyOcrKit import get_easyocr_reader
reader = get_easyocr_reader(gpu=True)
# 顶部标签栏通常在屏幕上部 10%-30% 区域
# 我们先识别所有文字,然后过滤出“价格”
results = reader.read_text(img)
for (quad, text, prob) in results:
if "价格" in text and prob > 0.3:
# 获取中心点
rect = reader.get_normalized_rect(quad, w, h)
center_x = (rect[0] + rect[2]) // 2
center_y = (rect[1] + rect[3]) // 2
# 校验位置:必须在屏幕上半部分
if center_y < 500:
logger.info(f"[OCR] 找到顶部价格标签: '{text}', 置信度: {prob:.4f}, 坐标: [{center_x}, {center_y}]")
return {
"found": True,
"point": [center_x, center_y]
}
return {"found": False}
async def find_price_entrance_ocr(self, image_path):
"""
@@ -239,7 +208,8 @@ class ReadImageKit:
"point": [center_x, center_y]
}
logger.warning(f"[OCR] 未能在页面中定位到‘全部时段’")
# 记录警告日志
logger.warning(f"⚠️ [OCR识别失败] 在图片中未发现‘全部时段’关键字: {image_path}")
return {"found": False}
async def find_close_button_vlm(self, image_path):
@@ -424,18 +394,31 @@ class ReadImageKit:
async def analyze_detail_basic_info(self, image_path):
"""
分析详情页首屏截图,提取场站名称精确地址
分析详情页首屏截图,提取场站名称精确地址以及电桩状态信息
"""
prompt = """
分析这张充电站详情页首屏截图,提取:
1. 场站名称 (通常在页面中部,大字体)
2. 详细地址 (通常在名称下方或页面下半部分,伴有地址图标)
3. 电桩状态信息:寻找包含“快充”、“慢充”、“空闲”、“总数”或类似描述的区域。
- 提取快充桩的总数和空闲数
- 提取慢充桩的总数和空闲数
- 计算总桩数 (total_piles) 和总空闲数 (free_piles)
4. 停车费信息:寻找“停车费”、“停车减免”、“免费停车”等相关描述。
输出格式为 JSON
{
"name": "xxx充电站",
"address": "xxx省xxx市xxx区xxx路xxx号"
"address": "xxx省xxx市xxx区xxx路xxx号",
"total_piles": 10,
"free_piles": 5,
"piles_detail": [
{"type": "快充", "total": 6, "free": 3},
{"type": "慢充", "total": 4, "free": 2}
],
"parking_info": "前2小时免费之后5元/小时"
}
注意:如果无法确定某个数值,请设为 null。
"""
try:
res_text = await self.vlm.analyze_image(image_path, prompt)

View File

@@ -64,19 +64,16 @@ class TeLaiDianService:
logger.info(f"仅保存场站基础信息: {station_name}")
return True
async def save_station_data(self, station_name, address, prices):
async def save_station_data(self, station_name, address, prices, total_piles=None, free_piles=None, piles_detail=None, parking_info=None):
"""
保存场站及其价格数据到数据库
保存场站全量数据名称、地址、24小时价格计划、当前状态
"""
if not prices:
return False
station_hash = self.get_hash(station_name)
now = datetime.now()
# 将价格转换为 24 小时的 schedule 格式 (0-23)
# 每个小时存储一个包含多重价格的字典以记录优惠价、PLUS价和挂牌价
station_hash = hashlib.md5(f"{station_name}{address}".encode('utf-8')).hexdigest()
# 预处理价格:生成 24 小时的价格映射
hourly_schedule = [None] * 24
for p in prices:
try:
start_parts = p['start'].split(':')
@@ -142,9 +139,10 @@ class TeLaiDianService:
session=session,
id=status_id,
station_hash=station_hash,
total_piles=None, # 特来电暂时没抓取总桩数
free_piles=None,
piles_detail_json=None,
total_piles=total_piles,
free_piles=free_piles,
piles_detail_json=piles_detail,
parking_info=parking_info,
current_price=current_price_info.get('price'),
pro_price=current_price_info.get('plus_price'),
market_price=current_price_info.get('market_price'),
@@ -152,5 +150,5 @@ class TeLaiDianService:
)
await session.commit()
logger.info(f"成功保存场站数据: {station_name}")
logger.info(f"成功保存场站数据: {station_name} (桩数: {total_piles}, 空闲: {free_piles}, 停车费: {parking_info})")
return True