重构番号插件为JavBus API在线查询版,替换失效Mongo方案

变更项:

1. 完全替换 fanhao_search 主逻辑,改为参考 koishi-plugin-javbus 的在线接口查询。

2. 移除 MongoDB 依赖与本地集合查询,改为 aiohttp 调用 javbus-api。

3. 新增 /api/v1/movies/{id} 与 /api/movies/{id} 双路由回退,提升兼容性。

4. 保留原有群权限与积分扣费流程,并增强异常日志。

5. 更新配置:新增 api_prefix、auth_token、movie_detail_paths、allow_download_link、allow_preview_cover 等参数。
This commit is contained in:
liuwei
2026-04-22 12:11:01 +08:00
parent 40ba461418
commit 25cc9df8e2
2 changed files with 260 additions and 183 deletions

View File

@@ -3,15 +3,26 @@ enable = true
command = ["番号", "番号查询"] command = ["番号", "番号查询"]
command-format = """ command-format = """
🔎番号查询指令: 🔎番号查询指令:
番号 番号编号 例如:番号 FNS-109 番号 番号编号 例如:番号 SSIS-406
""" """
# MongoDB 连接 # JavBus API 服务地址(必填)
mongo_uri = "mongodb+srv://readonly:cS9NSuiJ1ebHnUL0@cluster0.8mosa.mongodb.net/sehuatang?retryWrites=true&w=majority" # 示例api_prefix = "http://127.0.0.1:8922"
db = "sehuatang" api_prefix = ""
collections = ["hd_chinese_subtitles", "asia_codeless_originate", "asia_mosaic_originate","4k_video"]
# 尝试匹配的字段名(存在其一即可) # 可选鉴权 token对应请求头 j-auth-token
search_fields = ["number"] auth_token = ""
# 请求超时(秒)
request_timeout_seconds = 15
# 详情路由模板(兼容不同 javbus-api 版本)
movie_detail_paths = ["/api/v1/movies/{id}", "/api/movies/{id}"]
# 是否返回磁力(默认关闭,避免群里长文本刷屏)
allow_download_link = false
# 是否发送封面预览图(默认关闭)
allow_preview_cover = false

View File

@@ -1,17 +1,26 @@
from typing import Dict, Any, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import re import re
from urllib.parse import quote
import aiohttp
from loguru import logger from loguru import logger
from base.plugin_common.message_plugin_interface import MessagePluginInterface from base.plugin_common.message_plugin_interface import MessagePluginInterface
from base.plugin_common.plugin_interface import PluginStatus from base.plugin_common.plugin_interface import PluginStatus
from utils.decorator.plugin_decorators import plugin_stats_decorator from utils.decorator.plugin_decorators import plugin_stats_decorator
from utils.decorator.points_decorator import plugin_points_cost from utils.decorator.points_decorator import plugin_points_cost
from utils.robot_cmd.robot_command import Feature, PermissionStatus, GroupBotManager from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus
from wechat_ipad import WechatAPIClient from wechat_ipad import WechatAPIClient
class FanhaoSearchPlugin(MessagePluginInterface): class FanhaoSearchPlugin(MessagePluginInterface):
"""番号查询插件""" """番号查询插件JavBus API 在线版)。
重构说明:
1. 参考 koishi-plugin-javbus 的“在线 API 查询”模式,替换原 Mongo 本地库检索;
2. 与你当前 ABOT 插件接口对齐:群权限、积分扣费、消息发送、日志结构保持一致;
3. 为兼容不同 javbus-api 版本,内置 /api/v1 与 /api 两套路由回退,避免单点失效。
"""
FEATURE_KEY = "FANHAO" FEATURE_KEY = "FANHAO"
FEATURE_DESCRIPTION = "🔎 番号查询功能 [番号]" FEATURE_DESCRIPTION = "🔎 番号查询功能 [番号]"
@@ -22,15 +31,15 @@ class FanhaoSearchPlugin(MessagePluginInterface):
@property @property
def version(self) -> str: def version(self) -> str:
return "1.0.0" return "2.0.0"
@property @property
def description(self) -> str: def description(self) -> str:
return "提供基于MongoDB的番号搜索功能支持两个集合查询" return "基于JavBus API的番号查询支持详情/磁力/封面返回。"
@property @property
def author(self) -> str: def author(self) -> str:
return "liu.wei" return "ABOT Team"
@property @property
def command_prefix(self) -> Optional[str]: def command_prefix(self) -> Optional[str]:
@@ -50,237 +59,294 @@ class FanhaoSearchPlugin(MessagePluginInterface):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
# 注册功能权限,确保群管理页可配置开启/关闭“番号查询”。
self.feature = self.register_feature() self.feature = self.register_feature()
self.mongo_client = None self.enable = True
self.mongo_db = None self._commands: List[str] = ["番号", "番号查询"]
self.command_format = "番号 番号编号 例如:番号 SSIS-406"
# API 基础配置api_prefix 为必填,未配置时会显式报错提醒。
self.api_prefix = ""
self.api_token = ""
self.request_timeout_seconds = 15
# 参考 koishi-plugin-javbus 的两个关键开关。
self.allow_download_link = False
self.allow_preview_cover = False
# 详情接口路由模板:默认优先 v1再回退 v0 风格。
self.movie_detail_paths: List[str] = ["/api/v1/movies/{id}", "/api/movies/{id}"]
def initialize(self, context: Dict[str, Any]) -> bool: def initialize(self, context: Dict[str, Any]) -> bool:
"""初始化插件配置。"""
self.LOG = logger self.LOG = logger
self.LOG.debug(f"正在初始化 {self.name} 插件...") self.LOG.debug(f"正在初始化 {self.name} 插件...")
self.event_system = context.get("event_system") self.event_system = context.get("event_system")
cfg = self._config.get("FanhaoSearch", {}) cfg = self._config.get("FanhaoSearch", {})
self._commands = cfg.get("command", ["番号"]) # 例:"番号 FNS-109" self._commands = cfg.get("command", ["番号", "番号查询"])
self.command_format = cfg.get("command-format", "番号 番号编号 例如:番号 FNS-109") self.command_format = cfg.get("command-format", "番号 番号编号 例如:番号 SSIS-406")
self.enable = cfg.get("enable", True) self.enable = bool(cfg.get("enable", True))
self.mongo_uri = cfg.get( # 用户部署的 javbus-api 前缀例如https://xxx.com 或 http://127.0.0.1:8922
"mongo_uri", self.api_prefix = str(cfg.get("api_prefix", "") or "").strip().rstrip("/")
"mongodb+srv://readonly:cS9NSuiJ1ebHnUL0@cluster0.8mosa.mongodb.net/sehuatang?retryWrites=true&w=majority", # javbus-api 的可选鉴权 Token对应请求头 j-auth-token。
) self.api_token = str(cfg.get("auth_token", "") or "").strip()
self.mongo_db_name = cfg.get("db", "sehuatang") self.request_timeout_seconds = max(5, int(cfg.get("request_timeout_seconds", 15) or 15))
self.collections = cfg.get( self.allow_download_link = bool(cfg.get("allow_download_link", False))
"collections", ["hd_chinese_subtitles", "asia_codeless_originate", "asia_mosaic_originate", "4k_video"] self.allow_preview_cover = bool(cfg.get("allow_preview_cover", False))
)
self.search_fields = cfg.get("search_fields", ["number"]) # 可能的字段名
# 延迟连接,在首次查询时连接,避免初始化阻塞 # 允许外部覆盖路径列表,便于你后续升级 API 版本时无代码切换。
self.LOG.debug(f"[{self.name}] 插件初始化完成,指令:{self._commands}") configured_paths = cfg.get("movie_detail_paths", ["/api/v1/movies/{id}", "/api/movies/{id}"])
normalized_paths: List[str] = []
for path in configured_paths:
text = str(path or "").strip()
if text and "{id}" in text:
normalized_paths.append(text)
self.movie_detail_paths = normalized_paths or ["/api/v1/movies/{id}", "/api/movies/{id}"]
self.LOG.info(
f"[{self.name}] 初始化完成: enable={self.enable}, commands={self._commands}, "
f"api_prefix={'已配置' if self.api_prefix else '未配置'}, allow_download_link={self.allow_download_link}, "
f"allow_preview_cover={self.allow_preview_cover}, timeout={self.request_timeout_seconds}s"
)
return True return True
def start(self) -> bool: def start(self) -> bool:
self.LOG.debug(f"[{self.name}] 插件已启动")
self.status = PluginStatus.RUNNING self.status = PluginStatus.RUNNING
self.LOG.debug(f"[{self.name}] 插件已启动")
return True return True
def stop(self) -> bool: def stop(self) -> bool:
try:
if self.mongo_client:
self.mongo_client.close()
except Exception:
pass
self.LOG.info(f"[{self.name}] 插件已停止")
self.status = PluginStatus.STOPPED self.status = PluginStatus.STOPPED
self.LOG.info(f"[{self.name}] 插件已停止")
return True return True
def can_process(self, message: Dict[str, Any]) -> bool: def can_process(self, message: Dict[str, Any]) -> bool:
"""只处理配置命令开头的文本消息。"""
if not self.enable: if not self.enable:
return False return False
content = str(message.get("content", "")).strip() content = str(message.get("content", "") or "").strip()
if not content:
return False
command = content.split(" ")[0] command = content.split(" ")[0]
return command in self._commands return command in self._commands
def _redact_mongo_uri(self, uri: str) -> str: @staticmethod
try: def _normalize_code(text: str) -> str:
# 隐藏用户名密码,仅保留协议和主机段 """标准化番号。
return re.sub(r"(mongodb\+srv://)(.*?@)", r"\\1***@", uri)
except Exception:
return "***"
def _ensure_mongo(self): 规则说明:
if self.mongo_client: 1. 自动去空格并转大写;
return 2. 处理“字母+数字无横杠”场景,如 ipzz108 -> IPZZ-108
from pymongo import MongoClient 3. 保留用户已有横杠结构,避免误改合法编号。
"""
code = (text or "").strip().upper()
return re.sub(r"([A-Z])(\d)", r"\1-\2", code)
self.LOG.info( def _build_request_headers(self) -> Dict[str, str]:
f"[{self.name}] 准备连接MongoDB uri={self._redact_mongo_uri(self.mongo_uri)} db={self.mongo_db_name}" """构建请求头。
)
try: 说明:
self.mongo_client = MongoClient(self.mongo_uri, serverSelectionTimeoutMS=5000) - 如果配置了 auth_token则透传到 j-auth-token兼容 ovnrain/javbus-api 的鉴权模式;
# 探活 - 固定 UA 便于服务端日志追踪与风控排查。
self.mongo_client.admin.command("ping") """
self.mongo_db = self.mongo_client.get_database(self.mongo_db_name) headers = {
# 打印可见的数据库 "User-Agent": "ABOT-FanhaoSearch/2.0",
try: "Accept": "application/json",
dbs = self.mongo_client.list_database_names() }
self.LOG.info(f"[{self.name}] 可见数据库={dbs}") if self.api_token:
except Exception as e: headers["j-auth-token"] = self.api_token
self.LOG.warning(f"[{self.name}] 获取数据库列表失败: {e}") return headers
try:
colls = self.mongo_db.list_collection_names() async def _fetch_movie_detail(self, movie_id: str) -> Optional[Dict[str, Any]]:
except Exception as e: """请求影片详情,支持多路由回退。"""
colls = [] if not self.api_prefix:
self.LOG.warning(f"[{self.name}] 获取集合列表失败: {e}") return None
self.LOG.info(f"[{self.name}] MongoDB连接成功集合={colls}")
# 对配置集合进行计数探测 timeout = aiohttp.ClientTimeout(total=self.request_timeout_seconds)
for cname in self.collections: headers = self._build_request_headers()
safe_id = quote(movie_id, safe="")
last_error = ""
async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session:
for path in self.movie_detail_paths:
url = f"{self.api_prefix}{path.format(id=safe_id)}"
try: try:
c = self.mongo_db.get_collection(cname) async with session.get(url) as resp:
# 尝试快速计数(可能返回估算值,但足够判断可见性) # 非 2xx 先记录,继续尝试下一条路径。
cnt = c.estimated_document_count() if resp.status < 200 or resp.status >= 300:
self.LOG.info(f"[{self.name}] 集合探测 {self.mongo_db_name}.{cname} 文档数≈{cnt}") body_preview = (await resp.text())[:160]
self.LOG.warning(
f"[{self.name}] 详情接口返回异常: status={resp.status}, url={url}, body={body_preview}"
)
continue
data = await resp.json(content_type=None)
# 同时兼容两种返回形态:
# 1) 直接是详情对象2) 包在 data/result 字段内。
if isinstance(data, dict):
if isinstance(data.get("data"), dict):
return data.get("data")
if isinstance(data.get("result"), dict):
return data.get("result")
if data.get("id") or data.get("title"):
return data
except Exception as e: except Exception as e:
self.LOG.warning(f"[{self.name}] 集合探测失败 {self.mongo_db_name}.{cname}: {e}") last_error = str(e)
except Exception as e: self.LOG.warning(f"[{self.name}] 请求详情失败: url={url}, error={e}")
self.LOG.error(f"[{self.name}] MongoDB连接失败: {e}")
raise
def _normalize_code(self, text: str) -> str:
# 1. 基础清理:判空、去首尾空格、转大写
text = (text or "").strip().upper()
# 用户输入 处理后 说明
# IPzz108 IPZZ-108 目标场景:自动补全了横杠
# ipzz108 IPZZ-108 全小写自动转大写并补全
# IPZZ-108 IPZZ-108 已经有横杠,正则不匹配(字母后是横杠不是数字),保持原样
# ipzz-108 IPZZ-108 小写带横杠,仅转大写,保持原样
# ipzz108 IPZZ-108 去除前后空格
# A1 A-1 极短代码也能兼容
# 2. 核心逻辑:使用正则查找“字母后面紧跟数字”的情况,并在中间插入横杠
# r'([A-Z])(\d)' 含义捕获组1是任意大写字母捕获组2是任意数字
# r'\1-\2' 含义将匹配到的内容替换为“组1 + 横杠 + 组2”
return re.sub(r'([A-Z])(\d)', r'\1-\2', text)
def _build_queries(self, code_upper: str) -> List[Dict[str, Any]]:
# 精确匹配查询
or_exact = [{field: code_upper} for field in self.search_fields]
exact_query = {"$or": or_exact}
# 回退:大小写不敏感的等值匹配
or_regex = [
{field: {"$regex": f"^{re.escape(code_upper)}$", "$options": "i"}}
for field in self.search_fields
]
regex_query = {"$or": or_regex}
return [exact_query, regex_query]
def _query_collections(self, code_upper: str) -> Optional[Dict[str, Any]]:
self._ensure_mongo()
queries = self._build_queries(code_upper)
self.LOG.debug(f"[{self.name}] 标准化番号={code_upper},查询字段={self.search_fields}")
for idx, query in enumerate(queries):
self.LOG.debug(f"[{self.name}] 执行查询({idx + 1}/{len(queries)}): {query}")
for coll_name in self.collections:
try:
coll = self.mongo_db.get_collection(coll_name)
self.LOG.debug(f"[{self.name}] 在集合 {coll_name} 查找…")
doc = coll.find_one(query)
if doc:
doc["_collection"] = coll_name
self.LOG.info(f"[{self.name}] 命中集合 {coll_name}")
return doc
else:
self.LOG.debug(f"[{self.name}] 集合 {coll_name} 未命中")
except Exception as e:
self.LOG.error(f"[{self.name}] 查询集合 {coll_name} 出错: {e}")
continue continue
if last_error:
self.LOG.error(f"[{self.name}] 所有详情路由均失败: movie_id={movie_id}, last_error={last_error}")
return None return None
def _format_result(self, doc: Dict[str, Any]) -> str: @staticmethod
def pick(d: Dict[str, Any], keys: List[str]) -> str: def _extract_star_names(movie: Dict[str, Any]) -> str:
for k in keys: """提取女优名称,兼容数组/字符串两种结构。"""
v = d.get(k) stars = movie.get("stars")
if v: if isinstance(stars, list):
return str(v) names = []
for star in stars:
if isinstance(star, dict) and star.get("name"):
names.append(str(star.get("name")))
elif isinstance(star, str) and star.strip():
names.append(star.strip())
if names:
return ", ".join(names)
if isinstance(movie.get("actress"), str):
return str(movie.get("actress")).strip()
return ""
@staticmethod
def _pick_best_magnet(magnets: Any) -> str:
"""从磁力列表中挑选一条优先磁力链接。
选择策略(参考 koishi-plugin-javbus 并增强健壮性):
1. 优先有字幕的磁力;
2. 再按 numberSize / size 字段的“可解析数值”降序;
3. 返回首个有效 link/hash。
"""
if not isinstance(magnets, list):
return "" return ""
code = pick(doc, self.search_fields) candidates: List[Dict[str, Any]] = [m for m in magnets if isinstance(m, dict)]
title = pick(doc, ["title", "name", "标题"]) or "未提供" if not candidates:
actress = pick(doc, ["actress", "actors", "performer", "女优", "演员"]) # 可为空 return ""
date_val = pick(doc, ["date", "publish_date", "发行日"]) # 例如2025-09-10
post_time = pick(doc, ["post_time"]) # 例如2025-09-10 10:42:04
magnet = pick(doc, ["magnet"]) # 磁力 def parse_size_num(item: Dict[str, Any]) -> float:
magnet_115 = pick(doc, ["magnet_115"]) # 115专用磁力 raw = item.get("numberSize") or item.get("size") or 0
# numberSize 可能是数字,也可能是字符串,这里统一兜底到 float。
try:
return float(raw)
except Exception:
# 尝试从诸如 "2.3 GB" 文本中提取首个数字。
matched = re.search(r"(\d+(?:\.\d+)?)", str(raw))
return float(matched.group(1)) if matched else 0.0
lines = [ with_subtitle = [m for m in candidates if bool(m.get("hasSubtitle"))]
f"✅ 查询成功:{code}", pool = with_subtitle if with_subtitle else candidates
f"标题:{title}", pool_sorted = sorted(pool, key=parse_size_num, reverse=True)
]
if actress:
lines.append(f"演员:{actress}")
if date_val and post_time: for item in pool_sorted:
lines.append(f"日期:{date_val}(发帖:{post_time}") link = str(item.get("link") or "").strip()
elif date_val: if link:
lines.append(f"日期:{date_val}") return link
elif post_time: # 某些 API 只回 hash不回完整 magnet。
lines.append(f"发帖:{post_time}") hash_value = str(item.get("hash") or "").strip()
if hash_value:
return f"magnet:?xt=urn:btih:{hash_value}"
return ""
if magnet: def _format_result_text(self, movie: Dict[str, Any], normalized_code: str) -> str:
lines.append(f"磁力:{magnet}") """拼装文本消息内容。"""
if magnet_115: movie_id = str(movie.get("id") or normalized_code or "未知番号").strip()
lines.append(f"115磁力{magnet_115}") title = str(movie.get("title") or "未提供标题").strip()
date_value = str(movie.get("date") or movie.get("publishDate") or "").strip()
publisher_name = ""
publisher = movie.get("publisher")
if isinstance(publisher, dict):
publisher_name = str(publisher.get("name") or "").strip()
elif isinstance(publisher, str):
publisher_name = publisher.strip()
star_text = self._extract_star_names(movie)
lines = [f"✅ 查询成功:{movie_id}", f"标题:{title}"]
if date_value:
lines.append(f"发行日期:{date_value}")
if star_text:
lines.append(f"女优:{star_text}")
if publisher_name:
lines.append(f"发行商:{publisher_name}")
if self.allow_download_link:
magnet = self._pick_best_magnet(movie.get("magnets"))
if magnet:
lines.append(f"磁力:{magnet}")
return "\n".join(lines) return "\n".join(lines)
@plugin_stats_decorator(plugin_name="番号查询") @plugin_stats_decorator(plugin_name="番号查询")
@plugin_points_cost(10, "番号查询消耗积分", FEATURE_KEY) @plugin_points_cost(10, "番号查询消耗积分", FEATURE_KEY)
async def process_message(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]: async def process_message(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
content = str(message.get("content", "")).strip() """执行番号查询主流程。"""
self.LOG.debug(f"插件执行: {self.name}{content}") content = str(message.get("content", "") or "").strip()
self.LOG.debug(f"[{self.name}] 插件执行: content={content}")
command = content.split(" ")[0]
sender = message.get("sender") sender = message.get("sender")
roomid = message.get("roomid", "") room_id = str(message.get("roomid", "") or "").strip()
gbm: GroupBotManager = message.get("gbm") gbm: GroupBotManager = message.get("gbm")
bot: WechatAPIClient = message.get("bot") bot: WechatAPIClient = message.get("bot")
target = room_id if room_id else sender
# 参数检查 # 群开关关闭时不处理,避免越权。
parts = content.split(" ") if room_id and gbm and gbm.get_group_permission(room_id, self.feature) == PermissionStatus.DISABLED:
if len(parts) < 2:
await bot.send_text_message((roomid if roomid else sender), f"❌命令格式错误!\n{self.command_format}",
sender)
return False, "命令格式错误"
if roomid and gbm.get_group_permission(roomid, self.feature) == PermissionStatus.DISABLED:
return False, "没有权限" return False, "没有权限"
raw_code = content[len(command):].strip() parts = content.split(" ", 1)
user_code = self._normalize_code(raw_code) if len(parts) < 2 or not parts[1].strip():
self.LOG.info( await bot.send_text_message(target, f"❌命令格式错误!\n{self.command_format}", sender)
f"[{self.name}] 收到查询 command={command} raw='{raw_code}' normalized='{user_code}' db={self.mongo_db_name} collections={self.collections}"
)
if not user_code:
await bot.send_text_message((roomid if roomid else sender), f"❌命令格式错误!\n{self.command_format}",
sender)
return False, "命令格式错误" return False, "命令格式错误"
if not self.api_prefix:
await bot.send_text_message(
target,
"❌番号插件未配置 api_prefix请在 plugins/fanhao_search/config.toml 中补充后重试。",
sender,
)
return False, "api_prefix未配置"
raw_code = parts[1].strip()
normalized_code = self._normalize_code(raw_code)
if not normalized_code:
await bot.send_text_message(target, f"❌命令格式错误!\n{self.command_format}", sender)
return False, "命令格式错误"
self.LOG.info(
f"[{self.name}] 收到查询: raw={raw_code}, normalized={normalized_code}, api_prefix={self.api_prefix}"
)
try: try:
doc = self._query_collections(user_code) movie = await self._fetch_movie_detail(normalized_code)
target = roomid if roomid else sender if not movie:
if not doc: await bot.send_text_message(target, f"未找到番号:{normalized_code}", sender)
self.LOG.warning(f"[{self.name}] 未找到番号:{user_code}")
await bot.send_text_message(target, f"未找到番号:{user_code}", sender)
return False, "未找到" return False, "未找到"
text = self._format_result(doc) # 先发文本,确保最差场景也能看到核心信息。
await bot.send_text_message(target, text, sender) result_text = self._format_result_text(movie, normalized_code)
await bot.send_text_message(target, result_text, sender)
# 按配置决定是否额外发送封面预览(可能含敏感内容,默认关闭)。
if self.allow_preview_cover:
cover_url = str(movie.get("img") or movie.get("cover") or "").strip()
if cover_url:
try:
await bot.send_image_message(target, cover_url)
except Exception as image_error:
self.LOG.warning(f"[{self.name}] 封面发送失败: code={normalized_code}, error={image_error}")
return True, "查询成功" return True, "查询成功"
except Exception as e: except Exception as e:
self.LOG.exception(f"处理番号查询出错: {e}") self.LOG.exception(f"[{self.name}] 处理番号查询出错: {e}")
return False, f"处理出错: {e}" return False, f"处理出错: {e}"
def get_plugin(): def get_plugin():
"""返回插件实例。"""
return FanhaoSearchPlugin() return FanhaoSearchPlugin()