Files
WechatHookBot/plugins/PlayletSearch/main.py
2025-12-03 15:48:44 +08:00

472 lines
20 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.
"""
短剧搜索插件
用户发送 /搜索短剧 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)}"}