diff --git a/Apps/TeLaiDian/Crawler.py b/Apps/TeLaiDian/Crawler.py index 76c54a6..0551c98 100644 --- a/Apps/TeLaiDian/Crawler.py +++ b/Apps/TeLaiDian/Crawler.py @@ -7,7 +7,7 @@ import json import cv2 from Apps.TeLaiDian.Kit import ( take_screenshot, get_image_content_md5, clean_station_name, - setup_logger, read_image, save_image + setup_logger, read_image, save_image, detect_warm_popup_xczs_cv ) from Apps.TeLaiDian.ReadImageKit import ReadImageKit from Apps.TeLaiDian.Service import TeLaiDianService @@ -103,29 +103,18 @@ class TeLaiDianCrawler(BaseCrawler): popup_screen_path = take_screenshot(d, f"tld_detail_popup_{int(time.time())}.jpg") logger.info(f"[详情页] 截图用于检测温馨提示弹窗: {popup_screen_path}") - template_xczs = os.path.join(os.path.dirname(__file__), "Template", "xczs.jpg") - template_point = None - if os.path.exists(template_xczs): - try: - match_res = d.image.match(template_xczs) - if match_res: - if hasattr(match_res, "point") and match_res.point: - template_point = match_res.point - elif isinstance(match_res, dict): - if match_res.get("point"): - template_point = match_res["point"] - elif "x" in match_res and "y" in match_res: - template_point = (match_res["x"], match_res["y"]) - elif isinstance(match_res, (list, tuple)) and len(match_res) >= 2: - template_point = (match_res[0], match_res[1]) - if template_point: - logger.info(f"[详情页] 模板检测到疑似温馨提示弹窗位置: {template_point}") - else: - logger.info("[详情页] 模板未能确定温馨提示弹窗位置") - except Exception as e: - logger.error(f"[详情页] 模板检测温馨提示弹窗失败: {e}") + # 使用 OCR 探测“下次再说”按钮 (替代原来的模板匹配) + ocr_res = detect_warm_popup_xczs_cv(popup_screen_path) + ocr_point = None + if ocr_res: + w, h = d.window_size() + ocr_point = ( + int((ocr_res[0] + ocr_res[2]) / 2 * w / 1000), + int((ocr_res[1] + ocr_res[3]) / 2 * h / 1000) + ) + logger.info(f"[详情页] OCR 检测到“下次再说”按钮位置: {ocr_point}") else: - logger.info(f"[详情页] 温馨提示模板文件不存在: {template_xczs}") + logger.info("[详情页] OCR 未能检测到“下次再说”按钮") vlm_popup = {"has_popup": False} try: @@ -134,10 +123,9 @@ class TeLaiDianCrawler(BaseCrawler): logger.error(f"[详情页] VLM 检测温馨提示弹窗失败: {e}") has_vlm_popup = isinstance(vlm_popup, dict) and vlm_popup.get("has_popup") - if template_point and has_vlm_popup: - click_x = int(template_point[0]) - click_y = int(template_point[1]) - logger.info(f"[详情页] 模板与 VLM 均确认存在温馨提示弹窗,即将点击关闭按钮: ({click_x}, {click_y})") + if ocr_point and has_vlm_popup: + click_x, click_y = ocr_point + logger.info(f"[详情页] OCR 与 VLM 均确认存在温馨提示弹窗,即将点击“下次再说”按钮: ({click_x}, {click_y})") debug_popup_path = popup_screen_path.replace(".jpg", f"_xczs_click_{click_x}_{click_y}.jpg") try: img_popup = read_image(popup_screen_path) @@ -153,7 +141,7 @@ class TeLaiDianCrawler(BaseCrawler): except Exception as e: logger.error(f"[详情页] 点击温馨提示“下次再说”失败: {e}") else: - logger.info(f"[详情页] 温馨提示弹窗未通过双重确认,模板检测: {bool(template_point)} | VLM 检测: {vlm_popup}") + logger.info(f"[详情页] 温馨提示弹窗未通过双重确认,OCR检测: {bool(ocr_point)} | VLM 检测: {vlm_popup}") w, h = d.window_size() logger.info(f"执行显式下拉刷新操作: (x={w//2}, y1={int(h*0.3)} -> y2={int(h*0.8)})") @@ -162,10 +150,11 @@ class TeLaiDianCrawler(BaseCrawler): logger.info(f"等待 {WAIT_AFTER_SCROLL} 秒确保位置校准和列表刷新完成...") await asyncio.sleep(WAIT_AFTER_SCROLL) + max_to_crawl = 1 if FIRST_RUN_ONLY_ONE_STATION else MAX_STATIONS_COUNT processed_count = 0 last_md5 = None - while processed_count < MAX_STATIONS_COUNT: + while processed_count < max_to_crawl: # 1. 截图并分析 screenshot_path = take_screenshot(d, f"tld_list_{int(time.time())}.jpg") @@ -193,7 +182,7 @@ class TeLaiDianCrawler(BaseCrawler): continue for station in stations: - if processed_count >= MAX_STATIONS_COUNT: + if processed_count >= max_to_crawl: break name = station.get("name") @@ -209,7 +198,9 @@ class TeLaiDianCrawler(BaseCrawler): logger.info(f"跳过已处理场站 (Redis): {name}") continue - logger.info(f"处理场站: {name} (坐标: {point}, 距离: {station.get('distance')})") + current_idx = processed_count + 1 + remaining = max_to_crawl - current_idx + logger.info(f"--- [进度: {current_idx}/{max_to_crawl}, 剩余: {remaining}] 处理场站: {name} (坐标: {point}, 距离: {station.get('distance')}) ---") # 点击进入详情 d.click(point[0], point[1]) diff --git a/Apps/TeLaiDian/Kit.py b/Apps/TeLaiDian/Kit.py index bfaa3fe..1f9b767 100644 --- a/Apps/TeLaiDian/Kit.py +++ b/Apps/TeLaiDian/Kit.py @@ -188,6 +188,32 @@ def detect_price_info_container_cv(image_path): return None +def detect_warm_popup_xczs_cv(image_path): + """ + 使用 OCR 精准定位“温馨提示”弹窗中的“下次再说”按钮。 + 返回: [x1, y1, x2, y2] 归一化坐标,如果未找到则返回 None + """ + img = read_image(image_path) + if img is None: + return None + h, w = img.shape[:2] + + keyword = '下次再说' + + try: + reader = get_ocr_reader() + results = reader.read_text(img) + + for (quad, text, prob) in results: + if keyword in text and prob >= 0.5: + res = reader.get_normalized_rect(quad, w, h) + print(f"[OCR识别] 找到“下次再说”: '{text}', 置信度: {prob:.4f}, 归一化坐标: {res}") + return res + except Exception as e: + print(f"OCR 识别发生异常: {e}") + + return None + def setup_logger(name, log_file=None, clear_old_log=False): """ 配置日志,支持同时输出到控制台和文件。 diff --git a/Apps/TeLaiDian/Template/xczs.jpg b/Apps/TeLaiDian/Template/xczs.jpg deleted file mode 100644 index 4ab8964..0000000 Binary files a/Apps/TeLaiDian/Template/xczs.jpg and /dev/null differ diff --git a/Apps/TelaiDian/__pycache__/Crawler.cpython-310.pyc b/Apps/TelaiDian/__pycache__/Crawler.cpython-310.pyc index ef4f52a..649b302 100644 Binary files a/Apps/TelaiDian/__pycache__/Crawler.cpython-310.pyc and b/Apps/TelaiDian/__pycache__/Crawler.cpython-310.pyc differ diff --git a/Apps/TelaiDian/__pycache__/Kit.cpython-310.pyc b/Apps/TelaiDian/__pycache__/Kit.cpython-310.pyc index 7107044..f9ae26b 100644 Binary files a/Apps/TelaiDian/__pycache__/Kit.cpython-310.pyc and b/Apps/TelaiDian/__pycache__/Kit.cpython-310.pyc differ diff --git a/Apps/YeLiTe/Crawler.py b/Apps/YeLiTe/Crawler.py index f4a4a78..70b6e34 100644 --- a/Apps/YeLiTe/Crawler.py +++ b/Apps/YeLiTe/Crawler.py @@ -6,7 +6,7 @@ import sys import json import time from datetime import datetime -from Apps.YeLiTe.Kit import take_screenshot, clean_station_name, get_image_content_md5, find_template +from Apps.YeLiTe.Kit import take_screenshot, clean_station_name, get_image_content_md5, detect_price_info_container_cv from Apps.YeLiTe.ReadImageKit import ReadImageKit from Apps.YeLiTe.Service import YiLaiTeService from Apps.YeLiTe.Config.Setting import ( @@ -107,6 +107,7 @@ class YiLaiTeCrawler(BaseCrawler): name = station.get('name') address = station.get('address') + distance = station.get('distance') point = station.get('point') if not name or not point: @@ -182,7 +183,9 @@ class YiLaiTeCrawler(BaseCrawler): logger.info(f"成功进入详情页: {name}") # --- 新增:点击“阶段性电价”按钮以获取完整电价列表 --- - dqdf_pos = find_template(detail_shot, "dqdf.jpg") + # 使用 OCR 探测价格入口 + dqdf_pos = detect_price_info_container_cv(detail_shot) + detail_shots = [] if dqdf_pos: @@ -219,7 +222,7 @@ class YiLaiTeCrawler(BaseCrawler): # -------------------------------------------------- # 启动后台任务处理详情页 - task = asyncio.create_task(self.analyze_detail_background(name, detail_shots, address=address)) + task = asyncio.create_task(self.analyze_detail_background(name, detail_shots, address=address, distance=distance)) background_tasks.append(task) processed_count += 1 @@ -268,7 +271,7 @@ class YiLaiTeCrawler(BaseCrawler): return processed_count - async def analyze_detail_background(self, station_name, image_paths, address=None): + async def analyze_detail_background(self, station_name, image_paths, address=None, distance=None): """ 后台异步分析详情页 (支持多张截图合并) """ @@ -304,8 +307,8 @@ class YiLaiTeCrawler(BaseCrawler): unique_prices.sort(key=lambda x: x.get('start', '00:00')) if unique_prices: - await self.service.process_price_detail_data(station_name, unique_prices, address=address) - logger.info(f"场站 {station_name} 价格分析完成并入库 (记录数: {len(unique_prices)}, 地址: {address})") + await self.service.process_price_detail_data(station_name, unique_prices, address=address, distance=distance) + logger.info(f"场站 {station_name} 价格分析完成并入库 (记录数: {len(unique_prices)}, 地址: {address}, 距离: {distance})") else: logger.warning(f"场站 {station_name} 未识别到价格信息") diff --git a/Apps/YeLiTe/Kit.py b/Apps/YeLiTe/Kit.py index bdc2d95..68dd483 100644 --- a/Apps/YeLiTe/Kit.py +++ b/Apps/YeLiTe/Kit.py @@ -270,6 +270,45 @@ def draw_rectangles(image_path, points, output_path=None): logger.error(f"绘制矩形框失败: {e}") return image_path +from Util.EasyOcrKit import get_easyocr_reader + +# 预加载 EasyOCR Reader (单例模式) +def get_ocr_reader(): + return get_easyocr_reader(gpu=True) + +def detect_price_info_container_cv(image_path): + """ + 使用 OCR 精准定位详情页中的价格入口文本(“阶段性电价”)。 + 返回: [x1, y1, x2, y2] 绝对像素坐标,如果未找到则返回 None + """ + img = read_image(image_path) + if img is None: + return None + h, w = img.shape[:2] + + keywords = ['阶段性电价'] + + try: + reader = get_ocr_reader() + results = reader.read_text(img) + + for (quad, text, prob) in results: + if any(kw in text for kw in keywords) and prob >= 0.5: + pts = np.array(quad).astype(int) + x_min = np.min(pts[:, 0]) + y_min = np.min(pts[:, 1]) + x_max = np.max(pts[:, 0]) + y_max = np.max(pts[:, 1]) + + center_x = (x_min + x_max) // 2 + center_y = (y_min + y_max) // 2 + logger.info(f"[OCR识别] 找到文本: '{text}', 置信度: {prob:.4f}, 中心坐标: ({center_x}, {center_y})") + return (center_x, center_y) + except Exception as e: + logger.error(f"OCR 识别发生异常: {e}") + + return None + def setup_logger(name, log_file=None, clear_old_log=False): """ 配置日志,支持同时输出到控制台和文件。 diff --git a/Apps/YeLiTe/ReadImageKit.py b/Apps/YeLiTe/ReadImageKit.py index 9f2a901..0c51747 100644 --- a/Apps/YeLiTe/ReadImageKit.py +++ b/Apps/YeLiTe/ReadImageKit.py @@ -153,9 +153,10 @@ class ReadImageKit: 每个对象包含: - "name": 场站名称 (仅提取名称文字) - "address": 场站地址 (通常在名称下方,请完整提取地址文字) + - "distance": 距离 (如 "1.2km", "800m" 等,如果存在请提取) - "is_valid": true/false (是否为真实的场站卡片) - 注意:仅提取名称和地址,不要包含距离或价格。 + 注意:仅提取名称、地址和距离,不要包含价格。 """ else: # 如果 CV 没检测到,退回到纯 VLM 模式 @@ -165,6 +166,7 @@ class ReadImageKit: 输出格式为 JSON 数组,每个对象包含: - "name": 场站名称 - "address": 场站地址 (请仔细识别并提取场站的详细地址信息) + - "distance": 距离 (如 "1.2km", "800m" 等,如果存在请提取) - "point": 场站卡片的中心点击坐标 [x, y] - "bbox": 场站卡片的边界框 [x1, y1, x2, y2] """ @@ -187,6 +189,7 @@ class ReadImageKit: final_stations.append({ "name": res.get("name"), "address": res.get("address"), + "distance": res.get("distance"), "point": [(bbox[0] + bbox[2]) // 2, (bbox[1] + bbox[3]) // 2], "bbox": bbox }) diff --git a/Apps/YeLiTe/Service.py b/Apps/YeLiTe/Service.py index 681997c..ffa4d3a 100644 --- a/Apps/YeLiTe/Service.py +++ b/Apps/YeLiTe/Service.py @@ -90,16 +90,71 @@ class YiLaiTeService: except Exception as e: logger.error(f"更新任务结束日志失败: {e}") - async def process_price_detail_data(self, station_name, hourly_schedule, address=None) -> bool: + async def process_price_detail_data(self, station_name, prices, address=None, distance=None) -> bool: """ - 直接保存已处理好的小时段价格数据 + 保存场站数据:名称、地址、24小时价格计划、距离 """ - if not station_name or not hourly_schedule: + if not station_name or not prices: return False station_hash = self.get_hash(station_name) now = datetime.now() + # 1. 预处理价格:生成 24 小时的价格映射 + hourly_schedule = [None] * 24 + + for p in prices: + try: + start_str = p.get('start') or '00:00' + end_str = p.get('end') or '00:00' + + # 解析小时 + start_hour = int(start_str.split(':')[0]) + end_hour = int(end_str.split(':')[0]) + + # 处理 00:00 作为结束时间 + if end_hour == 0 and start_hour != 0: + end_hour = 24 + elif end_hour == 0 and start_hour == 0 and len(prices) == 1: + end_hour = 24 + + # 如果跨天 + if end_hour < start_hour: + end_hour += 24 + + price_val = self._to_float(p.get('price')) + + curr = start_hour + while curr < end_hour: + hour_idx = curr % 24 + hourly_schedule[hour_idx] = { + "start": f"{hour_idx:02d}:00", + "end": f"{(hour_idx + 1):02d}:00", + "price": price_val + } + curr += 1 + except Exception as e: + logger.error(f"解析价格时段失败: {p}, error: {e}") + + # 2. 兜底填充 (补全 24 小时) + last_valid = None + for item in hourly_schedule: + if item: + last_valid = item + break + + if last_valid: + for i in range(24): + if hourly_schedule[i] is None: + new_item = last_valid.copy() + new_item["start"] = f"{i:02d}:00" + new_item["end"] = f"{(i+1):02d}:00" + hourly_schedule[i] = new_item + else: + last_valid = hourly_schedule[i] + else: + logger.warning(f"场站 {station_name} 未能生成有效的 24 小时价格表") + async with await self.db.get_session() as session: # 1. 保存 Profile (如果不存在) profile_id = self.generate_id() @@ -122,7 +177,27 @@ class YiLaiTeService: schedule_json=hourly_schedule, valid_start_time=now ) + + # 3. 保存状态 (记录距离和当前价格) + current_hour = now.hour + current_price_info = hourly_schedule[current_hour] or {} + + status_id = self.generate_id() + await self.station_status_model.save( + session=session, + id=status_id, + station_hash=station_hash, + total_piles=None, + free_piles=None, + piles_detail_json=None, + current_price=current_price_info.get('price'), + distance=distance, + valid_start_time=now + ) + await session.commit() + + logger.info(f"成功保存驿来特场站数据: {station_name} | 距离: {distance} | 价格条数: {len([x for x in hourly_schedule if x])}") return True async def process_station_list_vl(self, image_path, json_metadata, device_info=None, max_count=None) -> list: diff --git a/Apps/YeLiTe/Template/dqdf.jpg b/Apps/YeLiTe/Template/dqdf.jpg deleted file mode 100644 index 424658f..0000000 Binary files a/Apps/YeLiTe/Template/dqdf.jpg and /dev/null differ diff --git a/Apps/YeLiTe/Template/nav.jpg b/Apps/YeLiTe/Template/nav.jpg deleted file mode 100644 index 51f123f..0000000 Binary files a/Apps/YeLiTe/Template/nav.jpg and /dev/null differ