mirror of
https://github.com/fish2018/pansou.git
synced 2025-11-25 03:14:59 +08:00
1252 lines
39 KiB
Markdown
1252 lines
39 KiB
Markdown
# PanSou 插件开发指南
|
||
|
||
## 概述
|
||
|
||
PanSou 采用异步插件架构,支持通过插件扩展搜索来源。插件系统基于 Go 接口设计,提供高性能的并发搜索能力和智能缓存机制。
|
||
|
||
## 系统架构
|
||
|
||
### 核心组件
|
||
|
||
- **插件管理器 (PluginManager)**: 管理所有插件的注册和调度
|
||
- **异步插件 (AsyncSearchPlugin)**: 实现异步搜索接口的插件
|
||
- **基础插件 (BaseAsyncPlugin)**: 提供通用功能的基础结构
|
||
- **工作池**: 管理并发请求和资源限制
|
||
- **缓存系统**: 二级缓存提供高性能数据存储
|
||
|
||
### 异步处理机制
|
||
|
||
1. **双级超时控制**:
|
||
- 短超时 (4秒): 确保快速响应用户
|
||
- 长超时 (30秒): 允许完整数据处理
|
||
|
||
2. **渐进式结果返回**:
|
||
- `isFinal=false`: 部分结果,继续后台处理
|
||
- `isFinal=true`: 完整结果,停止处理
|
||
|
||
3. **智能缓存更新**:
|
||
- 实时更新主缓存 (内存+磁盘)
|
||
- 结果合并去重
|
||
- 用户无感知数据更新
|
||
|
||
## 插件接口规范
|
||
|
||
### AsyncSearchPlugin 接口
|
||
|
||
```go
|
||
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.go` 的 `mergeResultsByType()` 中进行二次过滤
|
||
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. 基础结构
|
||
|
||
```go
|
||
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. 实现搜索逻辑(⭐ 推荐实现模式)
|
||
|
||
```go
|
||
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. 链接转换与 work_title 字段
|
||
|
||
#### Link 结构定义
|
||
|
||
```go
|
||
type Link struct {
|
||
Type string `json:"type"` // 网盘类型
|
||
URL string `json:"url"` // 链接地址
|
||
Password string `json:"password"` // 提取码/密码
|
||
Datetime time.Time `json:"datetime,omitempty"` // 链接更新时间(可选)
|
||
WorkTitle string `json:"work_title,omitempty"` // 作品标题(重要:用于区分同一消息中多个作品的链接)
|
||
}
|
||
```
|
||
|
||
#### work_title 字段详解
|
||
|
||
**字段作用**:
|
||
- 用于区分**同一条消息/结果中包含的多个不同作品**的链接
|
||
- 特别适用于论坛帖子、TG频道消息等一次性发布多部影视资源的场景
|
||
|
||
**使用场景示例**:
|
||
|
||
```
|
||
📺 TG频道消息示例:
|
||
【今日更新】多部热门剧集
|
||
1. 凡人修仙传 第30集
|
||
夸克:https://pan.quark.cn/s/abc123
|
||
2. 唐朝诡事录 第20集
|
||
夸克:https://pan.quark.cn/s/def456
|
||
3. 庆余年2 全集
|
||
百度:https://pan.baidu.com/s/xyz789?pwd=abcd
|
||
```
|
||
|
||
**不使用 work_title 的问题**:
|
||
- 所有链接的标题都是 "【今日更新】多部热门剧集"
|
||
- 用户无法区分哪个链接对应哪部剧集
|
||
|
||
**使用 work_title 后的效果**:
|
||
```go
|
||
links := []model.Link{
|
||
{
|
||
Type: "quark",
|
||
URL: "https://pan.quark.cn/s/abc123",
|
||
WorkTitle: "凡人修仙传 第30集", // 独立作品标题
|
||
},
|
||
{
|
||
Type: "quark",
|
||
URL: "https://pan.quark.cn/s/def456",
|
||
WorkTitle: "唐朝诡事录 第20集", // 独立作品标题
|
||
},
|
||
{
|
||
Type: "baidu",
|
||
URL: "https://pan.baidu.com/s/xyz789?pwd=abcd",
|
||
Password: "abcd",
|
||
WorkTitle: "庆余年2 全集", // 独立作品标题
|
||
},
|
||
}
|
||
```
|
||
|
||
**PanSou系统的智能处理**:
|
||
|
||
PanSou 会根据消息中的链接数量自动决定是否提取 work_title:
|
||
|
||
1. **链接数量 ≤ 4**:所有链接使用相同的 work_title(即消息标题)
|
||
```go
|
||
// 示例:一条消息只包含同一部剧的不同网盘链接
|
||
// 消息标题:"凡人修仙传 第30集"
|
||
// 链接1(夸克)、链接2(百度) → work_title 都是 "凡人修仙传 第30集"
|
||
```
|
||
|
||
2. **链接数量 > 4**:系统智能识别每个链接对应的作品标题
|
||
```go
|
||
// 示例:一条消息包含5个不同作品的链接
|
||
// 系统会分析消息文本,为每个链接提取独立的 work_title
|
||
```
|
||
|
||
**插件实现 work_title 的两种方式**:
|
||
|
||
**方式1: 依赖系统自动提取**(适用于TG频道、论坛等)
|
||
```go
|
||
// 直接返回链接,系统会自动调用 extractWorkTitlesForLinks 进行处理
|
||
links := []model.Link{
|
||
{Type: "quark", URL: "https://pan.quark.cn/s/abc123"},
|
||
{Type: "baidu", URL: "https://pan.baidu.com/s/xyz789"},
|
||
}
|
||
// PanSou会根据消息文本自动为每个链接提取work_title
|
||
```
|
||
|
||
**方式2: 插件手动设置**(适用于API插件、磁力搜索等)
|
||
```go
|
||
// 插件直接设置 work_title(如feikuai、thepiratebay等)
|
||
links := []model.Link{
|
||
{
|
||
Type: "magnet",
|
||
URL: magnetURL,
|
||
WorkTitle: buildWorkTitle(keyword, fileName), // 插件自己构建
|
||
Datetime: publishedTime,
|
||
},
|
||
}
|
||
```
|
||
|
||
**插件开发建议**:
|
||
- **网盘API插件**: 如果API直接返回单一作品,可以不设置 work_title(留空)
|
||
- **磁力搜索插件**: 建议设置 work_title,特别是文件名不含中文时需要拼接关键词
|
||
- **爬虫插件**: 如果能从页面提取每个链接的独立标题,建议设置 work_title
|
||
|
||
#### 支持的网盘类型
|
||
|
||
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://` | 磁力链接 |
|
||
|
||
```go
|
||
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. 插件Web路由注册(自定义HTTP接口)
|
||
|
||
#### 概述
|
||
|
||
PanSou 支持插件注册自定义的 HTTP 路由,用于实现插件专属的管理页面、配置接口或其他Web功能。
|
||
|
||
**典型应用场景**:
|
||
- 插件配置管理界面(如 QQPD 的用户登录和频道管理)
|
||
- 插件数据查询接口
|
||
- 插件状态监控页面
|
||
- OAuth回调接口
|
||
|
||
#### 接口定义
|
||
|
||
```go
|
||
// PluginWithWebHandler 支持Web路由的插件接口
|
||
// 插件可以选择实现此接口来注册自定义的HTTP路由
|
||
type PluginWithWebHandler interface {
|
||
AsyncSearchPlugin // 继承搜索插件接口
|
||
|
||
// RegisterWebRoutes 注册Web路由
|
||
// router: gin的路由组,插件可以在此注册自己的路由
|
||
RegisterWebRoutes(router *gin.RouterGroup)
|
||
}
|
||
```
|
||
|
||
#### 实现步骤
|
||
|
||
**步骤1: 插件结构实现接口**
|
||
|
||
```go
|
||
package myplugin
|
||
|
||
import (
|
||
"github.com/gin-gonic/gin"
|
||
"pansou/plugin"
|
||
"pansou/model"
|
||
)
|
||
|
||
type MyPlugin struct {
|
||
*plugin.BaseAsyncPlugin
|
||
// ... 其他字段
|
||
}
|
||
|
||
// 确保插件实现了 PluginWithWebHandler 接口
|
||
var _ plugin.PluginWithWebHandler = (*MyPlugin)(nil)
|
||
```
|
||
|
||
**步骤2: 实现 RegisterWebRoutes 方法**
|
||
|
||
```go
|
||
// RegisterWebRoutes 注册Web路由
|
||
func (p *MyPlugin) RegisterWebRoutes(router *gin.RouterGroup) {
|
||
// 创建插件专属的路由组
|
||
myGroup := router.Group("/myplugin")
|
||
|
||
// 注册GET路由
|
||
myGroup.GET("/status", p.handleGetStatus)
|
||
|
||
// 注册POST路由
|
||
myGroup.POST("/config", p.handleUpdateConfig)
|
||
|
||
// 支持动态路径参数
|
||
myGroup.GET("/:id", p.handleGetByID)
|
||
myGroup.POST("/:id/action", p.handleAction)
|
||
}
|
||
```
|
||
|
||
**步骤3: 实现路由处理函数**
|
||
|
||
```go
|
||
// handleGetStatus 获取插件状态
|
||
func (p *MyPlugin) handleGetStatus(c *gin.Context) {
|
||
c.JSON(200, gin.H{
|
||
"status": "ok",
|
||
"plugin": p.Name(),
|
||
"version": "1.0.0",
|
||
})
|
||
}
|
||
|
||
// handleUpdateConfig 更新插件配置
|
||
func (p *MyPlugin) handleUpdateConfig(c *gin.Context) {
|
||
var config map[string]interface{}
|
||
|
||
if err := c.BindJSON(&config); err != nil {
|
||
c.JSON(400, gin.H{"error": "Invalid JSON"})
|
||
return
|
||
}
|
||
|
||
// 处理配置更新逻辑
|
||
// ...
|
||
|
||
c.JSON(200, gin.H{
|
||
"success": true,
|
||
"message": "配置已更新",
|
||
})
|
||
}
|
||
|
||
// handleGetByID 根据ID获取数据
|
||
func (p *MyPlugin) handleGetByID(c *gin.Context) {
|
||
id := c.Param("id")
|
||
|
||
// 根据ID查询数据
|
||
// ...
|
||
|
||
c.JSON(200, gin.H{
|
||
"id": id,
|
||
"data": "...",
|
||
})
|
||
}
|
||
```
|
||
|
||
#### 实际案例: QQPD 插件
|
||
|
||
QQPD 插件实现了完整的用户管理和频道配置功能:
|
||
|
||
```go
|
||
// RegisterWebRoutes 注册Web路由
|
||
func (p *QQPDPlugin) RegisterWebRoutes(router *gin.RouterGroup) {
|
||
qqpd := router.Group("/qqpd")
|
||
|
||
// GET /:param - 显示管理页面(HTML)
|
||
qqpd.GET("/:param", p.handleManagePage)
|
||
|
||
// POST /:param - 处理管理操作(JSON API)
|
||
qqpd.POST("/:param", p.handleManagePagePOST)
|
||
}
|
||
|
||
// handleManagePage 渲染管理页面
|
||
func (p *QQPDPlugin) handleManagePage(c *gin.Context) {
|
||
param := c.Param("param")
|
||
|
||
// 生成用户专属的管理页面
|
||
html := strings.ReplaceAll(HTMLTemplate, "HASH_PLACEHOLDER", param)
|
||
|
||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||
c.String(200, html)
|
||
}
|
||
|
||
// handleManagePagePOST 处理管理操作
|
||
func (p *QQPDPlugin) handleManagePagePOST(c *gin.Context) {
|
||
param := c.Param("param")
|
||
|
||
var req struct {
|
||
Action string `json:"action"`
|
||
Channels []string `json:"channels,omitempty"`
|
||
Keyword string `json:"keyword,omitempty"`
|
||
}
|
||
|
||
if err := c.BindJSON(&req); err != nil {
|
||
respondError(c, "无效的请求格式")
|
||
return
|
||
}
|
||
|
||
// 根据不同的 action 执行不同的操作
|
||
switch req.Action {
|
||
case "get_status":
|
||
p.handleGetStatus(c, param)
|
||
case "set_channels":
|
||
p.handleSetChannels(c, param, req.Channels)
|
||
case "test_search":
|
||
p.handleTestSearch(c, param, req.Keyword)
|
||
case "logout":
|
||
p.handleLogout(c, param)
|
||
default:
|
||
respondError(c, "未知的操作")
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 实际案例: Gying 插件
|
||
|
||
```go
|
||
// RegisterWebRoutes 注册Web路由
|
||
func (p *GyingPlugin) RegisterWebRoutes(router *gin.RouterGroup) {
|
||
gying := router.Group("/gying")
|
||
gying.GET("/:param", p.handleManagePage)
|
||
gying.POST("/:param", p.handleManagePagePOST)
|
||
}
|
||
```
|
||
|
||
#### 路由访问示例
|
||
|
||
插件注册的路由可以通过以下方式访问:
|
||
|
||
```bash
|
||
# QQPD 插件管理页面
|
||
GET http://localhost:8888/qqpd/user123
|
||
|
||
# QQPD 插件配置接口
|
||
POST http://localhost:8888/qqpd/user123
|
||
Content-Type: application/json
|
||
{
|
||
"action": "set_channels",
|
||
"channels": ["pd97631607", "kuake12345"]
|
||
}
|
||
|
||
# 自定义插件接口
|
||
GET http://localhost:8888/myplugin/status
|
||
POST http://localhost:8888/myplugin/config
|
||
GET http://localhost:8888/myplugin/resource123
|
||
```
|
||
|
||
#### 系统集成
|
||
|
||
PanSou 在启动时会自动扫描并注册所有实现了 `PluginWithWebHandler` 接口的插件路由:
|
||
|
||
```go
|
||
// api/router.go 中的自动注册逻辑
|
||
func SetupRouter(searchService *service.SearchService) *gin.Engine {
|
||
r := gin.Default()
|
||
|
||
// ... 其他路由配置 ...
|
||
|
||
// 注册插件的Web路由(如果插件实现了PluginWithWebHandler接口)
|
||
allPlugins := plugin.GetRegisteredPlugins()
|
||
for _, p := range allPlugins {
|
||
if webPlugin, ok := p.(plugin.PluginWithWebHandler); ok {
|
||
webPlugin.RegisterWebRoutes(r.Group(""))
|
||
}
|
||
}
|
||
|
||
return r
|
||
}
|
||
```
|
||
|
||
#### 开发建议
|
||
|
||
1. **路由命名规范**: 使用插件名作为路由前缀,避免与其他插件冲突
|
||
```go
|
||
// ✅ 推荐
|
||
router.Group("/myplugin")
|
||
|
||
// ❌ 避免
|
||
router.Group("/config") // 可能与其他插件冲突
|
||
```
|
||
|
||
2. **安全考虑**:
|
||
- 对敏感操作进行身份验证
|
||
- 验证用户输入,防止注入攻击
|
||
- 使用哈希或加密保护敏感参数
|
||
|
||
3. **错误处理**: 统一错误响应格式
|
||
```go
|
||
func respondError(c *gin.Context, message string) {
|
||
c.JSON(400, gin.H{
|
||
"success": false,
|
||
"message": message,
|
||
})
|
||
}
|
||
|
||
func respondSuccess(c *gin.Context, message string, data interface{}) {
|
||
c.JSON(200, gin.H{
|
||
"success": true,
|
||
"message": message,
|
||
"data": data,
|
||
})
|
||
}
|
||
```
|
||
|
||
4. **HTML模板**: 可以内嵌HTML模板提供管理界面
|
||
```go
|
||
const HTMLTemplate = `<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>插件管理</title>
|
||
</head>
|
||
<body>
|
||
<h1>{{ .PluginName }} 管理界面</h1>
|
||
<!-- ... -->
|
||
</body>
|
||
</html>`
|
||
```
|
||
|
||
5. **可选实现**: Web路由是**可选功能**,只有需要自定义HTTP接口的插件才需要实现
|
||
|
||
### 2. Service层过滤控制详解
|
||
|
||
#### 构造函数选择
|
||
|
||
```go
|
||
// 标准插件构造函数(默认启用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插件示例**:
|
||
```go
|
||
// 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层通过以下机制动态判断是否需要过滤:
|
||
|
||
```go
|
||
// 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. 扩展参数处理
|
||
|
||
```go
|
||
// 支持的扩展参数示例
|
||
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. 缓存策略
|
||
|
||
```go
|
||
// 设置缓存TTL
|
||
p.SetCacheTTL(2 * time.Hour)
|
||
|
||
// 手动缓存更新
|
||
p.UpdateMainCache(cacheKey, results, ttl, true, keyword)
|
||
```
|
||
|
||
### 3. 错误处理
|
||
|
||
```go
|
||
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客户端优化
|
||
|
||
```go
|
||
// 使用连接池
|
||
client := &http.Client{
|
||
Timeout: 30 * time.Second,
|
||
Transport: &http.Transport{
|
||
MaxIdleConns: 100,
|
||
MaxIdleConnsPerHost: 10,
|
||
IdleConnTimeout: 90 * time.Second,
|
||
},
|
||
}
|
||
```
|
||
|
||
### 2. 内存优化
|
||
|
||
```go
|
||
// 预分配切片容量
|
||
results := make([]model.SearchResult, 0, expectedCount)
|
||
|
||
// 及时释放大对象
|
||
defer func() {
|
||
apiResp = APIResponse{}
|
||
}()
|
||
```
|
||
|
||
### 3. 并发控制
|
||
|
||
```go
|
||
// 使用插件内置的工作池,避免创建过多goroutine
|
||
// BaseAsyncPlugin 已经提供了工作池管理
|
||
```
|
||
|
||
## 测试和调试
|
||
|
||
### 1. 单元测试
|
||
|
||
```go
|
||
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. 集成测试
|
||
|
||
```bash
|
||
# 使用API测试插件
|
||
curl "http://localhost:8888/api/search?kw=测试&plugins=myplugin"
|
||
```
|
||
|
||
### 3. 性能测试
|
||
|
||
```bash
|
||
# 使用压力测试脚本
|
||
python3 stress_test.py
|
||
```
|
||
|
||
## 部署和配置
|
||
|
||
### 1. 插件注册
|
||
|
||
确保在 `init()` 函数中注册插件:
|
||
|
||
```go
|
||
func init() {
|
||
p := &MyPlugin{
|
||
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin", 3),
|
||
}
|
||
plugin.RegisterGlobalPlugin(p)
|
||
}
|
||
```
|
||
|
||
### 2. 环境配置
|
||
|
||
```bash
|
||
# 异步插件配置
|
||
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层过滤策略选择 ⭐ 新功能
|
||
|
||
```go
|
||
// ✅ 磁力搜索插件 - 跳过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字段设置规范 ⭐ 重要
|
||
|
||
```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请求最佳实践 ⭐ 重要
|
||
|
||
```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** - 使用信号量控制并发数
|
||
6. **Service层过滤常见问题**:
|
||
- ❌ **跳过Service层过滤但不在插件内过滤** - 会返回大量无关结果
|
||
- ❌ **磁力搜索插件使用默认构造函数** - 会被Service层误过滤
|
||
- ❌ **过滤关键词不一致** - 插件用`title_en`搜索但用原`keyword`过滤
|
||
- ❌ **标题格式化在过滤之后** - 格式化不会改善过滤效果 |