# 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< 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` | 主流网盘 | | **光鸭云盘** | `guangya` | `guangyapan.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, "guangyapan.com"): return "guangya" 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 = ` 插件管理

{{ .PluginName }} 管理界面

` ``` 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<