Files
pansou/docs/插件开发指南.md
www.xueximeng.com 4df4ad5952 update
2025-10-30 12:03:46 +08:00

28 KiB
Raw Blame History

PanSou 插件开发指南

概述

PanSou 采用异步插件架构,支持通过插件扩展搜索来源。插件系统基于 Go 接口设计,提供高性能的并发搜索能力和智能缓存机制。

系统架构

核心组件

  • 插件管理器 (PluginManager): 管理所有插件的注册和调度
  • 异步插件 (AsyncSearchPlugin): 实现异步搜索接口的插件
  • 基础插件 (BaseAsyncPlugin): 提供通用功能的基础结构
  • 工作池: 管理并发请求和资源限制
  • 缓存系统: 二级缓存提供高性能数据存储

异步处理机制

  1. 双级超时控制:

    • 短超时 (4秒): 确保快速响应用户
    • 长超时 (30秒): 允许完整数据处理
  2. 渐进式结果返回:

    • isFinal=false: 部分结果,继续后台处理
    • isFinal=true: 完整结果,停止处理
  3. 智能缓存更新:

    • 实时更新主缓存 (内存+磁盘)
    • 结果合并去重
    • 用户无感知数据更新

插件接口规范

AsyncSearchPlugin 接口

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.gomergeResultsByType() 中进行二次过滤
  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. 基础结构

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. 实现搜索逻辑( 推荐实现模式)

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. 链接转换

支持的网盘类型

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:// 磁力链接
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. Service层过滤控制详解

构造函数选择

// 标准插件构造函数默认启用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插件示例:

// 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层通过以下机制动态判断是否需要过滤

// 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. 扩展参数处理

// 支持的扩展参数示例
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. 缓存策略

// 设置缓存TTL
p.SetCacheTTL(2 * time.Hour)

// 手动缓存更新
p.UpdateMainCache(cacheKey, results, ttl, true, keyword)

3. 错误处理

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客户端优化

// 使用连接池
client := &http.Client{
    Timeout: 30 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
    },
}

2. 内存优化

// 预分配切片容量
results := make([]model.SearchResult, 0, expectedCount)

// 及时释放大对象
defer func() {
    apiResp = APIResponse{}
}()

3. 并发控制

// 使用插件内置的工作池避免创建过多goroutine
// BaseAsyncPlugin 已经提供了工作池管理

测试和调试

1. 单元测试

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. 集成测试

# 使用API测试插件
curl "http://localhost:8888/api/search?kw=测试&plugins=myplugin"

3. 性能测试

# 使用压力测试脚本
python3 stress_test.py

部署和配置

1. 插件注册

确保在 init() 函数中注册插件:

func init() {
    p := &MyPlugin{
        BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin", 3),
    }
    plugin.RegisterGlobalPlugin(p)
}

2. 环境配置

# 异步插件配置
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层过滤策略选择 新功能

// ✅ 磁力搜索插件 - 跳过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字段设置规范 重要

// ✅ 正确的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请求最佳实践 重要

// ✅ 正确的请求实现
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. 实现高级搜索接口 推荐

// ✅ 推荐:实现两个方法
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. 错误处理增强 重要

// ✅ 详细的错误信息
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. 重试机制模板 复制可用

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. 请求头模板 复制可用

// 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过滤
    • 标题格式化在过滤之后 - 格式化不会改善过滤效果