feat:初版
This commit is contained in:
7
plugins/VideoParser/__init__.py
Normal file
7
plugins/VideoParser/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
短视频自动解析插件
|
||||
"""
|
||||
|
||||
from .main import VideoParser
|
||||
|
||||
__all__ = ["VideoParser"]
|
||||
429
plugins/VideoParser/main.py
Normal file
429
plugins/VideoParser/main.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
短视频自动解析插件
|
||||
|
||||
自动检测消息中的短视频链接并解析,支持抖音、皮皮虾、哔哩哔哩等平台
|
||||
"""
|
||||
|
||||
import re
|
||||
import tomllib
|
||||
import aiohttp
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
from utils.plugin_base import PluginBase
|
||||
from utils.decorators import on_text_message
|
||||
from WechatHook import WechatHookClient
|
||||
|
||||
|
||||
class VideoParser(PluginBase):
|
||||
"""短视频解析插件"""
|
||||
|
||||
# 插件元数据
|
||||
description = "自动解析短视频链接并发送卡片"
|
||||
author = "ShiHao"
|
||||
version = "1.0.0"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.config = None
|
||||
|
||||
# 支持的短视频平台链接正则表达式
|
||||
self.video_patterns = [
|
||||
# 抖音
|
||||
r'https?://v\.douyin\.com/[A-Za-z0-9]+',
|
||||
r'https?://www\.douyin\.com/video/\d+',
|
||||
r'https?://www\.iesdouyin\.com/share/video/\d+',
|
||||
|
||||
# 快手
|
||||
r'https?://v\.kuaishou\.com/[A-Za-z0-9]+',
|
||||
r'https?://www\.kuaishou\.com/short-video/\d+',
|
||||
|
||||
# 小红书
|
||||
r'https?://xhslink\.com/[A-Za-z0-9]+',
|
||||
r'https?://www\.xiaohongshu\.com/discovery/item/[A-Za-z0-9]+',
|
||||
|
||||
# 微博
|
||||
r'https?://weibo\.com/tv/show/\d+:\d+',
|
||||
r'https?://video\.weibo\.com/show\?fid=\d+:\d+',
|
||||
|
||||
# 微视
|
||||
r'https?://video\.weishi\.qq\.com/[A-Za-z0-9]+',
|
||||
r'https?://h5\.weishi\.qq\.com/weishi/feed/[A-Za-z0-9]+',
|
||||
|
||||
# 西瓜视频
|
||||
r'https?://v\.ixigua\.com/[A-Za-z0-9]+',
|
||||
|
||||
# 最右
|
||||
r'https?://share\.izuiyou\.com/[A-Za-z0-9]+',
|
||||
|
||||
# 美拍
|
||||
r'https?://www\.meipai\.com/media/\d+',
|
||||
|
||||
# 虎牙
|
||||
r'https?://v\.huya\.com/play/\d+\.html',
|
||||
|
||||
# 梨视频
|
||||
r'https?://www\.pearvideo\.com/video_\d+',
|
||||
|
||||
# TikTok
|
||||
r'https?://(?:www\.)?tiktok\.com/@[^/]+/video/\d+',
|
||||
r'https?://vm\.tiktok\.com/[A-Za-z0-9]+',
|
||||
|
||||
# YouTube
|
||||
r'https?://(?:www\.)?youtube\.com/watch\?v=[A-Za-z0-9_-]+',
|
||||
r'https?://youtu\.be/[A-Za-z0-9_-]+',
|
||||
|
||||
# Instagram
|
||||
r'https?://(?:www\.)?instagram\.com/(?:p|reel)/[A-Za-z0-9_-]+',
|
||||
]
|
||||
|
||||
# 编译正则表达式
|
||||
self.compiled_patterns = [re.compile(pattern) for pattern in self.video_patterns]
|
||||
|
||||
async def async_init(self):
|
||||
"""插件异步初始化"""
|
||||
# 读取配置
|
||||
config_path = Path(__file__).parent / "config.toml"
|
||||
with open(config_path, "rb") as f:
|
||||
self.config = tomllib.load(f)
|
||||
|
||||
logger.success("[VideoParser] 短视频解析插件已加载")
|
||||
|
||||
@on_text_message(priority=60)
|
||||
async def handle_video_link(self, bot: WechatHookClient, message: dict):
|
||||
"""处理包含视频链接的消息"""
|
||||
# 检查是否启用
|
||||
if not self.config["behavior"]["enabled"]:
|
||||
return
|
||||
|
||||
content = message.get("Content", "").strip()
|
||||
from_wxid = message.get("FromWxid", "")
|
||||
is_group = message.get("IsGroup", False)
|
||||
|
||||
# 检查群聊/私聊过滤
|
||||
if is_group:
|
||||
if not self._should_parse_group(from_wxid):
|
||||
return
|
||||
else:
|
||||
if not self.config["behavior"]["enable_private"]:
|
||||
return
|
||||
|
||||
# 检测消息中的视频链接
|
||||
video_url = self._extract_video_url(content)
|
||||
if not video_url:
|
||||
return
|
||||
|
||||
logger.info(f"[VideoParser] 检测到视频链接: {video_url}")
|
||||
|
||||
# 调用 API 解析视频
|
||||
try:
|
||||
video_info = await self._parse_video(video_url)
|
||||
if video_info:
|
||||
# 发送链接卡片
|
||||
await self._send_video_card(bot, from_wxid, video_info)
|
||||
|
||||
# 下载并发送视频(使用原始分享链接)
|
||||
await self._download_and_send_video(bot, from_wxid, video_url)
|
||||
else:
|
||||
logger.warning(f"[VideoParser] 视频解析失败: {video_url}")
|
||||
except Exception as e:
|
||||
logger.error(f"[VideoParser] 处理视频链接失败: {e}")
|
||||
import traceback
|
||||
logger.error(f"详细错误: {traceback.format_exc()}")
|
||||
|
||||
def _extract_video_url(self, content: str) -> str:
|
||||
"""从消息内容中提取视频链接"""
|
||||
for pattern in self.compiled_patterns:
|
||||
match = pattern.search(content)
|
||||
if match:
|
||||
return match.group(0)
|
||||
return ""
|
||||
|
||||
async def _parse_video(self, video_url: str) -> dict:
|
||||
"""调用 API 解析视频"""
|
||||
get_aweme_id_url = self.config["api"]["get_aweme_id_url"]
|
||||
fetch_video_url = self.config["api"]["url"]
|
||||
timeout = self.config["api"]["timeout"]
|
||||
|
||||
try:
|
||||
import ssl
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
# 配置代理
|
||||
proxy_config = self.config.get("proxy", {})
|
||||
proxy_url = None
|
||||
if proxy_config.get("enabled", False):
|
||||
proxy_type = proxy_config.get("type", "socks5")
|
||||
proxy_host = proxy_config.get("host")
|
||||
proxy_port = proxy_config.get("port")
|
||||
if proxy_host and proxy_port:
|
||||
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
|
||||
logger.info(f"[VideoParser] 使用代理: {proxy_url}")
|
||||
|
||||
connector = aiohttp.TCPConnector(
|
||||
ssl=ssl_context,
|
||||
force_close=True,
|
||||
enable_cleanup_closed=True
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
# 第一步:提取 aweme_id
|
||||
logger.info(f"[VideoParser] 提取视频ID: {get_aweme_id_url}")
|
||||
async with session.get(
|
||||
get_aweme_id_url,
|
||||
params={"url": video_url},
|
||||
proxy=proxy_url,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
logger.error(f"[VideoParser] 提取视频ID失败: HTTP {response.status}")
|
||||
return None
|
||||
|
||||
result = await response.json()
|
||||
logger.debug(f"[VideoParser] 提取ID返回: {result}")
|
||||
|
||||
if result.get("code") != 200:
|
||||
logger.error(f"[VideoParser] 提取视频ID失败: {result.get('msg', '未知错误')}")
|
||||
return None
|
||||
|
||||
# data 可能是字符串类型的 aweme_id
|
||||
data = result.get("data")
|
||||
if isinstance(data, str):
|
||||
aweme_id = data
|
||||
elif isinstance(data, dict):
|
||||
aweme_id = data.get("aweme_id")
|
||||
else:
|
||||
aweme_id = None
|
||||
|
||||
if not aweme_id:
|
||||
logger.error("[VideoParser] 未找到 aweme_id")
|
||||
return None
|
||||
|
||||
logger.info(f"[VideoParser] 获取到视频ID: {aweme_id}")
|
||||
|
||||
# 第二步:获取视频数据
|
||||
logger.info(f"[VideoParser] 获取视频数据: {fetch_video_url}")
|
||||
async with session.get(
|
||||
fetch_video_url,
|
||||
params={"aweme_id": aweme_id},
|
||||
proxy=proxy_url,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as response:
|
||||
return await self._handle_response(response)
|
||||
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
logger.error(f"[VideoParser] 无法连接到 API 服务器: {e}")
|
||||
return None
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"[VideoParser] 网络请求失败: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[VideoParser] 解析视频失败: {e}")
|
||||
import traceback
|
||||
logger.error(f"详细错误: {traceback.format_exc()}")
|
||||
return None
|
||||
|
||||
async def _handle_response(self, response) -> dict:
|
||||
"""处理 API 响应"""
|
||||
if response.status != 200:
|
||||
response_text = await response.text()
|
||||
logger.error(f"[VideoParser] API 请求失败: HTTP {response.status}, 响应: {response_text[:200]}")
|
||||
return None
|
||||
|
||||
result = await response.json()
|
||||
logger.info(f"[VideoParser] API 返回: code={result.get('code')}, msg={result.get('msg')}")
|
||||
# 打印完整返回数据以便调试
|
||||
import json
|
||||
logger.info(f"[VideoParser] 完整返回数据: {json.dumps(result, ensure_ascii=False, indent=2)}")
|
||||
|
||||
# 检查返回状态(支持多种状态码格式)
|
||||
code = result.get("code")
|
||||
if code not in [200, "200", 1, "1", True]:
|
||||
logger.error(f"[VideoParser] API 返回错误: {result.get('msg', '未知错误')}")
|
||||
return None
|
||||
|
||||
return result
|
||||
|
||||
async def _send_video_card(self, bot: WechatHookClient, to_wxid: str, video_info: dict):
|
||||
"""发送视频信息卡片"""
|
||||
try:
|
||||
# 从 API 返回中提取字段
|
||||
data = video_info.get("data", {})
|
||||
aweme_detail = data.get("aweme_detail", {})
|
||||
|
||||
# 提取作者信息
|
||||
author = aweme_detail.get("author", {})
|
||||
nickname = author.get("nickname", "")
|
||||
|
||||
# 提取视频描述
|
||||
desc = aweme_detail.get("desc", "")
|
||||
|
||||
# 提取封面图(使用 cover_original_scale 的第一个链接)
|
||||
video = aweme_detail.get("video", {})
|
||||
cover_original_scale = video.get("cover_original_scale", {})
|
||||
cover_url_list = cover_original_scale.get("url_list", [])
|
||||
image_url = cover_url_list[0] if cover_url_list else ""
|
||||
|
||||
# 提取视频播放地址(使用 play_addr 的第一个链接)
|
||||
play_addr = video.get("play_addr", {})
|
||||
url_list = play_addr.get("url_list", [])
|
||||
video_url = url_list[0] if url_list else ""
|
||||
|
||||
# 使用默认值(如果字段为空)
|
||||
title = nickname or self.config["card"]["default_title"]
|
||||
desc = desc or self.config["card"]["default_desc"]
|
||||
image_url = image_url or "https://www.functen.cn/static/img/709a3f34713ef07b09d524bee2df69d6.DY.webp"
|
||||
url = video_url or self.config["card"]["default_url"]
|
||||
|
||||
# 限制标题和描述长度
|
||||
if len(title) > 50:
|
||||
title = title[:47] + "..."
|
||||
if len(desc) > 100:
|
||||
desc = desc[:97] + "..."
|
||||
|
||||
logger.info(f"[VideoParser] 发送卡片: title={title}, desc={desc[:30]}...")
|
||||
|
||||
# 发送链接卡片
|
||||
await bot.send_link_card(
|
||||
to_wxid=to_wxid,
|
||||
title=title,
|
||||
desc=desc,
|
||||
url=url,
|
||||
image_url=image_url,
|
||||
)
|
||||
|
||||
logger.success(f"[VideoParser] 视频卡片发送成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[VideoParser] 发送视频卡片失败: {e}")
|
||||
import traceback
|
||||
logger.error(f"详细错误: {traceback.format_exc()}")
|
||||
|
||||
async def _download_and_send_video(self, bot: WechatHookClient, to_wxid: str, video_url: str):
|
||||
"""下载视频并发送"""
|
||||
try:
|
||||
if not self.config.get("download", {}).get("enabled", False):
|
||||
logger.info("[VideoParser] 视频下载功能未启用")
|
||||
return False
|
||||
|
||||
download_api_url = self.config["download"]["download_api_url"]
|
||||
timeout = self.config["download"]["timeout"]
|
||||
|
||||
# 下载到插件目录下的 videos 文件夹
|
||||
videos_dir = Path(__file__).parent / "videos"
|
||||
videos_dir.mkdir(exist_ok=True)
|
||||
|
||||
logger.info(f"[VideoParser] 开始下载视频: {video_url}")
|
||||
|
||||
import ssl
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
# 配置代理
|
||||
proxy_config = self.config.get("proxy", {})
|
||||
proxy_url = None
|
||||
if proxy_config.get("enabled", False):
|
||||
proxy_type = proxy_config.get("type", "socks5")
|
||||
proxy_host = proxy_config.get("host")
|
||||
proxy_port = proxy_config.get("port")
|
||||
if proxy_host and proxy_port:
|
||||
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
|
||||
|
||||
connector = aiohttp.TCPConnector(
|
||||
ssl=ssl_context,
|
||||
force_close=True,
|
||||
enable_cleanup_closed=True
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
async with session.get(
|
||||
download_api_url,
|
||||
params={"url": video_url},
|
||||
proxy=proxy_url,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
logger.error(f"[VideoParser] 视频下载失败: HTTP {response.status}")
|
||||
return False
|
||||
|
||||
# 检查响应类型
|
||||
content_type = response.headers.get('Content-Type', '')
|
||||
logger.info(f"[VideoParser] 响应类型: {content_type}")
|
||||
|
||||
video_data = await response.read()
|
||||
|
||||
# 检查是否是视频文件(MP4文件头)
|
||||
if len(video_data) > 8:
|
||||
file_header = video_data[:8].hex()
|
||||
logger.info(f"[VideoParser] 文件头: {file_header}")
|
||||
# MP4文件头通常是 00 00 00 xx 66 74 79 70
|
||||
if not (b'ftyp' in video_data[:12] or b'moov' in video_data[:100]):
|
||||
logger.warning(f"[VideoParser] 下载的可能不是有效的视频文件,前100字节: {video_data[:100]}")
|
||||
|
||||
if len(video_data) < 1024:
|
||||
logger.warning(f"[VideoParser] 文件太小,可能下载失败,内容: {video_data[:200]}")
|
||||
|
||||
# 生成文件名
|
||||
filename = f"douyin_{datetime.now():%Y%m%d_%H%M%S}_{uuid.uuid4().hex[:8]}.mp4"
|
||||
file_path = videos_dir / filename
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(video_data)
|
||||
|
||||
logger.info(f"[VideoParser] 视频下载完成: {file_path}, 文件大小: {len(video_data)} 字节")
|
||||
|
||||
# 等待文件写入完成
|
||||
import os
|
||||
max_wait = 10
|
||||
wait_time = 0
|
||||
while wait_time < max_wait:
|
||||
if os.path.exists(file_path) and os.path.getsize(file_path) > 0:
|
||||
logger.info(f"[VideoParser] 文件已就绪: {file_path}")
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
wait_time += 0.5
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"[VideoParser] 文件写入失败: {file_path}")
|
||||
return False
|
||||
|
||||
logger.info(f"[VideoParser] 准备发送视频: {file_path}")
|
||||
video_sent = await bot.send_file(to_wxid, str(file_path.resolve()))
|
||||
|
||||
if not video_sent:
|
||||
logger.error(f"[VideoParser] 视频发送失败")
|
||||
return False
|
||||
|
||||
logger.success(f"[VideoParser] 视频发送成功")
|
||||
return True
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"[VideoParser] 视频下载网络错误: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[VideoParser] 视频下载失败: {e}")
|
||||
import traceback
|
||||
logger.error(f"详细错误: {traceback.format_exc()}")
|
||||
return False
|
||||
|
||||
def _should_parse_group(self, room_wxid: str) -> bool:
|
||||
"""判断是否应该在该群解析视频"""
|
||||
enabled_groups = self.config["behavior"]["enabled_groups"]
|
||||
disabled_groups = self.config["behavior"]["disabled_groups"]
|
||||
|
||||
# 如果在禁用列表中,不解析
|
||||
if room_wxid in disabled_groups:
|
||||
return False
|
||||
|
||||
# 如果启用列表为空,对所有群生效
|
||||
if not enabled_groups:
|
||||
return True
|
||||
|
||||
# 否则只对启用列表中的群生效
|
||||
return room_wxid in enabled_groups
|
||||
BIN
plugins/VideoParser/videos/douyin_20251126_213228_5a97e1fc.mp4
Normal file
BIN
plugins/VideoParser/videos/douyin_20251126_213228_5a97e1fc.mp4
Normal file
Binary file not shown.
Reference in New Issue
Block a user