This commit is contained in:
HuangHai
2026-01-12 08:26:13 +08:00
parent 4a45b27638
commit 7b4548c12c
13 changed files with 471 additions and 449 deletions

View File

@@ -16,18 +16,21 @@ if project_root not in sys.path:
import uiautomator2 as u2 import uiautomator2 as u2
from Core.BaseCrawler import BaseCrawler from Core.BaseCrawler import BaseCrawler
from Util import Kit from Apps.XinDianTu import Kit
from Util.Kit import take_screenshot from Apps.XinDianTu.Kit import take_screenshot
from Util.ObsUtil import ObsUploader from Util.ObsUtil import ObsUploader
from Util.RedisKit import RedisKit from Util.RedisKit import RedisKit
from Util.PaddleOCRKit import get_ocr_kit from Util.PaddleOCRKit import get_ocr_kit
import cv2 import cv2
from Apps.XinDianTu.Service import XinDianTuService from Apps.XinDianTu.Service import XinDianTuService
from Config.Config import ( from Config.Config import (
OBS_TMP_PREFIX, CDN_DOMAIN, SCROLL_DISTANCE_RATIO, OBS_TMP_PREFIX, CDN_DOMAIN, TEMP_IMAGE_DIR
)
from Apps.XinDianTu.Config.Setting import (
SCROLL_DISTANCE_RATIO,
MAX_SCROLLS, REDIS_STATION_EXPIRE, MAX_SCROLLS, REDIS_STATION_EXPIRE,
WAIT_DETAIL_PAGE_LOAD, WAIT_BACK_TO_LIST, WAIT_AFTER_SCROLL, WAIT_DETAIL_PAGE_LOAD, WAIT_BACK_TO_LIST, WAIT_AFTER_SCROLL,
MAX_CRAWL_DISTANCE, TEMP_IMAGE_DIR MAX_CRAWL_DISTANCE
) )
# --- 用户配置区域 --- # --- 用户配置区域 ---
@@ -57,14 +60,15 @@ logger = logging.getLogger("StationList")
# 强制设置所有相关模块的日志级别为 INFO,防止被第三方库干扰 # 强制设置所有相关模块的日志级别为 INFO,防止被第三方库干扰
logging.getLogger("PaddleOCRKit").setLevel(logging.INFO) logging.getLogger("PaddleOCRKit").setLevel(logging.INFO)
logging.getLogger("Util.Kit").setLevel(logging.INFO)
logging.getLogger("OpenXinDianTu").setLevel(logging.INFO) logging.getLogger("OpenXinDianTu").setLevel(logging.INFO)
logging.getLogger("FullProcess").setLevel(logging.INFO) logging.getLogger("FullProcess").setLevel(logging.INFO)
class XinDianTuCrawler(BaseCrawler): class XinDianTuCrawler(BaseCrawler):
""" """
新电途小程序爬虫实现 新电途小程序爬虫实现
""" """
def __init__(self, service=None): def __init__(self, service=None):
super().__init__(service) super().__init__(service)
# 初始化配置参数 # 初始化配置参数
@@ -90,6 +94,7 @@ class XinDianTuCrawler(BaseCrawler):
async def crawl_detail(self, station_info): async def crawl_detail(self, station_info):
pass pass
async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
""" """
获取场站列表并处理翻页 获取场站列表并处理翻页
@@ -140,7 +145,8 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
logger.info(f"截图实际尺寸: {img_w}x{img_h}") logger.info(f"截图实际尺寸: {img_w}x{img_h}")
# 如果截图尺寸与窗口尺寸不符,更新 device_info 以适配坐标换算 # 如果截图尺寸与窗口尺寸不符,更新 device_info 以适配坐标换算
if img_w != w or img_h != h: if img_w != w or img_h != h:
logger.warning(f"检测到截图尺寸 ({img_w}x{img_h}) 与窗口尺寸 ({w}x{h}) 不一致,将以截图尺寸为准进行坐标换算。") logger.warning(
f"检测到截图尺寸 ({img_w}x{img_h}) 与窗口尺寸 ({w}x{h}) 不一致,将以截图尺寸为准进行坐标换算。")
device_info['width'] = img_w device_info['width'] = img_w
device_info['height'] = img_h device_info['height'] = img_h
except Exception as e: except Exception as e:
@@ -158,7 +164,8 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
vl_img_path = screenshot_path.replace(".jpg", "_vl.jpg") vl_img_path = screenshot_path.replace(".jpg", "_vl.jpg")
if not os.path.exists(json_path) or not os.path.exists(vl_img_path): if not os.path.exists(json_path) or not os.path.exists(vl_img_path):
logger.error(f"❌ Step [2/6] 失败: 未生成有效的 JSON 或 VL 图片 (JSON: {os.path.exists(json_path)}, VL: {os.path.exists(vl_img_path)})") logger.error(
f"❌ Step [2/6] 失败: 未生成有效的 JSON 或 VL 图片 (JSON: {os.path.exists(json_path)}, VL: {os.path.exists(vl_img_path)})")
logger.warning("可能未识别到任何卡片,跳过当前页") logger.warning("可能未识别到任何卡片,跳过当前页")
continue continue
@@ -232,7 +239,8 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
"tags": parsed_data.get("tags", []), "tags": parsed_data.get("tags", []),
"parking_info": parsed_data.get("parking", "") "parking_info": parsed_data.get("parking", "")
} }
logger.info(f"✅ 成功解析场站: {station_info['station_name']} | 距离: {station_info['distance']}") logger.info(
f"✅ 成功解析场站: {station_info['station_name']} | 距离: {station_info['distance']}")
# 立即记录场站基础状态信息 (电桩、价格等) # 立即记录场站基础状态信息 (电桩、价格等)
try: try:
@@ -247,7 +255,8 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
except Exception as e: except Exception as e:
logger.error(f"❌ Local OCR processing failed: {e}", exc_info=True) logger.error(f"❌ Local OCR processing failed: {e}", exc_info=True)
logger.info(f"Step [5/6] 本地 OCR 解析完成 (总耗时: {time.time() - t_ocr_total:.2f}s) | 成功识别: {len(stations)}/{len(json_metadata.get('cards', []))}") logger.info(
f"Step [5/6] 本地 OCR 解析完成 (总耗时: {time.time() - t_ocr_total:.2f}s) | 成功识别: {len(stations)}/{len(json_metadata.get('cards', []))}")
if not stations: if not stations:
logger.warning("当前页面未检测到任何有效的场站信息") logger.warning("当前页面未检测到任何有效的场站信息")
@@ -304,7 +313,8 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
dist_log_part = f" (解析: {dist_km:.2f}km / 限: {MAX_CRAWL_DISTANCE}km)" dist_log_part = f" (解析: {dist_km:.2f}km / 限: {MAX_CRAWL_DISTANCE}km)"
if dist_km >= MAX_CRAWL_DISTANCE: if dist_km >= MAX_CRAWL_DISTANCE:
logger.info(f"当前场站 {name} 距离 {dist_km}km (原始: {distance_str}) 已达到或超过限制 {MAX_CRAWL_DISTANCE}km。") logger.info(
f"当前场站 {name} 距离 {dist_km}km (原始: {distance_str}) 已达到或超过限制 {MAX_CRAWL_DISTANCE}km。")
max_distance_reached = True max_distance_reached = True
except Exception as e: except Exception as e:
logger.warning(f"解析距离出错: {distance_str}, {e}") logger.warning(f"解析距离出错: {distance_str}, {e}")
@@ -312,7 +322,8 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
pro_info = f" | PRO会员价: {pro_price}" if pro_price else "" pro_info = f" | PRO会员价: {pro_price}" if pro_price else ""
# 确保距离始终显示,如果为空则显示 "未知" # 确保距离始终显示,如果为空则显示 "未知"
display_distance = distance_str if distance_str else "未知" display_distance = distance_str if distance_str else "未知"
logger.info(f"正在处理第 {s_idx+1} 个场站: {name} | 价格: {price}{pro_info} | 枪: {piles_str} | 距离: {display_distance}{dist_log_part}") logger.info(
f"正在处理第 {s_idx + 1} 个场站: {name} | 价格: {price}{pro_info} | 枪: {piles_str} | 距离: {display_distance}{dist_log_part}")
if max_distance_reached: if max_distance_reached:
break break
@@ -348,11 +359,13 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
if up_success: if up_success:
detail_url = f"https://{CDN_DOMAIN}/{detail_object_key}" detail_url = f"https://{CDN_DOMAIN}/{detail_object_key}"
# 3. 解析并保存地址 (异步后台处理) # 3. 解析并保存地址 (异步后台处理)
# 定义异步任务函数 # 定义异步任务函数
async def _process_address_task(task_name, task_url, task_dev_info, task_redis_key): async def _process_address_task(task_name, task_url, task_dev_info, task_redis_key):
t_addr_task = time.time() t_addr_task = time.time()
try: try:
addr_res = await service.process_station_address(task_name, task_url, device_info=task_dev_info) addr_res = await service.process_station_address(task_name, task_url,
device_info=task_dev_info)
logger.info(f"Step [解析并保存地址-后台] 耗时: {time.time() - t_addr_task:.4f}s") logger.info(f"Step [解析并保存地址-后台] 耗时: {time.time() - t_addr_task:.4f}s")
if addr_res.get("address"): if addr_res.get("address"):
logger.info(f"成功记录地址: {addr_res.get('address')}") logger.info(f"成功记录地址: {addr_res.get('address')}")
@@ -387,7 +400,8 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
# 尝试识别,将调试图片保存到 Images 目录 # 尝试识别,将调试图片保存到 Images 目录
t_find = time.time() t_find = time.time()
pos = Kit.find_expand_button_position(screenshot_path, debug_dir=save_dir, debug_filename_prefix=name) pos = Kit.find_expand_button_position(screenshot_path, debug_dir=save_dir,
debug_filename_prefix=name)
logger.info(f"Step [识别展开按钮] 耗时: {time.time() - t_find:.4f}s") logger.info(f"Step [识别展开按钮] 耗时: {time.time() - t_find:.4f}s")
# 清理截图 # 清理截图
@@ -435,7 +449,8 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
async def _process_price_task(task_name, task_urls, task_dev_info): async def _process_price_task(task_name, task_urls, task_dev_info):
t_price_task = time.time() t_price_task = time.time()
try: try:
await service.process_price_schedule(task_name, task_urls, device_info=task_dev_info) await service.process_price_schedule(task_name, task_urls,
device_info=task_dev_info)
logger.info(f"Step [解析电费时段数据-后台] 耗时: {time.time() - t_price_task:.4f}s") logger.info(f"Step [解析电费时段数据-后台] 耗时: {time.time() - t_price_task:.4f}s")
logger.info(f"电费时段数据解析完成: {task_name}") logger.info(f"电费时段数据解析完成: {task_name}")
except Exception as e: except Exception as e:
@@ -491,6 +506,7 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
logger.info(f"任务结束,共采集到 {len(all_stations)} 个场站。停止原因: {stop_reason}") logger.info(f"任务结束,共采集到 {len(all_stations)} 个场站。停止原因: {stop_reason}")
return all_stations return all_stations
def clean_images_dir(): def clean_images_dir():
"""清理 Images 目录下除保留文件外的所有文件""" """清理 Images 目录下除保留文件外的所有文件"""
base_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.dirname(os.path.abspath(__file__))
@@ -517,6 +533,7 @@ def clean_images_dir():
logger.info(f"清理完成,共删除 {deleted_count} 个文件。") logger.info(f"清理完成,共删除 {deleted_count} 个文件。")
async def clean_redis_data(redis_kit): async def clean_redis_data(redis_kit):
"""清除 Redis 中的场站处理记录""" """清除 Redis 中的场站处理记录"""
if TEST_CLEAR_REDIS: if TEST_CLEAR_REDIS:
@@ -528,6 +545,7 @@ async def clean_redis_data(redis_kit):
else: else:
logger.info("未发现旧的场站处理记录") logger.info("未发现旧的场站处理记录")
async def main(service=None, do_cleanup=True): async def main(service=None, do_cleanup=True):
# 连接设备 # 连接设备
d = u2.connect() d = u2.connect()
@@ -572,6 +590,7 @@ async def main(service=None, do_cleanup=True):
if should_close_service: if should_close_service:
await service.close_db() await service.close_db()
if __name__ == "__main__": if __name__ == "__main__":
try: try:
asyncio.run(main()) asyncio.run(main())

View File

@@ -3,11 +3,13 @@ import os
import cv2 import cv2
import numpy as np import numpy as np
import time import time
from Config.Config import BOTTOM_SAFE_EXCLUDE_RATIO, TEMP_IMAGE_DIR from Apps.XinDianTu.Config.Setting import BOTTOM_SAFE_EXCLUDE_RATIO
from Config.Config import TEMP_IMAGE_DIR
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def read_image(path): def read_image(path):
"""读取图片,支持中文路径""" """读取图片,支持中文路径"""
try: try:
@@ -16,6 +18,7 @@ def read_image(path):
logger.info(f"Error reading image {path}: {e}") logger.info(f"Error reading image {path}: {e}")
return None return None
def save_image(path, img): def save_image(path, img):
"""保存图片,支持中文路径""" """保存图片,支持中文路径"""
try: try:
@@ -28,6 +31,7 @@ def save_image(path, img):
logger.error(f"Error saving image {path}: {e}") logger.error(f"Error saving image {path}: {e}")
return False return False
# 截图 # 截图
def take_screenshot(d, image_uuid, save_dir=TEMP_IMAGE_DIR): def take_screenshot(d, image_uuid, save_dir=TEMP_IMAGE_DIR):
path = f"{save_dir}/{image_uuid}.jpg" path = f"{save_dir}/{image_uuid}.jpg"
@@ -35,6 +39,7 @@ def take_screenshot(d, image_uuid, save_dir=TEMP_IMAGE_DIR):
d.screenshot(path) d.screenshot(path)
return path return path
def click_image_template(d, template_path, timeout=5.0, threshold=0.8): def click_image_template(d, template_path, timeout=5.0, threshold=0.8):
""" """
使用 OpenCV 模板匹配查找并点击图片 使用 OpenCV 模板匹配查找并点击图片
@@ -112,6 +117,7 @@ def click_image_template(d, template_path, timeout=5.0, threshold=0.8):
logger.info(f"Image not found after {timeout}s (Best confidence: {best_val_overall:.2f})") logger.info(f"Image not found after {timeout}s (Best confidence: {best_val_overall:.2f})")
return False return False
def detect_black_agree_button(image_path, debug_dir=None): def detect_black_agree_button(image_path, debug_dir=None):
""" """
通过计算机图形学检测黑色的"同意"按钮 (Image 1 场景) 通过计算机图形学检测黑色的"同意"按钮 (Image 1 场景)
@@ -194,6 +200,7 @@ def detect_black_agree_button(image_path, debug_dir=None):
return None return None
def detect_ad_close_x(image_path, template_path, debug_dir=None, threshold=0.7): def detect_ad_close_x(image_path, template_path, debug_dir=None, threshold=0.7):
""" """
通过模板匹配检测"关闭(X)"按钮 (Image 2 场景) 通过模板匹配检测"关闭(X)"按钮 (Image 2 场景)
@@ -264,7 +271,8 @@ def detect_ad_close_x(image_path, template_path, debug_dir=None, threshold=0.7):
rel_x = center_x / target_w rel_x = center_x / target_w
rel_y = center_y / target_h rel_y = center_y / target_h
logger.info(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})") logger.info(
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_valid_pos = True
@@ -279,7 +287,8 @@ def detect_ad_close_x(image_path, template_path, debug_dir=None, threshold=0.7):
# 底部中间: Y > 0.6 # 底部中间: Y > 0.6
if is_valid_pos and max_val < 0.98: # 如果置信度非常高(0.98+),可能是极其标准的X,暂时放过(除非在禁区) if is_valid_pos and max_val < 0.98: # 如果置信度非常高(0.98+),可能是极其标准的X,暂时放过(除非在禁区)
if not ((rel_x > 0.7 and rel_y < 0.5) or (rel_y > 0.6)): 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).") logger.warning(
f"Ignored match at ({center_x}, {center_y}) - not in typical Close Button regions (Top-Right or Bottom).")
is_valid_pos = False is_valid_pos = False
if max_val >= threshold: if max_val >= threshold:
@@ -287,12 +296,14 @@ def detect_ad_close_x(image_path, template_path, debug_dir=None, threshold=0.7):
# [Safety Check] 底部安全区排除 # [Safety Check] 底部安全区排除
# 如果检测到的关闭按钮位于屏幕底部 BOTTOM_SAFE_EXCLUDE_RATIO 区域内,认为是误判(如误触底部功能按钮) # 如果检测到的关闭按钮位于屏幕底部 BOTTOM_SAFE_EXCLUDE_RATIO 区域内,认为是误判(如误触底部功能按钮)
if center_y > (target_h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)): if center_y > (target_h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)):
logger.warning(f"Ignored Ad Close Button at ({center_x}, {center_y}) - in Bottom Safety Zone ({int(BOTTOM_SAFE_EXCLUDE_RATIO*100)}%).") logger.warning(
f"Ignored Ad Close Button at ({center_x}, {center_y}) - in Bottom Safety Zone ({int(BOTTOM_SAFE_EXCLUDE_RATIO * 100)}%).")
# 也可以保存一下调试图 # 也可以保存一下调试图
if debug_dir: if debug_dir:
os.makedirs(debug_dir, exist_ok=True) os.makedirs(debug_dir, exist_ok=True)
debug_img = target.copy() debug_img = target.copy()
cv2.rectangle(debug_img, top_left, (top_left[0] + best_w, top_left[1] + best_h), (0, 0, 128), 2) # Dark Red for Safety Ignored cv2.rectangle(debug_img, top_left, (top_left[0] + best_w, top_left[1] + best_h), (0, 0, 128),
2) # Dark Red for Safety Ignored
cv2.putText(debug_img, f"SAFETY IGNORED", cv2.putText(debug_img, f"SAFETY IGNORED",
(top_left[0], top_left[1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 128), 1) (top_left[0], top_left[1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 128), 1)
save_image(os.path.join(debug_dir, "debug_ad_close_safety_ignored.jpg"), debug_img) save_image(os.path.join(debug_dir, "debug_ad_close_safety_ignored.jpg"), debug_img)
@@ -315,7 +326,8 @@ def detect_ad_close_x(image_path, template_path, debug_dir=None, threshold=0.7):
if debug_dir: if debug_dir:
os.makedirs(debug_dir, exist_ok=True) os.makedirs(debug_dir, exist_ok=True)
debug_img = target.copy() debug_img = target.copy()
cv2.rectangle(debug_img, top_left, (top_left[0] + best_w, top_left[1] + best_h), (0, 165, 255), 2) # Orange for ignored cv2.rectangle(debug_img, top_left, (top_left[0] + best_w, top_left[1] + best_h), (0, 165, 255),
2) # Orange for ignored
cv2.putText(debug_img, f"IGNORED Pos", cv2.putText(debug_img, f"IGNORED Pos",
(top_left[0], top_left[1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 165, 255), 1) (top_left[0], top_left[1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 165, 255), 1)
save_image(os.path.join(debug_dir, "debug_ad_close_ignored.jpg"), debug_img) save_image(os.path.join(debug_dir, "debug_ad_close_ignored.jpg"), debug_img)
@@ -332,6 +344,7 @@ def detect_ad_close_x(image_path, template_path, debug_dir=None, threshold=0.7):
return None return None
def detect_any_ad_close(image_path, template_dir, debug_dir=None): def detect_any_ad_close(image_path, template_dir, debug_dir=None):
""" """
遍历模板目录下的所有 ad_close*.jpg 进行匹配 遍历模板目录下的所有 ad_close*.jpg 进行匹配
@@ -348,6 +361,7 @@ def detect_any_ad_close(image_path, template_dir, debug_dir=None):
return pos return pos
return None return None
def detect_bottom_close_circle(image_path, debug_dir=None): def detect_bottom_close_circle(image_path, debug_dir=None):
""" """
通过几何特征检测底部的圆形关闭按钮 (常见于插屏广告) 通过几何特征检测底部的圆形关闭按钮 (常见于插屏广告)
@@ -416,7 +430,8 @@ def detect_bottom_close_circle(image_path, debug_dir=None):
# [Safety Check] 底部安全区排除 # [Safety Check] 底部安全区排除
# 如果检测到的圆形按钮位于屏幕底部 BOTTOM_SAFE_EXCLUDE_RATIO 区域内,认为是误判(如误触底部扫码充电等) # 如果检测到的圆形按钮位于屏幕底部 BOTTOM_SAFE_EXCLUDE_RATIO 区域内,认为是误判(如误触底部扫码充电等)
if cy > (h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)): if cy > (h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)):
logger.warning(f"Ignored Bottom Circle at ({cx}, {cy}) - in Bottom Safety Zone ({int(BOTTOM_SAFE_EXCLUDE_RATIO*100)}%).") logger.warning(
f"Ignored Bottom Circle at ({cx}, {cy}) - in Bottom Safety Zone ({int(BOTTOM_SAFE_EXCLUDE_RATIO * 100)}%).")
return None return None
logger.info(f"Found Bottom Circle Button via Hough: ({cx}, {cy}), r={r}") logger.info(f"Found Bottom Circle Button via Hough: ({cx}, {cy}), r={r}")
@@ -432,6 +447,7 @@ def detect_bottom_close_circle(image_path, debug_dir=None):
return None return None
def find_expand_button_position(image_path, debug_dir=None, debug_filename_prefix=None): def find_expand_button_position(image_path, debug_dir=None, debug_filename_prefix=None):
""" """
通过几何特征识别"全部时段"按钮的位置 通过几何特征识别"全部时段"按钮的位置
@@ -584,6 +600,7 @@ def get_row_stats(gray):
return row_means, row_stds, edge_means return row_means, row_stds, edge_means
def crop_cards_from_image(img_path, output_dir=None, save_debug=True): def crop_cards_from_image(img_path, output_dir=None, save_debug=True):
""" """
从图片中裁剪场站卡片 从图片中裁剪场站卡片
@@ -831,7 +848,8 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True):
for (y1, y2), h in zip(temp_valid_segments, heights): for (y1, y2), h in zip(temp_valid_segments, heights):
if h < threshold_h: if h < threshold_h:
logger.info(f" Filtering out segment Y={y1}-{y2} (H={h}) because it's too short (Threshold={threshold_h:.1f}).") logger.info(
f" Filtering out segment Y={y1}-{y2} (H={h}) because it's too short (Threshold={threshold_h:.1f}).")
else: else:
final_cards.append((y1, y2, final_x1, final_x2)) final_cards.append((y1, y2, final_x1, final_x2))
logger.info(f" Card: Y={y1}-{y2}, X={final_x1}-{final_x2}, H={h}") logger.info(f" Card: Y={y1}-{y2}, X={final_x1}-{final_x2}, H={h}")

View File

@@ -1,14 +1,15 @@
# coding=utf-8 # coding=utf-8
import asyncio import asyncio
import logging import logging
import os
import time import time
import uuid import uuid
import os
import uiautomator2 as u2 import uiautomator2 as u2
from Util.Kit import take_screenshot, detect_black_agree_button, click_image_template, detect_ad_close_x, detect_any_ad_close, detect_bottom_close_circle
from Util.ObsUtil import ObsUploader from Apps.XinDianTu.Kit import take_screenshot, detect_black_agree_button, detect_any_ad_close, \
from Util.XinDianTuReadImageKit import XinDianTuReadImageKit detect_bottom_close_circle
from Config.Config import OBS_TMP_PREFIX, CDN_DOMAIN, TEMP_IMAGE_DIR from Config.Config import TEMP_IMAGE_DIR
# pip install adbutils # pip install adbutils
# 配置日志输出,方便调试和监控 # 配置日志输出,方便调试和监控

View File

@@ -12,8 +12,6 @@ if project_root not in sys.path:
from Apps.XinDianTu.Service import XinDianTuService from Apps.XinDianTu.Service import XinDianTuService
from Apps.XinDianTu import Opener from Apps.XinDianTu import Opener
from Apps.XinDianTu import Crawler from Apps.XinDianTu import Crawler
from Apps.XinDianTu import Coord as Y4_Coord
from Apps.XinDianTu import GetCookie as T2_GetAmapCookie
# 配置日志 # 配置日志
logging.basicConfig( logging.basicConfig(
@@ -60,35 +58,6 @@ async def main():
logger.error(f"❌ 步骤 2 执行异常: {e}") logger.error(f"❌ 步骤 2 执行异常: {e}")
return return
# 步骤 3: 补全坐标 (调用 Y4_Coord)
logger.info(">>> 步骤 3: 启动高德地图坐标回填任务...")
try:
# 首次尝试,开启 Cookie 检查
await Y4_Coord.main(check_cookie_first=True)
except Y4_Coord.CookieInvalidError:
logger.warning("⚠️ 检测到高德地图 Cookie 失效或缺失。")
logger.info(">>> 正在启动 Cookie 获取工具 (T2)...")
logger.info("请在弹出的浏览器窗口中完成登录,然后在控制台按回车确认。")
# 调用同步的 T2 工具
# 由于 T2 包含 input() 阻塞,且这是客户端脚本,直接调用即可
try:
# 传入 force=True 跳过“文件已存在”的询问,因为我们确定它无效
# run_in_executor 用于避免完全阻塞事件循环(虽然在这里影响不大)
loop = asyncio.get_event_loop()
success = await loop.run_in_executor(None, T2_GetAmapCookie.run_login_tool, True)
if success:
logger.info("✅ Cookie 更新成功,正在重新启动坐标回填任务...")
# 再次尝试执行,这次可以跳过检查(刚获取完)或者继续检查
await Y4_Coord.main(check_cookie_first=False)
else:
logger.error("❌ Cookie 获取失败或用户取消,跳过坐标回填步骤。")
except Exception as e:
logger.error(f"❌ 调用 T2 工具失败: {e}")
except Exception as e:
logger.error(f"❌ 步骤 3 执行异常: {e}")
finally: finally:
# 确保数据库连接关闭 # 确保数据库连接关闭
if service: if service:

View File

@@ -1,19 +1,17 @@
import hashlib import hashlib
import logging
import os import os
import re
import sys import sys
import uuid import uuid
import logging from datetime import datetime
import json
import re
from datetime import datetime, timedelta
# Ensure sys path includes root for imports if not already # Ensure sys path includes root for imports if not already
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if project_root not in sys.path: if project_root not in sys.path:
sys.path.append(project_root) sys.path.append(project_root)
from sqlalchemy import text from Apps.XinDianTu.XinDianTuReadImageKit import XinDianTuReadImageKit
from Util.XinDianTuReadImageKit import XinDianTuReadImageKit
from DbKit.Db import Db from DbKit.Db import Db
from Config.Config import DB_URL from Config.Config import DB_URL
from Model.StationProfile import StationProfile from Model.StationProfile import StationProfile

View File

@@ -9,13 +9,16 @@ import logging
import base64 import base64
from openai import OpenAI, BadRequestError from openai import OpenAI, BadRequestError
from Config.Config import ( from Config.Config import (
ALY_LLM_API_KEY, VL_MODEL_NAME, VL_MODEL_NAME_AD, ALY_LLM_API_KEY, VL_MODEL_NAME, VL_MODEL_NAME_AD
)
from Apps.XinDianTu.Config.Setting import (
SAFE_EXCLUDE_RATIO, FALLBACK_WIDTH, FALLBACK_HEIGHT, SAFE_EXCLUDE_RATIO, FALLBACK_WIDTH, FALLBACK_HEIGHT,
BOTTOM_SAFE_EXCLUDE_RATIO BOTTOM_SAFE_EXCLUDE_RATIO
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class XinDianTuReadImageKit: class XinDianTuReadImageKit:
_client = OpenAI( _client = OpenAI(
api_key=ALY_LLM_API_KEY, api_key=ALY_LLM_API_KEY,
@@ -152,16 +155,20 @@ class XinDianTuReadImageKit:
return min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2) return min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)
# 优先使用场站名称区域进行点击 (最安全) # 优先使用场站名称区域进行点击 (最安全)
coords = get_pixel_coords(text_bn, display_width, display_height) or get_pixel_coords(text_bounds, display_width, display_height) coords = get_pixel_coords(text_bn, display_width, display_height) or get_pixel_coords(text_bounds,
display_width,
display_height)
if coords: if coords:
x1, y1, x2, y2 = coords x1, y1, x2, y2 = coords
uia_x = int(x1 + (x2 - x1) / 2) uia_x = int(x1 + (x2 - x1) / 2)
uia_y = int(y1 + (y2 - y1) / 2) uia_y = int(y1 + (y2 - y1) / 2)
logger.info(f"坐标计算: 使用文字区域 -> ({uia_x}, {uia_y}) | 区域: {coords} | 屏幕: {display_width}x{display_height}") logger.info(
f"坐标计算: 使用文字区域 -> ({uia_x}, {uia_y}) | 区域: {coords} | 屏幕: {display_width}x{display_height}")
# 备选:使用整个卡片区域 # 备选:使用整个卡片区域
if uia_x is None: if uia_x is None:
coords = get_pixel_coords(bn, display_width, display_height) or get_pixel_coords(bounds, display_width, display_height) coords = get_pixel_coords(bn, display_width, display_height) or get_pixel_coords(bounds, display_width,
display_height)
if coords: if coords:
x1, y1, x2, y2 = coords x1, y1, x2, y2 = coords
w, h = x2 - x1, y2 - y1 w, h = x2 - x1, y2 - y1
@@ -172,18 +179,21 @@ class XinDianTuReadImageKit:
# 默认中心点,但稍微偏上一点,避开底部可能的按钮 # 默认中心点,但稍微偏上一点,避开底部可能的按钮
uia_x = int(x1 + w / 2) uia_x = int(x1 + w / 2)
uia_y = int(y1 + h * 0.4) uia_y = int(y1 + h * 0.4)
logger.info(f"坐标计算: 使用卡片区域 -> ({uia_x}, {uia_y}) | 区域: {coords} | 屏幕: {display_width}x{display_height}") logger.info(
f"坐标计算: 使用卡片区域 -> ({uia_x}, {uia_y}) | 区域: {coords} | 屏幕: {display_width}x{display_height}")
# --- 安全过滤:过滤掉屏幕顶部的点击坐标 (通常是广告或菜单) --- # --- 安全过滤:过滤掉屏幕顶部的点击坐标 (通常是广告或菜单) ---
if uia_y is not None: if uia_y is not None:
# 如果点击点在屏幕顶部 SAFE_EXCLUDE_RATIO 范围内,极大概率是误触广告位,将其排除 # 如果点击点在屏幕顶部 SAFE_EXCLUDE_RATIO 范围内,极大概率是误触广告位,将其排除
if uia_y < (display_height * SAFE_EXCLUDE_RATIO): if uia_y < (display_height * SAFE_EXCLUDE_RATIO):
logger.warning(f"安全排除: 坐标 ({uia_x}, {uia_y}) 位于屏幕顶部 {int(SAFE_EXCLUDE_RATIO*100)}% 区域,疑似广告或菜单,已忽略。") logger.warning(
f"安全排除: 坐标 ({uia_x}, {uia_y}) 位于屏幕顶部 {int(SAFE_EXCLUDE_RATIO * 100)}% 区域,疑似广告或菜单,已忽略。")
return None return None
# 如果点击点在屏幕底部 BOTTOM_SAFE_EXCLUDE_RATIO 范围内,极大概率是误触底部功能区(如扫码充电),将其排除 # 如果点击点在屏幕底部 BOTTOM_SAFE_EXCLUDE_RATIO 范围内,极大概率是误触底部功能区(如扫码充电),将其排除
if uia_y > (display_height * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)): if uia_y > (display_height * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)):
logger.warning(f"安全排除: 坐标 ({uia_x}, {uia_y}) 位于屏幕底部 {int(BOTTOM_SAFE_EXCLUDE_RATIO*100)}% 区域,疑似底部功能区,已忽略。") logger.warning(
f"安全排除: 坐标 ({uia_x}, {uia_y}) 位于屏幕底部 {int(BOTTOM_SAFE_EXCLUDE_RATIO * 100)}% 区域,疑似底部功能区,已忽略。")
return None return None
if uia_x is not None and uia_y is not None: if uia_x is not None and uia_y is not None:
@@ -293,7 +303,9 @@ class XinDianTuReadImageKit:
try: try:
data = json.loads(raw) data = json.loads(raw)
if isinstance(data, dict) and (data.get("station_name_bounds") or data.get("station_name_bounds_norm") or data.get("bounds") or data.get("bounds_norm")): if isinstance(data, dict) and (
data.get("station_name_bounds") or data.get("station_name_bounds_norm") or data.get(
"bounds") or data.get("bounds_norm")):
data = XinDianTuReadImageKit._add_click_point(data, device_info, anchor="station_text") data = XinDianTuReadImageKit._add_click_point(data, device_info, anchor="station_text")
return { return {
"uia_center_x": data.get("uia_center_x"), "uia_center_x": data.get("uia_center_x"),
@@ -339,7 +351,8 @@ class XinDianTuReadImageKit:
data = json.loads(raw) data = json.loads(raw)
if isinstance(data, list): if isinstance(data, list):
# 过滤掉 None (即被 _add_click_point 排除掉的顶部项) # 过滤掉 None (即被 _add_click_point 排除掉的顶部项)
data = [XinDianTuReadImageKit._add_click_point(x, device_info, anchor="station_text") for x in data if x.get("b_use", 1) == 1] data = [XinDianTuReadImageKit._add_click_point(x, device_info, anchor="station_text") for x in data if
x.get("b_use", 1) == 1]
data = [x for x in data if x is not None] data = [x for x in data if x is not None]
elif isinstance(data, dict): elif isinstance(data, dict):
if data.get("b_use", 1) == 1: if data.get("b_use", 1) == 1:
@@ -389,7 +402,8 @@ class XinDianTuReadImageKit:
if tasks: if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
for i, r in zip(idxs, results): for i, r in zip(idxs, results):
if isinstance(r, dict) and r.get("uia_center_x") is not None and r.get("uia_center_y") is not None: if isinstance(r, dict) and r.get("uia_center_x") is not None and r.get(
"uia_center_y") is not None:
data[i]["uia_center_x"] = r.get("uia_center_x") data[i]["uia_center_x"] = r.get("uia_center_x")
data[i]["uia_center_y"] = r.get("uia_center_y") data[i]["uia_center_y"] = r.get("uia_center_y")
@@ -491,12 +505,14 @@ class XinDianTuReadImageKit:
# 顶部安全排除 # 顶部安全排除
if cy < (img_h * SAFE_EXCLUDE_RATIO): if cy < (img_h * SAFE_EXCLUDE_RATIO):
logger.warning(f"VL安全排除: 坐标 ({cx}, {cy}) 位于屏幕顶部 {int(SAFE_EXCLUDE_RATIO*100)}% 区域,已忽略。") logger.warning(
f"VL安全排除: 坐标 ({cx}, {cy}) 位于屏幕顶部 {int(SAFE_EXCLUDE_RATIO * 100)}% 区域,已忽略。")
continue continue
# 底部安全排除 # 底部安全排除
if cy > (img_h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)): if cy > (img_h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)):
logger.warning(f"VL安全排除: 坐标 ({cx}, {cy}) 位于屏幕底部 {int(BOTTOM_SAFE_EXCLUDE_RATIO*100)}% 区域,疑似底部功能区,已忽略。") logger.warning(
f"VL安全排除: 坐标 ({cx}, {cy}) 位于屏幕底部 {int(BOTTOM_SAFE_EXCLUDE_RATIO * 100)}% 区域,疑似底部功能区,已忽略。")
continue continue
item["uia_center_x"] = cx item["uia_center_x"] = cx
@@ -743,7 +759,8 @@ class XinDianTuReadImageKit:
"center_y": center_y, "center_y": center_y,
"y_range": (y1, y2) "y_range": (y1, y2)
}) })
logger.info(f" [+] 发现卡片 {i}: Y({y1}-{y2}), H={h}, Crop X({x1}-{x2}), 边缘(L={l_mean:.1f}, R={r_mean:.1f}), 已保存") logger.info(
f" [+] 发现卡片 {i}: Y({y1}-{y2}), H={h}, Crop X({x1}-{x2}), 边缘(L={l_mean:.1f}, R={r_mean:.1f}), 已保存")
logger.info(f"分析完成:识别到 {len(valid_segments)} 个区域") logger.info(f"分析完成:识别到 {len(valid_segments)} 个区域")
return valid_segments return valid_segments

Binary file not shown.