Files
pansou/docs/插件开发指南.md
www.xueximeng.com 5a3917c999 异步插件缓存
2025-07-15 00:03:02 +08:00

35 KiB
Raw Blame History

PanSou 搜索插件开发指南

目录

  1. 插件系统概述
  2. 插件接口说明
  3. 插件开发流程
  4. 数据结构标准
  5. 超时控制
  6. 异步插件开发
  7. 最佳实践
  8. 示例插件
  9. 常见问题

插件系统概述

PanSou 网盘搜索系统采用了灵活的插件架构,允许开发者轻松扩展搜索来源。插件系统具有以下特点:

  • 自动注册机制:插件通过 init 函数自动注册,无需修改主程序代码
  • 统一接口:所有插件实现相同的 SearchPlugin 接口
  • 双层超时控制:插件内部使用自定义超时时间,系统外部提供强制超时保障
  • 并发执行:插件搜索与频道搜索并发执行,提高整体性能
  • 结果标准化:插件返回标准化的搜索结果,便于统一处理
  • 异步处理:支持异步插件,实现"尽快响应,持续处理"的模式

插件系统的核心是全局插件注册表,它在应用启动时收集所有已注册的插件,并在搜索时并行调用这些插件。

插件接口说明

每个插件必须实现 SearchPlugin 接口,该接口定义如下:

// SearchPlugin 搜索插件接口
type SearchPlugin interface {
    // Name 返回插件名称
    Name() string
    
    // Search 执行搜索并返回结果
    Search(keyword string) ([]model.SearchResult, error)
    
    // Priority 返回插件优先级(用于控制结果排序)
    Priority() int
}

接口方法说明

  1. Name()

    • 返回插件的唯一标识名称
    • 名称应简洁明了,全小写,不含特殊字符
    • 例如:pansearchhunhepanjikepan
  2. Search(keyword string)

    • 执行搜索并返回结果
    • 参数 keyword 是用户输入的搜索关键词
    • 返回值是搜索结果数组和可能的错误
    • 实现时应处理超时和错误,确保不会无限阻塞
  3. Priority()

    • 返回插件的优先级,用于控制结果排序
    • 建议值1、2、3
    • 优先级高的插件结果可能会被优先展示

插件开发流程

1. 创建插件包

pansou/plugin 目录下创建新的插件包:

pansou/
  └── plugin/
      └── myplugin/
          └── myplugin.go

2. 实现插件结构体

package myplugin

import (
    "net/http"
    "time"
    
    "pansou/model"
    "pansou/plugin"
)

// 常量定义
const (
    // 默认超时时间
    DefaultTimeout = 5 * time.Second
)

// MyPlugin 自定义插件结构体
type MyPlugin struct {
    client  *http.Client
    timeout time.Duration
}

// NewMyPlugin 创建新的插件实例
func NewMyPlugin() *MyPlugin {
    timeout := DefaultTimeout
    
    return &MyPlugin{
        client: &http.Client{
            Timeout: timeout,
        },
        timeout: timeout,
    }
}

3. 实现 SearchPlugin 接口

// Name 返回插件名称
func (p *MyPlugin) Name() string {
    return "myplugin"
}

// Priority 返回插件优先级
func (p *MyPlugin) Priority() int {
    return 2 // 中等优先级
}

// Search 执行搜索并返回结果
func (p *MyPlugin) Search(keyword string) ([]model.SearchResult, error) {
    // 实现搜索逻辑
    // ...
    
    return results, nil
}

4. 注册插件

在插件包的 init 函数中注册插件:

// 在init函数中注册插件
func init() {
    plugin.RegisterGlobalPlugin(NewMyPlugin())
}

5. 在主程序中导入插件

pansou/main.go 中导入插件包(使用空导入):

import (
    // 导入插件包以触发init函数
    _ "pansou/plugin/myplugin"
)

数据结构标准

SearchResult 结构体

插件需要返回 []model.SearchResult 类型的数据:

// SearchResult 表示搜索结果
type SearchResult struct {
    UniqueID  string    // 唯一标识
    Title     string    // 标题
    Content   string    // 内容描述
    Datetime  time.Time // 日期时间
    Links     []Link    // 链接列表
    Tags      []string  // 标签列表
}

// Link 表示网盘链接
type Link struct {
    URL      string // 链接地址
    Type     string // 链接类型
    Password string // 提取码
}

字段说明

  1. UniqueID

    • 结果的唯一标识,建议格式:插件名-序号
    • 例如:myplugin-1myplugin-2
  2. Title

    • 资源的标题
    • 应尽可能保留原始标题,不要添加额外信息
    • 例如:火影忍者全集高清资源
  3. Content

    • 资源的描述内容
    • 可以包含文件列表、大小、格式等信息
    • 应清理HTML标签等无关内容
  4. Datetime

    • 资源的发布时间或更新时间
    • 如果没有时间信息,使用零值 time.Time{}
    • 不要使用当前时间 time.Now()
  5. Links

    • 资源的链接列表
    • 每个资源可以有多个不同类型的链接
    • 每个链接必须包含URL和TypePassword可选
  6. URL

    • 网盘链接的完整URL
    • 必须包含协议部分(如http://或https://
    • 例如:https://pan.baidu.com/s/1abcdefg
  7. Type

    • 链接类型,必须使用以下标准值之一:
      • baidu - 百度网盘
      • aliyun - 阿里云盘
      • xunlei - 迅雷云盘
      • quark - 夸克网盘
      • tianyi - 天翼云盘
      • 115 - 115网盘
      • weiyun - 微云
      • lanzou - 蓝奏云
      • jianguoyun - 坚果云
      • mobile - 移动云盘(彩云)
      • uc - UC网盘
      • 123 - 123网盘
      • pikpak - PikPak网盘
      • ed2k - 电驴链接
      • magnet - 磁力链接
      • others - 其他类型
  8. Password

    • 提取码或访问密码
    • 如果没有密码,设置为空字符串
  9. Tags

    • 资源的标签列表
    • 可选字段,不是必须提供

具体示例

下面是几个完整的 SearchResult 结构体示例,展示了不同情况下的数据填充方式:

示例1带有百度网盘链接的电影资源

// 创建一个带有百度网盘链接的电影资源搜索结果
movieResult := model.SearchResult{
    UniqueID: "myplugin-1",
    Title:    "速度与激情10 4K蓝光原盘",
    Content:  "文件列表:\n- 速度与激情10.mp4 (25.6GB)\n- 花絮.mp4 (1.2GB)\n- 字幕.zip (15MB)",
    Datetime: time.Date(2023, 8, 15, 10, 30, 0, 0, time.Local), // 2023-08-15 10:30:00
    Links: []model.Link{
        {
            URL:      "https://pan.baidu.com/s/1abcdefghijklmn",
            Type:     "baidu",
            Password: "a1b2",
        },
    },
    Tags: []string{"电影", "动作", "4K"},
}

示例2带有多个网盘链接的软件资源

// 创建一个带有多个网盘链接的软件资源搜索结果
softwareResult := model.SearchResult{
    UniqueID: "myplugin-2",
    Title:    "Photoshop 2023 完整破解版 Win+Mac",
    Content:  "Adobe Photoshop 2023 完整破解版支持Windows和Mac系统内含安装教程和注册机。",
    Datetime: time.Date(2023, 6, 20, 15, 45, 0, 0, time.Local), // 2023-06-20 15:45:00
    Links: []model.Link{
        {
            URL:      "https://pan.baidu.com/s/1opqrstuvwxyz",
            Type:     "baidu",
            Password: "c3d4",
        },
        {
            URL:      "https://www.aliyundrive.com/s/abcdefghijk",
            Type:     "aliyun",
            Password: "",  // 阿里云盘无提取码
        },
        {
            URL:      "https://pan.xunlei.com/s/12345678",
            Type:     "xunlei",
            Password: "xunl",
        },
    },
    Tags: []string{"软件", "设计", "Adobe"},
}

示例3带有磁力链接的资源

// 创建一个带有磁力链接的资源搜索结果
torrentResult := model.SearchResult{
    UniqueID: "myplugin-3",
    Title:    "权力的游戏 第一季 1080P 中英双字",
    Content:  "权力的游戏第一季全10集1080P高清版本内封中英双字幕。",
    Datetime: time.Date(2022, 12, 5, 8, 0, 0, 0, time.Local), // 2022-12-05 08:00:00
    Links: []model.Link{
        {
            URL:      "magnet:?xt=urn:btih:1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t",
            Type:     "magnet",
            Password: "", // 磁力链接没有密码
        },
    },
    Tags: []string{"美剧", "奇幻", "1080P"},
}

示例4没有时间信息的资源

// 创建一个没有时间信息的资源搜索结果
noTimeResult := model.SearchResult{
    UniqueID: "myplugin-4",
    Title:    "中国历史文化名人传记合集",
    Content:  "包含100位中国历史文化名人的详细传记PDF格式。",
    Datetime: time.Time{}, // 使用零值表示没有时间信息
    Links: []model.Link{
        {
            URL:      "https://pan.quark.cn/s/12345abcde",
            Type:     "quark",
            Password: "qwer",
        },
    },
    Tags: []string{"电子书", "历史", "传记"},
}

示例5多种文件格式的教程资源

// 创建一个包含多种文件格式的教程资源搜索结果
tutorialResult := model.SearchResult{
    UniqueID: "myplugin-5",
    Title:    "Python数据分析实战教程 2023最新版",
    Content:  "包含视频教程、源代码、PPT讲义和练习题。适合Python初学者和有一定基础的开发者。",
    Datetime: time.Date(2023, 9, 1, 12, 0, 0, 0, time.Local), // 2023-09-01 12:00:00
    Links: []model.Link{
        {
            URL:      "https://cloud.189.cn/t/abcdefg123456",
            Type:     "tianyi",
            Password: "189t",
        },
        {
            URL:      "https://caiyun.139.com/m/i?abcdefghijk",
            Type:     "mobile",
            Password: "139c",
        },
    },
    Tags: []string{"教程", "Python", "数据分析"},
}

返回结果示例

插件的 Search 方法应返回一个 []model.SearchResult 切片,包含所有搜索结果:

// Search 执行搜索并返回结果
func (p *MyPlugin) Search(keyword string) ([]model.SearchResult, error) {
    // ... 执行搜索逻辑 ...
    
    // 创建结果切片
    results := []model.SearchResult{
        movieResult,
        softwareResult,
        torrentResult,
        noTimeResult,
        tutorialResult,
    }
    
    return results, nil
}

注意事项

  1. 链接类型映射 如果源站点使用的链接类型名称与标准不同,需要进行映射,例如:

    func mapLinkType(sourceType string) string {
        switch strings.ToLower(sourceType) {
        case "bd", "bdy", "baidu_pan":
            return "baidu"
        case "al", "aly", "aliyundrive":
            return "aliyun"
        case "ty", "tianyi_pan":
            return "tianyi"
        // ... 其他映射
        default:
            return "others"
        }
    }
    
  2. URL格式化 确保URL格式正确特别是对于特殊链接类型

    // 确保百度网盘链接格式正确
    if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
        url = "https://" + url
    }
    
    // 确保磁力链接格式正确
    if strings.HasPrefix(url, "magnet:") && !strings.HasPrefix(url, "magnet:?xt=urn:btih:") {
        // 格式不正确,尝试修复或跳过
    }
    
  3. 密码处理 对于不同网盘的密码格式可能有所不同,需要适当处理:

    // 百度网盘密码通常为4位
    if linkType == "baidu" && len(password) > 4 {
        password = password[:4]
    }
    
    // 有些网盘可能在URL中包含密码参数
    if linkType == "aliyun" && password == "" {
        // 尝试从URL中提取密码
        if pwdIndex := strings.Index(url, "password="); pwdIndex != -1 {
            password = url[pwdIndex+9:]
            if endIndex := strings.Index(password, "&"); endIndex != -1 {
                password = password[:endIndex]
            }
        }
    }
    

超时控制

PanSou 采用双层超时控制机制,确保搜索请求能够在合理的时间内完成:

插件内部超时控制

每个插件应定义并使用自己的默认超时时间:

const (
    // 默认超时时间
    DefaultTimeout = 5 * time.Second
)

// NewMyPlugin 创建新的插件实例
func NewMyPlugin() *MyPlugin {
    timeout := DefaultTimeout
    
    return &MyPlugin{
        client: &http.Client{
            Timeout: timeout,
        },
        timeout: timeout,
    }
}

插件应根据自身特点设置合适的超时时间:

  • 需要并发请求多个页面的插件可能设置较短的单次请求超时
  • 需要处理大量数据的插件可能设置较长的超时

系统外部超时控制

系统使用 ExecuteBatchWithTimeout 函数对所有插件任务进行统一的超时控制。即使插件内部没有正确处理超时,系统也能确保整体搜索在合理时间内完成。

超时时间通过环境变量 PLUGIN_TIMEOUT 配置,默认为 30 秒。

异步插件开发

对于响应时间较长的搜索源,建议使用异步插件模式,实现"尽快响应,持续处理"的搜索体验。

1. 异步插件架构

异步插件基于 BaseAsyncPlugin 基类实现,具有以下特点:

  • 双级超时控制:短超时确保快速响应,长超时允许完整处理
  • 缓存机制:自动缓存搜索结果,避免重复请求
  • 后台处理:响应超时后继续在后台处理请求
  • 增量更新:智能合并新旧结果,保留有价值的数据
  • 资源管理:通过工作池控制并发任务数量,避免资源耗尽

2. 创建异步插件

2.1 基本结构

package myplugin_async

import (
    "net/http"
    
    "pansou/model"
    "pansou/plugin"
)

// MyAsyncPlugin 自定义异步插件结构体
type MyAsyncPlugin struct {
    *plugin.BaseAsyncPlugin
}

// NewMyAsyncPlugin 创建新的异步插件实例
func NewMyAsyncPlugin() *MyAsyncPlugin {
    return &MyAsyncPlugin{
        BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin_async", 3),
    }
}

// 在init函数中注册插件
func init() {
    plugin.RegisterGlobalPlugin(NewMyAsyncPlugin())
}

2.2 实现Search方法

// Search 执行搜索并返回结果
func (p *MyAsyncPlugin) Search(keyword string) ([]model.SearchResult, error) {
    // 生成缓存键
    cacheKey := keyword
    
    // 使用异步搜索基础方法
    return p.AsyncSearch(keyword, cacheKey, p.doSearch)
}

// doSearch 实际的搜索实现
func (p *MyAsyncPlugin) doSearch(client *http.Client, keyword string) ([]model.SearchResult, error) {
    // 实现具体搜索逻辑
    // 注意client已经配置了适当的超时时间
    // ...
    
    return results, nil
}

3. 异步搜索流程

异步搜索的工作流程如下:

  1. 缓存检查:首先检查是否有有效缓存
  2. 快速响应:如果有缓存,立即返回;如果缓存接近过期,在后台刷新
  3. 双通道处理:如果没有缓存,启动快速响应通道和后台处理通道
  4. 超时控制:在响应超时时返回当前结果(可能为空),后台继续处理
  5. 缓存更新:后台处理完成后更新缓存,供后续查询使用

4. 异步缓存机制

4.1 缓存键设计

异步插件使用插件特定的缓存键,确保不同插件的缓存不会相互干扰:

// 在AsyncSearch方法中
pluginSpecificCacheKey := fmt.Sprintf("%s:%s", p.name, cacheKey)

4.2 缓存结构

// 缓存响应结构
type cachedResponse struct {
    Results     []model.SearchResult // 搜索结果
    Timestamp   time.Time           // 缓存创建时间
    Complete    bool                // 是否完整结果
    LastAccess  time.Time          // 最后访问时间
    AccessCount int                // 访问计数
}

4.3 缓存持久化

异步插件缓存会自动保存到磁盘,并在程序启动时加载:

  • 定期保存每2分钟保存一次缓存
  • 即时保存:缓存更新后立即触发保存
  • 优雅关闭:程序退出前保存缓存

4.4 智能缓存管理

系统实现了基于多因素的缓存淘汰策略:

// 计算得分:访问次数 / (空闲时间的平方 * 年龄)
// 这样:
// - 访问频率高的得分高
// - 最近访问的得分高
// - 较新的缓存得分高
score := float64(item.AccessCount) / (idleTime.Seconds() * idleTime.Seconds() * age.Seconds())

5. 异步插件示例

5.1 混合盘异步插件

// HunhepanAsyncPlugin 混合盘搜索异步插件
type HunhepanAsyncPlugin struct {
    *plugin.BaseAsyncPlugin
}

// NewHunhepanAsyncPlugin 创建新的混合盘搜索异步插件
func NewHunhepanAsyncPlugin() *HunhepanAsyncPlugin {
    return &HunhepanAsyncPlugin{
        BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("hunhepan_async", 3),
    }
}

// Search 执行搜索并返回结果
func (p *HunhepanAsyncPlugin) Search(keyword string) ([]model.SearchResult, error) {
    // 生成缓存键
    cacheKey := keyword
    
    // 使用异步搜索基础方法
    return p.AsyncSearch(keyword, cacheKey, p.doSearch)
}

// doSearch 实际的搜索实现
func (p *HunhepanAsyncPlugin) doSearch(client *http.Client, keyword string) ([]model.SearchResult, error) {
    // 创建结果通道和错误通道
    resultChan := make(chan []HunhepanItem, 3)
    errChan := make(chan error, 3)
    
    // 创建等待组
    var wg sync.WaitGroup
    wg.Add(3)
    
    // 并行请求三个API
    go func() {
        defer wg.Done()
        items, err := p.searchAPI(client, HunhepanAPI, keyword)
        if err != nil {
            errChan <- fmt.Errorf("hunhepan API error: %w", err)
            return
        }
        resultChan <- items
    }()
    
    // ... 其他API请求 ...
    
    // 启动一个goroutine等待所有请求完成并关闭通道
    go func() {
        wg.Wait()
        close(resultChan)
        close(errChan)
    }()
    
    // 收集结果
    var allItems []HunhepanItem
    var errors []error
    
    // 从通道读取结果
    for items := range resultChan {
        allItems = append(allItems, items...)
    }
    
    // 处理错误
    for err := range errChan {
        errors = append(errors, err)
    }
    
    // 如果没有获取到任何结果且有错误,则返回第一个错误
    if len(allItems) == 0 && len(errors) > 0 {
        return nil, errors[0]
    }
    
    // 去重处理
    uniqueItems := p.deduplicateItems(allItems)
    
    // 转换为标准格式
    results := p.convertResults(uniqueItems)
    
    return results, nil
}

6. 配置选项

异步插件系统提供了多种配置选项,可通过环境变量设置:

ASYNC_PLUGIN_ENABLED=true           # 是否启用异步插件
ASYNC_RESPONSE_TIMEOUT=4            # 响应超时时间(秒)
ASYNC_MAX_BACKGROUND_WORKERS=20     # 最大后台工作者数量
ASYNC_MAX_BACKGROUND_TASKS=100      # 最大后台任务数量
ASYNC_CACHE_TTL_HOURS=1             # 异步缓存有效期(小时)

最佳实践

1. 错误处理

  • 妥善处理HTTP请求错误
  • 解析失败时返回有意义的错误信息
  • 单个结果解析失败不应影响整体搜索
if err != nil {
    return nil, fmt.Errorf("请求失败: %w", err)
}

2. 并发控制

  • 如果需要发起多个请求,使用并发控制
  • 使用信号量或工作池限制并发数
  • 确保所有goroutine都能正确退出
// 创建信号量限制并发数
semaphore := make(chan struct{}, maxConcurrent)

// 使用信号量
semaphore <- struct{}{}
defer func() { <-semaphore }()

3. 结果去重

  • 在返回结果前进行初步去重
  • 使用map存储唯一标识符
  • 系统会在合并所有插件结果时进行最终去重
// 使用map进行去重
uniqueMap := make(map[string]Item)

// 将去重后的结果转换为切片
results := make([]Item, 0, len(uniqueMap))
for _, item := range uniqueMap {
    results = append(results, item)
}

4. 清理HTML标签

  • 清理标题和内容中的HTML标签
  • 移除多余的空格和换行符
  • 保留有用的格式信息
func cleanHTML(html string) string {
    // 替换常见HTML标签
    replacements := map[string]string{
        "<em>": "",
        "</em>": "",
        "<b>": "",
        "</b>": "",
    }
    
    result := html
    for tag, replacement := range replacements {
        result = strings.Replace(result, tag, replacement, -1)
    }
    
    return strings.TrimSpace(result)
}

5. 时间解析

  • 正确解析资源的发布时间
  • 如果无法获取时间,使用零值
  • 不要使用当前时间代替缺失的时间
// 尝试解析时间
var datetime time.Time
if item.Time != "" {
    parsedTime, err := time.Parse("2006-01-02 15:04:05", item.Time)
    if err == nil {
        datetime = parsedTime
    }
}

// 如果解析失败,使用零值
if datetime.IsZero() {
    datetime = time.Time{}
}

示例插件

以下是一个完整的示例插件实现:

package exampleplugin

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "strings"
    "time"

    "pansou/model"
    "pansou/plugin"
)

// 在init函数中注册插件
func init() {
    plugin.RegisterGlobalPlugin(NewExamplePlugin())
}

const (
    // API端点
    ApiURL = "https://example.com/api/search"
    
    // 默认超时时间
    DefaultTimeout = 5 * time.Second
)

// ExamplePlugin 示例插件
type ExamplePlugin struct {
    client  *http.Client
    timeout time.Duration
}

// NewExamplePlugin 创建新的示例插件
func NewExamplePlugin() *ExamplePlugin {
    timeout := DefaultTimeout
    
    return &ExamplePlugin{
        client: &http.Client{
            Timeout: timeout,
        },
        timeout: timeout,
    }
}

// Name 返回插件名称
func (p *ExamplePlugin) Name() string {
    return "exampleplugin"
}

// Priority 返回插件优先级
func (p *ExamplePlugin) Priority() int {
    return 2 // 中等优先级
}

// Search 执行搜索并返回结果
func (p *ExamplePlugin) Search(keyword string) ([]model.SearchResult, error) {
    // 构建请求URL
    reqURL := fmt.Sprintf("%s?q=%s", ApiURL, url.QueryEscape(keyword))
    
    // 发送请求
    req, err := http.NewRequest("GET", reqURL, nil)
    if err != nil {
        return nil, fmt.Errorf("创建请求失败: %w", 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")
    
    // 发送请求
    resp, err := p.client.Do(req)
    if err != nil {
        return nil, fmt.Errorf("请求失败: %w", err)
    }
    defer resp.Body.Close()
    
    // 读取响应体
    respBody, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, fmt.Errorf("读取响应失败: %w", err)
    }
    
    // 解析响应
    var apiResp ApiResponse
    if err := json.Unmarshal(respBody, &apiResp); err != nil {
        return nil, fmt.Errorf("解析响应失败: %w", err)
    }
    
    // 转换为标准格式
    results := make([]model.SearchResult, 0, len(apiResp.Items))
    
    for i, item := range apiResp.Items {
        // 解析时间
        var datetime time.Time
        if item.Time != "" {
            parsedTime, err := time.Parse("2006-01-02 15:04:05", item.Time)
            if err == nil {
                datetime = parsedTime
            }
        }
        
        // 如果解析失败,使用零值
        if datetime.IsZero() {
            datetime = time.Time{}
        }
        
        // 创建链接
        link := model.Link{
            URL:      item.URL,
            Type:     p.determineLinkType(item.URL),
            Password: item.Password,
        }
        
        // 创建唯一ID
        uniqueID := fmt.Sprintf("exampleplugin-%d", i)
        
        // 创建搜索结果
        result := model.SearchResult{
            UniqueID:  uniqueID,
            Title:     cleanHTML(item.Title),
            Content:   cleanHTML(item.Description),
            Datetime:  datetime,
            Links:     []model.Link{link},
        }
        
        results = append(results, result)
    }
    
    return results, nil
}

// determineLinkType 根据URL确定链接类型
func (p *ExamplePlugin) determineLinkType(url string) string {
    lowerURL := strings.ToLower(url)
    
    switch {
    case strings.Contains(lowerURL, "pan.baidu.com"):
        return "baidu"
    case strings.Contains(lowerURL, "alipan.com") || strings.Contains(lowerURL, "aliyundrive.com"):
        return "aliyun"
    case strings.Contains(lowerURL, "pan.xunlei.com"):
        return "xunlei"
    // ... 其他类型判断
    default:
        return "others"
    }
}

// cleanHTML 清理HTML标签
func cleanHTML(html string) string {
    // 替换常见HTML标签
    replacements := map[string]string{
        "<em>": "",
        "</em>": "",
        "<b>": "",
        "</b>": "",
    }
    
    result := html
    for tag, replacement := range replacements {
        result = strings.Replace(result, tag, replacement, -1)
    }
    
    return strings.TrimSpace(result)
}

// ApiResponse API响应结构
type ApiResponse struct {
    Items []ApiItem `json:"items"`
    Total int       `json:"total"`
}

// ApiItem API响应中的单个结果项
type ApiItem struct {
    Title       string `json:"title"`
    Description string `json:"description"`
    URL         string `json:"url"`
    Password    string `json:"password"`
    Time        string `json:"time"`
}

插件缓存实现

1. 缓存概述

插件级缓存是对系统整体两级缓存(内存+磁盘的补充主要针对插件内部的API调用和数据处理进行优化减少重复计算和网络请求提高系统整体性能和响应速度。

2. 设计目标

  1. 减少重复请求避免短时间内对同一资源的重复请求降低外部API负载
  2. 提高响应速度:通过缓存常用查询结果,减少网络延迟和处理时间
  3. 降低资源消耗减少CPU和网络资源的使用
  4. 保持数据新鲜度:通过合理的缓存过期策略,平衡性能和数据时效性
  5. 线程安全:支持并发访问,避免竞态条件
  6. 内存管理:防止内存泄漏,控制缓存大小

3. 架构设计

3.1 整体架构

插件缓存采用分层设计,主要包含以下组件:

┌─────────────────────────┐
│      插件缓存系统        │
└───────────┬─────────────┘
            │
┌───────────▼─────────────┐
│     缓存存储层          │
│  (sync.Map实现的内存缓存) │
└───────────┬─────────────┘
            │
┌───────────▼─────────────┐
│     缓存管理层          │
│ (缓存清理、过期策略、统计) │
└───────────┬─────────────┘
            │
┌───────────▼─────────────┐
│     缓存接口层          │
│   (Load/Store操作封装)   │
└─────────────────────────┘

3.2 缓存类型

根据不同插件的需求,可以实现多种类型的缓存:

  1. API响应缓存缓存外部API的响应结果
  2. 解析结果缓存缓存HTML解析、正则匹配等计算密集型操作的结果
  3. 链接提取缓存:缓存从文本中提取的链接结果
  4. 元数据缓存缓存帖子ID、发布时间等元数据信息

4. 核心组件

4.1 缓存存储

使用sync.Map实现线程安全的内存缓存:

// 缓存相关变量
var (
    // API响应缓存键为"apiURL:keyword",值为缓存的响应
    apiResponseCache = sync.Map{}
    
    // 最后一次清理缓存的时间
    lastCacheCleanTime = time.Now()
    
    // 缓存有效期
    cacheTTL = 1 * time.Hour
)

4.2 缓存结构

每个缓存项包含数据和时间戳,用于判断是否过期:

// 缓存响应结构
type cachedResponse struct {
    data      interface{} // 缓存的数据
    timestamp time.Time   // 缓存创建时间
}

4.3 缓存清理机制

定期清理过期缓存,防止内存泄漏:

// startCacheCleaner 启动一个定期清理缓存的goroutine
func startCacheCleaner() {
    // 每小时清理一次缓存
    ticker := time.NewTicker(1 * time.Hour)
    defer ticker.Stop()
    
    for range ticker.C {
        // 清空所有缓存
        apiResponseCache = sync.Map{}
        lastCacheCleanTime = time.Now()
    }
}

5. 实现方案

5.1 初始化缓存

在插件初始化时启动缓存清理机制:

func init() {
    // 注册插件
    plugin.RegisterGlobalPlugin(NewPlugin())
    
    // 启动缓存清理goroutine
    go startCacheCleaner()
}

5.2 缓存键生成

设计合理的缓存键,确保唯一性和高效查找:

// 生成缓存键
cacheKey := fmt.Sprintf("%s:%s", apiURL, keyword)

对于复杂的缓存键,可以使用结构体:

// 缓存键结构
type passwordCacheKey struct {
    content string
    url     string
}

5.3 缓存读取

在执行操作前先检查缓存:

// 检查缓存中是否已有结果
if cachedItems, ok := apiResponseCache.Load(cacheKey); ok {
    // 检查缓存是否过期
    cachedResult := cachedItems.(cachedResponse)
    if time.Since(cachedResult.timestamp) < cacheTTL {
        return cachedResult.items, nil
    }
}

5.4 缓存写入

操作完成后更新缓存:

// 缓存结果
apiResponseCache.Store(cacheKey, cachedResponse{
    items:     result,
    timestamp: time.Now(),
})

5.5 缓存过期策略

使用TTLTime-To-Live机制控制缓存过期

// 检查缓存是否过期
if time.Since(cachedResult.timestamp) < cacheTTL {
    return cachedResult.items, nil
}

6. 具体实现案例

6.1 API响应缓存Hunhepan插件

// searchAPI 向单个API发送请求
func (p *HunhepanPlugin) searchAPI(apiURL, keyword string) ([]HunhepanItem, error) {
    // 生成缓存键
    cacheKey := fmt.Sprintf("%s:%s", apiURL, keyword)
    
    // 检查缓存中是否已有结果
    if cachedItems, ok := apiResponseCache.Load(cacheKey); ok {
        // 检查缓存是否过期
        cachedResult := cachedItems.(cachedResponse)
        if time.Since(cachedResult.timestamp) < cacheTTL {
            return cachedResult.items, nil
        }
    }
    
    // 构建请求并发送...
    
    // 缓存结果
    apiResponseCache.Store(cacheKey, cachedResponse{
        items:     apiResp.Data.List,
        timestamp: time.Now(),
    })
    
    return apiResp.Data.List, nil
}

6.2 解析结果缓存Panta插件

// extractLinksFromElement 从HTML元素中提取链接
func (p *PantaPlugin) extractLinksFromElement(s *goquery.Selection, yearFromTitle string) []model.Link {
    // 创建缓存键
    html, _ := s.Html()
    cacheKey := fmt.Sprintf("%s:%s", html, yearFromTitle)
    
    // 检查缓存
    if cachedLinks, ok := linkExtractCache.Load(cacheKey); ok {
        return cachedLinks.([]model.Link)
    }
    
    // 提取链接...
    
    // 缓存结果
    linkExtractCache.Store(cacheKey, links)
    
    return links
}

6.3 元数据缓存Panta插件

// 从href中提取topicId - 使用缓存
var topicID string
if cachedID, ok := topicIDCache.Load(href); ok {
    topicID = cachedID.(string)
} else {
    match := topicIDRegex.FindStringSubmatch(href)
    if len(match) < 2 {
        return
    }
    topicID = match[1]
    topicIDCache.Store(href, topicID)
}

7. 性能优化

7.1 缓存粒度控制

根据数据特性选择合适的缓存粒度:

  1. 粗粒度缓存缓存整个API响应适合查询结果较小且稳定的场景
  2. 细粒度缓存:缓存处理过程中的中间结果,适合复杂处理流程

7.2 缓存预热

对于常用查询,可以实现缓存预热机制:

// 预热常用关键词的缓存
func warmupCache() {
    commonKeywords := []string{"电影", "音乐", "软件", "教程"}
    for _, keyword := range commonKeywords {
        go func(kw string) {
            _, _ = searchAPI(apiURL, kw)
        }(keyword)
    }
}

7.3 自适应TTL

根据数据更新频率动态调整缓存有效期:

// 根据内容类型确定TTL
func determineTTL(contentType string) time.Duration {
    switch contentType {
    case "movie":
        return 24 * time.Hour // 电影资源更新较慢
    case "news":
        return 30 * time.Minute // 新闻更新较快
    default:
        return 1 * time.Hour
    }
}

8. 监控与统计

8.1 缓存命中率统计

var (
    cacheHits   int64
    cacheMisses int64
)

// 记录缓存命中
func recordCacheHit() {
    atomic.AddInt64(&cacheHits, 1)
}

// 记录缓存未命中
func recordCacheMiss() {
    atomic.AddInt64(&cacheMisses, 1)
}

// 获取缓存命中率
func getCacheHitRate() float64 {
    hits := atomic.LoadInt64(&cacheHits)
    misses := atomic.LoadInt64(&cacheMisses)
    total := hits + misses
    if total == 0 {
        return 0
    }
    return float64(hits) / float64(total)
}

8.2 缓存大小监控

// 估算缓存大小
func estimateCacheSize() int64 {
    var size int64
    apiResponseCache.Range(func(key, value interface{}) bool {
        // 估算每个缓存项的大小
        cachedResp := value.(cachedResponse)
        // 根据实际数据结构估算大小
        size += int64(len(fmt.Sprintf("%v", cachedResp.data))) * 2 // 粗略估计
        return true
    })
    return size
}

9. 最佳实践

  1. 选择性缓存:只缓存频繁访问且计算成本高的数据
  2. 合理的TTL:根据数据更新频率设置合适的过期时间
  3. 缓存键设计:确保缓存键唯一且能反映所有影响结果的因素
  4. 错误处理:不缓存错误响应,避免错误传播
  5. 缓存大小控制:设置缓存项数量上限,避免内存溢出
  6. 并发安全:使用线程安全的数据结构和原子操作
  7. 定期清理:实现自动清理机制,避免内存泄漏

常见问题

1. 插件注册失败

问题:插件未被系统识别和加载

解决方案

  • 确保在 init() 函数中调用了 plugin.RegisterGlobalPlugin()
  • 确保在 main.go 中导入了插件包(使用空导入)
  • 检查插件名称是否为空或重复

2. 搜索超时

问题:插件搜索经常超时

解决方案

  • 调整插件的默认超时时间
  • 使用并发请求减少总体响应时间
  • 实现请求重试机制
  • 优化请求逻辑,减少不必要的请求

3. 结果格式错误

问题:插件返回的结果格式不正确

解决方案

  • 严格按照数据结构标准构造返回值
  • 确保链接类型使用标准值
  • 正确处理时间格式
  • 清理HTML标签和特殊字符

4. 内存泄漏

问题:插件导致内存使用量持续增长

解决方案

  • 确保所有goroutine都能正确退出
  • 关闭HTTP响应体
  • 避免无限循环
  • 限制结果集大小

5. 错误处理不当

问题:插件错误影响了整个系统

解决方案

  • 捕获并记录所有可能的错误
  • 使用超时控制避免长时间阻塞
  • 在返回错误前进行必要的资源清理
  • 对于非致命错误,返回部分结果而不是完全失败