2025-07-30 20:07:49 +08:00
|
|
|
|
# PanSou 插件开发指南
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
## 概述
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
PanSou 采用异步插件架构,支持通过插件扩展搜索来源。插件系统基于 Go 接口设计,提供高性能的并发搜索能力和智能缓存机制。
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
## 系统架构
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 核心组件
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
- **插件管理器 (PluginManager)**: 管理所有插件的注册和调度
|
|
|
|
|
|
- **异步插件 (AsyncSearchPlugin)**: 实现异步搜索接口的插件
|
|
|
|
|
|
- **基础插件 (BaseAsyncPlugin)**: 提供通用功能的基础结构
|
|
|
|
|
|
- **工作池**: 管理并发请求和资源限制
|
|
|
|
|
|
- **缓存系统**: 二级缓存提供高性能数据存储
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 异步处理机制
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
1. **双级超时控制**:
|
|
|
|
|
|
- 短超时 (4秒): 确保快速响应用户
|
|
|
|
|
|
- 长超时 (30秒): 允许完整数据处理
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
2. **渐进式结果返回**:
|
|
|
|
|
|
- `isFinal=false`: 部分结果,继续后台处理
|
|
|
|
|
|
- `isFinal=true`: 完整结果,停止处理
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
3. **智能缓存更新**:
|
|
|
|
|
|
- 实时更新主缓存 (内存+磁盘)
|
|
|
|
|
|
- 结果合并去重
|
|
|
|
|
|
- 用户无感知数据更新
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
## 插件接口规范
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### AsyncSearchPlugin 接口
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2025-07-30 20:07:49 +08:00
|
|
|
|
type AsyncSearchPlugin interface {
|
|
|
|
|
|
// Name 返回插件名称 (必须唯一)
|
|
|
|
|
|
Name() string
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-08-01 21:21:42 +08:00
|
|
|
|
// Priority 返回插件优先级 (1-4,数字越小优先级越高,影响搜索结果排序)
|
2025-07-30 20:07:49 +08:00
|
|
|
|
Priority() int
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// AsyncSearch 异步搜索方法 (核心方法)
|
|
|
|
|
|
AsyncSearch(keyword string, searchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error), mainCacheKey string, ext map[string]interface{}) ([]model.SearchResult, error)
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// SetMainCacheKey 设置主缓存键 (由系统调用)
|
|
|
|
|
|
SetMainCacheKey(key string)
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// SetCurrentKeyword 设置当前搜索关键词 (用于日志显示)
|
|
|
|
|
|
SetCurrentKeyword(keyword string)
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// Search 同步搜索方法 (兼容性方法)
|
|
|
|
|
|
Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error)
|
2025-07-12 19:53:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 参数说明
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
- **keyword**: 搜索关键词
|
|
|
|
|
|
- **searchFunc**: HTTP搜索函数,处理实际的网络请求
|
|
|
|
|
|
- **mainCacheKey**: 主缓存键,用于缓存管理
|
|
|
|
|
|
- **ext**: 扩展参数,支持自定义搜索选项
|
2025-07-21 18:51:58 +08:00
|
|
|
|
|
2025-08-01 21:21:42 +08:00
|
|
|
|
## 插件优先级系统
|
|
|
|
|
|
|
|
|
|
|
|
### 优先级等级
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
## 开发新插件
|
2025-07-21 18:51:58 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 1. 基础结构
|
2025-07-21 18:51:58 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
package myplugin
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
|
|
|
|
|
import (
|
2025-07-31 19:45:26 +08:00
|
|
|
|
"context"
|
2025-07-15 00:03:02 +08:00
|
|
|
|
"net/http"
|
2025-07-31 19:45:26 +08:00
|
|
|
|
"time"
|
2025-07-15 00:03:02 +08:00
|
|
|
|
"pansou/model"
|
|
|
|
|
|
"pansou/plugin"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
type MyPlugin struct {
|
2025-07-15 00:03:02 +08:00
|
|
|
|
*plugin.BaseAsyncPlugin
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
func init() {
|
|
|
|
|
|
p := &MyPlugin{
|
2025-08-01 21:21:42 +08:00
|
|
|
|
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin", 3), // 优先级3 = 普通质量数据源
|
2025-07-15 00:03:02 +08:00
|
|
|
|
}
|
2025-07-30 20:07:49 +08:00
|
|
|
|
plugin.RegisterGlobalPlugin(p)
|
2025-07-15 00:03:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-31 19:45:26 +08:00
|
|
|
|
// Search 执行搜索并返回结果(兼容性方法)
|
2025-07-30 20:07:49 +08:00
|
|
|
|
func (p *MyPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
|
2025-07-31 19:45:26 +08:00
|
|
|
|
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)
|
2025-07-15 00:03:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2025-07-31 19:45:26 +08:00
|
|
|
|
### 2. 实现搜索逻辑(⭐ 推荐实现模式)
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2025-07-30 20:07:49 +08:00
|
|
|
|
func (p *MyPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
|
|
|
|
|
|
// 1. 构建请求URL
|
2025-07-31 19:45:26 +08:00
|
|
|
|
searchURL := fmt.Sprintf("https://api.example.com/search?q=%s", url.QueryEscape(keyword))
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// 2. 处理扩展参数
|
|
|
|
|
|
if titleEn, ok := ext["title_en"].(string); ok && titleEn != "" {
|
2025-07-31 19:45:26 +08:00
|
|
|
|
searchURL += "&title_en=" + url.QueryEscape(titleEn)
|
2025-07-30 20:07:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-31 19:45:26 +08:00
|
|
|
|
// 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)
|
2025-07-21 18:51:58 +08:00
|
|
|
|
}
|
2025-07-30 20:07:49 +08:00
|
|
|
|
defer resp.Body.Close()
|
2025-07-21 18:51:58 +08:00
|
|
|
|
|
2025-07-31 19:45:26 +08:00
|
|
|
|
// 7. 检查状态码
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
|
return nil, fmt.Errorf("[%s] 请求返回状态码: %d", p.Name(), resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 8. 解析响应
|
2025-07-30 20:07:49 +08:00
|
|
|
|
var apiResp APIResponse
|
|
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
2025-07-31 19:45:26 +08:00
|
|
|
|
return nil, fmt.Errorf("[%s] JSON解析失败: %w", p.Name(), err)
|
2025-07-15 00:03:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-31 19:45:26 +08:00
|
|
|
|
// 9. 转换为标准格式
|
2025-07-30 20:07:49 +08:00
|
|
|
|
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), // 转换链接格式
|
2025-07-15 00:03:02 +08:00
|
|
|
|
}
|
2025-07-30 20:07:49 +08:00
|
|
|
|
results = append(results, result)
|
2025-07-15 00:03:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-31 19:45:26 +08:00
|
|
|
|
// 10. 关键词过滤
|
2025-07-30 20:07:49 +08:00
|
|
|
|
return plugin.FilterResultsByKeyword(results, keyword), nil
|
2025-07-15 00:03:02 +08:00
|
|
|
|
}
|
2025-07-31 19:45:26 +08:00
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
2025-07-15 00:03:02 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 3. 链接转换
|
2025-07-19 15:56:03 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2025-07-30 20:07:49 +08:00
|
|
|
|
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,
|
2025-07-21 18:51:58 +08:00
|
|
|
|
}
|
2025-07-30 20:07:49 +08:00
|
|
|
|
links = append(links, link)
|
2025-07-21 18:51:58 +08:00
|
|
|
|
}
|
2025-07-30 20:07:49 +08:00
|
|
|
|
return links
|
2025-07-21 18:51:58 +08:00
|
|
|
|
}
|
2025-07-19 15:56:03 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
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"
|
2025-07-21 18:51:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-07-15 00:03:02 +08:00
|
|
|
|
```
|
2025-07-21 18:51:58 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
## 高级特性
|
2025-07-21 18:51:58 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 1. 扩展参数处理
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// 支持的扩展参数示例
|
|
|
|
|
|
ext := map[string]interface{}{
|
|
|
|
|
|
"title_en": "English Title", // 英文标题
|
|
|
|
|
|
"is_all": true, // 全量搜索标志
|
|
|
|
|
|
"year": 2023, // 年份限制
|
|
|
|
|
|
"type": "movie", // 内容类型
|
2025-07-12 19:53:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// 在插件中处理
|
|
|
|
|
|
func (p *MyPlugin) handleExtParams(ext map[string]interface{}) searchOptions {
|
|
|
|
|
|
opts := searchOptions{}
|
|
|
|
|
|
|
|
|
|
|
|
if titleEn, ok := ext["title_en"].(string); ok {
|
|
|
|
|
|
opts.TitleEn = titleEn
|
2025-07-12 19:53:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
if isAll, ok := ext["is_all"].(bool); ok {
|
|
|
|
|
|
opts.IsAll = isAll
|
2025-07-12 19:53:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
return opts
|
2025-07-12 19:53:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 2. 缓存策略
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// 设置缓存TTL
|
|
|
|
|
|
p.SetCacheTTL(2 * time.Hour)
|
2025-07-12 19:53:44 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// 手动缓存更新
|
|
|
|
|
|
p.UpdateMainCache(cacheKey, results, ttl, true, keyword)
|
2025-07-12 19:53:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 3. 错误处理
|
2025-07-22 12:12:21 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2025-07-30 20:07:49 +08:00
|
|
|
|
func (p *MyPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
|
|
|
|
|
|
// 网络错误处理
|
|
|
|
|
|
resp, err := client.Get(url)
|
2025-07-12 19:53:44 +08:00
|
|
|
|
if err != nil {
|
2025-07-30 20:07:49 +08:00
|
|
|
|
return nil, fmt.Errorf("[%s] 网络请求失败: %w", p.Name(), err)
|
2025-07-12 19:53:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// HTTP状态码检查
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
|
return nil, fmt.Errorf("[%s] HTTP错误: %d", p.Name(), resp.StatusCode)
|
2025-07-12 19:53:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// JSON解析错误
|
|
|
|
|
|
var apiResp APIResponse
|
|
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("[%s] JSON解析失败: %w", p.Name(), err)
|
2025-07-12 19:53:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// 业务逻辑错误
|
|
|
|
|
|
if apiResp.Code != 0 {
|
|
|
|
|
|
return nil, fmt.Errorf("[%s] API错误: %s", p.Name(), apiResp.Message)
|
2025-07-12 19:53:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return results, nil
|
|
|
|
|
|
}
|
2025-07-15 00:03:02 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
## 性能优化
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 1. HTTP客户端优化
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// 使用连接池
|
|
|
|
|
|
client := &http.Client{
|
|
|
|
|
|
Timeout: 30 * time.Second,
|
|
|
|
|
|
Transport: &http.Transport{
|
|
|
|
|
|
MaxIdleConns: 100,
|
|
|
|
|
|
MaxIdleConnsPerHost: 10,
|
|
|
|
|
|
IdleConnTimeout: 90 * time.Second,
|
|
|
|
|
|
},
|
2025-07-15 00:03:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 2. 内存优化
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// 预分配切片容量
|
|
|
|
|
|
results := make([]model.SearchResult, 0, expectedCount)
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// 及时释放大对象
|
|
|
|
|
|
defer func() {
|
|
|
|
|
|
apiResp = APIResponse{}
|
|
|
|
|
|
}()
|
2025-07-15 00:03:02 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 3. 并发控制
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2025-07-30 20:07:49 +08:00
|
|
|
|
// 使用插件内置的工作池,避免创建过多goroutine
|
|
|
|
|
|
// BaseAsyncPlugin 已经提供了工作池管理
|
2025-07-15 00:03:02 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
## 测试和调试
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 1. 单元测试
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2025-07-30 20:07:49 +08:00
|
|
|
|
func TestMyPlugin_Search(t *testing.T) {
|
|
|
|
|
|
plugin := &MyPlugin{
|
|
|
|
|
|
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("test", 3),
|
2025-07-15 00:03:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
results, err := plugin.Search("测试关键词", nil)
|
|
|
|
|
|
assert.NoError(t, err)
|
|
|
|
|
|
assert.NotEmpty(t, results)
|
2025-07-15 00:03:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 2. 集成测试
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
```bash
|
|
|
|
|
|
# 使用API测试插件
|
|
|
|
|
|
curl "http://localhost:8888/api/search?kw=测试&plugins=myplugin"
|
2025-07-15 00:03:02 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 3. 性能测试
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
```bash
|
|
|
|
|
|
# 使用压力测试脚本
|
|
|
|
|
|
python3 stress_test.py
|
2025-07-15 00:03:02 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
## 部署和配置
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 1. 插件注册
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
确保在 `init()` 函数中注册插件:
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2025-07-30 20:07:49 +08:00
|
|
|
|
func init() {
|
|
|
|
|
|
p := &MyPlugin{
|
|
|
|
|
|
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin", 3),
|
2025-07-15 00:03:02 +08:00
|
|
|
|
}
|
2025-07-30 20:07:49 +08:00
|
|
|
|
plugin.RegisterGlobalPlugin(p)
|
2025-07-15 00:03:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 2. 环境配置
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
```bash
|
|
|
|
|
|
# 异步插件配置
|
|
|
|
|
|
export ASYNC_PLUGIN_ENABLED=true
|
|
|
|
|
|
export ASYNC_RESPONSE_TIMEOUT=4
|
|
|
|
|
|
export ASYNC_MAX_BACKGROUND_WORKERS=40
|
|
|
|
|
|
export ASYNC_MAX_BACKGROUND_TASKS=200
|
2025-07-15 00:03:02 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
### 3. 生产部署注意事项
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
1. **资源限制**: 根据服务器配置调整工作池大小
|
|
|
|
|
|
2. **监控告警**: 监控插件响应时间和错误率
|
|
|
|
|
|
3. **日志管理**: 合理设置日志级别,避免日志过多
|
|
|
|
|
|
4. **缓存配置**: 根据数据更新频率调整缓存TTL
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
## 现有插件参考
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
2025-08-01 10:55:37 +08:00
|
|
|
|
- **jikepan**
|
|
|
|
|
|
- **pan666**
|
|
|
|
|
|
- **hunhepan**
|
|
|
|
|
|
- **pansearch**
|
|
|
|
|
|
- **qupansou**
|
|
|
|
|
|
- **panta**
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
2025-07-31 19:45:26 +08:00
|
|
|
|
## 插件开发最佳实践 ⭐
|
|
|
|
|
|
|
|
|
|
|
|
### 核心原则
|
2025-07-15 00:03:02 +08:00
|
|
|
|
|
2025-07-30 20:07:49 +08:00
|
|
|
|
1. **命名规范**: 插件名使用小写字母和数字
|
|
|
|
|
|
2. **优先级设置**: 1-2为高优先级,3为标准,4-5为低优先级
|
2025-07-31 19:45:26 +08:00
|
|
|
|
3. **关键词过滤**: 使用 `FilterResultsByKeyword` 提高结果相关性
|
|
|
|
|
|
4. **缓存友好**: 合理设置缓存TTL,避免频繁请求
|
|
|
|
|
|
5. **资源清理**: 及时关闭连接和释放资源
|
|
|
|
|
|
|
|
|
|
|
|
### 必须实现的优化点
|
|
|
|
|
|
|
2025-08-01 10:55:37 +08:00
|
|
|
|
#### 1. 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请求最佳实践 ⭐ 重要
|
2025-07-31 19:45:26 +08:00
|
|
|
|
|
|
|
|
|
|
```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<<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. 请求头模板 ⭐ 复制可用
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// 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** - 使用信号量控制并发数
|