This commit is contained in:
HuangHai
2026-01-12 14:50:20 +08:00
parent efcdc7b949
commit cc5547f37c
21 changed files with 858 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,18 @@
# 采集配置
SCROLL_DISTANCE_RATIO = 0.4
MAX_SCROLLS = 100
MAX_CRAWL_DISTANCE = 50
REDIS_STATION_EXPIRE = 120
DATA_RETENTION_DAYS = 365
# 等待时间配置 (秒)
WAIT_DETAIL_PAGE_LOAD = 3.0
WAIT_BACK_TO_LIST = 1.5
WAIT_AFTER_SCROLL = 2.5
# 坐标计算与安全防护
SAFE_EXCLUDE_RATIO = 0.20
BOTTOM_SAFE_EXCLUDE_RATIO = 0.1
FALLBACK_WIDTH = 1080
FALLBACK_HEIGHT = 2400

View File

@@ -0,0 +1,100 @@
# coding=utf-8
import asyncio
import logging
import uuid
import os
import sys
import time
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 Apps.AiTeJiYiChong import Kit
from Apps.AiTeJiYiChong.Kit import take_screenshot
from Util.RedisKit import RedisKit
from Apps.AiTeJiYiChong.Service import AiTeJiYiChongService
from Config.Config import TEMP_IMAGE_DIR
from Apps.AiTeJiYiChong.Config.Setting import (
SCROLL_DISTANCE_RATIO,
MAX_SCROLLS, REDIS_STATION_EXPIRE,
WAIT_AFTER_SCROLL,
MAX_CRAWL_DISTANCE
)
logger = logging.getLogger("AiTeJiYiChongCrawler")
async def get_station_list(d, service, 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}")
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. 执行图形学分析,生成 _flag.jpg, _vl.jpg 和 .json
logger.info("正在执行图形学切片分析...")
json_data = Kit.crop_cards_from_image(screenshot_path)
# 3. 调用 VL 模型识别并保存数据
# 这里的 service.process_station_list_vl 应该支持传入 json_data 或直接读取图片
stations = await service.process_station_list_vl(screenshot_path, device_info=device_info)
logger.info(f"本页识别到 {len(stations)} 个场站")
if not stations:
logger.warning("本页未识别到任何场站,可能已到底或加载中")
# 如果连续几页没数据可以考虑跳出,这里先简单处理
# 3. 翻页滑动
logger.info("执行翻页滑动...")
start_x, start_y = w // 2, int(h * 0.8)
end_x, end_y = w // 2, int(h * (0.8 - SCROLL_DISTANCE_RATIO))
d.swipe(start_x, start_y, end_x, end_y, duration=0.5)
await asyncio.sleep(WAIT_AFTER_SCROLL)
logger.info("达到最大翻页次数,爬取结束。")
return True
async def main(service=None, do_cleanup=True):
"""
爬虫主入口
"""
if do_cleanup:
Kit.clear_temp_dir()
if service is None:
service = AiTeJiYiChongService()
await service.init_db()
d = u2.connect()
try:
await get_station_list(d, service)
return True
except Exception as e:
logger.error(f"爬取过程中出现异常: {e}")
return False
finally:
# 如果是内部初始化的 service则在此关闭
pass
if __name__ == "__main__":
asyncio.run(main())

324
Apps/AiTeJiYiChong/Kit.py Normal file
View File

@@ -0,0 +1,324 @@
import logging
import os
import cv2
import numpy as np
import time
import json
from Apps.AiTeJiYiChong.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")
logger = logging.getLogger(__name__)
def read_image(path):
"""读取图片,支持中文路径"""
try:
return cv2.imdecode(np.fromfile(path, dtype=np.uint8), -1)
except Exception as e:
logger.info(f"Error reading image {path}: {e}")
return None
def save_image(path, img):
"""保存图片,支持中文路径"""
try:
ext = os.path.splitext(path)[1]
if not ext:
ext = ".jpg"
cv2.imencode(ext, img)[1].tofile(path)
return True
except Exception as e:
logger.error(f"Error saving image {path}: {e}")
return False
# 截图
def take_screenshot(d, image_uuid, save_dir=TEMP_IMAGE_DIR):
path = os.path.join(save_dir, f"{image_uuid}.jpg")
os.makedirs(save_dir, exist_ok=True)
d.screenshot(path)
return path
def clear_temp_dir(save_dir=TEMP_IMAGE_DIR):
"""清空临时目录中的所有文件"""
if not os.path.exists(save_dir):
return
logger.info(f"正在清空临时目录: {save_dir}")
for file in os.listdir(save_dir):
file_path = os.path.join(save_dir, file)
try:
if os.path.isfile(file_path):
os.remove(file_path)
elif os.path.isdir(file_path):
import shutil
shutil.rmtree(file_path)
except Exception as e:
logger.error(f"无法删除文件 {file_path}: {e}")
def click_image_template(d, template_path, timeout=5.0, threshold=0.8):
"""
使用 OpenCV 模板匹配查找并点击图片
"""
if not os.path.exists(template_path):
logger.info(f"Template file not found: {template_path}")
return False
template = read_image(template_path)
if template is None:
logger.info(f"Failed to load template: {template_path}")
return False
t_h, t_w = template.shape[:2]
start_time = time.time()
while time.time() - start_time < timeout:
temp_uuid = "temp_click_check"
screenshot_path = take_screenshot(d, temp_uuid, save_dir=TEMP_IMAGE_DIR)
target = read_image(screenshot_path)
if target is None:
time.sleep(0.5)
continue
# 多尺度匹配
best_match = None
for scale in np.linspace(0.8, 1.2, 5):
resized = cv2.resize(template, (int(t_w * scale), int(t_h * scale)))
res = cv2.matchTemplate(target, resized, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(res)
if best_match is None or max_val > best_match[0]:
best_match = (max_val, max_loc, resized.shape[1], resized.shape[0])
if best_match and best_match[0] >= threshold:
max_val, max_loc, r_w, r_h = best_match
center_x = max_loc[0] + r_w // 2
center_y = max_loc[1] + r_h // 2
logger.info(f"Found template at ({center_x}, {center_y}) with confidence {max_val:.2f}")
d.click(center_x, center_y)
return True
time.sleep(0.5)
return False
def crop_cards_from_image(img_path, output_dir=None, save_debug=True):
"""
从图片中裁剪场站卡片并生成 _flag.jpg 和 _vl.jpg
算法:以导航图标 (arrow.jpg) 为主要锚点,辅以红色价格区域,向上/下/左/右探测背景边界
"""
logger.info(f"Processing: {img_path}")
if not os.path.exists(img_path):
return []
img = read_image(img_path)
if img is None:
return []
h, w = img.shape[:2]
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
s_channel = hsv[:, :, 1]
anchors = []
# 1. 使用导航图标 (arrow.jpg) 进行模板匹配
template_path = os.path.join(os.path.dirname(__file__), "BiaoShi", "arrow.jpg")
if os.path.exists(template_path):
template = read_image(template_path)
if template is not None:
t_h, t_w = template.shape[:2]
# 模板匹配
res = cv2.matchTemplate(img, template, cv2.TM_CCOEFF_NORMED)
threshold = 0.7
loc = np.where(res >= threshold)
# 去重并记录锚点
matched_points = []
for pt in zip(*loc[::-1]): # (x, y)
is_duplicate = False
for mx, my in matched_points:
if abs(mx - pt[0]) < 50 and abs(my - pt[1]) < 50:
is_duplicate = True
break
if not is_duplicate:
matched_points.append(pt)
anchors.append((pt[0] + t_w // 2, pt[1] + t_h // 2))
logger.info(f"Found {len(matched_points)} anchors via arrow template matching.")
# 2. 如果模板匹配找得不够,或者作为补充,使用红色价格区域
# 进一步放宽红色范围
lower_red1 = np.array([0, 30, 30])
upper_red1 = np.array([15, 255, 255])
lower_red2 = np.array([150, 30, 30])
upper_red2 = np.array([180, 255, 255])
mask1 = cv2.inRange(hsv, lower_red1, upper_red1)
mask2 = cv2.inRange(hsv, lower_red2, upper_red2)
red_mask = cv2.bitwise_or(mask1, mask2)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (40, 20))
morphed = cv2.dilate(red_mask, kernel, iterations=1)
contours, _ = cv2.findContours(morphed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
red_anchors_count = 0
red_rects = [] # 记录所有红色的矩形区域
for cnt in contours:
x, y, cw, ch = cv2.boundingRect(cnt)
if 20 < cw < 600 and 10 < ch < 150 and y > h * 0.2:
# 在原始 red_mask 中精确查找该区域的边界,避免膨胀带来的误差
roi_mask = red_mask[y:y+ch, x:x+cw]
points = cv2.findNonZero(roi_mask)
if points is not None:
rx, ry, rcw, rch = cv2.boundingRect(points)
# 转换回原图坐标
exact_rect = (x + rx, y + ry, rcw, rch)
red_rects.append(exact_rect)
ax, ay = exact_rect[0] + exact_rect[2] // 2, exact_rect[1] + exact_rect[3] // 2
else:
red_rects.append((x, y, cw, ch))
ax, ay = x + cw // 2, y + ch // 2
# 检查是否与已有锚点重合
is_duplicate = False
for ex, ey in anchors:
if abs(ey - ay) < 100:
is_duplicate = True
break
if not is_duplicate:
anchors.append((ax, ay))
red_anchors_count += 1
logger.info(f"Found {red_anchors_count} additional anchors via red price detection.")
# 3. 定位背景分隔行 (Blue Separator Rows)
# 计算每一行的平均饱和度和色调
h_channel = hsv[:, :, 0]
row_s_means = np.mean(s_channel[:, int(w*0.2):int(w*0.8)], axis=1)
row_h_means = np.mean(h_channel[:, int(w*0.2):int(w*0.8)], axis=1)
row_s_stds = np.std(s_channel[:, int(w*0.2):int(w*0.8)], axis=1)
# 分隔行的特征:浅蓝色 (H~105, S>15) 且整行均匀 (std小)
is_separator = (row_s_means > 15) & (row_h_means > 90) & (row_h_means < 120) & (row_s_stds < 10)
# 找出所有“非分隔”区域
segments = []
start_y = None
for y in range(h):
if not is_separator[y]:
if start_y is None:
start_y = y
else:
if start_y is not None:
segments.append((start_y, y))
start_y = None
if start_y is not None:
segments.append((start_y, h))
# 合并非常接近的区域 (可能被误认为分隔线的行)
merged_segments = []
if segments:
curr_start, curr_end = segments[0]
for i in range(1, len(segments)):
next_start, next_end = segments[i]
if next_start - curr_end < 20: # 间隙小于 20 像素则合并
curr_end = next_end
else:
merged_segments.append((curr_start, curr_end))
curr_start, curr_end = next_start, next_end
merged_segments.append((curr_start, curr_end))
logger.info(f"Found {len(merged_segments)} merged segments: {merged_segments}")
final_cards = []
for b_start, b_end in merged_segments:
card_h = b_end - b_start
# 场站卡片高度通常在 250 到 500 之间
if card_h > 150:
# 检查这个段落里是否有锚点
segment_anchors = [ (ax, ay) for ax, ay in anchors if b_start - 20 < ay < b_end + 20 ]
if segment_anchors:
# 优化下边界:如果存在红色价格,以下边界为准 (用户建议)
segment_red_rects = [ (rx, ry, rcw, rch) for rx, ry, rcw, rch in red_rects if b_start < ry < b_end ]
y2_refined = b_end
if segment_red_rects:
# 找到最下方的红色区域
max_red_bottom = max([ry + rch for rx, ry, rcw, rch in segment_red_rects])
# 用户建议:发现红色字结束就停止计算下边界。给予微小缓冲空间 (5px)
y2_refined = min(b_end, max_red_bottom + 5)
logger.info(f"Refined y2 from {b_end} to {y2_refined} based on red text.")
# 如果段落太大,可能包含了多个卡片(分隔线没断开)
if card_h > 600:
logger.info(f"Segment at y=[{b_start}, {b_end}] is too large ({card_h}), attempting split by anchors...")
segment_anchors.sort(key=lambda a: a[1])
# 只有当锚点之间距离足够大时才拆分
splits = [b_start]
for i in range(len(segment_anchors) - 1):
ay1 = segment_anchors[i][1]
ay2 = segment_anchors[i+1][1]
if ay2 - ay1 > 200: # 锚点间距大于 200 才考虑拆分
# 在两个锚点之间找最像分隔线的行 (饱和度最高)
split_y = ay1 + np.argmax(row_s_means[ay1:ay2])
splits.append(split_y)
splits.append(b_end)
for i in range(len(splits) - 1):
s1, s2 = splits[i], splits[i+1]
if s2 - s1 > 150:
# 对拆分后的每个部分也尝试优化下边界
part_red_rects = [ (rx, ry, rcw, rch) for rx, ry, rcw, rch in red_rects if s1 < ry < s2 ]
s2_refined = s2
if part_red_rects:
max_red_bottom = max([ry + rch for rx, ry, rcw, rch in part_red_rects])
s2_refined = min(s2, max_red_bottom + 5)
final_cards.append((s1, s2_refined, int(w*0.02), int(w*0.98)))
logger.info(f"Added split card: y=[{s1}, {s2_refined}]")
else:
final_cards.append((b_start, y2_refined, int(w*0.02), int(w*0.98)))
logger.info(f"Added card: y=[{b_start}, {y2_refined}], original_h={card_h}")
# 4. 排序 (按 y1 从上到下)
final_cards.sort(key=lambda c: c[0])
# 保存结果图
if output_dir is None:
output_dir = os.path.dirname(img_path)
base_name = os.path.basename(img_path)
stem, ext = os.path.splitext(base_name)
debug_img = img.copy() # _flag.jpg
vl_img = img.copy() # _vl.jpg
json_data = {"image": base_name, "width": w, "height": h, "cards": []}
for idx, (y1, y2, x1, x2) in enumerate(final_cards):
# 计算点击点 (卡片上方区域)
click_x = int(x1 + (x2 - x1) * 0.2)
click_y = int(y1 + (y2 - y1) * 0.2)
# 在 flag 图上画绿框和红点
cv2.rectangle(debug_img, (x1, y1), (x2, y2), (0, 255, 0), 2)
cv2.circle(debug_img, (click_x, click_y), 10, (0, 0, 255), -1)
# 在 vl 图上只画绿框
cv2.rectangle(vl_img, (x1, y1), (x2, y2), (0, 255, 0), 2)
json_data["cards"].append({
"id": idx + 1,
"rect": [int(x1), int(y1), int(x2), int(y2)],
"click_point": [int(click_x), int(click_y)]
})
# 保存文件
if save_debug:
save_image(os.path.join(output_dir, f"{stem}_flag{ext}"), debug_img)
save_image(os.path.join(output_dir, f"{stem}_vl{ext}"), vl_img)
with open(os.path.join(output_dir, f"{stem}.json"), 'w', encoding='utf-8') as f:
json.dump(json_data, f, ensure_ascii=False, indent=2)
logger.info(f"Generated _flag and _vl images for {len(final_cards)} cards.")
return json_data

View File

@@ -0,0 +1,62 @@
# coding=utf-8
import asyncio
import logging
import os
import time
import uiautomator2 as u2
from Apps.AiTeJiYiChong.Kit import click_image_template
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("OpenAiTeJiYiChong")
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
async def open_mini_program():
"""
异步形式的进入微信小程序: 艾特吉易充
"""
d = u2.connect()
logger.info("执行进入小程序: 艾特吉易充")
# 1. 启动微信
logger.info("启动微信...")
d.app_start("com.tencent.mm", stop=True)
await asyncio.sleep(5)
# 2. 确保在消息列表页并点击搜索
logger.info("尝试查找并点击 '搜索按钮'...")
# 优先尝试从 XinDianTu 的模板中复用 SearchButton.jpg (如果存在)
search_template = os.path.join(os.path.dirname(BASE_DIR), "XinDianTu", "Templates", "SearchButton.jpg")
if not os.path.exists(search_template):
search_template = os.path.join(BASE_DIR, "Templates", "SearchButton.jpg")
if click_image_template(d, search_template):
logger.info("点击了搜索按钮")
else:
logger.warning("未找到搜索按钮,使用坐标点击 (84%, 8%)")
w, h = d.window_size()
d.click(int(w * 0.84), int(h * 0.08))
await asyncio.sleep(2)
# 3. 输入搜索内容
logger.info("输入搜索内容: 艾特吉易充")
d.send_keys("艾特吉易充")
await asyncio.sleep(3)
# 4. 点击小程序
logger.info("点击搜索结果中的小程序...")
# 这里由于没有模板,先使用坐标点击作为第一版的测试逻辑 (通常第一个结果在 50%, 18%)
# 后续有了截图后再补充模板匹配
w, h = d.window_size()
d.click(int(w * 0.5), int(h * 0.18))
logger.info("已点击搜索结果第一项")
await asyncio.sleep(8)
return True
if __name__ == "__main__":
asyncio.run(open_mini_program())

View File

@@ -0,0 +1,149 @@
import numpy as np
from PIL import Image
import os
import asyncio
import hashlib
import json
import aiohttp
import logging
import base64
from openai import OpenAI, BadRequestError
from Config.Config import (
ALY_LLM_API_KEY, VL_MODEL_NAME, VL_MODEL_NAME_AD
)
from Apps.AiTeJiYiChong.Config.Setting import (
SAFE_EXCLUDE_RATIO, FALLBACK_WIDTH, FALLBACK_HEIGHT,
BOTTOM_SAFE_EXCLUDE_RATIO
)
logger = logging.getLogger(__name__)
class ReadImageKit:
_client = OpenAI(
api_key=ALY_LLM_API_KEY,
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
)
# 通用回退设备信息,仅在无法动态获取设备信息时使用
_FALLBACK_DEVICE_INFO = {
"displayWidth": FALLBACK_WIDTH,
"displayHeight": FALLBACK_HEIGHT,
"productName": "generic"
}
_prompt = (
"仅输出JSON数组不含任何说明文字按从左到右、从上到下的顺序识别图片中由【绿色方框】标识的充电站区域。识别规则如下\n"
"1. 必须是图中用绿色实线方框圈出的区域。\n"
"2. 每一个卡片区域必须同时具备以下所有要素,否则严禁识别:\n"
" - 场站名称 (station_name)\n"
" - 距离信息 (distance, 例如 '2.82km''90m'),通常位于卡片右侧蓝色胶囊区域内;\n"
" - 金额/电费 (price例如 '1.2500'),通常以红色字体显示;\n"
" - 充电枪信息 (piles包含''''的类型、总枪数和空闲枪数,例如 '快 4/4')。\n"
"3. 如果绿色方框内缺少上述任何一项要素,说明它不是真正的场站卡片,请直接跳过。\n"
"\n"
"JSON对象字段要求\n"
"1. b_use: 状态标识1或0。如果场站名称为灰色或带有“暂停使用”等标签则为0否则为1。\n"
"2. station_name: 场站名称;\n"
"3. price: 一度电的价格(数字,如 1.2500\n"
"4. piles: 充电枪列表 [{type: '', free: 4, total: 4}]\n"
"5. parking: 停车费用描述(通常在蓝色'P'图标后,例如 '免费停车三小时'\n"
"6. distance: 距离信息字符串(例如 '2.82km''90m'\n"
"7. bounds: {x1,y1,x2,y2} 区域像素坐标0-1000\n"
"8. bounds_norm: {left,top,right,bottom} 归一化坐标(0-1)\n"
"9. station_name_bounds: 场站名称文字区域坐标 {x1,y1,x2,y2}0-1000\n"
"10. station_name_bounds_norm: 场站名称文字归一化坐标(0-1)。\n"
"\n"
"重要约束:\n"
"A. 严禁识别未被绿色方框圈出的区域。如顶部的“长春市”选择、顶部的搜索框、以及中间的“推荐站点”等标签。\n"
"B. 真正的场站卡片在绿色方框内包含:场站名称、金额(红色文字)、距离(右侧蓝色背景内)、充电枪状态(绿色或蓝色徽章,格式为 闲x/x 或 x/x\n"
"C. 严禁将顶部的功能图标(如我的订单、收藏站点等)误认为场站卡片。\n"
"\n"
"严格返回纯JSON格式。"
)
@staticmethod
def _extract_json(text: str) -> str:
if not text:
return "[]"
cleaned = text.strip()
if "```" in cleaned:
lines = []
for line in cleaned.splitlines():
if line.strip().startswith("```"):
continue
lines.append(line)
cleaned = "\n".join(lines).strip()
decoder = json.JSONDecoder()
pos = 0
while pos < len(cleaned):
idx_dict = cleaned.find("{", pos)
idx_list = cleaned.find("[", pos)
candidates = [i for i in (idx_dict, idx_list) if i != -1]
if not candidates:
break
start = min(candidates)
snippet = cleaned[start:]
try:
_, end = decoder.raw_decode(snippet)
return snippet[:end]
except json.JSONDecodeError:
pos = start + 1
continue
return "[]"
@classmethod
async def get_stations_from_image(cls, image_path: str, device_info=None):
"""
使用 Qwen-VL 模型从截图中识别充电站列表
"""
if device_info is None:
device_info = cls._FALLBACK_DEVICE_INFO
if not os.path.exists(image_path):
logger.error(f"Image not found: {image_path}")
return []
# 将图片转换为 Base64
with open(image_path, "rb") as image_file:
encoded_image = base64.b64encode(image_file.read()).decode("utf-8")
try:
response = await asyncio.to_thread(
cls._client.chat.completions.create,
model=VL_MODEL_NAME,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": cls._prompt},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{encoded_image}"},
},
],
}
],
max_tokens=2000,
temperature=0.01
)
content = response.choices[0].message.content
logger.info(f"VL Model Response: {content}")
json_str = cls._extract_json(content)
stations = json.loads(json_str)
# 后处理:如果 bounds 是归一化的,则转换为像素坐标(如果需要)
# 或者如果 bounds 是 0-1000 的,则保持原样或按需转换
return stations
except Exception as e:
logger.error(f"Error calling VL model: {e}")
return []

62
Apps/AiTeJiYiChong/Run.py Normal file
View File

@@ -0,0 +1,62 @@
# coding=utf-8
import asyncio
import logging
import os
import sys
# 将项目根目录添加到 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)
from Apps.AiTeJiYiChong.Service import AiTeJiYiChongService
from Apps.AiTeJiYiChong import Opener
from Apps.AiTeJiYiChong import Crawler
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("FullProcess_AiTeJiYiChong")
async def main():
logger.info("=== 开始全流程任务 (艾特吉易充): 打开小程序 -> 爬取数据 ===")
# 步骤 0: 初始化基础服务
logger.info(">>> 步骤 0: 初始化基础服务 (数据库连接)...")
service = AiTeJiYiChongService()
await service.init_db()
try:
# 步骤 1: 打开小程序
logger.info(">>> 步骤 1: 启动 艾特吉易充 小程序...")
success = await Opener.open_mini_program()
if not success:
logger.error("❌ 无法成功打开小程序,任务终止。")
return
logger.info("✅ 小程序启动成功,等待 5 秒确保界面稳定...")
await asyncio.sleep(5)
# 步骤 2: 执行爬取任务
logger.info(">>> 步骤 2: 开始执行场站爬取任务...")
success = await Crawler.main(service=service, do_cleanup=False)
if not success:
logger.error("❌ 步骤 2 爬取任务失败。")
return
logger.info("✅ 爬取任务完成!")
except Exception as e:
logger.error(f"❌ 运行异常: {e}")
finally:
if service:
await service.close_db()
logger.info("=== 全流程任务结束 ===")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("程序被用户中断.")

View File

@@ -0,0 +1,114 @@
# coding=utf-8
import hashlib
import logging
import os
import sys
import uuid
from datetime import datetime
# 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__))))
if project_root not in sys.path:
sys.path.append(project_root)
from Apps.AiTeJiYiChong.ReadImageKit import ReadImageKit
from DbKit.Db import Db
from Config.Config import DB_URL
from Model.StationProfile import StationProfile
from Model.StationStatus import StationStatus
from Model.StationPriceSchedule import StationPriceSchedule
# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class AiTeJiYiChongService:
def __init__(self):
self.db = Db(db_url=DB_URL)
self.station_profile_model = StationProfile()
self.station_status_model = StationStatus()
self.station_price_schedule_model = StationPriceSchedule()
self.operator = "艾特吉易充"
def generate_id(self):
return str(uuid.uuid4())
def get_hash(self, s: str) -> str:
return hashlib.md5(s.encode('utf-8')).hexdigest()
async def init_db(self):
await self.db.init_db()
async def close_db(self):
await self.db.close()
async def process_station_list_vl(self, image_path, device_info=None) -> list:
"""
基于 VL 模式处理场站列表
"""
# 优先使用带绿框的 _vl.jpg 图片进行识别
vl_img_path = image_path.replace(".jpg", "_vl.jpg")
if os.path.exists(vl_img_path):
logger.info(f"使用带绿框的图片进行识别: {vl_img_path}")
image_to_process = vl_img_path
else:
image_to_process = image_path
station_list = await ReadImageKit.get_stations_from_image(image_to_process, device_info=device_info)
if not station_list:
return []
processed_stations = []
async with await self.db.get_session() as session:
for station in station_list:
name = station.get("station_name")
if not name:
continue
station_hash = self.get_hash(name)
now = datetime.now()
station["station_hash"] = station_hash
# 1. 保存 Profile
profile_id = self.generate_id()
await self.station_profile_model.save(
session=session,
id=profile_id,
station_hash=station_hash,
operator=self.operator,
station_name=name,
valid_start_time=now
)
station["profile_id"] = profile_id
station["valid_start_time"] = now.isoformat()
# 2. 保存 Status (解析价格和电桩)
status_id = self.generate_id()
# 处理 piles 字段
piles_data = station.get("piles")
total, free = 0, 0
if isinstance(piles_data, list):
for p in piles_data:
total += int(p.get("total", 0))
free += int(p.get("free", 0))
await self.station_status_model.save(
session=session,
id=status_id,
station_hash=station_hash,
total_piles=total,
free_piles=free,
piles_detail_json=piles_data,
current_price=float(station.get("price", 0)) if station.get("price") else 0.0,
parking_info=station.get("parking", ""),
distance=station.get("distance", ""),
valid_start_time=now
)
station["status_id"] = status_id
processed_stations.append(station)
await session.commit()
return processed_stations

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
微信小程序名称: 艾特吉易充

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

28
T2_AiTeJiYiChong.py Normal file
View File

@@ -0,0 +1,28 @@
# coding=utf-8
import sys
import os
import asyncio
# 添加当前目录到 sys.path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
def main():
print(f"🚀 正在启动 艾特吉易充 小程序爬虫...")
try:
from Apps.AiTeJiYiChong import Run, Kit
# 启动前清空临时目录
Kit.clear_temp_dir()
# 执行全流程逻辑
asyncio.run(Run.main())
except ImportError as e:
print(f"❌ 导入错误: {e}")
except KeyboardInterrupt:
print(f"\n🛑 用户手动停止了程序 (Ctrl+C)。")
except Exception as e:
print(f"❌ 运行错误: {e}")
if __name__ == "__main__":
main()