This commit is contained in:
HuangHai
2026-01-12 21:33:21 +08:00
parent 2051cb61c2
commit 4612ee2d81
18 changed files with 457 additions and 191 deletions

View File

@@ -23,7 +23,9 @@ from Apps.AiTeJiYiChong.Config.Setting import (
SCROLL_DISTANCE_RATIO,
MAX_SCROLLS, REDIS_STATION_EXPIRE,
WAIT_AFTER_SCROLL,
MAX_CRAWL_DISTANCE
MAX_CRAWL_DISTANCE,
SAFE_EXCLUDE_RATIO,
BOTTOM_SAFE_EXCLUDE_RATIO
)
logger = logging.getLogger("AiTeJiYiChongCrawler")
@@ -66,10 +68,14 @@ async def get_station_list(d, service, max_scrolls=MAX_SCROLLS):
image_uuid = str(uuid.uuid4())
screenshot_path = take_screenshot(d, image_uuid, save_dir=TEMP_IMAGE_DIR)
# 检查是否已经滚动到底部 (截图内容与上次一致)
current_md5 = Kit.get_file_md5(screenshot_path)
# 检查是否已经滚动到底部 (排除状态栏后,内容与上次一致)
current_md5 = Kit.get_image_content_md5(
screenshot_path,
top_ratio=SAFE_EXCLUDE_RATIO,
bottom_ratio=BOTTOM_SAFE_EXCLUDE_RATIO
)
if last_list_md5 and current_md5 == last_list_md5:
logger.info("检测到列表截图内容无变化,已到达底部或加载完成,提前结束。")
logger.info("检测到列表核心内容无变化,已到达底部或加载完成,提前结束。")
if os.path.exists(screenshot_path): os.remove(screenshot_path)
break
last_list_md5 = current_md5
@@ -123,41 +129,58 @@ async def get_station_list(d, service, max_scrolls=MAX_SCROLLS):
task_detail = asyncio.create_task(detail_task_wrapper(detail_path, station_name))
background_tasks.append(task_detail)
# 4. 寻找 timePrice.jpg 图标并进入三级页面
time_price_template = os.path.join(os.path.dirname(__file__), "BiaoShi", "timePrice.jpg")
coords = Kit.find_template_coords(detail_path, time_price_template)
# 4. 寻找分时价格入口并进入三级页面
# 优先使用 VL 模型精准识别,避免误判导致退回微信
res = await ReadImageKit.find_time_price_button_coordinate(detail_path, device_info=device_info)
pos = (res.get("uia_center_x"), res.get("uia_center_y")) if res.get("uia_center_x") else None
if coords:
cx, cy, conf = coords
logger.info(f"找到分时价格图标,进入三级页面...")
d.click(cx, cy)
entered_price_page = False
if pos and pos[0] is not None and pos[1] is not None:
ex, ey = pos
logger.info(f"找到 '分时价格' 按钮: ({ex}, {ey})")
# 记录点击前的页面特征,用于验证是否成功进入三级页面
before_click_md5 = Kit.get_image_content_md5(detail_path)
d.click(ex, ey)
await asyncio.sleep(2)
# 抓取三级页面图片
price_screenshots = []
price_detail_uuid = f"price_detail_{station_name}_{image_uuid}"
price_path = take_screenshot(d, price_detail_uuid, save_dir=TEMP_IMAGE_DIR)
price_screenshots.append(price_path)
# 检查页面是否真的变化了
after_click_path = take_screenshot(d, f"check_{detail_uuid}", save_dir=TEMP_IMAGE_DIR)
after_click_md5 = Kit.get_image_content_md5(after_click_path)
# 滑动处理
last_price_md5 = Kit.get_file_md5(price_path)
for scroll_idx in range(2):
d.swipe(w // 2, int(h * 0.7), w // 2, int(h * 0.3), duration=0.4)
await asyncio.sleep(1)
scroll_path = take_screenshot(d, f"price_scroll_{scroll_idx}_{station_name}", save_dir=TEMP_IMAGE_DIR)
curr_price_md5 = Kit.get_file_md5(scroll_path)
if curr_price_md5 == last_price_md5: break
price_screenshots.append(scroll_path)
last_price_md5 = curr_price_md5
if before_click_md5 != after_click_md5:
entered_price_page = True
# 抓取三级页面图片
price_screenshots = []
price_screenshots.append(after_click_path)
# 滑动处理
last_price_md5 = after_click_md5
for scroll_idx in range(2):
d.swipe(w // 2, int(h * 0.7), w // 2, int(h * 0.3), duration=0.4)
await asyncio.sleep(1)
scroll_path = take_screenshot(d, f"price_scroll_{scroll_idx}_{station_name}", save_dir=TEMP_IMAGE_DIR)
curr_price_md5 = Kit.get_image_content_md5(
scroll_path,
top_ratio=SAFE_EXCLUDE_RATIO,
bottom_ratio=BOTTOM_SAFE_EXCLUDE_RATIO
)
if curr_price_md5 == last_price_md5: break
price_screenshots.append(scroll_path)
last_price_md5 = curr_price_md5
# 后台处理价格图片
logger.info(f"已启动后台分析价格页: {station_name},共 {len(price_screenshots)} 张图")
task_price = asyncio.create_task(analyze_prices_background(service, station_name, price_screenshots))
background_tasks.append(task_price)
# 立即返回
d.press("back")
await asyncio.sleep(0.5)
# 后台处理价格图片
logger.info(f"已启动后台分析价格页: {station_name},共 {len(price_screenshots)} 张图")
task_price = asyncio.create_task(analyze_prices_background(service, station_name, price_screenshots))
background_tasks.append(task_price)
# 立即返回
d.press("back")
await asyncio.sleep(0.5)
else:
logger.warning(f"点击 '{station_name}' 的 '分时价格' 按钮后页面无明显变化,跳过三级页面处理。")
if os.path.exists(after_click_path): os.remove(after_click_path)
else:
logger.warning(f"场站 {station_name} 未找到分时价格入口。")

View File

@@ -36,6 +36,29 @@ def get_file_md5(file_path):
return hash_md5.hexdigest()
def get_image_content_md5(file_path, top_ratio=0.1, bottom_ratio=0.1):
"""
计算图片核心内容的 MD5 值(排除状态栏和导航栏)
"""
img = read_image(file_path)
if img is None:
return None
h, w = img.shape[:2]
top = int(h * top_ratio)
bottom = int(h * (1 - bottom_ratio))
# 裁剪中间部分
content = img[top:bottom, :]
# 将图片数据转换为字节流计算 MD5
# 使用 cv2.imencode 转为 jpg 字节流,避免原始 numpy 数组可能的细微差异(虽然通常没问题)
success, encoded_img = cv2.imencode(".jpg", content)
if success:
return hashlib.md5(encoded_img.tobytes()).hexdigest()
return hashlib.md5(content.tobytes()).hexdigest()
def read_image(path):
"""读取图片,支持中文路径"""
if not path or not os.path.exists(path):

View File

@@ -21,6 +21,16 @@ from Apps.AiTeJiYiChong import Kit
logger = logging.getLogger(__name__)
# 场站过滤黑名单
STATION_EXCLUDED_TITLES = {
"推荐站点", "距离优先", "价格最低", "空闲较多", "筛选", "地图", "列表",
"我的订单", "收藏站点", "优惠券", "站内搜索"
}
STATION_BLACKLIST_KEYWORDS = [
"广告", "点击进入", "立即参与", "咨询电话", "活动详情", "导航到", "停车说明"
]
class ReadImageKit:
_client = AsyncOpenAI(
@@ -42,14 +52,17 @@ class ReadImageKit:
" - 场站名称 (station_name);\n"
" - 距离信息 (distance, 例如 '2.82km' 或 '90m'),通常位于卡片右侧蓝色胶囊区域内;\n"
" - 金额/电费 (price,例如 '1.2500'),通常以红色字体显示;\n"
" - 充电枪信息 (piles,包含'快'或'慢'的类型、总枪数和空闲枪数,例如 '快 4/4')。\n"
" - **充电枪信息 (piles)**: 必须包含类型('快充'、'慢充'、'超充')、总枪数和空闲枪数。识别规则如下:\n"
" - 如果明确标有'快'、'慢'或'超',则对应为'快充'、'慢充'、'超充'。\n"
" - 如果图中只显示数字(如 '34/42')而没有明确标明类型,请统一识别为 `type: \"慢充\"`。\n"
" - 必须以 JSON 对象列表形式输出,例如:`[{\"type\": \"快充\", \"free\": 4, \"total\": 4}, {\"type\": \"慢充\", \"free\": 1, \"total\": 2}]`。\n"
"3. 如果绿色方框内缺少上述任何一项要素,说明它不是真正的场站卡片,请直接跳过。\n"
"\n"
"JSON对象字段要求:\n"
"1. b_use: 状态标识(1或0)。如果场站名称为灰色或带有“暂停使用”等标签,则为0,否则为1。\n"
"2. station_name: 场站名称;\n"
"3. price: 一度电的价格(数字,如 1.2500);\n"
"4. piles: 充电枪列表 [{type: '快', free: 4, total: 4}];\n"
"4. piles: 充电枪列表 [{type: '快'|'慢'|'超', free: 4, total: 4}];\n"
"5. parking: 停车费用描述(通常在蓝色'P'图标后,例如 '免费停车三小时');\n"
"6. distance: 距离信息字符串(例如 '2.82km' 或 '90m');\n"
"7. bounds: {x1,y1,x2,y2} 区域像素坐标(0-1000);\n"
@@ -303,15 +316,27 @@ class ReadImageKit:
img_input = {"url": f"data:image/jpeg;base64,{encoded}"}
prompt = (
"请识别图片中的充电站卡片信息,并以 JSON 格式输出:\n"
"你是一个专业的充电桩信息提取助手。请识别图片中充电站卡片的核心信息。\n"
"注意以下识别规则:\n"
"1. **场站名称 (station_name)**: 场站名称通常位于卡片的第一行(左上角),字体最大且加粗。严禁提取'距离优先'、'空闲较多'、'价格最低'等筛选标签。严禁将卡片中下部的描述信息(如'导航到...'、'停车说明...'、'距您...'等)识别为名称。名称通常是一个具体的地点或品牌名。\n"
"2. **价格 (price)**: 提取数字部分,通常以'元/度'结尾,字体颜色通常为红色或加粗。\n"
"3. **桩数 (piles)**: 识别所有类型的充电桩。请提取'快'、'慢'或'超'字样及其对应的'空闲/总数'(如 '快 4/4')。\n"
" - 如果明确标有'快'、'慢'或'超',则对应为'快充'、'慢充'、'超充'。\n"
" - 如果图中只显示数字(如 '34/42')而没有明确标明类型,请统一识别为 `type: \"慢充\"`。\n"
" - 必须以 JSON 对象列表形式输出,例如:`[{\"type\": \"快充\", \"free\": 4, \"total\": 4}]`。\n"
"4. **距离 (distance)**: 提取如 '2.5km' 格式,通常在蓝色胶囊背景内。\n"
"5. **停车 (parking)**: 简要描述停车收费信息,通常在蓝色'P'图标后。\n\n"
"请以 JSON 格式输出:\n"
"{\n"
" \"station_name\": \"场站名称\",\n"
" \"price\": \"价格(数字)\",\n"
" \"piles\": \"空闲/总数(如 4/4)\",\n"
" \"distance\": \"距离(如 2.5km)\",\n"
" \"parking\": \"停车描述\"\n"
" \"price\": \"价格\",\n"
" \"piles\": [\n"
" {\"type\": \"快\", \"free\": 4, \"total\": 4}\n"
" ],\n"
" \"distance\": \"距离\",\n"
" \"parking\": \"停车信息\"\n"
"}\n"
"只输出 JSON,不要有其他文字。"
"只输出 JSON,不要有任何解释文字。"
)
try:
@@ -410,6 +435,20 @@ class ReadImageKit:
except:
pass
# 3. 后置过滤
if final_stations:
processed_excluded_titles = {str(x).replace(" ", "").strip() for x in STATION_EXCLUDED_TITLES}
filtered = []
for item in final_stations:
name = item.get("station_name")
normalized_name = str(name).replace(" ", "").strip()
if normalized_name in processed_excluded_titles:
continue
if any(kw in normalized_name for kw in STATION_BLACKLIST_KEYWORDS):
continue
filtered.append(item)
final_stations = filtered
return final_stations
@classmethod
@@ -460,3 +499,55 @@ class ReadImageKit:
except Exception as e:
logger.error(f"Error calling VL model: {e}")
return []
@classmethod
async def find_time_price_button_coordinate(cls, image_path: str, device_info=None):
"""
使用 VL 模型定位详情页中的 '分时价格' 按钮
"""
if not os.path.exists(image_path):
return {}
with open(image_path, "rb") as f:
encoded = base64.b64encode(f.read()).decode("utf-8")
img_input = {"url": f"data:image/jpeg;base64,{encoded}"}
prompt = (
"请识别图片中 '分时价格' 或 '查看分时电价' 按钮的中心坐标。\n"
"该按钮通常位于充电站详情页,可能是一个带有价格图标或箭头的小型矩形区域。\n"
"请直接以 JSON 格式输出该按钮的中心点坐标:\n"
"{\"uia_center_x\": 500, \"uia_center_y\": 500}\n"
"注意:坐标系为 0-1000 归一化坐标,其中 (0,0) 为左上角,(1000,1000) 为右下角。\n"
"只输出 JSON,不要有任何解释文字。"
)
try:
resp = await cls._client.chat.completions.create(
model=VL_MODEL_NAME,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": img_input}
]
}
],
max_tokens=100,
temperature=0.01
)
content = resp.choices[0].message.content.strip()
raw = cls._extract_json(content)
res = json.loads(raw)
# 转换 0-1000 坐标到实际像素坐标
if res.get("uia_center_x") is not None and device_info:
w = device_info.get("width", FALLBACK_WIDTH)
h = device_info.get("height", FALLBACK_HEIGHT)
res["uia_center_x"] = int(res["uia_center_x"] * w / 1000)
res["uia_center_y"] = int(res["uia_center_y"] * h / 1000)
return res
except Exception as e:
logger.error(f"VL find_time_price_button_coordinate error: {e}")
return {}

View File

@@ -36,6 +36,25 @@ class AiTeJiYiChongService:
def get_hash(self, s: str) -> str:
return hashlib.md5(s.encode('utf-8')).hexdigest()
def _to_float(self, value) -> float:
"""
从字符串中提取浮点数,过滤掉非数字字符
"""
if value is None:
return 0.0
if isinstance(value, (int, float)):
return float(value)
import re
try:
# 提取第一个匹配的数字部分(包含小数点)
match = re.search(r"[-+]?\d*\.?\d+", str(value))
if match:
return float(match.group())
return 0.0
except:
return 0.0
async def init_db(self):
await self.db.init_db()
@@ -163,10 +182,23 @@ class AiTeJiYiChongService:
# 处理 piles 字段
piles_data = station.get("piles")
total, free = 0, 0
standardized_piles = []
if isinstance(piles_data, list):
for p in piles_data:
total += int(p.get("total", 0))
free += int(p.get("free", 0))
for idx, p in enumerate(piles_data):
try:
t = int(p.get("total", 0))
f = int(p.get("free", 0))
total += t
free += f
standardized_piles.append({
"pile_no": f"G{idx+1}",
"type": p.get("type", "未知"),
"power": "",
"status_text": f"空闲{f}/总{t}",
"remark": "列表页VL"
})
except:
pass
await self.station_status_model.save(
session=session,
@@ -174,8 +206,8 @@ class AiTeJiYiChongService:
station_hash=station_hash,
total_piles=total,
free_piles=free,
piles_detail_json=piles_data,
current_price=float(station.get("price", 0)) if station.get("price") else 0.0,
piles_detail_json=standardized_piles if standardized_piles else None,
current_price=self._to_float(station.get("price")),
parking_info=station.get("parking", ""),
distance=station.get("distance", ""),
valid_start_time=now
@@ -234,10 +266,23 @@ class AiTeJiYiChongService:
# 处理 piles 字段
piles_data = station.get("piles")
total, free = 0, 0
standardized_piles = []
if isinstance(piles_data, list):
for p in piles_data:
total += int(p.get("total", 0))
free += int(p.get("free", 0))
for idx, p in enumerate(piles_data):
try:
t = int(p.get("total", 0))
f = int(p.get("free", 0))
total += t
free += f
standardized_piles.append({
"pile_no": f"G{idx+1}",
"type": p.get("type", "未知"),
"power": "",
"status_text": f"空闲{f}/总{t}",
"remark": "列表页VL"
})
except:
pass
await self.station_status_model.save(
session=session,
@@ -245,8 +290,8 @@ class AiTeJiYiChongService:
station_hash=station_hash,
total_piles=total,
free_piles=free,
piles_detail_json=piles_data,
current_price=float(station.get("price", 0)) if station.get("price") else 0.0,
piles_detail_json=standardized_piles if standardized_piles else None,
current_price=self._to_float(station.get("price")),
parking_info=station.get("parking", ""),
distance=station.get("distance", ""),
valid_start_time=now