This commit is contained in:
fish2018
2025-07-12 19:53:44 +08:00
commit 5004e4f99f
30 changed files with 7885 additions and 0 deletions

804
docs/插件开发指南.md Normal file
View File

@@ -0,0 +1,804 @@
# PanSou 搜索插件开发指南
## 目录
1. [插件系统概述](#插件系统概述)
2. [插件接口说明](#插件接口说明)
3. [插件开发流程](#插件开发流程)
4. [数据结构标准](#数据结构标准)
5. [超时控制](#超时控制)
6. [最佳实践](#最佳实践)
7. [示例插件](#示例插件)
8. [常见问题](#常见问题)
## 插件系统概述
PanSou 网盘搜索系统采用了灵活的插件架构,允许开发者轻松扩展搜索来源。插件系统具有以下特点:
- **自动注册机制**:插件通过 init 函数自动注册,无需修改主程序代码
- **统一接口**:所有插件实现相同的 SearchPlugin 接口
- **双层超时控制**:插件内部使用自定义超时时间,系统外部提供强制超时保障
- **并发执行**:插件搜索与频道搜索并发执行,提高整体性能
- **结果标准化**:插件返回标准化的搜索结果,便于统一处理
插件系统的核心是全局插件注册表,它在应用启动时收集所有已注册的插件,并在搜索时并行调用这些插件。
## 插件接口说明
每个插件必须实现 `SearchPlugin` 接口,该接口定义如下:
```go
// SearchPlugin 搜索插件接口
type SearchPlugin interface {
// Name 返回插件名称
Name() string
// Search 执行搜索并返回结果
Search(keyword string) ([]model.SearchResult, error)
// Priority 返回插件优先级(用于控制结果排序)
Priority() int
}
```
### 接口方法说明
1. **Name()**
- 返回插件的唯一标识名称
- 名称应简洁明了,全小写,不含特殊字符
- 例如:`pansearch``hunhepan``jikepan`
2. **Search(keyword string)**
- 执行搜索并返回结果
- 参数 `keyword` 是用户输入的搜索关键词
- 返回值是搜索结果数组和可能的错误
- 实现时应处理超时和错误,确保不会无限阻塞
3. **Priority()**
- 返回插件的优先级,用于控制结果排序
- 建议值1、2、3
- 优先级高的插件结果可能会被优先展示
## 插件开发流程
### 1. 创建插件包
`pansou/plugin` 目录下创建新的插件包:
```
pansou/
└── plugin/
└── myplugin/
└── myplugin.go
```
### 2. 实现插件结构体
```go
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 接口
```go
// 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 函数中注册插件:
```go
// 在init函数中注册插件
func init() {
plugin.RegisterGlobalPlugin(NewMyPlugin())
}
```
### 5. 在主程序中导入插件
`pansou/main.go` 中导入插件包(使用空导入):
```go
import (
// 导入插件包以触发init函数
_ "pansou/plugin/myplugin"
)
```
## 数据结构标准
### SearchResult 结构体
插件需要返回 `[]model.SearchResult` 类型的数据:
```go
// 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-1``myplugin-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带有百度网盘链接的电影资源
```go
// 创建一个带有百度网盘链接的电影资源搜索结果
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带有多个网盘链接的软件资源
```go
// 创建一个带有多个网盘链接的软件资源搜索结果
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带有磁力链接的资源
```go
// 创建一个带有磁力链接的资源搜索结果
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没有时间信息的资源
```go
// 创建一个没有时间信息的资源搜索结果
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多种文件格式的教程资源
```go
// 创建一个包含多种文件格式的教程资源搜索结果
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` 切片,包含所有搜索结果:
```go
// Search 执行搜索并返回结果
func (p *MyPlugin) Search(keyword string) ([]model.SearchResult, error) {
// ... 执行搜索逻辑 ...
// 创建结果切片
results := []model.SearchResult{
movieResult,
softwareResult,
torrentResult,
noTimeResult,
tutorialResult,
}
return results, nil
}
```
### 注意事项
1. **链接类型映射**
如果源站点使用的链接类型名称与标准不同,需要进行映射,例如:
```go
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格式正确特别是对于特殊链接类型
```go
// 确保百度网盘链接格式正确
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. **密码处理**
对于不同网盘的密码格式可能有所不同,需要适当处理:
```go
// 百度网盘密码通常为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 采用双层超时控制机制,确保搜索请求能够在合理的时间内完成:
### 插件内部超时控制
每个插件应定义并使用自己的默认超时时间:
```go
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. 错误处理
- 妥善处理HTTP请求错误
- 解析失败时返回有意义的错误信息
- 单个结果解析失败不应影响整体搜索
```go
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
```
### 2. 并发控制
- 如果需要发起多个请求,使用并发控制
- 使用信号量或工作池限制并发数
- 确保所有goroutine都能正确退出
```go
// 创建信号量限制并发数
semaphore := make(chan struct{}, maxConcurrent)
// 使用信号量
semaphore <- struct{}{}
defer func() { <-semaphore }()
```
### 3. 结果去重
- 在返回结果前进行初步去重
- 使用map存储唯一标识符
- 系统会在合并所有插件结果时进行最终去重
```go
// 使用map进行去重
uniqueMap := make(map[string]Item)
// 将去重后的结果转换为切片
results := make([]Item, 0, len(uniqueMap))
for _, item := range uniqueMap {
results = append(results, item)
}
```
### 4. 清理HTML标签
- 清理标题和内容中的HTML标签
- 移除多余的空格和换行符
- 保留有用的格式信息
```go
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. 时间解析
- 正确解析资源的发布时间
- 如果无法获取时间,使用零值
- 不要使用当前时间代替缺失的时间
```go
// 尝试解析时间
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{}
}
```
## 示例插件
以下是一个完整的示例插件实现:
```go
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. 插件注册失败
**问题**:插件未被系统识别和加载
**解决方案**
- 确保在 `init()` 函数中调用了 `plugin.RegisterGlobalPlugin()`
- 确保在 `main.go` 中导入了插件包(使用空导入)
- 检查插件名称是否为空或重复
### 2. 搜索超时
**问题**:插件搜索经常超时
**解决方案**
- 调整插件的默认超时时间
- 使用并发请求减少总体响应时间
- 实现请求重试机制
- 优化请求逻辑,减少不必要的请求
### 3. 结果格式错误
**问题**:插件返回的结果格式不正确
**解决方案**
- 严格按照数据结构标准构造返回值
- 确保链接类型使用标准值
- 正确处理时间格式
- 清理HTML标签和特殊字符
### 4. 内存泄漏
**问题**:插件导致内存使用量持续增长
**解决方案**
- 确保所有goroutine都能正确退出
- 关闭HTTP响应体
- 避免无限循环
- 限制结果集大小
### 5. 错误处理不当
**问题**:插件错误影响了整个系统
**解决方案**
- 捕获并记录所有可能的错误
- 使用超时控制避免长时间阻塞
- 在返回错误前进行必要的资源清理
- 对于非致命错误,返回部分结果而不是完全失败