Files
pansou/docs/插件开发指南.md
www.xueximeng.com 3d61e6e33e 新增插件feikuai
2025-11-18 18:11:53 +08:00

1252 lines
39 KiB
Markdown
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.
# PanSou 插件开发指南
## 概述
PanSou 采用异步插件架构,支持通过插件扩展搜索来源。插件系统基于 Go 接口设计,提供高性能的并发搜索能力和智能缓存机制。
## 系统架构
### 核心组件
- **插件管理器 (PluginManager)**: 管理所有插件的注册和调度
- **异步插件 (AsyncSearchPlugin)**: 实现异步搜索接口的插件
- **基础插件 (BaseAsyncPlugin)**: 提供通用功能的基础结构
- **工作池**: 管理并发请求和资源限制
- **缓存系统**: 二级缓存提供高性能数据存储
### 异步处理机制
1. **双级超时控制**:
- 短超时 (4秒): 确保快速响应用户
- 长超时 (30秒): 允许完整数据处理
2. **渐进式结果返回**:
- `isFinal=false`: 部分结果,继续后台处理
- `isFinal=true`: 完整结果,停止处理
3. **智能缓存更新**:
- 实时更新主缓存 (内存+磁盘)
- 结果合并去重
- 用户无感知数据更新
## 插件接口规范
### AsyncSearchPlugin 接口
```go
type AsyncSearchPlugin interface {
// Name 返回插件名称 (必须唯一)
Name() string
// Priority 返回插件优先级 (1-4数字越小优先级越高影响搜索结果排序)
Priority() int
// AsyncSearch 异步搜索方法 (核心方法)
AsyncSearch(keyword string, searchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error), mainCacheKey string, ext map[string]interface{}) ([]model.SearchResult, error)
// SetMainCacheKey 设置主缓存键 (由系统调用)
SetMainCacheKey(key string)
// SetCurrentKeyword 设置当前搜索关键词 (用于日志显示)
SetCurrentKeyword(keyword string)
// Search 同步搜索方法 (兼容性方法)
Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error)
// SkipServiceFilter 返回是否跳过Service层的关键词过滤 (新增功能)
// 对于磁力搜索等需要宽泛结果的插件应返回true
SkipServiceFilter() bool
}
```
### 参数说明
- **keyword**: 搜索关键词
- **searchFunc**: HTTP搜索函数处理实际的网络请求
- **mainCacheKey**: 主缓存键,用于缓存管理
- **ext**: 扩展参数,支持自定义搜索选项
### Service层过滤控制 (新功能)
PanSou支持插件级别的Service层过滤控制允许插件自主决定是否在Service层进行关键词过滤
#### 过滤机制说明
1. **插件层过滤**: 在插件内部使用 `FilterResultsByKeyword()` 进行精确过滤
2. **Service层过滤**: 在 `search_service.go``mergeResultsByType()` 中进行二次过滤
3. **双层过滤问题**: 某些插件(如磁力搜索)需要更宽泛的搜索结果,二次过滤会误删有效结果
#### 适用场景
**应该跳过Service层过滤的插件类型**:
-**磁力搜索插件**: 如 thepiratebay标题格式特殊点号分隔需要宽泛匹配
-**国外资源插件**: 英文资源标题与中文关键词匹配度低
-**特殊格式插件**: 标题包含大量符号或编码,标准过滤可能失效
-**聚合搜索插件**: 需要保留所有相关结果供用户筛选
**应该保持Service层过滤的插件类型**:
- ⚠️ **网盘搜索插件**: 标准中文资源,过滤有助于提高精确度
- ⚠️ **API接口插件**: 结构化数据,关键词匹配准确
- ⚠️ **论坛爬取插件**: 标题格式标准,过滤效果良好
## 插件优先级系统
### 优先级等级
PanSou 采用4级插件优先级系统直接影响搜索结果的排序权重
| 等级 | 得分 | 适用场景 | 示例插件 |
|------|------|----------|----------|
| **等级1** | **1000分** | 高质量、稳定可靠的数据源 | panta, zhizhen, labi |
| **等级2** | **500分** | 质量良好、响应稳定的数据源 | huban, shandian, duoduo |
| **等级3** | **0分** | 普通质量的数据源 | pansearch, hunhepan, pan666 |
| **等级4** | **-200分** | 质量较低或不稳定的数据源 | - |
### 排序算法影响
插件优先级在PanSou的多维度排序算法中占据主导地位
```
总得分 = 插件得分(1000/500/0/-200) + 时间得分(最高500) + 关键词得分(最高420)
```
**权重分配**
- 🥇 **插件等级**: ~52% (主导因素)
- 🥈 **关键词匹配**: ~22% (重要因素)
- 🥉 **时间新鲜度**: ~26% (重要因素)
**实际效果**
- 等级1插件的结果通常排在前列
- 即使是较旧的等级1插件结果也会优于新的等级3插件结果
- 包含优先关键词的等级2插件可能超越等级1插件
### 如何选择优先级
在开发新插件时,应根据以下标准选择合适的优先级:
#### 选择等级1的条件
- ✅ 数据源质量极高,很少出现无效链接
- ✅ 服务稳定性好,响应时间短
- ✅ 数据更新频率高,内容新颖
- ✅ 链接有效性高(>90%
#### 选择等级2的条件
- ✅ 数据源质量良好,偶有无效链接
- ✅ 服务相对稳定,响应时间适中
- ✅ 数据更新较为及时
- ✅ 链接有效性中等70-90%
#### 选择等级3的条件
- ⚠️ 数据源质量一般,存在一定比例无效链接
- ⚠️ 服务稳定性一般,可能偶有超时
- ⚠️ 数据更新不够及时
- ⚠️ 链接有效性较低50-70%
#### 选择等级4的条件
- ❌ 数据源质量较差,大量无效链接
- ❌ 服务不稳定,经常超时或失败
- ❌ 数据更新缓慢或过时
- ❌ 链接有效性很低(<50%
### 启动时显示
系统启动时会按优先级排序显示所有已加载的插件:
```
已加载插件:
- panta (优先级: 1)
- zhizhen (优先级: 1)
- labi (优先级: 1)
- huban (优先级: 2)
- duoduo (优先级: 2)
- pansearch (优先级: 3)
- hunhepan (优先级: 3)
```
## 开发新插件
### 1. 基础结构
```go
package myplugin
import (
"context"
"io"
"net/http"
"time"
"pansou/model"
"pansou/plugin"
"pansou/util/json" // 使用项目统一的高性能JSON工具
)
type MyPlugin struct {
*plugin.BaseAsyncPlugin
}
func init() {
p := &MyPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin", 3), // 优先级3 = 普通质量数据源
}
plugin.RegisterGlobalPlugin(p)
}
// 对于需要跳过Service层过滤的插件如磁力搜索插件
func init() {
p := &MyMagnetPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("mymagnet", 4, true), // 跳过Service层过滤
}
plugin.RegisterGlobalPlugin(p)
}
// Search 执行搜索并返回结果(兼容性方法)
func (p *MyPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
result, err := p.SearchWithResult(keyword, ext)
if err != nil {
return nil, err
}
return result.Results, nil
}
// SearchWithResult 执行搜索并返回包含IsFinal标记的结果推荐方法
func (p *MyPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)
}
```
### 2. 实现搜索逻辑(⭐ 推荐实现模式)
```go
func (p *MyPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
// 1. 构建请求URL
searchURL := fmt.Sprintf("https://api.example.com/search?q=%s", url.QueryEscape(keyword))
// 2. 处理扩展参数
if titleEn, ok := ext["title_en"].(string); ok && titleEn != "" {
searchURL += "&title_en=" + url.QueryEscape(titleEn)
}
// 3. 创建带超时的上下文 ⭐ 重要:避免请求超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 4. 创建请求对象 ⭐ 重要使用context控制超时
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err)
}
// 5. 设置完整请求头 ⭐ 重要:避免反爬虫检测
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Referer", "https://api.example.com/")
// 6. 发送HTTP请求带重试机制⭐ 重要:提高稳定性
resp, err := p.doRequestWithRetry(req, client)
if err != nil {
return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err)
}
defer resp.Body.Close()
// 7. 检查状态码
if resp.StatusCode != 200 {
return nil, fmt.Errorf("[%s] 请求返回状态码: %d", p.Name(), resp.StatusCode)
}
// 8. 解析响应
var apiResp APIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("[%s] JSON解析失败: %w", p.Name(), err)
}
// 9. 转换为标准格式
results := make([]model.SearchResult, 0, len(apiResp.Data))
for _, item := range apiResp.Data {
result := model.SearchResult{
UniqueID: fmt.Sprintf("%s-%s", p.Name(), item.ID),
Title: item.Title,
Content: item.Description,
Datetime: item.CreateTime,
Tags: item.Tags,
Links: convertLinks(item.Links), // 转换链接格式
}
results = append(results, result)
}
// 10. 关键词过滤
return plugin.FilterResultsByKeyword(results, keyword), nil
}
// doRequestWithRetry 带重试机制的HTTP请求 ⭐ 重要:提高稳定性
func (p *MyPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {
maxRetries := 3
var lastErr error
for i := 0; i < maxRetries; i++ {
if i > 0 {
// 指数退避重试
backoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond
time.Sleep(backoff)
}
// 克隆请求避免并发问题
reqClone := req.Clone(req.Context())
resp, err := client.Do(reqClone)
if err == nil && resp.StatusCode == 200 {
return resp, nil
}
if resp != nil {
resp.Body.Close()
}
lastErr = err
}
return nil, fmt.Errorf("重试 %d 次后仍然失败: %w", maxRetries, lastErr)
}
```
### 3. 链接转换与 work_title 字段
#### Link 结构定义
```go
type Link struct {
Type string `json:"type"` // 网盘类型
URL string `json:"url"` // 链接地址
Password string `json:"password"` // 提取码/密码
Datetime time.Time `json:"datetime,omitempty"` // 链接更新时间(可选)
WorkTitle string `json:"work_title,omitempty"` // 作品标题(重要:用于区分同一消息中多个作品的链接)
}
```
#### work_title 字段详解
**字段作用**:
- 用于区分**同一条消息/结果中包含的多个不同作品**的链接
- 特别适用于论坛帖子、TG频道消息等一次性发布多部影视资源的场景
**使用场景示例**:
```
📺 TG频道消息示例
【今日更新】多部热门剧集
1. 凡人修仙传 第30集
夸克https://pan.quark.cn/s/abc123
2. 唐朝诡事录 第20集
夸克https://pan.quark.cn/s/def456
3. 庆余年2 全集
百度https://pan.baidu.com/s/xyz789?pwd=abcd
```
**不使用 work_title 的问题**:
- 所有链接的标题都是 "【今日更新】多部热门剧集"
- 用户无法区分哪个链接对应哪部剧集
**使用 work_title 后的效果**:
```go
links := []model.Link{
{
Type: "quark",
URL: "https://pan.quark.cn/s/abc123",
WorkTitle: "凡人修仙传 第30集", // 独立作品标题
},
{
Type: "quark",
URL: "https://pan.quark.cn/s/def456",
WorkTitle: "唐朝诡事录 第20集", // 独立作品标题
},
{
Type: "baidu",
URL: "https://pan.baidu.com/s/xyz789?pwd=abcd",
Password: "abcd",
WorkTitle: "庆余年2 全集", // 独立作品标题
},
}
```
**PanSou系统的智能处理**:
PanSou 会根据消息中的链接数量自动决定是否提取 work_title
1. **链接数量 ≤ 4**:所有链接使用相同的 work_title即消息标题
```go
// 示例:一条消息只包含同一部剧的不同网盘链接
// 消息标题:"凡人修仙传 第30集"
// 链接1(夸克)、链接2(百度) → work_title 都是 "凡人修仙传 第30集"
```
2. **链接数量 > 4**:系统智能识别每个链接对应的作品标题
```go
// 示例一条消息包含5个不同作品的链接
// 系统会分析消息文本,为每个链接提取独立的 work_title
```
**插件实现 work_title 的两种方式**:
**方式1: 依赖系统自动提取**适用于TG频道、论坛等
```go
// 直接返回链接,系统会自动调用 extractWorkTitlesForLinks 进行处理
links := []model.Link{
{Type: "quark", URL: "https://pan.quark.cn/s/abc123"},
{Type: "baidu", URL: "https://pan.baidu.com/s/xyz789"},
}
// PanSou会根据消息文本自动为每个链接提取work_title
```
**方式2: 插件手动设置**适用于API插件、磁力搜索等
```go
// 插件直接设置 work_title如feikuai、thepiratebay等
links := []model.Link{
{
Type: "magnet",
URL: magnetURL,
WorkTitle: buildWorkTitle(keyword, fileName), // 插件自己构建
Datetime: publishedTime,
},
}
```
**插件开发建议**:
- **网盘API插件**: 如果API直接返回单一作品可以不设置 work_title留空
- **磁力搜索插件**: 建议设置 work_title特别是文件名不含中文时需要拼接关键词
- **爬虫插件**: 如果能从页面提取每个链接的独立标题,建议设置 work_title
#### 支持的网盘类型
PanSou系统支持以下网盘类型的自动识别完整列表
| 网盘类型 | 类型标识 | 域名特征 | 说明 |
|---------|---------|----------|------|
| **夸克网盘** | `quark` | `pan.quark.cn` | 主流网盘 |
| **UC网盘** | `uc` | `drive.uc.cn` | 主流网盘 |
| **百度网盘** | `baidu` | `pan.baidu.com` | 主流网盘 |
| **阿里云盘** | `aliyun` | `aliyundrive.com`, `alipan.com` | 主流网盘 |
| **迅雷网盘** | `xunlei` | `pan.xunlei.com` | 主流网盘 |
| **天翼云盘** | `tianyi` | `cloud.189.cn` | 主流网盘 |
| **115网盘** | `115` | `115.com`,`115cdn.com`,`anxia.com` | 主流网盘 |
| **123网盘** | `123` | `123pan.com`,`123684.com`,`123685.com`,`123912.com`,`123pan.cn`,`123592.com` | 主流网盘 |
| **移动云盘** | `mobile` | `caiyun.139.com` | 其他网盘 |
| **PikPak** | `pikpak` | `mypikpak.com` | 其他网盘 |
| **磁力链接** | `magnet` | `magnet:?xt=urn:btih:` | 磁力链接 |
| **ED2K链接** | `ed2k` | `ed2k://` | 磁力链接 |
```go
func convertLinks(apiLinks []APILink) []model.Link {
links := make([]model.Link, 0, len(apiLinks))
for _, apiLink := range apiLinks {
link := model.Link{
Type: determineCloudType(apiLink.URL), // 自动识别网盘类型
URL: apiLink.URL,
Password: apiLink.Password,
}
links = append(links, link)
}
return links
}
func determineCloudType(url string) string {
switch {
case strings.Contains(url, "pan.quark.cn"):
return "quark"
case strings.Contains(url, "drive.uc.cn"):
return "uc"
case strings.Contains(url, "pan.baidu.com"):
return "baidu"
case strings.Contains(url, "aliyundrive.com") || strings.Contains(url, "alipan.com"):
return "aliyun"
case strings.Contains(url, "pan.xunlei.com"):
return "xunlei"
case strings.Contains(url, "cloud.189.cn"):
return "tianyi"
case strings.Contains(url, "115.com") || strings.Contains(url, "115cdn.com") || strings.Contains(url, "anxia.com"):
return "115"
case strings.Contains(url, "123684.com") || strings.Contains(url, "123685.com") ||
strings.Contains(url, "123912.com") || strings.Contains(url, "123pan.com") ||
strings.Contains(url, "123pan.cn") || strings.Contains(url, "123592.com"):
return "123"
case strings.Contains(url, "caiyun.139.com"):
return "mobile"
case strings.Contains(url, "mypikpak.com"):
return "pikpak"
case strings.Contains(url, "magnet:"):
return "magnet"
case strings.Contains(url, "ed2k://"):
return "ed2k"
default:
return "others"
}
}
// 使用示例
func convertAPILinks(apiLinks []APILink) []model.Link {
links := make([]model.Link, 0, len(apiLinks))
for _, apiLink := range apiLinks {
// 自动识别网盘类型
cloudType := determineCloudType(apiLink.URL)
// 只添加识别成功的链接
if cloudType != "others" || strings.HasPrefix(apiLink.URL, "http") {
link := model.Link{
Type: cloudType,
URL: apiLink.URL,
Password: apiLink.Password,
}
links = append(links, link)
}
}
return links
}
```
## 高级特性
### 1. 插件Web路由注册自定义HTTP接口
#### 概述
PanSou 支持插件注册自定义的 HTTP 路由用于实现插件专属的管理页面、配置接口或其他Web功能。
**典型应用场景**:
- 插件配置管理界面(如 QQPD 的用户登录和频道管理)
- 插件数据查询接口
- 插件状态监控页面
- OAuth回调接口
#### 接口定义
```go
// PluginWithWebHandler 支持Web路由的插件接口
// 插件可以选择实现此接口来注册自定义的HTTP路由
type PluginWithWebHandler interface {
AsyncSearchPlugin // 继承搜索插件接口
// RegisterWebRoutes 注册Web路由
// router: gin的路由组插件可以在此注册自己的路由
RegisterWebRoutes(router *gin.RouterGroup)
}
```
#### 实现步骤
**步骤1: 插件结构实现接口**
```go
package myplugin
import (
"github.com/gin-gonic/gin"
"pansou/plugin"
"pansou/model"
)
type MyPlugin struct {
*plugin.BaseAsyncPlugin
// ... 其他字段
}
// 确保插件实现了 PluginWithWebHandler 接口
var _ plugin.PluginWithWebHandler = (*MyPlugin)(nil)
```
**步骤2: 实现 RegisterWebRoutes 方法**
```go
// RegisterWebRoutes 注册Web路由
func (p *MyPlugin) RegisterWebRoutes(router *gin.RouterGroup) {
// 创建插件专属的路由组
myGroup := router.Group("/myplugin")
// 注册GET路由
myGroup.GET("/status", p.handleGetStatus)
// 注册POST路由
myGroup.POST("/config", p.handleUpdateConfig)
// 支持动态路径参数
myGroup.GET("/:id", p.handleGetByID)
myGroup.POST("/:id/action", p.handleAction)
}
```
**步骤3: 实现路由处理函数**
```go
// handleGetStatus 获取插件状态
func (p *MyPlugin) handleGetStatus(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"plugin": p.Name(),
"version": "1.0.0",
})
}
// handleUpdateConfig 更新插件配置
func (p *MyPlugin) handleUpdateConfig(c *gin.Context) {
var config map[string]interface{}
if err := c.BindJSON(&config); err != nil {
c.JSON(400, gin.H{"error": "Invalid JSON"})
return
}
// 处理配置更新逻辑
// ...
c.JSON(200, gin.H{
"success": true,
"message": "配置已更新",
})
}
// handleGetByID 根据ID获取数据
func (p *MyPlugin) handleGetByID(c *gin.Context) {
id := c.Param("id")
// 根据ID查询数据
// ...
c.JSON(200, gin.H{
"id": id,
"data": "...",
})
}
```
#### 实际案例: QQPD 插件
QQPD 插件实现了完整的用户管理和频道配置功能:
```go
// RegisterWebRoutes 注册Web路由
func (p *QQPDPlugin) RegisterWebRoutes(router *gin.RouterGroup) {
qqpd := router.Group("/qqpd")
// GET /:param - 显示管理页面HTML
qqpd.GET("/:param", p.handleManagePage)
// POST /:param - 处理管理操作JSON API
qqpd.POST("/:param", p.handleManagePagePOST)
}
// handleManagePage 渲染管理页面
func (p *QQPDPlugin) handleManagePage(c *gin.Context) {
param := c.Param("param")
// 生成用户专属的管理页面
html := strings.ReplaceAll(HTMLTemplate, "HASH_PLACEHOLDER", param)
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(200, html)
}
// handleManagePagePOST 处理管理操作
func (p *QQPDPlugin) handleManagePagePOST(c *gin.Context) {
param := c.Param("param")
var req struct {
Action string `json:"action"`
Channels []string `json:"channels,omitempty"`
Keyword string `json:"keyword,omitempty"`
}
if err := c.BindJSON(&req); err != nil {
respondError(c, "无效的请求格式")
return
}
// 根据不同的 action 执行不同的操作
switch req.Action {
case "get_status":
p.handleGetStatus(c, param)
case "set_channels":
p.handleSetChannels(c, param, req.Channels)
case "test_search":
p.handleTestSearch(c, param, req.Keyword)
case "logout":
p.handleLogout(c, param)
default:
respondError(c, "未知的操作")
}
}
```
#### 实际案例: Gying 插件
```go
// RegisterWebRoutes 注册Web路由
func (p *GyingPlugin) RegisterWebRoutes(router *gin.RouterGroup) {
gying := router.Group("/gying")
gying.GET("/:param", p.handleManagePage)
gying.POST("/:param", p.handleManagePagePOST)
}
```
#### 路由访问示例
插件注册的路由可以通过以下方式访问:
```bash
# QQPD 插件管理页面
GET http://localhost:8888/qqpd/user123
# QQPD 插件配置接口
POST http://localhost:8888/qqpd/user123
Content-Type: application/json
{
"action": "set_channels",
"channels": ["pd97631607", "kuake12345"]
}
# 自定义插件接口
GET http://localhost:8888/myplugin/status
POST http://localhost:8888/myplugin/config
GET http://localhost:8888/myplugin/resource123
```
#### 系统集成
PanSou 在启动时会自动扫描并注册所有实现了 `PluginWithWebHandler` 接口的插件路由:
```go
// api/router.go 中的自动注册逻辑
func SetupRouter(searchService *service.SearchService) *gin.Engine {
r := gin.Default()
// ... 其他路由配置 ...
// 注册插件的Web路由如果插件实现了PluginWithWebHandler接口
allPlugins := plugin.GetRegisteredPlugins()
for _, p := range allPlugins {
if webPlugin, ok := p.(plugin.PluginWithWebHandler); ok {
webPlugin.RegisterWebRoutes(r.Group(""))
}
}
return r
}
```
#### 开发建议
1. **路由命名规范**: 使用插件名作为路由前缀,避免与其他插件冲突
```go
// ✅ 推荐
router.Group("/myplugin")
// ❌ 避免
router.Group("/config") // 可能与其他插件冲突
```
2. **安全考虑**:
- 对敏感操作进行身份验证
- 验证用户输入,防止注入攻击
- 使用哈希或加密保护敏感参数
3. **错误处理**: 统一错误响应格式
```go
func respondError(c *gin.Context, message string) {
c.JSON(400, gin.H{
"success": false,
"message": message,
})
}
func respondSuccess(c *gin.Context, message string, data interface{}) {
c.JSON(200, gin.H{
"success": true,
"message": message,
"data": data,
})
}
```
4. **HTML模板**: 可以内嵌HTML模板提供管理界面
```go
const HTMLTemplate = `<!DOCTYPE html>
<html>
<head>
<title>插件管理</title>
</head>
<body>
<h1>{{ .PluginName }} 管理界面</h1>
<!-- ... -->
</body>
</html>`
```
5. **可选实现**: Web路由是**可选功能**只有需要自定义HTTP接口的插件才需要实现
### 2. Service层过滤控制详解
#### 构造函数选择
```go
// 标准插件构造函数默认启用Service层过滤
func NewStandardPlugin() *StandardPlugin {
return &StandardPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("standard", 3), // 默认skipServiceFilter=false
}
}
// 磁力搜索插件构造函数跳过Service层过滤
func NewMagnetPlugin() *MagnetPlugin {
return &MagnetPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("magnet", 4, true), // skipServiceFilter=true
}
}
```
#### 实际应用示例
**ThePirateBay插件示例**:
```go
// thepiratebay插件的实际实现
func NewThePirateBayPlugin() *ThePirateBayPlugin {
return &ThePirateBayPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("thepiratebay", 4, true), // 跳过Service层过滤
optimizedClient: createOptimizedHTTPClient(),
}
}
func (p *ThePirateBayPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
// 支持英文搜索优化
searchKeyword := keyword
if ext != nil {
if titleEn, exists := ext["title_en"]; exists {
if titleEnStr, ok := titleEn.(string); ok && titleEnStr != "" {
searchKeyword = titleEnStr
}
}
}
// 获取搜索结果
allResults := p.fetchAllResults(client, searchKeyword)
// 标题格式优化:将'.'替换为空格,提高关键词匹配准确度
for i := range allResults {
allResults[i].Title = strings.ReplaceAll(allResults[i].Title, ".", " ")
}
// 插件层过滤(使用处理后的搜索关键词)
filteredResults := plugin.FilterResultsByKeyword(allResults, searchKeyword)
return filteredResults, nil
// 注意Service层会通过SkipServiceFilter()方法跳过二次过滤
}
```
#### 过滤策略对比
| 过滤类型 | 标准插件 | 磁力搜索插件 |
|----------|----------|--------------|
| **插件层过滤** | ✅ 使用原始关键词 | ✅ 使用searchKeyword支持title_en |
| **Service层过滤** | ✅ 再次过滤 | ❌ 跳过过滤 |
| **结果特点** | 精确匹配 | 宽泛搜索 |
| **适用场景** | 中文网盘资源 | 英文磁力资源 |
#### 动态过滤检测机制
Service层通过以下机制动态判断是否需要过滤
```go
// service/search_service.go 中的实现
func mergeResultsByType(...) {
// 检查插件是否需要跳过Service层过滤
var skipKeywordFilter bool = false
if result.UniqueID != "" && strings.Contains(result.UniqueID, "-") {
parts := strings.SplitN(result.UniqueID, "-", 2)
if len(parts) >= 1 {
pluginName := parts[0]
// 通过插件注册表动态获取过滤设置
if pluginInstance, exists := plugin.GetPluginByName(pluginName); exists {
skipKeywordFilter = pluginInstance.SkipServiceFilter()
}
}
}
// 根据插件设置决定是否过滤
if !skipKeywordFilter && keyword != "" && !strings.Contains(strings.ToLower(title), lowerKeyword) {
continue // 过滤掉不匹配的结果
}
}
```
### 2. 扩展参数处理
```go
// 支持的扩展参数示例
ext := map[string]interface{}{
"title_en": "English Title", // 英文标题
"is_all": true, // 全量搜索标志
"year": 2023, // 年份限制
"type": "movie", // 内容类型
}
// 在插件中处理
func (p *MyPlugin) handleExtParams(ext map[string]interface{}) searchOptions {
opts := searchOptions{}
if titleEn, ok := ext["title_en"].(string); ok {
opts.TitleEn = titleEn
}
if isAll, ok := ext["is_all"].(bool); ok {
opts.IsAll = isAll
}
return opts
}
```
### 2. 缓存策略
```go
// 设置缓存TTL
p.SetCacheTTL(2 * time.Hour)
// 手动缓存更新
p.UpdateMainCache(cacheKey, results, ttl, true, keyword)
```
### 3. 错误处理
```go
func (p *MyPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
// 网络错误处理
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("[%s] 网络请求失败: %w", p.Name(), err)
}
// HTTP状态码检查
if resp.StatusCode != 200 {
return nil, fmt.Errorf("[%s] HTTP错误: %d", p.Name(), resp.StatusCode)
}
// JSON解析错误 - 推荐使用项目统一的JSON工具
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("[%s] 读取响应失败: %w", p.Name(), err)
}
var apiResp APIResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("[%s] JSON解析失败: %w", p.Name(), err)
}
// 业务逻辑错误
if apiResp.Code != 0 {
return nil, fmt.Errorf("[%s] API错误: %s", p.Name(), apiResp.Message)
}
return results, nil
}
```
## 性能优化
### 1. HTTP客户端优化
```go
// 使用连接池
client := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
```
### 2. 内存优化
```go
// 预分配切片容量
results := make([]model.SearchResult, 0, expectedCount)
// 及时释放大对象
defer func() {
apiResp = APIResponse{}
}()
```
### 3. 并发控制
```go
// 使用插件内置的工作池避免创建过多goroutine
// BaseAsyncPlugin 已经提供了工作池管理
```
## 测试和调试
### 1. 单元测试
```go
func TestMyPlugin_Search(t *testing.T) {
plugin := &MyPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("test", 3),
}
results, err := plugin.Search("测试关键词", nil)
assert.NoError(t, err)
assert.NotEmpty(t, results)
}
```
### 2. 集成测试
```bash
# 使用API测试插件
curl "http://localhost:8888/api/search?kw=测试&plugins=myplugin"
```
### 3. 性能测试
```bash
# 使用压力测试脚本
python3 stress_test.py
```
## 部署和配置
### 1. 插件注册
确保在 `init()` 函数中注册插件:
```go
func init() {
p := &MyPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin", 3),
}
plugin.RegisterGlobalPlugin(p)
}
```
### 2. 环境配置
```bash
# 异步插件配置
export ASYNC_PLUGIN_ENABLED=true
export ASYNC_RESPONSE_TIMEOUT=4
export ASYNC_MAX_BACKGROUND_WORKERS=40
export ASYNC_MAX_BACKGROUND_TASKS=200
```
### 3. 生产部署注意事项
1. **资源限制**: 根据服务器配置调整工作池大小
2. **监控告警**: 监控插件响应时间和错误率
3. **日志管理**: 合理设置日志级别,避免日志过多
4. **缓存配置**: 根据数据更新频率调整缓存TTL
## 现有插件参考
### 标准网盘搜索插件
- **jikepan** - 标准网盘插件启用Service层过滤
- **pan666** - 标准网盘插件启用Service层过滤
- **hunhepan** - 标准网盘插件启用Service层过滤
- **pansearch** - 标准网盘插件启用Service层过滤
- **qupansou** - 标准网盘插件启用Service层过滤
- **panta** - 高质量网盘插件启用Service层过滤
### 特殊搜索插件
- **thepiratebay** - 磁力搜索插件跳过Service层过滤支持title_en参数标题格式化处理
## 插件开发最佳实践 ⭐
### 核心原则
1. **命名规范**: 插件名使用小写字母和数字
2. **优先级设置**: 1-2为高优先级3为标准4-5为低优先级
3. **关键词过滤**: 使用 `FilterResultsByKeyword` 提高结果相关性
4. **缓存友好**: 合理设置缓存TTL避免频繁请求
5. **资源清理**: 及时关闭连接和释放资源
6. **过滤策略**: 根据插件类型选择合适的Service层过滤策略
### 必须实现的优化点
#### 1. Service层过滤策略选择 ⭐ 新功能
```go
// ✅ 磁力搜索插件 - 跳过Service层过滤
func NewMagnetSearchPlugin() *MagnetSearchPlugin {
return &MagnetSearchPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("magnet", 4, true), // skipServiceFilter=true
}
}
// ✅ 标准网盘插件 - 启用Service层过滤
func NewPanSearchPlugin() *PanSearchPlugin {
return &PanSearchPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("pansearch", 3), // 默认skipServiceFilter=false
}
}
```
**选择指南**:
- **跳过过滤** (`true`): 磁力搜索、英文资源、特殊格式标题、聚合搜索
- **启用过滤** (`false`): 网盘搜索、中文资源、API接口、标准格式标题
**注意事项**:
- 跳过Service层过滤的插件**必须**在插件内部进行`FilterResultsByKeyword`过滤
- 插件层过滤使用的关键词应与实际搜索关键词一致(支持`title_en`等参数)
- 标题格式化处理应在过滤之前进行(如将`"."` 替换为`" "`
#### 2. SearchResult字段设置规范 ⭐ 重要
```go
// ✅ 正确的SearchResult设置
result := model.SearchResult{
UniqueID: fmt.Sprintf("%s-%s", p.Name(), itemID), // 插件名-资源ID
Title: title, // 资源标题
Content: description, // 资源描述
Links: downloadLinks, // 下载链接列表
Tags: tags, // 分类标签
Channel: "", // ⭐ 重要:插件搜索结果必须为空字符串
Datetime: time.Now(), // 发布时间
}
// ❌ 错误的Channel设置
result.Channel = p.Name() // 不要设置为插件名!
```
**Channel字段使用规则**:
- **插件搜索结果**: `Channel` 必须为空字符串 `""`
- **Telegram频道**: `Channel` 才设置为频道名称
- **目的**: 区分搜索来源,便于前端展示和后端统计
**Links字段处理规则** ⭐ 重要:
- **必须有链接**: 系统会自动过滤掉 `Links` 为空或长度为0的结果
- **链接质量**: 确保返回的链接都是有效的网盘链接,避免返回无效链接
- **链接验证**: 建议使用 `isValidNetworkDriveURL()` 函数预先验证链接有效性
#### 2. HTTP请求最佳实践 ⭐ 重要
```go
// ✅ 正确的请求实现
func (p *MyPlugin) makeRequest(url string, client *http.Client) (*http.Response, error) {
// 使用context控制超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 创建请求
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
// 设置完整的请求头(避免反爬虫)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Referer", "https://example.com/")
// 使用重试机制
return p.doRequestWithRetry(req, client)
}
// ❌ 错误的简单实现
func (p *MyPlugin) badRequest(url string, client *http.Client) (*http.Response, error) {
return client.Get(url) // 没有超时控制、没有请求头、没有重试
}
```
#### 2. 实现高级搜索接口 ⭐ 推荐
```go
// ✅ 推荐:实现两个方法
func (p *MyPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
result, err := p.SearchWithResult(keyword, ext)
if err != nil {
return nil, err
}
return result.Results, nil
}
func (p *MyPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)
}
```
#### 3. 错误处理增强 ⭐ 重要
```go
// ✅ 详细的错误信息
if resp.StatusCode != 200 {
return nil, fmt.Errorf("[%s] 请求失败,状态码: %d", p.Name(), resp.StatusCode)
}
// ✅ 包装外部错误
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("[%s] JSON解析失败: %w", p.Name(), err)
}
```
#### 4. 重试机制模板 ⭐ 复制可用
```go
func (p *MyPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {
maxRetries := 3
var lastErr error
for i := 0; i < maxRetries; i++ {
if i > 0 {
backoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond
time.Sleep(backoff)
}
reqClone := req.Clone(req.Context())
resp, err := client.Do(reqClone)
if err == nil && resp.StatusCode == 200 {
return resp, nil
}
if resp != nil {
resp.Body.Close()
}
lastErr = err
}
return nil, fmt.Errorf("重试 %d 次后仍然失败: %w", maxRetries, lastErr)
}
```
#### 5. 请求头模板 ⭐ 复制可用
```go
// HTML页面请求头
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Upgrade-Insecure-Requests", "1")
req.Header.Set("Cache-Control", "max-age=0")
req.Header.Set("Referer", "https://example.com/")
// JSON API请求头
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Referer", "https://example.com/")
```
### 常见问题避免
1. **不要使用 `client.Get(url)`** - 缺少超时控制和请求头
2. **不要忘记设置 User-Agent** - 很多网站会阻止空UA请求
3. **不要忘记错误上下文** - 使用 `fmt.Errorf("[%s] 错误描述: %w", p.Name(), err)`
4. **不要忘记关闭响应体** - `defer resp.Body.Close()`
5. **不要在循环中创建大量goroutine** - 使用信号量控制并发数
6. **Service层过滤常见问题**:
- ❌ **跳过Service层过滤但不在插件内过滤** - 会返回大量无关结果
- ❌ **磁力搜索插件使用默认构造函数** - 会被Service层误过滤
- ❌ **过滤关键词不一致** - 插件用`title_en`搜索但用原`keyword`过滤
- ❌ **标题格式化在过滤之后** - 格式化不会改善过滤效果