Files
aiData/Apps/XinDianTu/Crawler.py
HuangHai 2051cb61c2 'commit'
2026-01-12 20:39:06 +08:00

372 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# coding=utf-8
import asyncio
import logging
import uuid
import os
import sys
import json
import time
import hashlib
from PIL import Image
# 将项目根目录添加到 sys.path
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if project_root not in sys.path:
sys.path.append(project_root)
import uiautomator2 as u2
from Core.BaseCrawler import BaseCrawler
from Apps.XinDianTu import Kit
from Apps.XinDianTu.Kit import take_screenshot
from Util.ObsUtil import ObsUploader
from Util.RedisKit import RedisKit
import cv2
from Apps.XinDianTu.Service import XinDianTuService
from Apps.XinDianTu.ReadImageKit import ReadImageKit
from Config.Config import (
OBS_TMP_PREFIX, CDN_DOMAIN, TEMP_IMAGE_DIR
)
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
)
# --- 用户配置区域 ---
# 是否保留截图文件(True=保留备查,False=随用随删)
KEEP_SCREENSHOTS = True
# [Testing] 是否在启动时清除 Redis 中的场站处理记录
# True: 每次运行前清除记录,方便反复测试同一个场站
# False: 生产模式,保留记录以避免重复爬取
TEST_CLEAR_REDIS = True
# 配置说明:
# SCROLL_DISTANCE_RATIO 控制翻页时的滑动距离(在 Config.py 中修改)。
# 对于华为 Mate 20x 等分辨率较低或屏幕较小的手机,如果发现翻页时跳过了某些场站,
# 请尝试减小 SCROLL_DISTANCE_RATIO(例如设置为 0.4 或 0.3)。
# 这样每次滑动的距离变短,可以确保所有场站都能被完整显示并识别。
# 配置日志输出
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger("StationList")
# 强制设置所有相关模块的日志级别为 INFO,防止被第三方库干扰
logging.getLogger("OpenXinDianTu").setLevel(logging.INFO)
logging.getLogger("FullProcess").setLevel(logging.INFO)
class XinDianTuCrawler(BaseCrawler):
"""
新电途小程序爬虫实现
"""
def __init__(self, service=None):
super().__init__(service)
# 初始化配置参数
self.max_scrolls = MAX_SCROLLS
self.uploader = ObsUploader()
self.redis_kit = RedisKit()
async def start(self):
"""
实现 BaseCrawler 的启动入口
"""
# 兼容旧逻辑,直接调用 main
await main(self.service)
async def open_app(self):
# 实际逻辑在 Opener.py,此处可作为封装层
pass
async def crawl_list(self):
# 实际逻辑在 get_station_list,此处可作为封装层
pass
async def crawl_detail(self, station_info):
pass
async def is_already_crawled(redis_kit, station_name):
"""
检查场站是否已经爬取过,仅按名称去重
"""
cleaned_name = Kit.clean_station_name(station_name)
if not cleaned_name:
return False
redis_key = f"crawled:xdt:{cleaned_name}"
if await redis_kit.get_data(redis_key):
return True
return False
async def analyze_prices_background(service, station_name, image_paths):
"""
后台异步处理价格图片分析
"""
try:
# 使用多图解析并去重
all_rows = await ReadImageKit.parse_price_schedule_multi(station_name, image_paths)
if all_rows:
# 24小时铺平
hourly_schedule = ReadImageKit.hourly_full_day(all_rows)
await service.process_price_detail_data(station_name, hourly_schedule)
logger.info(f"后台处理完成: {station_name} 的分时价格已保存。")
else:
logger.warning(f"后台处理失败: {station_name} 未能解析出价格时段。")
# 处理完成后清理图片
for path in image_paths:
try:
if os.path.exists(path):
os.remove(path)
except:
pass
except Exception as e:
logger.error(f"分析价格后台任务异常 ({station_name}): {e}")
return True
async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
"""
获取场站列表并处理翻页 (异步优化版)
"""
redis_kit = RedisKit()
window_size = d.window_size()
w, h = window_size[0], window_size[1]
device_info = d.info
device_info['width'] = w
device_info['height'] = h
logger.info(f"开始爬取列表,设备: {device_info.get('productName')} | 分辨率: {w}x{h}")
background_tasks = []
for i in range(max_scrolls + 1):
logger.info(f"正在处理第 {i + 1} 页...")
# 1. 拍摄截图
image_uuid = str(uuid.uuid4())
screenshot_path = take_screenshot(d, image_uuid, save_dir=TEMP_IMAGE_DIR)
logger.info(f"列表页截图已完成: {screenshot_path}")
# 2. 执行本地图形学分析,生成 _vl.jpg 和 .json
logger.info("正在执行图形学切片分析...")
Kit.crop_cards_from_image(screenshot_path)
json_path = screenshot_path.replace(".jpg", ".json")
vl_img_path = screenshot_path.replace(".jpg", "_vl.jpg")
if not os.path.exists(json_path) or not os.path.exists(vl_img_path):
logger.warning("未识别到任何卡片,跳过当前页")
continue
with open(json_path, 'r', encoding='utf-8') as f:
json_metadata = json.load(f)
# 3. 【优化】后台异步上传 _vl.jpg 供以后参考,不再等待上传
vl_object_key = f"{OBS_TMP_PREFIX}/{image_uuid}_vl.jpg"
loop = asyncio.get_running_loop()
loop.run_in_executor(None, uploader.upload_file, vl_object_key, vl_img_path)
# 4. 【优化】直接使用本地路径调用 VL 模型识别,避免等待上传
logger.info("正在调用 VL 模型识别场站信息...")
stations = await service.process_station_list_vl(vl_img_path, json_metadata, device_info=device_info)
logger.info(f"本页识别到 {len(stations)} 个场站")
# 5. 匹配几何卡片与 VL 识别结果 (XinDianTu 的 parse_vl_image 已经按顺序返回了)
# 这里的 stations 列表长度应该与 json_metadata["cards"] 对应
if json_metadata.get("cards") and stations:
for idx, card in enumerate(json_metadata["cards"]):
# 检查索引是否越界 (VL 模型可能返回的数组长度不一致)
if idx < len(stations) and stations[idx]:
st = stations[idx]
station_name = st.get("station_name")
if not station_name: continue
# 检查 Redis 去重
if await is_already_crawled(redis_kit, station_name):
logger.info(f"场站 {station_name} 匹配到已处理记录,跳过。")
continue
click_x, click_y = card["click_point"]
logger.info(f"准备处理第 {idx + 1} 个场站: {station_name}, 点击坐标: ({click_x}, {click_y})")
d.click(int(click_x), int(click_y))
# 等待二级页面加载
await asyncio.sleep(WAIT_DETAIL_PAGE_LOAD)
# 截取二级页面图
detail_uuid = str(uuid.uuid4())
detail_path = take_screenshot(d, detail_uuid, save_dir=TEMP_IMAGE_DIR)
# 【优化】后台解析详情页地址,直接传本地路径,避免等待上传
logger.info(f"已启动后台分析详情页: {station_name}")
task_addr = asyncio.create_task(service.process_station_address(station_name, detail_path, device_info=device_info))
background_tasks.append(task_addr)
# 【优化】后台异步上传详情页截图供以后参考
detail_object_key = f"{OBS_TMP_PREFIX}/{detail_uuid}.jpg"
loop = asyncio.get_running_loop()
loop.run_in_executor(None, uploader.upload_file, detail_object_key, detail_path)
# --- 抓取价格时段信息 ---
# 识别 "全部时段" 按钮
pos = Kit.find_expand_button_position(detail_path, debug_dir=TEMP_IMAGE_DIR, debug_filename_prefix=station_name)
if pos:
ex, ey = pos
logger.info(f"找到 '全部时段' 按钮: ({ex}, {ey})")
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)
# 从三级页面返回
d.press("back")
await asyncio.sleep(0.5)
# 从二级页面返回
d.press("back")
await asyncio.sleep(WAIT_BACK_TO_LIST)
# 记录 Redis 去重 (仅按名称去重)
cleaned = Kit.clean_station_name(station_name)
await redis_kit.set_data(f"crawled:xdt:{cleaned}", "1", expire=REDIS_STATION_EXPIRE)
# 清理已完成的后台任务
done_tasks = [t for t in background_tasks if t.done()]
for t in done_tasks:
if t.exception():
logger.error(f"后台任务异常: {t.exception()}")
background_tasks.remove(t)
# 6. 翻页
logger.info("执行翻页滑动...")
d.swipe_ext("up", scale=SCROLL_DISTANCE_RATIO)
await asyncio.sleep(WAIT_AFTER_SCROLL)
# 等待所有后台任务完成
if background_tasks:
logger.info(f"正在等待剩余 {len(background_tasks)} 个后台任务完成...")
await asyncio.gather(*background_tasks, return_exceptions=True)
logger.info("采集任务完成")
return True
def clean_images_dir():
"""清理 Images 目录下除保留文件外的所有文件"""
base_dir = os.path.dirname(os.path.abspath(__file__))
images_dir = os.path.join(base_dir, "Images")
if not os.path.exists(images_dir):
return
keep_files = {'1.jpg', '2.jpg', '3.jpg', '4.jpg'}
logger.info(f"正在清理 Images 目录 (保留: {keep_files})...")
deleted_count = 0
for filename in os.listdir(images_dir):
if filename in keep_files:
continue
file_path = os.path.join(images_dir, filename)
try:
if os.path.isfile(file_path):
os.remove(file_path)
deleted_count += 1
except Exception as e:
logger.warning(f"删除文件失败 {filename}: {e}")
logger.info(f"清理完成,共删除 {deleted_count} 个文件。")
async def clean_redis_data(redis_kit):
"""清除 Redis 中的场站处理记录"""
if TEST_CLEAR_REDIS:
logger.info("测试模式开启:正在清除 Redis 中的场站处理记录 (processed_station:*)...")
keys = await redis_kit.keys("processed_station:*")
if keys:
count = await redis_kit.delete(*keys)
logger.info(f"已清除 {count} 条场站处理记录")
else:
logger.info("未发现旧的场站处理记录")
async def main(service=None, do_cleanup=True):
# 连接设备
d = u2.connect()
# 初始化服务
should_close_service = False
if service is None:
service = XinDianTuService()
should_close_service = True
# 初始化数据库连接(XinDianTuService 需要)
await service.init_db()
uploader = ObsUploader()
redis_kit = RedisKit()
try:
if do_cleanup:
# 清理图片目录
clean_images_dir()
# [Testing] 如果配置为测试模式,启动时清除 Redis 记录
await clean_redis_data(redis_kit)
# 清理过期数据
# await service.cleanup_old_data()
# 获取场站列表
stations = await get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS)
if stations:
logger.info("场站列表采集完成。")
else:
logger.warning("未采集到任何场站信息。")
return True
except Exception as e:
logger.exception(f"运行过程中出现异常: {e}")
return False
finally:
# 关闭数据库连接
if should_close_service:
await service.close_db()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("程序被用户中断.")
except Exception as e:
logger.exception(f"程序崩溃: {e}")