diff --git a/Apps/AiTeJiYiChong/Crawler.py b/Apps/AiTeJiYiChong/Crawler.py index f2a94db..5c6d17e 100644 --- a/Apps/AiTeJiYiChong/Crawler.py +++ b/Apps/AiTeJiYiChong/Crawler.py @@ -23,7 +23,9 @@ from Apps.AiTeJiYiChong.Config.Setting import ( SCROLL_DISTANCE_RATIO, MAX_SCROLLS, REDIS_STATION_EXPIRE, WAIT_AFTER_SCROLL, - MAX_CRAWL_DISTANCE + MAX_CRAWL_DISTANCE, + SAFE_EXCLUDE_RATIO, + BOTTOM_SAFE_EXCLUDE_RATIO ) logger = logging.getLogger("AiTeJiYiChongCrawler") @@ -66,10 +68,14 @@ async def get_station_list(d, service, max_scrolls=MAX_SCROLLS): image_uuid = str(uuid.uuid4()) screenshot_path = take_screenshot(d, image_uuid, save_dir=TEMP_IMAGE_DIR) - # 检查是否已经滚动到底部 (截图内容与上次一致) - current_md5 = Kit.get_file_md5(screenshot_path) + # 检查是否已经滚动到底部 (排除状态栏后,内容与上次一致) + current_md5 = Kit.get_image_content_md5( + screenshot_path, + top_ratio=SAFE_EXCLUDE_RATIO, + bottom_ratio=BOTTOM_SAFE_EXCLUDE_RATIO + ) if last_list_md5 and current_md5 == last_list_md5: - logger.info("检测到列表截图内容无变化,已到达底部或加载完成,提前结束。") + logger.info("检测到列表核心内容无变化,已到达底部或加载完成,提前结束。") if os.path.exists(screenshot_path): os.remove(screenshot_path) break last_list_md5 = current_md5 @@ -123,41 +129,58 @@ async def get_station_list(d, service, max_scrolls=MAX_SCROLLS): task_detail = asyncio.create_task(detail_task_wrapper(detail_path, station_name)) background_tasks.append(task_detail) - # 4. 寻找 timePrice.jpg 图标并进入三级页面 - time_price_template = os.path.join(os.path.dirname(__file__), "BiaoShi", "timePrice.jpg") - coords = Kit.find_template_coords(detail_path, time_price_template) + # 4. 寻找分时价格入口并进入三级页面 + # 优先使用 VL 模型精准识别,避免误判导致退回微信 + res = await ReadImageKit.find_time_price_button_coordinate(detail_path, device_info=device_info) + pos = (res.get("uia_center_x"), res.get("uia_center_y")) if res.get("uia_center_x") else None - if coords: - cx, cy, conf = coords - logger.info(f"找到分时价格图标,进入三级页面...") - d.click(cx, cy) + entered_price_page = False + if pos and pos[0] is not None and pos[1] is not None: + ex, ey = pos + logger.info(f"找到 '分时价格' 按钮: ({ex}, {ey})") + + # 记录点击前的页面特征,用于验证是否成功进入三级页面 + before_click_md5 = Kit.get_image_content_md5(detail_path) + + d.click(ex, ey) await asyncio.sleep(2) - # 抓取三级页面图片 - price_screenshots = [] - price_detail_uuid = f"price_detail_{station_name}_{image_uuid}" - price_path = take_screenshot(d, price_detail_uuid, save_dir=TEMP_IMAGE_DIR) - price_screenshots.append(price_path) + # 检查页面是否真的变化了 + after_click_path = take_screenshot(d, f"check_{detail_uuid}", save_dir=TEMP_IMAGE_DIR) + after_click_md5 = Kit.get_image_content_md5(after_click_path) - # 滑动处理 - last_price_md5 = Kit.get_file_md5(price_path) - for scroll_idx in range(2): - d.swipe(w // 2, int(h * 0.7), w // 2, int(h * 0.3), duration=0.4) - await asyncio.sleep(1) - scroll_path = take_screenshot(d, f"price_scroll_{scroll_idx}_{station_name}", save_dir=TEMP_IMAGE_DIR) - curr_price_md5 = Kit.get_file_md5(scroll_path) - if curr_price_md5 == last_price_md5: break - price_screenshots.append(scroll_path) - last_price_md5 = curr_price_md5 + if before_click_md5 != after_click_md5: + entered_price_page = True + # 抓取三级页面图片 + price_screenshots = [] + price_screenshots.append(after_click_path) + + # 滑动处理 + last_price_md5 = after_click_md5 + for scroll_idx in range(2): + d.swipe(w // 2, int(h * 0.7), w // 2, int(h * 0.3), duration=0.4) + await asyncio.sleep(1) + scroll_path = take_screenshot(d, f"price_scroll_{scroll_idx}_{station_name}", save_dir=TEMP_IMAGE_DIR) + curr_price_md5 = Kit.get_image_content_md5( + scroll_path, + top_ratio=SAFE_EXCLUDE_RATIO, + bottom_ratio=BOTTOM_SAFE_EXCLUDE_RATIO + ) + if curr_price_md5 == last_price_md5: break + price_screenshots.append(scroll_path) + last_price_md5 = curr_price_md5 - # 后台处理价格图片 - logger.info(f"已启动后台分析价格页: {station_name},共 {len(price_screenshots)} 张图") - task_price = asyncio.create_task(analyze_prices_background(service, station_name, price_screenshots)) - background_tasks.append(task_price) - - # 立即返回 - d.press("back") - await asyncio.sleep(0.5) + # 后台处理价格图片 + logger.info(f"已启动后台分析价格页: {station_name},共 {len(price_screenshots)} 张图") + task_price = asyncio.create_task(analyze_prices_background(service, station_name, price_screenshots)) + background_tasks.append(task_price) + + # 立即返回 + d.press("back") + await asyncio.sleep(0.5) + else: + logger.warning(f"点击 '{station_name}' 的 '分时价格' 按钮后页面无明显变化,跳过三级页面处理。") + if os.path.exists(after_click_path): os.remove(after_click_path) else: logger.warning(f"场站 {station_name} 未找到分时价格入口。") diff --git a/Apps/AiTeJiYiChong/Kit.py b/Apps/AiTeJiYiChong/Kit.py index 24f7a87..e4e9f19 100644 --- a/Apps/AiTeJiYiChong/Kit.py +++ b/Apps/AiTeJiYiChong/Kit.py @@ -36,6 +36,29 @@ def get_file_md5(file_path): return hash_md5.hexdigest() +def get_image_content_md5(file_path, top_ratio=0.1, bottom_ratio=0.1): + """ + 计算图片核心内容的 MD5 值(排除状态栏和导航栏) + """ + img = read_image(file_path) + if img is None: + return None + + h, w = img.shape[:2] + top = int(h * top_ratio) + bottom = int(h * (1 - bottom_ratio)) + + # 裁剪中间部分 + content = img[top:bottom, :] + + # 将图片数据转换为字节流计算 MD5 + # 使用 cv2.imencode 转为 jpg 字节流,避免原始 numpy 数组可能的细微差异(虽然通常没问题) + success, encoded_img = cv2.imencode(".jpg", content) + if success: + return hashlib.md5(encoded_img.tobytes()).hexdigest() + return hashlib.md5(content.tobytes()).hexdigest() + + def read_image(path): """读取图片,支持中文路径""" if not path or not os.path.exists(path): diff --git a/Apps/AiTeJiYiChong/ReadImageKit.py b/Apps/AiTeJiYiChong/ReadImageKit.py index f57d598..2c5c735 100644 --- a/Apps/AiTeJiYiChong/ReadImageKit.py +++ b/Apps/AiTeJiYiChong/ReadImageKit.py @@ -21,6 +21,16 @@ from Apps.AiTeJiYiChong import Kit logger = logging.getLogger(__name__) +# 场站过滤黑名单 +STATION_EXCLUDED_TITLES = { + "推荐站点", "距离优先", "价格最低", "空闲较多", "筛选", "地图", "列表", + "我的订单", "收藏站点", "优惠券", "站内搜索" +} + +STATION_BLACKLIST_KEYWORDS = [ + "广告", "点击进入", "立即参与", "咨询电话", "活动详情", "导航到", "停车说明" +] + class ReadImageKit: _client = AsyncOpenAI( @@ -42,14 +52,17 @@ class ReadImageKit: " - 场站名称 (station_name);\n" " - 距离信息 (distance, 例如 '2.82km' 或 '90m'),通常位于卡片右侧蓝色胶囊区域内;\n" " - 金额/电费 (price,例如 '1.2500'),通常以红色字体显示;\n" - " - 充电枪信息 (piles,包含'快'或'慢'的类型、总枪数和空闲枪数,例如 '快 4/4')。\n" + " - **充电枪信息 (piles)**: 必须包含类型('快充'、'慢充'、'超充')、总枪数和空闲枪数。识别规则如下:\n" + " - 如果明确标有'快'、'慢'或'超',则对应为'快充'、'慢充'、'超充'。\n" + " - 如果图中只显示数字(如 '34/42')而没有明确标明类型,请统一识别为 `type: \"慢充\"`。\n" + " - 必须以 JSON 对象列表形式输出,例如:`[{\"type\": \"快充\", \"free\": 4, \"total\": 4}, {\"type\": \"慢充\", \"free\": 1, \"total\": 2}]`。\n" "3. 如果绿色方框内缺少上述任何一项要素,说明它不是真正的场站卡片,请直接跳过。\n" "\n" "JSON对象字段要求:\n" "1. b_use: 状态标识(1或0)。如果场站名称为灰色或带有“暂停使用”等标签,则为0,否则为1。\n" "2. station_name: 场站名称;\n" "3. price: 一度电的价格(数字,如 1.2500);\n" - "4. piles: 充电枪列表 [{type: '快', free: 4, total: 4}];\n" + "4. piles: 充电枪列表 [{type: '快'|'慢'|'超', free: 4, total: 4}];\n" "5. parking: 停车费用描述(通常在蓝色'P'图标后,例如 '免费停车三小时');\n" "6. distance: 距离信息字符串(例如 '2.82km' 或 '90m');\n" "7. bounds: {x1,y1,x2,y2} 区域像素坐标(0-1000);\n" @@ -303,15 +316,27 @@ class ReadImageKit: img_input = {"url": f"data:image/jpeg;base64,{encoded}"} prompt = ( - "请识别图片中的充电站卡片信息,并以 JSON 格式输出:\n" + "你是一个专业的充电桩信息提取助手。请识别图片中充电站卡片的核心信息。\n" + "注意以下识别规则:\n" + "1. **场站名称 (station_name)**: 场站名称通常位于卡片的第一行(左上角),字体最大且加粗。严禁提取'距离优先'、'空闲较多'、'价格最低'等筛选标签。严禁将卡片中下部的描述信息(如'导航到...'、'停车说明...'、'距您...'等)识别为名称。名称通常是一个具体的地点或品牌名。\n" + "2. **价格 (price)**: 提取数字部分,通常以'元/度'结尾,字体颜色通常为红色或加粗。\n" + "3. **桩数 (piles)**: 识别所有类型的充电桩。请提取'快'、'慢'或'超'字样及其对应的'空闲/总数'(如 '快 4/4')。\n" + " - 如果明确标有'快'、'慢'或'超',则对应为'快充'、'慢充'、'超充'。\n" + " - 如果图中只显示数字(如 '34/42')而没有明确标明类型,请统一识别为 `type: \"慢充\"`。\n" + " - 必须以 JSON 对象列表形式输出,例如:`[{\"type\": \"快充\", \"free\": 4, \"total\": 4}]`。\n" + "4. **距离 (distance)**: 提取如 '2.5km' 格式,通常在蓝色胶囊背景内。\n" + "5. **停车 (parking)**: 简要描述停车收费信息,通常在蓝色'P'图标后。\n\n" + "请以 JSON 格式输出:\n" "{\n" " \"station_name\": \"场站名称\",\n" - " \"price\": \"价格(数字)\",\n" - " \"piles\": \"空闲/总数(如 4/4)\",\n" - " \"distance\": \"距离(如 2.5km)\",\n" - " \"parking\": \"停车描述\"\n" + " \"price\": \"价格\",\n" + " \"piles\": [\n" + " {\"type\": \"快\", \"free\": 4, \"total\": 4}\n" + " ],\n" + " \"distance\": \"距离\",\n" + " \"parking\": \"停车信息\"\n" "}\n" - "只输出 JSON,不要有其他文字。" + "只输出 JSON,不要有任何解释文字。" ) try: @@ -410,6 +435,20 @@ class ReadImageKit: except: pass + # 3. 后置过滤 + if final_stations: + processed_excluded_titles = {str(x).replace(" ", "").strip() for x in STATION_EXCLUDED_TITLES} + filtered = [] + for item in final_stations: + name = item.get("station_name") + normalized_name = str(name).replace(" ", "").strip() + if normalized_name in processed_excluded_titles: + continue + if any(kw in normalized_name for kw in STATION_BLACKLIST_KEYWORDS): + continue + filtered.append(item) + final_stations = filtered + return final_stations @classmethod @@ -460,3 +499,55 @@ class ReadImageKit: except Exception as e: logger.error(f"Error calling VL model: {e}") return [] + + @classmethod + async def find_time_price_button_coordinate(cls, image_path: str, device_info=None): + """ + 使用 VL 模型定位详情页中的 '分时价格' 按钮 + """ + if not os.path.exists(image_path): + return {} + + with open(image_path, "rb") as f: + encoded = base64.b64encode(f.read()).decode("utf-8") + img_input = {"url": f"data:image/jpeg;base64,{encoded}"} + + prompt = ( + "请识别图片中 '分时价格' 或 '查看分时电价' 按钮的中心坐标。\n" + "该按钮通常位于充电站详情页,可能是一个带有价格图标或箭头的小型矩形区域。\n" + "请直接以 JSON 格式输出该按钮的中心点坐标:\n" + "{\"uia_center_x\": 500, \"uia_center_y\": 500}\n" + "注意:坐标系为 0-1000 归一化坐标,其中 (0,0) 为左上角,(1000,1000) 为右下角。\n" + "只输出 JSON,不要有任何解释文字。" + ) + + try: + resp = await cls._client.chat.completions.create( + model=VL_MODEL_NAME, + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": img_input} + ] + } + ], + max_tokens=100, + temperature=0.01 + ) + content = resp.choices[0].message.content.strip() + raw = cls._extract_json(content) + res = json.loads(raw) + + # 转换 0-1000 坐标到实际像素坐标 + if res.get("uia_center_x") is not None and device_info: + w = device_info.get("width", FALLBACK_WIDTH) + h = device_info.get("height", FALLBACK_HEIGHT) + res["uia_center_x"] = int(res["uia_center_x"] * w / 1000) + res["uia_center_y"] = int(res["uia_center_y"] * h / 1000) + + return res + except Exception as e: + logger.error(f"VL find_time_price_button_coordinate error: {e}") + return {} diff --git a/Apps/AiTeJiYiChong/Service.py b/Apps/AiTeJiYiChong/Service.py index 6253d01..733a9c9 100644 --- a/Apps/AiTeJiYiChong/Service.py +++ b/Apps/AiTeJiYiChong/Service.py @@ -36,6 +36,25 @@ class AiTeJiYiChongService: def get_hash(self, s: str) -> str: return hashlib.md5(s.encode('utf-8')).hexdigest() + def _to_float(self, value) -> float: + """ + 从字符串中提取浮点数,过滤掉非数字字符 + """ + if value is None: + return 0.0 + if isinstance(value, (int, float)): + return float(value) + + import re + try: + # 提取第一个匹配的数字部分(包含小数点) + match = re.search(r"[-+]?\d*\.?\d+", str(value)) + if match: + return float(match.group()) + return 0.0 + except: + return 0.0 + async def init_db(self): await self.db.init_db() @@ -163,10 +182,23 @@ class AiTeJiYiChongService: # 处理 piles 字段 piles_data = station.get("piles") total, free = 0, 0 + standardized_piles = [] if isinstance(piles_data, list): - for p in piles_data: - total += int(p.get("total", 0)) - free += int(p.get("free", 0)) + for idx, p in enumerate(piles_data): + try: + t = int(p.get("total", 0)) + f = int(p.get("free", 0)) + total += t + free += f + standardized_piles.append({ + "pile_no": f"G{idx+1}", + "type": p.get("type", "未知"), + "power": "", + "status_text": f"空闲{f}/总{t}", + "remark": "列表页VL" + }) + except: + pass await self.station_status_model.save( session=session, @@ -174,8 +206,8 @@ class AiTeJiYiChongService: station_hash=station_hash, total_piles=total, free_piles=free, - piles_detail_json=piles_data, - current_price=float(station.get("price", 0)) if station.get("price") else 0.0, + piles_detail_json=standardized_piles if standardized_piles else None, + current_price=self._to_float(station.get("price")), parking_info=station.get("parking", ""), distance=station.get("distance", ""), valid_start_time=now @@ -234,10 +266,23 @@ class AiTeJiYiChongService: # 处理 piles 字段 piles_data = station.get("piles") total, free = 0, 0 + standardized_piles = [] if isinstance(piles_data, list): - for p in piles_data: - total += int(p.get("total", 0)) - free += int(p.get("free", 0)) + for idx, p in enumerate(piles_data): + try: + t = int(p.get("total", 0)) + f = int(p.get("free", 0)) + total += t + free += f + standardized_piles.append({ + "pile_no": f"G{idx+1}", + "type": p.get("type", "未知"), + "power": "", + "status_text": f"空闲{f}/总{t}", + "remark": "列表页VL" + }) + except: + pass await self.station_status_model.save( session=session, @@ -245,8 +290,8 @@ class AiTeJiYiChongService: station_hash=station_hash, total_piles=total, free_piles=free, - piles_detail_json=piles_data, - current_price=float(station.get("price", 0)) if station.get("price") else 0.0, + piles_detail_json=standardized_piles if standardized_piles else None, + current_price=self._to_float(station.get("price")), parking_info=station.get("parking", ""), distance=station.get("distance", ""), valid_start_time=now diff --git a/Apps/AiTeJiYiChong/__pycache__/Crawler.cpython-310.pyc b/Apps/AiTeJiYiChong/__pycache__/Crawler.cpython-310.pyc index dd733f6..b2a2c11 100644 Binary files a/Apps/AiTeJiYiChong/__pycache__/Crawler.cpython-310.pyc and b/Apps/AiTeJiYiChong/__pycache__/Crawler.cpython-310.pyc differ diff --git a/Apps/AiTeJiYiChong/__pycache__/Kit.cpython-310.pyc b/Apps/AiTeJiYiChong/__pycache__/Kit.cpython-310.pyc index 477567e..39a0375 100644 Binary files a/Apps/AiTeJiYiChong/__pycache__/Kit.cpython-310.pyc and b/Apps/AiTeJiYiChong/__pycache__/Kit.cpython-310.pyc differ diff --git a/Apps/AiTeJiYiChong/__pycache__/ReadImageKit.cpython-310.pyc b/Apps/AiTeJiYiChong/__pycache__/ReadImageKit.cpython-310.pyc index 46ca2e7..100e953 100644 Binary files a/Apps/AiTeJiYiChong/__pycache__/ReadImageKit.cpython-310.pyc and b/Apps/AiTeJiYiChong/__pycache__/ReadImageKit.cpython-310.pyc differ diff --git a/Apps/AiTeJiYiChong/__pycache__/Service.cpython-310.pyc b/Apps/AiTeJiYiChong/__pycache__/Service.cpython-310.pyc index 5a6a5d5..5566fa3 100644 Binary files a/Apps/AiTeJiYiChong/__pycache__/Service.cpython-310.pyc and b/Apps/AiTeJiYiChong/__pycache__/Service.cpython-310.pyc differ diff --git a/Apps/XinDianTu/Crawler.py b/Apps/XinDianTu/Crawler.py index b4074c2..e02d1b9 100644 --- a/Apps/XinDianTu/Crawler.py +++ b/Apps/XinDianTu/Crawler.py @@ -30,7 +30,9 @@ from Apps.XinDianTu.Config.Setting import ( SCROLL_DISTANCE_RATIO, MAX_SCROLLS, REDIS_STATION_EXPIRE, WAIT_DETAIL_PAGE_LOAD, WAIT_BACK_TO_LIST, WAIT_AFTER_SCROLL, - MAX_CRAWL_DISTANCE + MAX_CRAWL_DISTANCE, + SAFE_EXCLUDE_RATIO, + BOTTOM_SAFE_EXCLUDE_RATIO ) # --- 用户配置区域 --- @@ -152,6 +154,7 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): logger.info(f"开始爬取列表,设备: {device_info.get('productName')} | 分辨率: {w}x{h}") background_tasks = [] + last_list_md5 = None for i in range(max_scrolls + 1): logger.info(f"正在处理第 {i + 1} 页...") @@ -159,6 +162,19 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): # 1. 拍摄截图 image_uuid = str(uuid.uuid4()) screenshot_path = take_screenshot(d, image_uuid, save_dir=TEMP_IMAGE_DIR) + + # 检查是否已经滚动到底部 (排除状态栏后,内容与上次一致) + current_md5 = Kit.get_image_content_md5( + screenshot_path, + top_ratio=SAFE_EXCLUDE_RATIO, + bottom_ratio=BOTTOM_SAFE_EXCLUDE_RATIO + ) + if last_list_md5 and current_md5 == last_list_md5: + logger.info("检测到列表核心内容无变化,已到达底部或加载完成,提前结束。") + if os.path.exists(screenshot_path): os.remove(screenshot_path) + break + last_list_md5 = current_md5 + logger.info(f"列表页截图已完成: {screenshot_path}") # 2. 执行本地图形学分析,生成 _vl.jpg 和 .json @@ -224,30 +240,51 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): # --- 抓取价格时段信息 --- # 识别 "全部时段" 按钮 - pos = Kit.find_expand_button_position(detail_path, debug_dir=TEMP_IMAGE_DIR, debug_filename_prefix=station_name) - if pos: + # 优先使用 VL 模型精准识别,避免几何特征误判导致退回微信 + res = await ReadImageKit.find_all_time_button_coordinate(detail_path, device_info=device_info) + pos = (res.get("uia_center_x"), res.get("uia_center_y")) if res.get("uia_center_x") else None + + entered_price_page = False + if pos and pos[0] is not None and pos[1] is not None: ex, ey = pos logger.info(f"找到 '全部时段' 按钮: ({ex}, {ey})") + + # 记录点击前的页面特征,用于验证是否成功进入三级页面 + before_click_md5 = Kit.get_image_content_md5(detail_path) + d.click(ex, ey) await asyncio.sleep(1.5) - - price_screenshots = [] - for p_idx in range(1, 4): - p_uuid = f"{hashlib.md5(station_name.encode('utf-8')).hexdigest()}_p_{p_idx}" - p_path = take_screenshot(d, p_uuid, save_dir=TEMP_IMAGE_DIR) - price_screenshots.append(p_path) - if p_idx < 3: - d.swipe_ext("up", scale=0.7) - await asyncio.sleep(1.0) - # 【异步优化】后台处理价格 - logger.info(f"已启动后台分析价格页: {station_name}") - task_price = asyncio.create_task(analyze_prices_background(service, station_name, price_screenshots)) - background_tasks.append(task_price) + # 检查页面是否真的变化了 + after_click_path = take_screenshot(d, f"check_{detail_uuid}", save_dir=TEMP_IMAGE_DIR) + after_click_md5 = Kit.get_image_content_md5(after_click_path) + + if before_click_md5 != after_click_md5: + entered_price_page = True + price_screenshots = [] + for p_idx in range(1, 4): + p_uuid = f"{hashlib.md5(station_name.encode('utf-8')).hexdigest()}_p_{p_idx}" + p_path = take_screenshot(d, p_uuid, save_dir=TEMP_IMAGE_DIR) + price_screenshots.append(p_path) + if p_idx < 3: + d.swipe_ext("up", scale=0.7) + await asyncio.sleep(1.0) + + # 【异步优化】后台处理价格 + logger.info(f"已启动后台分析价格页: {station_name}") + task_price = asyncio.create_task(analyze_prices_background(service, station_name, price_screenshots)) + background_tasks.append(task_price) - # 从三级页面返回 - d.press("back") - await asyncio.sleep(0.5) + # 从三级页面返回二级页面 + d.press("back") + await asyncio.sleep(0.5) + else: + logger.warning(f"点击 '{station_name}' 的 '全部时段' 按钮后页面无明显变化,跳过三级页面处理。") + + # 清理检查用的截图 + if os.path.exists(after_click_path): os.remove(after_click_path) + else: + logger.info(f"场站 {station_name} 未发现 '全部时段' 按钮。") # 从二级页面返回 d.press("back") diff --git a/Apps/XinDianTu/Kit.py b/Apps/XinDianTu/Kit.py index 3a9a161..a698dde 100644 --- a/Apps/XinDianTu/Kit.py +++ b/Apps/XinDianTu/Kit.py @@ -24,6 +24,28 @@ def read_image(path): return None +def get_image_content_md5(file_path, top_ratio=0.1, bottom_ratio=0.1): + """ + 计算图片核心内容的 MD5 值(排除状态栏和导航栏) + """ + img = read_image(file_path) + if img is None: + return None + + h, w = img.shape[:2] + top = int(h * top_ratio) + bottom = int(h * (1 - bottom_ratio)) + + # 裁剪中间部分 + content = img[top:bottom, :] + + # 将图片数据转换为字节流计算 MD5 + success, encoded_img = cv2.imencode(".jpg", content) + if success: + return hashlib.md5(encoded_img.tobytes()).hexdigest() + return hashlib.md5(content.tobytes()).hexdigest() + + def save_image(path, img): """保存图片,支持中文路径及鲁棒性检查""" try: diff --git a/Apps/XinDianTu/Opener.py b/Apps/XinDianTu/Opener.py index 427981b..de173ba 100644 --- a/Apps/XinDianTu/Opener.py +++ b/Apps/XinDianTu/Opener.py @@ -156,34 +156,14 @@ async def open_mini_program(): tab_chat.click() await asyncio.sleep(1) - # 1. 点击搜索按钮(放大镜图标) - # 仅保留坐标点击逻辑 - # window_size = d.window_size() - # w, h = window_size[0], window_size[1] - # - # # 精确相对坐标:x=84%, y=8% (基于截图推算) - # click_x = int(w * 0.84) - # click_y = int(h * 0.08) - # logger.info(f"使用精确坐标点击搜索按钮: ({click_x}, {click_y})") - # d.click(click_x, click_y) - - # 点击搜索按钮(放大镜图标) - logger.info("尝试查找并点击 '搜索按钮(放大镜图标)'...") - search_template = os.path.join(BASE_DIR, "Templates", "SearchButton.jpg") - if not os.path.exists(search_template): - search_template = os.path.join(BASE_DIR, "SearchButton.jpg") - - if click_image_template(d, search_template): - logger.info("点击了搜索按钮(放大镜图标)") - else: - logger.warning("未找到搜索按钮(放大镜图标),尝试兜底坐标点击...") - # 兜底点击:精确相对坐标:x=84%, y=8% (基于截图推算) - window_size = d.window_size() - w, h = window_size[0], window_size[1] - click_x = int(w * 0.84) - click_y = int(h * 0.08) - d.click(click_x, click_y) - logger.info(f"使用精确坐标点击搜索按钮: ({click_x}, {click_y})") + # 1. 直接使用坐标点击搜索按钮(放大镜图标) + logger.info("直接使用百分比坐标点击 '搜索按钮' (84%, 8%)...") + window_size = d.window_size() + w, h = window_size[0], window_size[1] + click_x = int(w * 0.84) + click_y = int(h * 0.08) + d.click(click_x, click_y) + logger.info(f"使用精确坐标点击搜索按钮: ({click_x}, {click_y})") # 点击后给予一定的加载时间 await asyncio.sleep(2) @@ -227,18 +207,11 @@ async def open_mini_program(): # logger.info(f"尝试坐标点击第一项: ({res_x}, {res_y})") # d.click(res_x, res_y) - # 点击符合新电途图标的小程序 - logger.info("尝试查找并点击 '新电途小程序'...") - xdt_template = os.path.join(BASE_DIR, "Templates", "xdt.jpg") - if click_image_template(d, xdt_template): - logger.info("点击了新电途的小程序") - else: - logger.warning("未找到新电途小程序图标,尝试坐标点击第一项...") - window_size = d.window_size() - w, h = window_size[0], window_size[1] - res_x, res_y = int(w * 0.5), int(h * 0.18) - d.click(res_x, res_y) - logger.info(f"坐标点击搜索结果第一项: ({res_x}, {res_y})") + # 3. 直接使用坐标点击搜索结果第一项 (50%, 18%) + logger.info("直接使用百分比坐标点击 '搜索结果第一项' (50%, 18%)...") + res_x, res_y = int(w * 0.5), int(h * 0.18) + d.click(res_x, res_y) + logger.info(f"坐标点击搜索结果第一项: ({res_x}, {res_y})") await asyncio.sleep(10) # 进入小程序后,检查并处理广告 diff --git a/Apps/XinDianTu/ReadImageKit.py b/Apps/XinDianTu/ReadImageKit.py index 0eebf83..e59c5dc 100644 --- a/Apps/XinDianTu/ReadImageKit.py +++ b/Apps/XinDianTu/ReadImageKit.py @@ -18,6 +18,17 @@ from Apps.XinDianTu.Config.Setting import ( logger = logging.getLogger(__name__) +# 场站过滤黑名单 +STATION_EXCLUDED_TITLES = { + "畅充会员", "组团充电", "微信社群", "申卡有礼", "夜间免停", + "洗手间", "不限车长", "不限车高", "组团", "综合排序", "距离优先", + "价格最低", "空闲较多", "筛选", "地图", "列表" +} + +STATION_BLACKLIST_KEYWORDS = [ + "广告", "优惠券", "点击进入", "立即参与", "咨询电话", "活动详情" +] + class ReadImageKit: _client = AsyncOpenAI( @@ -36,18 +47,18 @@ class ReadImageKit: "仅输出JSON数组(不含任何说明文字),按从左到右、从上到下的顺序识别图片中的充电站区域。识别规则如下:\n" "1. 必须是卡片形式的充电站信息区域。\n" "2. 每一个卡片必须同时具备以下所有要素,否则严禁识别:\n" - " - 场站名称 (station_name);\n" + " - **场站名称 (station_name)**: 场站名称通常位于卡片的第一行(左上角),字体最大且加粗。严禁提取'距离优先'、'空闲较多'、'价格最低'等筛选标签。严禁将卡片中下部的描述信息(如'导航到...'、'停车说明...'、'距您...'等)识别为名称。\n" " - 距离信息 (distance, 例如 '5.3km' 或 '90m'),位于卡片右上角;\n" " - 金额/电费 (price,例如 '0.8490');\n" - " - 充电枪信息 (piles,包含'超'、'快'或'慢'的类型、总枪数和空闲枪数,例如 '快 闲4/4')。\n" - "3. 如果缺少上述任何一项要素(例如只有名称和距离,但没有电费或枪数信息),说明它不是真正的场站卡片(可能是广告或功能入口),请直接跳过。\n" + " - **充电枪信息 (piles)**: 包含'超'、'快'或'慢'的类型、总枪数和空闲枪数(例如 '快 闲4/4')。如果图中只显示数字(如 '34/42')而没有明确标明类型,请统一识别为 `type: \"慢\"`。\n" + "3. 如果缺少上述任何一项要素(例如只有名称 and 距离,但没有电费或枪数信息),说明它不是真正的场站卡片(可能是广告或功能入口),请直接跳过。\n" "\n" "JSON对象字段要求:\n" "1. b_use: 状态标识(1或0)。如果场站名称为灰色或带有“暂停使用”等标签,则为0,否则为1。\n" "2. station_name: 场站名称;\n" "3. price: 一度电的价格(数字);\n" "4. pro_price: Pro会员价格(数字),无则为null;\n" - "5. piles: 充电枪列表 [{type: '快', free: 4, total: 4}];\n" + "5. piles: 充电枪列表 [{type: '快'|'慢'|'超', free: 4, total: 4}];\n" "6. parking: 停车费用描述(通常在'P'图标后,例如 '收费停车:以场站实际收费规则为准' 或 '限时免费停车...')。\n" "7. distance: 距离信息字符串(例如 '5.3km' 或 '90m')。\n" "8. bounds: {x1,y1,x2,y2} 区域像素坐标(0-1000);\n" @@ -454,7 +465,7 @@ class ReadImageKit: "1. station_name: 场站名称;\n" "2. price: 价格(数字);\n" "3. pro_price: Pro会员价(数字,无则null);\n" - "4. piles: 充电枪描述字符串(例如 '快 闲4/4');\n" + "4. piles: 充电枪列表 [{type: '快'|'慢'|'超', free: 4, total: 4}]。如果图中只显示数字(如 '34/42')而没有明确标明类型,请统一识别为 `type: \"慢\"`;\n" "5. tags: 标签列表(如 ['限时免费']);\n" "6. parking: 停车费用描述(通常在'P'图标后,例如 '收费停车:以场站实际收费规则为准' 或 '限时免费停车...')。\n" "7. distance: 距离信息字符串(例如 '5.3km')。\n" @@ -583,7 +594,6 @@ class ReadImageKit: return final_stations - @staticmethod @staticmethod def get_card_segments(image_path, output_dir="./Debug/Patches"): """ @@ -785,7 +795,18 @@ class ReadImageKit: "content": [ { "type": "text", - "text": "请识别图片中的充电站信息,并以 JSON 格式输出:{\"station_name\": \"...\", \"price\": \"...\", \"piles\": \"空闲数/总数\"}。只输出 JSON,不要有其他文字。" + "text": ( + "你是一个专业的充电桩信息提取助手。请识别图片中充电站卡片的核心信息。\n" + "注意以下识别规则:\n" + "1. **场站名称 (station_name)**: 场站名称通常位于卡片的第一行,字体较大且加粗。严禁提取'距离优先'、'空闲较多'、'价格最低'等筛选标签。严禁将卡片中间的长段描述信息识别为名称。\n" + "2. **价格 (price)**: 提取数字部分,通常以'元/度'结尾。\n" + "3. **桩数 (piles)**: 识别所有类型的充电桩。请提取'快'、'慢'或'超'字样及其对应的'空闲/总数'(如 '快 4/4')。\n" + " - 如果图中只显示数字(如 '34/42')而没有明确标明类型,请统一识别为 `type: \"慢\"`。\n" + " - 必须以列表形式输出所有识别到的类型。\n" + "请以 JSON 格式输出:\n" + "{\"station_name\": \"场站名称\", \"price\": \"价格\", \"piles\": [{\"type\": \"快\", \"free\": 4, \"total\": 4}]}\n" + "只输出 JSON,不要有任何解释文字。" + ) }, {"type": "image_url", "image_url": {"url": patch_url}} ] diff --git a/Apps/XinDianTu/Service.py b/Apps/XinDianTu/Service.py index abe30fc..663ee90 100644 --- a/Apps/XinDianTu/Service.py +++ b/Apps/XinDianTu/Service.py @@ -36,6 +36,25 @@ class XinDianTuService: def get_hash(self, s: str) -> str: return hashlib.md5(s.encode('utf-8')).hexdigest() + def _to_float(self, value) -> float: + """ + 从字符串中提取浮点数,过滤掉非数字字符 + """ + if value is None: + return 0.0 + if isinstance(value, (int, float)): + return float(value) + + import re + try: + # 提取第一个匹配的数字部分(包含小数点) + match = re.search(r"[-+]?\d*\.?\d+", str(value)) + if match: + return float(match.group()) + return 0.0 + except: + return 0.0 + async def init_db(self): await self.db.init_db() @@ -129,12 +148,7 @@ class XinDianTuService: }) price_str = station.get("price", "") - current_price = None - try: - price_match = re.search(r"\d+\.\d+", str(price_str)) - if price_match: - current_price = float(price_match.group()) - except: pass + current_price = self._to_float(price_str) await self.station_status_model.save( session=session, @@ -193,32 +207,47 @@ class XinDianTuService: # 2. 保存 Status (解析价格和电桩) status_id = self.generate_id() - piles_str = station.get("piles", "0/0") + + # 处理 piles 字段 (由 VL 返回的列表结构) + piles_data = station.get("piles") total, free = 0, 0 standardized_piles = [] - if "/" in piles_str: + if isinstance(piles_data, list): + for idx, p in enumerate(piles_data): + try: + t = int(p.get("total", 0)) + f = int(p.get("free", 0)) + total += t + free += f + + standardized_piles.append({ + "pile_no": f"G{idx+1}", + "type": p.get("type", "未知"), + "power": "", + "status_text": f"空闲{f}/总{t}", + "remark": "列表页VL" + }) + except: + pass + elif isinstance(piles_data, str) and "/" in piles_data: + # 兼容旧格式或意外返回的字符串格式 try: - parts = piles_str.split("/") - free = int(parts[0]) - total = int(parts[1]) + parts = piles_data.split("/") + free = int(re.search(r"\d+", parts[0]).group()) + total = int(re.search(r"\d+", parts[1]).group()) standardized_piles.append({ "pile_no": "G1", "type": "未知", "power": "", "status_text": f"空闲{free}/总{total}", - "remark": "列表页VL" + "remark": "列表页VL(兼容)" }) except: pass price_str = station.get("price", "") - current_price = None - try: - price_match = re.search(r"\d+\.\d+", str(price_str)) - if price_match: - current_price = float(price_match.group()) - except: pass + current_price = self._to_float(price_str) await self.station_status_model.save( session=session, @@ -299,20 +328,10 @@ class XinDianTuService: continue price = station.get("price") - current_price = None - if price is not None and price != "": - try: - current_price = float(price) - except (ValueError, TypeError): - current_price = None + current_price = self._to_float(price) if price is not None and price != "" else None pro_price_val = station.get("pro_price") - pro_price = None - if pro_price_val is not None and pro_price_val != "": - try: - pro_price = float(pro_price_val) - except (ValueError, TypeError): - pro_price = None + pro_price = self._to_float(pro_price_val) if pro_price_val is not None and pro_price_val != "" else None await self.station_status_model.save( session=session, @@ -414,44 +433,64 @@ class XinDianTuService: # 2. 保存 Status status_id = self.generate_id() - # 解析电桩数量 (支持多组,如 "快:2/10 慢:1/5") - piles_str = station_info.get("piles", "") + # 解析电桩数量 (支持由 VL 返回的列表或字符串) + piles_data = station_info.get("piles", []) total, free = 0, 0 standardized_piles = [] - # 尝试匹配 "快:2/10" 或 "2/10" 这种格式 - # 模式 1: 有类型,如 "快:2/10" - matches_with_type = re.findall(r"(\w+):(\d+)/(\d+)", piles_str) - if matches_with_type: - for idx, (p_type, f, t) in enumerate(matches_with_type): - f_int, t_int = int(f), int(t) - free += f_int - total += t_int - standardized_piles.append({ - "pile_no": f"G{idx+1}", - "type": p_type, - "power": "", - "status_text": f"空闲{f_int}/总{t_int}", - "remark": "列表页VL" - }) - else: - # 模式 2: 无类型,仅 "2/10" - matches_simple = re.findall(r"(\d+)/(\d+)", piles_str) - for idx, (f, t) in enumerate(matches_simple): - f_int, t_int = int(f), int(t) - free += f_int - total += t_int - standardized_piles.append({ - "pile_no": f"G{idx+1}", - "type": "未知", - "power": "", - "status_text": f"空闲{f_int}/总{t_int}", - "remark": "列表页VL" - }) + if isinstance(piles_data, list): + # 模式 1: VL 返回的结构化列表 + for idx, p in enumerate(piles_data): + if isinstance(p, dict): + try: + t = int(p.get("total", 0)) + f = int(p.get("free", 0)) + total += t + free += f + standardized_piles.append({ + "pile_no": f"G{idx+1}", + "type": p.get("type", "未知"), + "power": "", + "status_text": f"空闲{f}/总{t}", + "remark": "列表页VL" + }) + except: + pass + elif isinstance(piles_data, str): + # 模式 2: 字符串格式 (兼容) + piles_str = piles_data + # 尝试匹配 "快:2/10" 或 "2/10" 这种格式 + matches_with_type = re.findall(r"(\w+):(\d+)/(\d+)", piles_str) + if matches_with_type: + for idx, (p_type, f, t) in enumerate(matches_with_type): + f_int, t_int = int(f), int(t) + free += f_int + total += t_int + standardized_piles.append({ + "pile_no": f"G{idx+1}", + "type": p_type, + "power": "", + "status_text": f"空闲{f_int}/总{t_int}", + "remark": "列表页VL(兼容)" + }) + else: + # 模式 3: 无类型,仅 "2/10" + matches_simple = re.findall(r"(\d+)/(\d+)", piles_str) + for idx, (f, t) in enumerate(matches_simple): + f_int, t_int = int(f), int(t) + free += f_int + total += t_int + standardized_piles.append({ + "pile_no": f"G{idx+1}", + "type": "未知", + "power": "", + "status_text": f"空闲{f_int}/总{t_int}", + "remark": "列表页VL(兼容)" + }) - if not standardized_piles: + if not standardized_piles and isinstance(piles_data, str): # 兜底匹配所有数字 - nums = re.findall(r"(\d+)", piles_str) + nums = re.findall(r"(\d+)", piles_data) if len(nums) >= 2: free = int(nums[0]) total = int(nums[1]) @@ -463,17 +502,9 @@ class XinDianTuService: "remark": "列表页VL兜底" }) - # 解析价格 + # 解析价格 price_val = station_info.get("price") - current_price = None - if price_val: - try: - # 提取数字部分 - price_match = re.search(r"(\d+(\.\d+)?)", str(price_val)) - if price_match: - current_price = float(price_match.group(1)) - except: - pass + current_price = self._to_float(price_val) await self.station_status_model.save( session=session, diff --git a/Apps/XinDianTu/__pycache__/Crawler.cpython-310.pyc b/Apps/XinDianTu/__pycache__/Crawler.cpython-310.pyc index 4b4cf41..eb75772 100644 Binary files a/Apps/XinDianTu/__pycache__/Crawler.cpython-310.pyc and b/Apps/XinDianTu/__pycache__/Crawler.cpython-310.pyc differ diff --git a/Apps/XinDianTu/__pycache__/Kit.cpython-310.pyc b/Apps/XinDianTu/__pycache__/Kit.cpython-310.pyc index 5ef23a8..b395b42 100644 Binary files a/Apps/XinDianTu/__pycache__/Kit.cpython-310.pyc and b/Apps/XinDianTu/__pycache__/Kit.cpython-310.pyc differ diff --git a/Apps/XinDianTu/__pycache__/Opener.cpython-310.pyc b/Apps/XinDianTu/__pycache__/Opener.cpython-310.pyc index ca86a99..d23bbb3 100644 Binary files a/Apps/XinDianTu/__pycache__/Opener.cpython-310.pyc and b/Apps/XinDianTu/__pycache__/Opener.cpython-310.pyc differ diff --git a/Apps/XinDianTu/__pycache__/ReadImageKit.cpython-310.pyc b/Apps/XinDianTu/__pycache__/ReadImageKit.cpython-310.pyc index ac8642a..a7e1396 100644 Binary files a/Apps/XinDianTu/__pycache__/ReadImageKit.cpython-310.pyc and b/Apps/XinDianTu/__pycache__/ReadImageKit.cpython-310.pyc differ diff --git a/Apps/XinDianTu/__pycache__/Service.cpython-310.pyc b/Apps/XinDianTu/__pycache__/Service.cpython-310.pyc index 3d49a39..0096c3f 100644 Binary files a/Apps/XinDianTu/__pycache__/Service.cpython-310.pyc and b/Apps/XinDianTu/__pycache__/Service.cpython-310.pyc differ