Skip to content

Latest commit

 

History

History
813 lines (641 loc) · 25.8 KB

File metadata and controls

813 lines (641 loc) · 25.8 KB

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

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.baidu.com"):
        return "baidu"
    case strings.Contains(url, "aliyundrive.com"):
        return "aliyun"
    case strings.Contains(url, "pan.quark.cn"):
        return "quark"
    default:
        return "others"
    }
}

高级特性

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