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,23 +16,26 @@ 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
) )
# --- 用户配置区域 --- # --- 用户配置区域 ---
# 是否保留截图文件(True=保留备查,False=随用随删) # 是否保留截图文件(True=保留备查,False=随用随删)
KEEP_SCREENSHOTS = True KEEP_SCREENSHOTS = True
# [Testing] 是否在启动时清除 Redis 中的场站处理记录 # [Testing] 是否在启动时清除 Redis 中的场站处理记录
# True: 每次运行前清除记录,方便反复测试同一个场站 # True: 每次运行前清除记录,方便反复测试同一个场站
@@ -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):
""" """
获取场站列表并处理翻页 获取场站列表并处理翻页
@@ -97,21 +102,21 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
all_stations = [] all_stations = []
seen_names = set() seen_names = set()
redis_kit = RedisKit() redis_kit = RedisKit()
# 确保在“找桩”或者首页列表界面 # 确保在“找桩”或者首页列表界面
window_size = d.window_size() window_size = d.window_size()
w, h = window_size[0], window_size[1] w, h = window_size[0], window_size[1]
# 获取设备信息以用于坐标计算 # 获取设备信息以用于坐标计算
device_info = d.info device_info = d.info
device_info['width'] = w device_info['width'] = w
device_info['height'] = h device_info['height'] = h
logger.info(f"设备信息: {device_info.get('productName')} | 窗口分辨率: {w}x{h}") logger.info(f"设备信息: {device_info.get('productName')} | 窗口分辨率: {w}x{h}")
max_distance_reached = False max_distance_reached = False
stop_reason = "max_scrolls_reached" # 默认停止原因:达到最大翻页次数 stop_reason = "max_scrolls_reached" # 默认停止原因:达到最大翻页次数
# 存储后台异步任务 # 存储后台异步任务
background_tasks = [] background_tasks = []
@@ -121,7 +126,7 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
stop_reason = "max_distance_reached" stop_reason = "max_distance_reached"
break break
logger.info(f"正在处理第 {i + 1} 页...") logger.info(f"正在处理第 {i + 1} 页...")
# 1. 拍摄截图 # 1. 拍摄截图
logger.info(f"Step [1/6] 正在拍摄列表页截图...") logger.info(f"Step [1/6] 正在拍摄列表页截图...")
t_shot = time.time() t_shot = time.time()
@@ -131,7 +136,7 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
save_dir = TEMP_IMAGE_DIR save_dir = TEMP_IMAGE_DIR
screenshot_path = take_screenshot(d, image_uuid, save_dir=save_dir) screenshot_path = take_screenshot(d, image_uuid, save_dir=save_dir)
logger.info(f"Step [1/6] 列表页截图已完成: {screenshot_path} (耗时: {time.time() - t_shot:.2f}s)") logger.info(f"Step [1/6] 列表页截图已完成: {screenshot_path} (耗时: {time.time() - t_shot:.2f}s)")
# 校验截图实际尺寸 # 校验截图实际尺寸
img_w, img_h = 0, 0 img_w, img_h = 0, 0
try: try:
@@ -140,115 +145,119 @@ 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:
logger.warning(f"无法获取截图尺寸信息: {e}") logger.warning(f"无法获取截图尺寸信息: {e}")
# 2. 使用 Kit.crop_cards_from_image 进行切片和生成 _vl.jpg # 2. 使用 Kit.crop_cards_from_image 进行切片和生成 _vl.jpg
logger.info("Step [2/6] 正在执行本地图形学切片分析,识别场站卡片并生成绿色方框图...") logger.info("Step [2/6] 正在执行本地图形学切片分析,识别场站卡片并生成绿色方框图...")
t_crop = time.time() t_crop = time.time()
# 这会生成 _vl.jpg 和 .json # 这会生成 _vl.jpg 和 .json
Kit.crop_cards_from_image(screenshot_path) Kit.crop_cards_from_image(screenshot_path)
logger.info(f"Step [2/6] 图形学切片分析完成 (耗时: {time.time() - t_crop:.2f}s)") logger.info(f"Step [2/6] 图形学切片分析完成 (耗时: {time.time() - t_crop:.2f}s)")
# 读取生成的元数据 # 读取生成的元数据
json_path = screenshot_path.replace(".jpg", ".json") json_path = screenshot_path.replace(".jpg", ".json")
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
logger.info(f"Step [3/6] 绿色方框图已生成: {vl_img_path}") logger.info(f"Step [3/6] 绿色方框图已生成: {vl_img_path}")
with open(json_path, 'r', encoding='utf-8') as f: with open(json_path, 'r', encoding='utf-8') as f:
json_metadata = json.load(f) json_metadata = json.load(f)
# --- New Logic: Local OCR via PaddleOCRKit --- # --- New Logic: Local OCR via PaddleOCRKit ---
logger.info("Step [4/6] 正在开始本地 OCR 识别流程,将识别出的卡片逐一交给 OCR...") logger.info("Step [4/6] 正在开始本地 OCR 识别流程,将识别出的卡片逐一交给 OCR...")
t_ocr_total = time.time() t_ocr_total = time.time()
stations = [] stations = []
try: try:
ocr_kit = get_ocr_kit() ocr_kit = get_ocr_kit()
# 读取原始截图进行裁剪 (比读取 _vl.jpg 更干净,没有绿框干扰) # 读取原始截图进行裁剪 (比读取 _vl.jpg 更干净,没有绿框干扰)
original_img = cv2.imread(screenshot_path) original_img = cv2.imread(screenshot_path)
if "cards" in json_metadata and original_img is not None: if "cards" in json_metadata and original_img is not None:
h_img, w_img = original_img.shape[:2] h_img, w_img = original_img.shape[:2]
num_cards = len(json_metadata['cards']) num_cards = len(json_metadata['cards'])
logger.info(f"检测到 {num_cards} 个场站卡片,准备开始逐一 OCR 识别...") logger.info(f"检测到 {num_cards} 个场站卡片,准备开始逐一 OCR 识别...")
for idx, card in enumerate(json_metadata["cards"]): for idx, card in enumerate(json_metadata["cards"]):
# card: {"id": 1, "rect": [x1, y1, x2, y2], "click_point": [cx, cy]} # card: {"id": 1, "rect": [x1, y1, x2, y2], "click_point": [cx, cy]}
rect = card.get("rect") rect = card.get("rect")
if not rect: continue if not rect: continue
x1, y1, x2, y2 = rect x1, y1, x2, y2 = rect
# 边界检查 # 边界检查
x1 = max(0, min(x1, w_img)) x1 = max(0, min(x1, w_img))
x2 = max(0, min(x2, w_img)) x2 = max(0, min(x2, w_img))
y1 = max(0, min(y1, h_img)) y1 = max(0, min(y1, h_img))
y2 = max(0, min(y2, h_img)) y2 = max(0, min(y2, h_img))
if x2 <= x1 or y2 <= y1: continue if x2 <= x1 or y2 <= y1: continue
# 裁剪卡片 # 裁剪卡片
cropped_card = original_img[y1:y2, x1:x2] cropped_card = original_img[y1:y2, x1:x2]
# 识别 # 识别
logger.info(f"正在识别第 {idx+1}/{num_cards} 个卡片 (区域: {rect})...") logger.info(f"正在识别第 {idx + 1}/{num_cards} 个卡片 (区域: {rect})...")
t_card_start = time.time() t_card_start = time.time()
parsed_data = ocr_kit.recognize(cropped_card) parsed_data = ocr_kit.recognize(cropped_card)
t_card_end = time.time() t_card_end = time.time()
# Log detailed OCR result # Log detailed OCR result
logger.info(f"卡片 {idx+1} OCR 识别耗时: {t_card_end - t_card_start:.2f}s") logger.info(f"卡片 {idx + 1} OCR 识别耗时: {t_card_end - t_card_start:.2f}s")
if parsed_data and parsed_data.get("station_name"): if parsed_data and parsed_data.get("station_name"):
# 格式化数据以匹配原有结构 # 格式化数据以匹配原有结构
piles_list = parsed_data.get("piles", []) piles_list = parsed_data.get("piles", [])
piles_str_parts = [] piles_str_parts = []
for p in piles_list: for p in piles_list:
p_type = p.get("type", "") p_type = p.get("type", "")
p_free = p.get("free", 0) p_free = p.get("free", 0)
p_total = p.get("total", 0) p_total = p.get("total", 0)
piles_str_parts.append(f"{p_type}:{p_free}/{p_total}") piles_str_parts.append(f"{p_type}:{p_free}/{p_total}")
piles_str = " ".join(piles_str_parts) piles_str = " ".join(piles_str_parts)
station_info = { station_info = {
"station_name": parsed_data.get("station_name"), "station_name": parsed_data.get("station_name"),
"price": str(parsed_data.get("price")) if parsed_data.get("price") is not None else "", "price": str(parsed_data.get("price")) if parsed_data.get("price") is not None else "",
"pro_price": "", # 暂未解析 "pro_price": "", # 暂未解析
"piles": piles_str, "piles": piles_str,
"distance": parsed_data.get("distance", ""), "distance": parsed_data.get("distance", ""),
"uia_center_x": card["click_point"][0], "uia_center_x": card["click_point"][0],
"uia_center_y": card["click_point"][1], "uia_center_y": card["click_point"][1],
"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: # 立即记录场站基础状态信息 (电桩、价格等)
await service.record_station_status(station_info) try:
except Exception as e: await service.record_station_status(station_info)
logger.error(f"❌ 记录场站状态失败: {e}") except Exception as e:
logger.error(f"❌ 记录场站状态失败: {e}")
stations.append(station_info)
stations.append(station_info)
else: else:
logger.warning(f"⚠️ 卡片 {idx+1} 未能识别出有效场站名称") logger.warning(f"⚠️ 卡片 {idx + 1} 未能识别出有效场站名称")
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("当前页面未检测到任何有效的场站信息")
else: else:
@@ -256,25 +265,25 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
new_stations_found = False new_stations_found = False
for s_idx, s in enumerate(stations): for s_idx, s in enumerate(stations):
name = s.get("station_name") name = s.get("station_name")
# 过滤掉明显的非场站名称 # 过滤掉明显的非场站名称
if not name: if not name:
continue continue
# --- 短期去重:检查 Redis 缓存 --- # --- 短期去重:检查 Redis 缓存 ---
# 2分钟内如果处理过该场站,则跳过 # 2分钟内如果处理过该场站,则跳过
redis_key = f"processed_station:{name}" redis_key = f"processed_station:{name}"
if await redis_kit.exists(redis_key): if await redis_kit.exists(redis_key):
logger.info(f"场站 {name} 在 2 分钟内已处理过,跳过") logger.info(f"场站 {name} 在 2 分钟内已处理过,跳过")
continue continue
if name in seen_names: if name in seen_names:
continue continue
# 使用从 JSON 元数据中恢复的点击坐标 # 使用从 JSON 元数据中恢复的点击坐标
x = s.get("uia_center_x") x = s.get("uia_center_x")
y = s.get("uia_center_y") y = s.get("uia_center_y")
if x is None or y is None: if x is None or y is None:
logger.warning(f"场站 {name} 缺少坐标信息,无法进入详情页") logger.warning(f"场站 {name} 缺少坐标信息,无法进入详情页")
continue continue
@@ -282,7 +291,7 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
seen_names.add(name) seen_names.add(name)
all_stations.append(s) all_stations.append(s)
new_stations_found = True new_stations_found = True
# 打印基本信息 # 打印基本信息
price = s.get("price") price = s.get("price")
pro_price = s.get("pro_price") pro_price = s.get("pro_price")
@@ -299,20 +308,22 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
dist_val = float(dist_match.group(1)) dist_val = float(dist_match.group(1))
is_km = "km" in distance_str.lower() is_km = "km" in distance_str.lower()
dist_km = dist_val if is_km else dist_val / 1000.0 dist_km = dist_val if is_km else dist_val / 1000.0
# 将距离解析结果和限制检查合并到日志中 # 将距离解析结果和限制检查合并到日志中
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}")
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
@@ -328,7 +339,7 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
logger.info(f"👉 正在点击进入详情页: {name} (绝对坐标: {x}, {y})") logger.info(f"👉 正在点击进入详情页: {name} (绝对坐标: {x}, {y})")
d.click(x, y) d.click(x, y)
await asyncio.sleep(WAIT_DETAIL_PAGE_LOAD) # 等待详情页加载 await asyncio.sleep(WAIT_DETAIL_PAGE_LOAD) # 等待详情页加载
# 尝试获取并保存地址 # 尝试获取并保存地址
address_recorded = False address_recorded = False
@@ -338,21 +349,23 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
detail_uuid = str(uuid.uuid4()) detail_uuid = str(uuid.uuid4())
detail_screenshot_path = take_screenshot(d, detail_uuid, save_dir=save_dir) detail_screenshot_path = take_screenshot(d, detail_uuid, save_dir=save_dir)
logger.info(f"Step [详情页截图] 耗时: {time.time() - t_d_shot:.4f}s") logger.info(f"Step [详情页截图] 耗时: {time.time() - t_d_shot:.4f}s")
# 2. 上传至 OBS # 2. 上传至 OBS
t_d_up = time.time() t_d_up = time.time()
detail_object_key = f"{OBS_TMP_PREFIX}/{detail_uuid}.jpg" detail_object_key = f"{OBS_TMP_PREFIX}/{detail_uuid}.jpg"
up_success, _ = uploader.upload_file(detail_object_key, detail_screenshot_path) up_success, _ = uploader.upload_file(detail_object_key, detail_screenshot_path)
logger.info(f"Step [上传详情页截图] 耗时: {time.time() - t_d_up:.4f}s") logger.info(f"Step [上传详情页截图] 耗时: {time.time() - t_d_up:.4f}s")
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')}")
@@ -368,40 +381,41 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
# 标记为已记录以跳出 retry 循环 # 标记为已记录以跳出 retry 循环
address_recorded = True address_recorded = True
# 清理详情页临时截图 # 清理详情页临时截图
if not KEEP_SCREENSHOTS and os.path.exists(detail_screenshot_path): if not KEEP_SCREENSHOTS and os.path.exists(detail_screenshot_path):
os.remove(detail_screenshot_path) os.remove(detail_screenshot_path)
break # 直接跳出 retry 循环 break # 直接跳出 retry 循环
# --- 抓取价格时段信息 (New Feature) --- # --- 抓取价格时段信息 (New Feature) ---
# 仅在测试模式下或特定需求下启用 # 仅在测试模式下或特定需求下启用
try: try:
logger.info(f"开始抓取价格时段信息: {name}") logger.info(f"开始抓取价格时段信息: {name}")
# 使用几何特征识别 "全部时段" 按钮 # 使用几何特征识别 "全部时段" 按钮
# 临时截图 # 临时截图
temp_uuid = "temp_find_expand" temp_uuid = "temp_find_expand"
screenshot_path = take_screenshot(d, temp_uuid, save_dir=TEMP_IMAGE_DIR) screenshot_path = take_screenshot(d, temp_uuid, save_dir=TEMP_IMAGE_DIR)
# 尝试识别,将调试图片保存到 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")
# 清理截图 # 清理截图
try: try:
os.remove(screenshot_path) os.remove(screenshot_path)
except: except:
pass pass
if pos: if pos:
x, y = pos x, y = pos
logger.info(f"通过几何特征找到 '全部时段' 按钮: ({x}, {y})") logger.info(f"通过几何特征找到 '全部时段' 按钮: ({x}, {y})")
d.click(x, y) d.click(x, y)
await asyncio.sleep(1.5) await asyncio.sleep(1.5)
# 抓取3屏截图 # 抓取3屏截图
price_image_urls = [] price_image_urls = []
for p_idx in range(1, 4): for p_idx in range(1, 4):
@@ -411,7 +425,7 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
p_uuid = f"{name_md5}_price_{p_idx}" p_uuid = f"{name_md5}_price_{p_idx}"
p_path = take_screenshot(d, p_uuid, save_dir=save_dir) p_path = take_screenshot(d, p_uuid, save_dir=save_dir)
logger.info(f"已保存价格时段截图 {p_idx} (Station: {name}, MD5: {name_md5}): {p_path}") logger.info(f"已保存价格时段截图 {p_idx} (Station: {name}, MD5: {name_md5}): {p_path}")
# 上传到 OBS # 上传到 OBS
p_object_key = f"{OBS_TMP_PREFIX}/{p_uuid}.jpg" p_object_key = f"{OBS_TMP_PREFIX}/{p_uuid}.jpg"
success, _ = uploader.upload_file(p_object_key, p_path) success, _ = uploader.upload_file(p_object_key, p_path)
@@ -427,15 +441,16 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
if p_idx < 3: if p_idx < 3:
d.swipe_ext("up", scale=0.7) d.swipe_ext("up", scale=0.7)
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
# 调用服务解析价格时段 (异步后台处理) # 调用服务解析价格时段 (异步后台处理)
if price_image_urls: if price_image_urls:
logger.info(f"正在调用接口解析电费时段数据 ({len(price_image_urls)} 张图片) - 转入后台...") logger.info(f"正在调用接口解析电费时段数据 ({len(price_image_urls)} 张图片) - 转入后台...")
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:
@@ -447,7 +462,7 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
logger.warning("未能获取到有效的价格时段截图,跳过解析") logger.warning("未能获取到有效的价格时段截图,跳过解析")
else: else:
logger.warning(f"未能通过几何特征识别 '全部时段' 按钮") logger.warning(f"未能通过几何特征识别 '全部时段' 按钮")
except Exception as e: except Exception as e:
logger.warning(f"抓取价格时段信息时发生异常: {e}") logger.warning(f"抓取价格时段信息时发生异常: {e}")
@@ -457,8 +472,8 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
# 返回列表页 # 返回列表页
d.press("back") d.press("back")
await asyncio.sleep(WAIT_BACK_TO_LIST) # 等待列表页重新稳定 await asyncio.sleep(WAIT_BACK_TO_LIST) # 等待列表页重新稳定
if not new_stations_found and i > 0: if not new_stations_found and i > 0:
logger.info("未发现更多新场站,停止下拉。") logger.info("未发现更多新场站,停止下拉。")
# 如果需要强行爬取所有,可以注释掉 break # 如果需要强行爬取所有,可以注释掉 break
@@ -472,17 +487,17 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
start_y = 0.5 + (SCROLL_DISTANCE_RATIO / 2) start_y = 0.5 + (SCROLL_DISTANCE_RATIO / 2)
end_y = 0.5 - (SCROLL_DISTANCE_RATIO / 2) end_y = 0.5 - (SCROLL_DISTANCE_RATIO / 2)
d.swipe(w * 0.5, h * start_y, w * 0.5, h * end_y, duration=0.5) d.swipe(w * 0.5, h * start_y, w * 0.5, h * end_y, duration=0.5)
await asyncio.sleep(WAIT_AFTER_SCROLL) # 等待页面加载和列表稳定 await asyncio.sleep(WAIT_AFTER_SCROLL) # 等待页面加载和列表稳定
# 清理列表页截图 # 清理列表页截图
if not KEEP_SCREENSHOTS: if not KEEP_SCREENSHOTS:
if os.path.exists(screenshot_path): if os.path.exists(screenshot_path):
os.remove(screenshot_path) os.remove(screenshot_path)
if os.path.exists(vl_img_path): if os.path.exists(vl_img_path):
os.remove(vl_img_path) os.remove(vl_img_path)
if os.path.exists(json_path): if os.path.exists(json_path):
os.remove(json_path) os.remove(json_path)
logger.info(f"爬取任务结束,正在等待 {len(background_tasks)} 个后台处理任务完成...") logger.info(f"爬取任务结束,正在等待 {len(background_tasks)} 个后台处理任务完成...")
if background_tasks: if background_tasks:
await asyncio.gather(*background_tasks) await asyncio.gather(*background_tasks)
@@ -491,22 +506,23 @@ 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__))
images_dir = os.path.join(base_dir, "Images") images_dir = os.path.join(base_dir, "Images")
if not os.path.exists(images_dir): if not os.path.exists(images_dir):
return return
keep_files = {'1.jpg', '2.jpg', '3.jpg', '4.jpg'} keep_files = {'1.jpg', '2.jpg', '3.jpg', '4.jpg'}
logger.info(f"正在清理 Images 目录 (保留: {keep_files})...") logger.info(f"正在清理 Images 目录 (保留: {keep_files})...")
deleted_count = 0 deleted_count = 0
for filename in os.listdir(images_dir): for filename in os.listdir(images_dir):
if filename in keep_files: if filename in keep_files:
continue continue
file_path = os.path.join(images_dir, filename) file_path = os.path.join(images_dir, filename)
try: try:
if os.path.isfile(file_path): if os.path.isfile(file_path):
@@ -514,9 +530,10 @@ def clean_images_dir():
deleted_count += 1 deleted_count += 1
except Exception as e: except Exception as e:
logger.warning(f"删除文件失败 {filename}: {e}") logger.warning(f"删除文件失败 {filename}: {e}")
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,10 +545,11 @@ 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()
# 初始化服务 # 初始化服务
should_close_service = False should_close_service = False
if service is None: if service is None:
@@ -542,28 +560,28 @@ async def main(service=None, do_cleanup=True):
uploader = ObsUploader() uploader = ObsUploader()
redis_kit = RedisKit() redis_kit = RedisKit()
try: try:
if do_cleanup: if do_cleanup:
# 清理图片目录 # 清理图片目录
clean_images_dir() clean_images_dir()
# [Testing] 如果配置为测试模式,启动时清除 Redis 记录 # [Testing] 如果配置为测试模式,启动时清除 Redis 记录
await clean_redis_data(redis_kit) await clean_redis_data(redis_kit)
# 清理过期数据 # 清理过期数据
# await service.cleanup_old_data() # await service.cleanup_old_data()
# 获取场站列表 # 获取场站列表
stations = await get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS) stations = await get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS)
if stations: if stations:
logger.info("场站列表采集完成。") logger.info("场站列表采集完成。")
else: else:
logger.warning("未采集到任何场站信息。") logger.warning("未采集到任何场站信息。")
return True return True
except Exception as e: except Exception as e:
logger.exception(f"运行过程中出现异常: {e}") logger.exception(f"运行过程中出现异常: {e}")
return False return False
@@ -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())

File diff suppressed because it is too large Load Diff

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(
@@ -59,35 +57,6 @@ async def main():
except Exception as e: except Exception as e:
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:
# 确保数据库连接关闭 # 确保数据库连接关闭

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,
@@ -105,7 +108,7 @@ class XinDianTuReadImageKit:
# 获取显示宽高,增加容错 # 获取显示宽高,增加容错
display_width = float(device_info.get("displayWidth") or device_info.get("width") or 1080) display_width = float(device_info.get("displayWidth") or device_info.get("width") or 1080)
display_height = float(device_info.get("displayHeight") or device_info.get("height") or 2400) display_height = float(device_info.get("displayHeight") or device_info.get("height") or 2400)
bounds = obj.get("bounds") bounds = obj.get("bounds")
bn = obj.get("bounds_norm") bn = obj.get("bounds_norm")
text_bounds = obj.get("station_name_bounds") text_bounds = obj.get("station_name_bounds")
@@ -116,7 +119,7 @@ class XinDianTuReadImageKit:
def get_pixel_coords(b_data, d_w, d_h): def get_pixel_coords(b_data, d_w, d_h):
if not b_data: if not b_data:
return None return None
# Extract values based on dict or list # Extract values based on dict or list
if isinstance(b_data, list) and len(b_data) == 4: if isinstance(b_data, list) and len(b_data) == 4:
v1, v2, v3, v4 = b_data v1, v2, v3, v4 = b_data
@@ -126,7 +129,7 @@ class XinDianTuReadImageKit:
v2 = b_data.get("top") if b_data.get("top") is not None else b_data.get("y1") v2 = b_data.get("top") if b_data.get("top") is not None else b_data.get("y1")
v3 = b_data.get("right") if b_data.get("right") is not None else b_data.get("x2") v3 = b_data.get("right") if b_data.get("right") is not None else b_data.get("x2")
v4 = b_data.get("bottom") if b_data.get("bottom") is not None else b_data.get("y2") v4 = b_data.get("bottom") if b_data.get("bottom") is not None else b_data.get("y2")
if any(v is None for v in (v1, v2, v3, v4)): if any(v is None for v in (v1, v2, v3, v4)):
return None return None
else: else:
@@ -138,9 +141,9 @@ class XinDianTuReadImageKit:
return None return None
max_v = max(v1, v2, v3, v4) max_v = max(v1, v2, v3, v4)
# 1. 0-1 归一化坐标 # 1. 0-1 归一化坐标
if max_v <= 1.05: if max_v <= 1.05:
x1, y1, x2, y2 = v1 * d_w, v2 * d_h, v3 * d_w, v4 * d_h x1, y1, x2, y2 = v1 * d_w, v2 * d_h, v3 * d_w, v4 * d_h
# 2. 0-1000 归一化坐标 (Qwen-VL 常用) # 2. 0-1000 归一化坐标 (Qwen-VL 常用)
elif max_v <= 1005: elif max_v <= 1005:
@@ -148,20 +151,24 @@ class XinDianTuReadImageKit:
# 3. 绝对像素坐标 # 3. 绝对像素坐标
else: else:
x1, y1, x2, y2 = v1, v2, v3, v4 x1, y1, x2, y2 = v1, v2, v3, v4
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,24 +179,27 @@ 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:
obj["uia_center_x"] = uia_x obj["uia_center_x"] = uia_x
obj["uia_center_y"] = uia_y obj["uia_center_y"] = uia_y
# Clean up temporary fields # Clean up temporary fields
for k in ["bounds", "bounds_norm", "station_name_bounds", "station_name_bounds_norm"]: for k in ["bounds", "bounds_norm", "station_name_bounds", "station_name_bounds_norm"]:
if k in obj: if k in obj:
@@ -261,13 +271,13 @@ class XinDianTuReadImageKit:
device_info = XinDianTuReadImageKit._FALLBACK_DEVICE_INFO device_info = XinDianTuReadImageKit._FALLBACK_DEVICE_INFO
prompt = ( prompt = (
"仅输出JSON对象(不含任何说明文字)。" "仅输出JSON对象(不含任何说明文字)。"
"请在图片中找到场站名称完全匹配“" + str(station_name) + "”的那一行/卡片," "请在图片中找到场站名称完全匹配“" + str(station_name) + "”的那一行/卡片,"
"返回该场站名称文字本身的区域坐标(优先返回归一化坐标)。" "返回该场站名称文字本身的区域坐标(优先返回归一化坐标)。"
"返回格式示例:" "返回格式示例:"
"{\"station_name_bounds\": {\"x1\": 100, \"y1\": 200, \"x2\": 300, \"y2\": 240}, " "{\"station_name_bounds\": {\"x1\": 100, \"y1\": 200, \"x2\": 300, \"y2\": 240}, "
"\"station_name_bounds_norm\": {\"left\": 0.1, \"top\": 0.2, \"right\": 0.3, \"bottom\": 0.24}}" "\"station_name_bounds_norm\": {\"left\": 0.1, \"top\": 0.2, \"right\": 0.3, \"bottom\": 0.24}}"
"注意:bounds使用0-1000归一化坐标空间;如果找不到,返回空JSON {}。" "注意:bounds使用0-1000归一化坐标空间;如果找不到,返回空JSON {}。"
) )
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
@@ -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")
@@ -418,11 +432,11 @@ class XinDianTuReadImageKit:
""" """
if not json_metadata or "cards" not in json_metadata: if not json_metadata or "cards" not in json_metadata:
return [] return []
cards_meta = json_metadata["cards"] cards_meta = json_metadata["cards"]
# 按 id 或 Y 坐标排序,确保顺序一致 (Kit 生成时已经是 top-down) # 按 id 或 Y 坐标排序,确保顺序一致 (Kit 生成时已经是 top-down)
cards_meta.sort(key=lambda x: x["rect"][1]) cards_meta.sort(key=lambda x: x["rect"][1])
prompt = ( prompt = (
"图片中用绿色矩形框标记了若干个充电站卡片区域。\n" "图片中用绿色矩形框标记了若干个充电站卡片区域。\n"
"请按从上到下的顺序,依次识别每个绿色框内的场站信息,并返回一个JSON数组。\n" "请按从上到下的顺序,依次识别每个绿色框内的场站信息,并返回一个JSON数组。\n"
@@ -440,7 +454,7 @@ class XinDianTuReadImageKit:
"\n" "\n"
"严格返回纯JSON格式。" "严格返回纯JSON格式。"
) )
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
resp = await loop.run_in_executor( resp = await loop.run_in_executor(
None, None,
@@ -457,10 +471,10 @@ class XinDianTuReadImageKit:
], ],
) )
) )
content = resp.choices[0].message.content or "" content = resp.choices[0].message.content or ""
raw = XinDianTuReadImageKit._extract_json(content) raw = XinDianTuReadImageKit._extract_json(content)
final_stations = [] final_stations = []
try: try:
llm_data = json.loads(raw) llm_data = json.loads(raw)
@@ -470,46 +484,48 @@ class XinDianTuReadImageKit:
for i in range(count): for i in range(count):
item = llm_data[i] item = llm_data[i]
meta = cards_meta[i] meta = cards_meta[i]
if not item or not isinstance(item, dict): if not item or not isinstance(item, dict):
continue continue
name = item.get("station_name") name = item.get("station_name")
if not name: if not name:
continue continue
# 注入元数据中的点击坐标 # 注入元数据中的点击坐标
click_pt = meta.get("click_point", [0, 0]) click_pt = meta.get("click_point", [0, 0])
cx, cy = click_pt cx, cy = click_pt
# 安全检查 # 安全检查
img_h = 2400 img_h = 2400
if device_info: if device_info:
img_h = float(device_info.get("displayHeight") or device_info.get("height") or 2400) img_h = float(device_info.get("displayHeight") or device_info.get("height") or 2400)
else: else:
img_h = json_metadata.get("height", 2400) img_h = json_metadata.get("height", 2400)
# 顶部安全排除 # 顶部安全排除
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
item["uia_center_y"] = cy item["uia_center_y"] = cy
item["rect"] = meta.get("rect") item["rect"] = meta.get("rect")
final_stations.append(item) final_stations.append(item)
else: else:
logger.warning(f"LLM return format error: expected list, got {type(llm_data)}") logger.warning(f"LLM return format error: expected list, got {type(llm_data)}")
except Exception as e: except Exception as e:
logger.error(f"Error parsing VL response: {e}") logger.error(f"Error parsing VL response: {e}")
return final_stations return final_stations
@staticmethod @staticmethod
@@ -530,7 +546,7 @@ class XinDianTuReadImageKit:
remote_path = f"tmp/patches/{os.path.basename(patch_path)}" remote_path = f"tmp/patches/{os.path.basename(patch_path)}"
uploader.upload_file(patch_path, remote_path) uploader.upload_file(patch_path, remote_path)
patch_url = f"{cdn_domain}/{remote_path}" patch_url = f"{cdn_domain}/{remote_path}"
# 记录上传后的 URL 供识别使用 # 记录上传后的 URL 供识别使用
seg["patch_url"] = patch_url seg["patch_url"] = patch_url
tasks.append(XinDianTuReadImageKit.recognize_card_text(patch_url)) tasks.append(XinDianTuReadImageKit.recognize_card_text(patch_url))
@@ -547,7 +563,7 @@ class XinDianTuReadImageKit:
res["x"] = seg["center_x"] res["x"] = seg["center_x"]
res["y"] = seg["center_y"] res["y"] = seg["center_y"]
final_stations.append(res) final_stations.append(res)
# 4. 后置过滤 (复用原有过滤逻辑) # 4. 后置过滤 (复用原有过滤逻辑)
if final_stations: if final_stations:
processed_excluded_titles = {str(x).replace(" ", "").strip() for x in STATION_EXCLUDED_TITLES} processed_excluded_titles = {str(x).replace(" ", "").strip() for x in STATION_EXCLUDED_TITLES}
@@ -580,40 +596,40 @@ class XinDianTuReadImageKit:
img_data = np.array(img) img_data = np.array(img)
logger.info(f"页面分析 - 分辨率: {width}x{height}") logger.info(f"页面分析 - 分辨率: {width}x{height}")
# 1. 行特征分析:识别分割线 (Divider) # 1. 行特征分析:识别分割线 (Divider)
# 分割线特征:横贯全屏,亮度均匀,通常比纯白(255)稍暗,比文字内容亮 # 分割线特征:横贯全屏,亮度均匀,通常比纯白(255)稍暗,比文字内容亮
# 典型值:Mean=242-247, Std<10 # 典型值:Mean=242-247, Std<10
row_types = [] # 0: Unknown/Content, 1: Divider, 2: White/Empty row_types = [] # 0: Unknown/Content, 1: Divider, 2: White/Empty
# 采样点:左中右 # 采样点:左中右
l_x, m_x, r_x = int(width * 0.05), int(width * 0.5), int(width * 0.95) l_x, m_x, r_x = int(width * 0.05), int(width * 0.5), int(width * 0.95)
debug_rows = [] debug_rows = []
for y in range(height): for y in range(height):
# 避开顶部和底部导航栏 (15% - 85%) - 扩大排除范围以避免误识别 Header/Footer # 避开顶部和底部导航栏 (15% - 85%) - 扩大排除范围以避免误识别 Header/Footer
if y < height * 0.15 or y > height * 0.85: if y < height * 0.15 or y > height * 0.85:
row_types.append(1) # 视为无关区域 (标记为 Divider 以防止形成 Segment) row_types.append(1) # 视为无关区域 (标记为 Divider 以防止形成 Segment)
continue continue
row = img_data[y] row = img_data[y]
row_mean = np.mean(row) row_mean = np.mean(row)
row_std = np.std(row) row_std = np.std(row)
# 判定逻辑: # 判定逻辑:
# 1. 纯白行 (卡片底色) -> mean > 252 (允许微小噪点) # 1. 纯白行 (卡片底色) -> mean > 252 (允许微小噪点)
# 2. 分割线 (Divider) -> 230 < mean < 252 且 std < 15 (颜色均匀) # 2. 分割线 (Divider) -> 230 < mean < 252 且 std < 15 (颜色均匀)
# 3. 内容行 (Content) -> mean <= 230 或 (mean > 230 且 std >= 15) (有文字/图片导致方差大) # 3. 内容行 (Content) -> mean <= 230 或 (mean > 230 且 std >= 15) (有文字/图片导致方差大)
if row_mean > 252: if row_mean > 252:
r_type = 2 # White/Empty r_type = 2 # White/Empty
elif 230 < row_mean <= 252 and row_std < 15: elif 230 < row_mean <= 252 and row_std < 15:
r_type = 1 # Divider r_type = 1 # Divider
else: else:
r_type = 0 # Content r_type = 0 # Content
row_types.append(r_type) row_types.append(r_type)
# Debug log sampling # Debug log sampling
@@ -626,33 +642,33 @@ class XinDianTuReadImageKit:
# 2. 聚合连续的 Content 区域 (Type 0 or Type 2 sandwiched by Type 0) # 2. 聚合连续的 Content 区域 (Type 0 or Type 2 sandwiched by Type 0)
# 实际上,卡片是由 Divider 分隔开的区域。 # 实际上,卡片是由 Divider 分隔开的区域。
# 我们寻找两个 Divider 之间的区域,且该区域必须包含 Content (Type 0)。 # 我们寻找两个 Divider 之间的区域,且该区域必须包含 Content (Type 0)。
segments = [] segments = []
# 简化状态机: # 简化状态机:
# 寻找非 Divider 的连续段 # 寻找非 Divider 的连续段
in_segment = False in_segment = False
seg_start = -1 seg_start = -1
has_content = False has_content = False
raw_blocks = [] raw_blocks = []
for y, r_type in enumerate(row_types): for y, r_type in enumerate(row_types):
if r_type != 1: # Not Divider if r_type != 1: # Not Divider
if not in_segment: if not in_segment:
in_segment = True in_segment = True
seg_start = y seg_start = y
has_content = False has_content = False
if r_type == 0: if r_type == 0:
has_content = True has_content = True
else: # Is Divider else: # Is Divider
if in_segment: if in_segment:
# 结束一段 # 结束一段
if has_content: # 只有包含内容的段才算 if has_content: # 只有包含内容的段才算
raw_blocks.append((seg_start, y)) raw_blocks.append((seg_start, y))
in_segment = False in_segment = False
# 处理最后一段 # 处理最后一段
if in_segment and has_content: if in_segment and has_content:
raw_blocks.append((seg_start, len(row_types))) raw_blocks.append((seg_start, len(row_types)))
@@ -660,52 +676,52 @@ class XinDianTuReadImageKit:
# 3. 过滤和后处理 # 3. 过滤和后处理
# 合并距离很近的块?或者过滤太小的块 # 合并距离很近的块?或者过滤太小的块
base_name = os.path.splitext(os.path.basename(image_path))[0] base_name = os.path.splitext(os.path.basename(image_path))[0]
valid_segments = [] valid_segments = []
logger.info(f"Initial raw blocks count: {len(raw_blocks)}") logger.info(f"Initial raw blocks count: {len(raw_blocks)}")
for i, (y1, y2) in enumerate(raw_blocks): for i, (y1, y2) in enumerate(raw_blocks):
h = y2 - y1 h = y2 - y1
# 过滤太小的块 (可能是杂噪或单纯的文字行) # 过滤太小的块 (可能是杂噪或单纯的文字行)
if h < 50: if h < 50:
# logger.debug(f"Block {i} too small: {h}") # logger.debug(f"Block {i} too small: {h}")
continue continue
# 过滤太大的块 (可能是全屏错误) # 过滤太大的块 (可能是全屏错误)
if h > 1000: if h > 1000:
continue continue
# 再次确认内部是否有足够的“暗像素”(内容) # 再次确认内部是否有足够的“暗像素”(内容)
# 避免切出纯白的空隙 # 避免切出纯白的空隙
region = img_data[y1:y2] region = img_data[y1:y2]
region_mean = np.mean(region) region_mean = np.mean(region)
if region_mean > 254.5: # 整体太白 (放宽阈值,避免误杀大面积白色的卡片) if region_mean > 254.5: # 整体太白 (放宽阈值,避免误杀大面积白色的卡片)
logger.info(f" [-] 忽略区域 {i}: Y({y1}-{y2}), H={h}, 整体太白 (Mean={region_mean:.1f} > 254.5)") logger.info(f" [-] 忽略区域 {i}: Y({y1}-{y2}), H={h}, 整体太白 (Mean={region_mean:.1f} > 254.5)")
continue continue
# --- 优化:边缘背景检查与宽度裁剪 (Margin Check & Crop) --- # --- 优化:边缘背景检查与宽度裁剪 (Margin Check & Crop) ---
# 不再直接拒绝白边,而是尝试计算内容的有效宽度 # 不再直接拒绝白边,而是尝试计算内容的有效宽度
# 假设:有效内容行的方差较高,或者亮度显著不同于背景 # 假设:有效内容行的方差较高,或者亮度显著不同于背景
# 简单策略:保留全宽,但增加高度限制 # 简单策略:保留全宽,但增加高度限制
if h < 60: # 稍微降低阈值以测试 if h < 60: # 稍微降低阈值以测试
logger.info(f" [-] 忽略区域 {i}: Y({y1}-{y2}), H={h}, 高度不足 (<60)") logger.info(f" [-] 忽略区域 {i}: Y({y1}-{y2}), H={h}, 高度不足 (<60)")
continue continue
# 计算左右边距的平均亮度,辅助判断(仅记录日志,不强行过滤) # 计算左右边距的平均亮度,辅助判断(仅记录日志,不强行过滤)
margin_w = max(5, int(width * 0.05)) margin_w = max(5, int(width * 0.05))
l_margin = img_data[y1:y2, 0:margin_w] l_margin = img_data[y1:y2, 0:margin_w]
r_margin = img_data[y1:y2, width-margin_w:width] r_margin = img_data[y1:y2, width - margin_w:width]
l_mean = np.mean(l_margin) l_mean = np.mean(l_margin)
r_mean = np.mean(r_margin) r_mean = np.mean(r_margin)
# 如果是全宽卡片,左右边缘可能是白色的。 # 如果是全宽卡片,左右边缘可能是白色的。
# 之前的逻辑: if l_mean > 252 or r_mean > 252: continue (导致漏检) # 之前的逻辑: if l_mean > 252 or r_mean > 252: continue (导致漏检)
# 现在移除该逻辑。 # 现在移除该逻辑。
# --- 新增:自动裁剪水平宽度 (Auto Horizontal Crop) --- # --- 新增:自动裁剪水平宽度 (Auto Horizontal Crop) ---
# 尝试找到内容的左右边界 (基于列的方差或亮度差异) # 尝试找到内容的左右边界 (基于列的方差或亮度差异)
x1, x2 = 0, width x1, x2 = 0, width
# 从左向右扫描 # 从左向右扫描
for x in range(0, int(width * 0.4), 2): for x in range(0, int(width * 0.4), 2):
col = img_data[y1:y2, x] col = img_data[y1:y2, x]
@@ -713,22 +729,22 @@ class XinDianTuReadImageKit:
if np.std(col) > 5 or np.mean(col) < 245: if np.std(col) > 5 or np.mean(col) < 245:
x1 = x x1 = x
break break
# 从右向左扫描 # 从右向左扫描
for x in range(width - 1, int(width * 0.6), -2): for x in range(width - 1, int(width * 0.6), -2):
col = img_data[y1:y2, x] col = img_data[y1:y2, x]
if np.std(col) > 5 or np.mean(col) < 245: if np.std(col) > 5 or np.mean(col) < 245:
x2 = x + 1 x2 = x + 1
break break
# 增加一点 padding # 增加一点 padding
x1 = max(0, x1 - 10) x1 = max(0, x1 - 10)
x2 = min(width, x2 + 10) x2 = min(width, x2 + 10)
# 如果裁剪后宽度太小,可能不是有效卡片 # 如果裁剪后宽度太小,可能不是有效卡片
if (x2 - x1) < width * 0.5: if (x2 - x1) < width * 0.5:
logger.info(f" [-] 忽略区域 {i}: 裁剪后宽度过小 ({x2-x1})") logger.info(f" [-] 忽略区域 {i}: 裁剪后宽度过小 ({x2 - x1})")
continue continue
# 保存 # 保存
patch = img.crop((x1, y1, x2, y2)) patch = img.crop((x1, y1, x2, y2))
@@ -743,8 +759,9 @@ 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
@@ -767,7 +784,7 @@ class XinDianTuReadImageKit:
"role": "user", "role": "user",
"content": [ "content": [
{ {
"type": "text", "type": "text",
"text": "请识别图片中的充电站信息,并以 JSON 格式输出:{\"station_name\": \"...\", \"price\": \"...\", \"piles\": \"空闲数/总数\"}。只输出 JSON,不要有其他文字。" "text": "请识别图片中的充电站信息,并以 JSON 格式输出:{\"station_name\": \"...\", \"price\": \"...\", \"piles\": \"空闲数/总数\"}。只输出 JSON,不要有其他文字。"
}, },
{"type": "image_url", "image_url": {"url": patch_url}} {"type": "image_url", "image_url": {"url": patch_url}}
@@ -787,7 +804,7 @@ class XinDianTuReadImageKit:
return json.loads(json_match.group()) return json.loads(json_match.group())
except Exception: except Exception:
logger.warning(f"OCR 结果解析 JSON 失败: {content}") logger.warning(f"OCR 结果解析 JSON 失败: {content}")
return {"station_name": "未知"} return {"station_name": "未知"}
@staticmethod @staticmethod
@@ -940,8 +957,8 @@ class XinDianTuReadImageKit:
# 尝试捕获 DataInspection 错误并进行 Base64 回退 # 尝试捕获 DataInspection 错误并进行 Base64 回退
err_code = getattr(e, 'code', '') or '' err_code = getattr(e, 'code', '') or ''
if not err_code and hasattr(e, 'body') and isinstance(e.body, dict): if not err_code and hasattr(e, 'body') and isinstance(e.body, dict):
err_code = e.body.get('code', '') err_code = e.body.get('code', '')
# 如果是媒体格式或数据检查错误 # 如果是媒体格式或数据检查错误
if 'InvalidParameter.DataInspection' in str(err_code) or 'media format' in str(e).lower(): if 'InvalidParameter.DataInspection' in str(err_code) or 'media format' in str(e).lower():
logger.warning(f"Image URL rejected ({err_code}). Attempting Base64 fallback: {image_url}") logger.warning(f"Image URL rejected ({err_code}). Attempting Base64 fallback: {image_url}")
@@ -956,7 +973,7 @@ class XinDianTuReadImageKit:
ext = "webp" ext = "webp"
elif ".jpg" in lower_url or ".jpeg" in lower_url: elif ".jpg" in lower_url or ".jpeg" in lower_url:
ext = "jpeg" ext = "jpeg"
data_uri = f"data:image/{ext};base64,{base64_str}" data_uri = f"data:image/{ext};base64,{base64_str}"
resp = await loop.run_in_executor(None, lambda: _do_request(data_uri)) resp = await loop.run_in_executor(None, lambda: _do_request(data_uri))
else: else:
@@ -966,8 +983,8 @@ class XinDianTuReadImageKit:
logger.error(f"API BadRequestError: {e}") logger.error(f"API BadRequestError: {e}")
return [] return []
except Exception as e: except Exception as e:
logger.error(f"API Unexpected Error: {e}") logger.error(f"API Unexpected Error: {e}")
return [] return []
content = resp.choices[0].message.content or "" content = resp.choices[0].message.content or ""
raw = XinDianTuReadImageKit._extract_json(content) raw = XinDianTuReadImageKit._extract_json(content)
@@ -1147,7 +1164,7 @@ class XinDianTuReadImageKit:
content = resp.choices[0].message.content or "" content = resp.choices[0].message.content or ""
raw = XinDianTuReadImageKit._extract_json(content) raw = XinDianTuReadImageKit._extract_json(content)
result = {} result = {}
try: try:
data = json.loads(raw) data = json.loads(raw)
@@ -1155,7 +1172,7 @@ class XinDianTuReadImageKit:
result = data result = data
except Exception as e: except Exception as e:
logger.error(f"Error parsing address JSON: {e}") logger.error(f"Error parsing address JSON: {e}")
# 等待并合并按钮坐标结果 # 等待并合并按钮坐标结果
try: try:
button_result = await button_task button_result = await button_task
@@ -1284,7 +1301,7 @@ async def test3(station_name: str):
if __name__ == "__main__": if __name__ == "__main__":
#asyncio.run(test1()) # asyncio.run(test1())
station_name = '吉林省看守所充电站' station_name = '吉林省看守所充电站'
#asyncio.run(test2(station_name)) # asyncio.run(test2(station_name))
asyncio.run(test3(station_name)) asyncio.run(test3(station_name))

Binary file not shown.