feat:初版

This commit is contained in:
2025-12-03 15:48:44 +08:00
commit b4df26f61d
199 changed files with 23434 additions and 0 deletions

View File

@@ -0,0 +1 @@
# 短剧搜索插件

View File

@@ -0,0 +1,471 @@
"""
短剧搜索插件
用户发送 /搜索短剧 xxx 来搜索短剧并获取视频链接
"""
import tomllib
import asyncio
import aiohttp
from pathlib import Path
from loguru import logger
from typing import List, Dict, Optional
from utils.plugin_base import PluginBase
from utils.decorators import on_text_message
class PlayletSearch(PluginBase):
"""短剧搜索插件"""
description = "搜索短剧并获取视频链接"
author = "Assistant"
version = "1.0.0"
def __init__(self):
super().__init__()
self.config = None
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("短剧搜索插件已加载")
@on_text_message(priority=70)
async def handle_message(self, bot, message: dict):
"""处理文本消息"""
content = message.get("Content", "").strip()
from_wxid = message.get("FromWxid", "")
is_group = message.get("IsGroup", False)
# 精确匹配指令
if not content.startswith("/搜索短剧 "):
return True
# 检查是否启用
if not self.config["behavior"]["enabled"]:
return True
# 检查群聊过滤
if is_group:
enabled_groups = self.config["behavior"]["enabled_groups"]
disabled_groups = self.config["behavior"]["disabled_groups"]
if from_wxid in disabled_groups:
return True
if enabled_groups and from_wxid not in enabled_groups:
return True
# 提取短剧名称
keyword = content[6:].strip() # 去掉 "/搜索短剧 "
if not keyword:
await bot.send_text(from_wxid, "❌ 请输入短剧名称\n格式:/搜索短剧 短剧名称")
return False
logger.info(f"搜索短剧: {keyword}")
await bot.send_text(from_wxid, f"🔍 正在搜索短剧:{keyword}\n请稍候...")
try:
# 第一步:搜索短剧
search_result = await self._search_playlet(keyword)
if not search_result:
await bot.send_text(from_wxid, f"❌ 未找到短剧:{keyword}")
return False
book_id, cover_url = search_result
# 第二步:获取剧集列表
episode_result = await self._get_episode_list(book_id, keyword)
if episode_result is None:
await bot.send_text(from_wxid, "❌ 获取剧集列表失败")
return False
video_list, detail_cover = episode_result
if not video_list:
await bot.send_text(from_wxid, "❌ 获取剧集列表失败")
return False
# 优先使用搜索结果的cover因为detail_cover可能是错误的
final_cover_url = cover_url if cover_url else detail_cover
# 限制集数
max_episodes = self.config["behavior"]["max_episodes"]
if len(video_list) > max_episodes:
video_list = video_list[:max_episodes]
# 第三步并发获取所有视频URL
video_urls = await self._get_video_urls(video_list)
# 第四步:构造并发送聊天记录
await self._send_chat_records(bot, from_wxid, keyword, video_urls, final_cover_url)
logger.success(f"短剧搜索完成: {keyword}, {len(video_urls)}")
except Exception as e:
logger.error(f"短剧搜索失败: {e}")
await bot.send_text(from_wxid, f"❌ 搜索失败: {str(e)}")
return False
async def _search_playlet(self, keyword: str) -> Optional[tuple]:
"""搜索短剧,返回 (book_id, cover_url)"""
url = self.config["api"]["base_url"]
params = {
"key": self.config["api"]["api_key"],
"keyword": keyword
}
timeout = aiohttp.ClientTimeout(total=self.config["api"]["timeout"])
max_retries = self.config["behavior"].get("max_retries", 15)
for attempt in range(max_retries):
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, params=params) as resp:
if resp.status != 200:
logger.warning(f"搜索短剧失败 (尝试{attempt+1}/{max_retries}): HTTP {resp.status}")
if attempt < max_retries - 1:
await asyncio.sleep(1)
continue
return None
result = await resp.json()
if result.get("code") != 0:
logger.warning(f"搜索短剧失败 (尝试{attempt+1}/{max_retries}): {result.get('msg')}")
if attempt < max_retries - 1:
await asyncio.sleep(1)
continue
return None
data = result.get("data", [])
if not data:
logger.warning(f"搜索短剧无结果 (尝试{attempt+1}/{max_retries})")
if attempt < max_retries - 1:
await asyncio.sleep(1)
continue
return None
# 返回第一个结果的 book_id 和 cover
first_result = data[0]
book_id = first_result.get("book_id")
cover_url = first_result.get("cover", "")
# URL解码
import urllib.parse
if cover_url:
cover_url = urllib.parse.unquote(cover_url)
logger.info(f"找到短剧: {first_result.get('title')}, book_id={book_id}, cover_url={cover_url}")
return (book_id, cover_url)
except Exception as e:
logger.error(f"搜索短剧异常 (尝试{attempt+1}/{max_retries}): {e}")
if attempt < max_retries - 1:
await asyncio.sleep(1)
else:
return None
return None
async def _get_episode_list(self, book_id: str, keyword: str) -> tuple:
"""获取剧集列表(带重试),返回 (video_list, detail_cover)"""
url = self.config["api"]["base_url"]
params = {
"key": self.config["api"]["api_key"],
"book_id": book_id,
"keyword": keyword
}
timeout = aiohttp.ClientTimeout(total=self.config["api"]["timeout"])
max_retries = self.config["behavior"].get("max_retries", 15)
for attempt in range(max_retries):
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, params=params) as resp:
if resp.status != 200:
logger.error(f"获取剧集列表失败 (尝试{attempt+1}/{max_retries}): HTTP {resp.status}")
if attempt < max_retries - 1:
await asyncio.sleep(1)
continue
return ([], "")
result = await resp.json()
if result.get("code") != 0:
logger.error(f"获取剧集列表失败: code={result.get('code')}, msg={result.get('msg')}")
return ([], "")
data = result.get("data", {})
video_list = data.get("video_list") or []
detail = data.get("detail", {})
detail_cover = detail.get("cover", "")
# URL解码
import urllib.parse
if detail_cover:
detail_cover = urllib.parse.unquote(detail_cover)
if not video_list:
logger.warning(f"剧集列表为空 (尝试{attempt+1}/{max_retries})")
if attempt < max_retries - 1:
await asyncio.sleep(1)
continue
return ([], "")
logger.info(f"获取到 {len(video_list)} 集, detail_cover={detail_cover}")
return (video_list, detail_cover)
except Exception as e:
logger.error(f"获取剧集列表异常 (尝试{attempt+1}/{max_retries}): {e}")
if attempt < max_retries - 1:
await asyncio.sleep(1)
else:
return ([], "")
return ([], "")
async def _get_video_url(self, session: aiohttp.ClientSession, video_id: str) -> Optional[str]:
"""获取单个视频URL带重试"""
url = self.config["api"]["base_url"]
params = {
"key": self.config["api"]["api_key"],
"video_id": video_id
}
max_retries = self.config["behavior"].get("max_retries", 15)
for attempt in range(max_retries):
try:
async with session.post(url, params=params) as resp:
if resp.status != 200:
logger.warning(f"获取视频URL失败 (video_id={video_id}, 尝试{attempt+1}/{max_retries}): HTTP {resp.status}")
if attempt < max_retries - 1:
await asyncio.sleep(1)
continue
return None
result = await resp.json()
if result.get("code") != 0:
logger.warning(f"获取视频URL失败 (video_id={video_id}, 尝试{attempt+1}/{max_retries}): code={result.get('code')}, msg={result.get('msg')}")
if attempt < max_retries - 1:
await asyncio.sleep(1)
continue
return None
data = result.get("data", {})
video = data.get("video", {})
video_url = video.get("url")
if not video_url:
logger.warning(f"获取视频URL失败 (video_id={video_id}, 尝试{attempt+1}/{max_retries}): 返回数据中没有url字段")
if attempt < max_retries - 1:
await asyncio.sleep(1)
continue
return None
return video_url
except Exception as e:
logger.error(f"获取视频URL异常 (video_id={video_id}, 尝试{attempt+1}/{max_retries}): {e}")
if attempt < max_retries - 1:
await asyncio.sleep(1)
else:
return None
return None
async def _get_video_urls(self, video_list: List[Dict]) -> List[Dict]:
"""并发获取所有视频URL"""
timeout = aiohttp.ClientTimeout(total=self.config["api"]["timeout"])
max_concurrent = self.config["behavior"].get("max_concurrent_videos", 10)
async with aiohttp.ClientSession(timeout=timeout) as session:
semaphore = asyncio.Semaphore(max_concurrent)
async def get_single_video(video):
async with semaphore:
video_id = video.get("video_id")
title = video.get("title")
url = await self._get_video_url(session, video_id)
if url:
return {"title": title, "url": url}
else:
logger.warning(f"获取视频URL失败: {title} (video_id={video_id})")
return None
# 并发执行所有任务
tasks = [get_single_video(video) for video in video_list]
results = await asyncio.gather(*tasks, return_exceptions=True)
# 过滤掉失败的结果
valid_results = []
for result in results:
if isinstance(result, dict) and result:
valid_results.append(result)
elif isinstance(result, Exception):
logger.error(f"获取视频URL异常: {result}")
return valid_results
async def _send_chat_records(self, bot, from_wxid: str, playlet_name: str, video_urls: List[Dict], cover_url: str = ""):
if not video_urls:
await bot.send_text(from_wxid, "❌ 未获取到任何视频链接")
return
import uuid
import time
import hashlib
import xml.etree.ElementTree as ET
is_group = from_wxid.endswith("@chatroom")
recordinfo = ET.Element("recordinfo")
info_el = ET.SubElement(recordinfo, "info")
info_el.text = f"{playlet_name} 链接合集"
is_group_el = ET.SubElement(recordinfo, "isChatRoom")
is_group_el.text = "1" if is_group else "0"
datalist = ET.SubElement(recordinfo, "datalist")
datalist.set("count", str(len(video_urls)))
desc_el = ET.SubElement(recordinfo, "desc")
desc_el.text = f"{playlet_name} 链接合集"
fromscene_el = ET.SubElement(recordinfo, "fromscene")
fromscene_el.text = "3"
for item in video_urls:
di = ET.SubElement(datalist, "dataitem")
di.set("datatype", "5")
di.set("dataid", uuid.uuid4().hex)
src_local_id = str((int(time.time() * 1000) % 90000) + 10000)
new_msg_id = str(int(time.time() * 1000))
create_time = str(int(time.time()))
ET.SubElement(di, "srcMsgLocalid").text = src_local_id
ET.SubElement(di, "sourcetime").text = time.strftime("%Y-%m-%d %H:%M", time.localtime(int(create_time)))
ET.SubElement(di, "fromnewmsgid").text = new_msg_id
ET.SubElement(di, "srcMsgCreateTime").text = create_time
ET.SubElement(di, "sourcename").text = playlet_name
ET.SubElement(di, "sourceheadurl").text = cover_url or ""
ET.SubElement(di, "datatitle").text = item.get("title") or ""
ET.SubElement(di, "datadesc").text = "点击观看"
ET.SubElement(di, "datafmt").text = "url"
ET.SubElement(di, "link").text = item.get("url") or ""
ET.SubElement(di, "ischatroom").text = "1" if is_group else "0"
weburlitem = ET.SubElement(di, "weburlitem")
ET.SubElement(weburlitem, "thumburl").text = cover_url or ""
ET.SubElement(di, "thumbwidth").text = "200"
ET.SubElement(di, "thumbheight").text = "200"
ET.SubElement(weburlitem, "title").text = item.get("title") or ""
ET.SubElement(weburlitem, "link").text = item.get("url") or ""
ET.SubElement(weburlitem, "desc").text = "点击观看"
appmsgshareitem = ET.SubElement(weburlitem, "appmsgshareitem")
ET.SubElement(appmsgshareitem, "itemshowtype").text = "-1"
dataitemsource = ET.SubElement(di, "dataitemsource")
ET.SubElement(dataitemsource, "hashusername").text = hashlib.sha256(from_wxid.encode("utf-8")).hexdigest()
record_xml = ET.tostring(recordinfo, encoding="unicode")
appmsg_parts = [
"<appmsg appid=\"\" sdkver=\"0\">",
f"<title>{playlet_name} 链接合集</title>",
f"<des>{playlet_name}</des>",
"<type>19</type>",
"<url>https://support.weixin.qq.com/cgi-bin/mmsupport-bin/readtemplate?t=page/favorite_record__w_unsupport</url>",
"<appattach><cdnthumbaeskey></cdnthumbaeskey><aeskey></aeskey></appattach>",
f"<recorditem><![CDATA[{record_xml}]]></recorditem>",
"<percent>0</percent>",
"</appmsg>"
]
appmsg_xml = "".join(appmsg_parts)
await bot._send_data_async(11214, {"to_wxid": from_wxid, "content": appmsg_xml})
logger.success(f"已发送聊天记录,包含 {len(video_urls)} 集视频链接")
def get_llm_tools(self) -> List[dict]:
"""返回LLM工具定义供AIChat插件调用"""
return [
{
"type": "function",
"function": {
"name": "search_playlet",
"description": "搜索短剧并获取视频链接",
"parameters": {
"type": "object",
"properties": {
"keyword": {
"type": "string",
"description": "短剧名称或关键词"
}
},
"required": ["keyword"]
}
}
}
]
async def execute_llm_tool(self, tool_name: str, arguments: dict, bot, from_wxid: str) -> dict:
"""执行LLM工具调用供AIChat插件调用"""
try:
if not self.config["behavior"]["enabled"]:
return {"success": False, "message": "短剧搜索插件未启用"}
# 检查群聊过滤
is_group = from_wxid.endswith("@chatroom")
if is_group:
enabled_groups = self.config["behavior"]["enabled_groups"]
disabled_groups = self.config["behavior"]["disabled_groups"]
if from_wxid in disabled_groups:
return {"success": False, "message": "此群聊未启用短剧搜索功能"}
if enabled_groups and from_wxid not in enabled_groups:
return {"success": False, "message": "此群聊未启用短剧搜索功能"}
if tool_name == "search_playlet":
keyword = arguments.get("keyword")
if not keyword:
return {"success": False, "message": "缺少短剧名称参数"}
logger.info(f"LLM工具调用搜索短剧: {keyword}")
await bot.send_text(from_wxid, f"🔍 正在搜索短剧:{keyword}\n请稍候...")
# 第一步:搜索短剧
search_result = await self._search_playlet(keyword)
if not search_result:
await bot.send_text(from_wxid, f"❌ 未找到短剧:{keyword}")
return {"success": False, "message": f"未找到短剧:{keyword}"}
book_id, cover_url = search_result
# 第二步:获取剧集列表
episode_result = await self._get_episode_list(book_id, keyword)
if episode_result is None:
await bot.send_text(from_wxid, "❌ 获取剧集列表失败")
return {"success": False, "message": "获取剧集列表失败"}
video_list, detail_cover = episode_result
if not video_list:
await bot.send_text(from_wxid, "❌ 获取剧集列表失败")
return {"success": False, "message": "获取剧集列表失败"}
# 优先使用搜索结果的cover
final_cover_url = cover_url if cover_url else detail_cover
# 限制集数
max_episodes = self.config["behavior"]["max_episodes"]
if len(video_list) > max_episodes:
video_list = video_list[:max_episodes]
# 第三步并发获取所有视频URL
video_urls = await self._get_video_urls(video_list)
# 第四步:构造并发送聊天记录
await self._send_chat_records(bot, from_wxid, keyword, video_urls, final_cover_url)
logger.success(f"短剧搜索完成: {keyword}, {len(video_urls)}")
return {"success": True, "message": f"短剧搜索完成:{keyword},共{len(video_urls)}"}
else:
return None # 不是本插件的工具返回None让其他插件处理
except Exception as e:
logger.error(f"LLM工具执行失败: {e}")
await bot.send_text(from_wxid, f"❌ 搜索失败: {str(e)}")
return {"success": False, "message": f"执行失败: {str(e)}"}