新增插件sdso

This commit is contained in:
www.xueximeng.com
2025-09-04 11:46:43 +08:00
parent 7b2c9ef9bd
commit bc71900fcc
3 changed files with 506 additions and 4 deletions

View File

@@ -29,7 +29,7 @@ PanSou 还提供了一个基于 [Model Context Protocol (MCP)](https://modelcont
### 使用Docker部署
#### 前后端集成版
#### **1、前后端集成版**
##### 直接使用Docker命令
@@ -51,7 +51,7 @@ docker-compose up -d
docker-compose logs -f
```
#### 纯后端API
#### **2、纯后端API版**
##### 直接使用Docker命令

View File

@@ -25,7 +25,6 @@ import (
// 以下是插件的空导入用于触发各插件的init函数实现自动注册
// 添加新插件时,只需在此处添加对应的导入语句即可
_ "pansou/plugin/hdr4k"
_ "pansou/plugin/hunhepan"
_ "pansou/plugin/jikepan"
_ "pansou/plugin/panwiki"
@@ -59,11 +58,11 @@ import (
_ "pansou/plugin/xys"
_ "pansou/plugin/ddys"
_ "pansou/plugin/hdmoli"
_ "pansou/plugin/javdb"
_ "pansou/plugin/yuhuage"
_ "pansou/plugin/u3c3"
_ "pansou/plugin/clxiong"
_ "pansou/plugin/jutoushe"
_ "pansou/plugin/sdso"
)
// 全局缓存写入管理器

503
plugin/sdso/sdso.go Normal file
View File

@@ -0,0 +1,503 @@
package sdso
import (
"context"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"pansou/model"
"pansou/plugin"
)
const (
// 调试日志开关
DebugLog = false
// 默认每种网盘类型获取页数
DefaultPagesPerType = 2
// 最大允许每种网盘类型页数(防止过度请求)
MaxAllowedPagesPerType = 5
// AES解密配置
AESKey = "4OToScUFOaeVTrHE"
AESIV = "9CLGao1vHKqm17Oz"
)
// 支持的网盘类型列表
var SupportedCloudTypes = []string{"baidu", "quark", "xunlei", "ali"}
// SDSOPlugin SDSO搜索插件
type SDSOPlugin struct {
*plugin.BaseAsyncPlugin
}
// APIResponse SDSO API响应结构
type APIResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Total int `json:"total"`
List []DataItem `json:"list"`
} `json:"data"`
}
// DataItem 搜索结果项
type DataItem struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"` // 加密的网盘链接
Type string `json:"type"`
From string `json:"from"` // 网盘类型: quark/xunlei/aliyun/baidu
Content *string `json:"content"`
GmtCreate string `json:"gmtCreate"`
GmtShare string `json:"gmtShare"`
FileCount int `json:"fileCount"`
CreatorID *string `json:"creatorId"`
CreatorName string `json:"creatorName"`
FileInfos []FileInfo `json:"fileInfos"`
}
// FileInfo 文件信息
type FileInfo struct {
Category *string `json:"category"`
FileExtension *string `json:"fileExtension"`
FileID string `json:"fileId"`
FileName string `json:"fileName"`
Type *string `json:"type"`
}
// PageResult 页面搜索结果
type PageResult struct {
pageNo int
fromType string
results []model.SearchResult
err error
}
func init() {
p := &SDSOPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("sdso", 3), // 优先级3 = 普通质量数据源
}
plugin.RegisterGlobalPlugin(p)
}
// Search 执行搜索并返回结果(兼容性方法)
func (p *SDSOPlugin) 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 *SDSOPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)
}
// searchImpl 实际的搜索实现
func (p *SDSOPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
if DebugLog {
fmt.Printf("[%s] 开始搜索,关键词: %s\n", p.Name(), keyword)
}
// 1. 从扩展参数中获取每种网盘类型的页数配置
pagesPerType := DefaultPagesPerType
if ext != nil {
if pages, ok := ext["pages_per_type"].(int); ok && pages > 0 {
pagesPerType = pages
if pagesPerType > MaxAllowedPagesPerType {
pagesPerType = MaxAllowedPagesPerType
if DebugLog {
fmt.Printf("[%s] 每种网盘类型页数限制在最大值: %d\n", p.Name(), MaxAllowedPagesPerType)
}
}
} else if pagesFloat, ok := ext["pages_per_type"].(float64); ok && pagesFloat > 0 {
pagesPerType = int(pagesFloat)
if pagesPerType > MaxAllowedPagesPerType {
pagesPerType = MaxAllowedPagesPerType
}
}
// 保持向后兼容:如果设置了 pages 参数,则平均分配给各网盘类型
if pages, ok := ext["pages"].(int); ok && pages > 0 {
pagesPerType = pages / len(SupportedCloudTypes)
if pagesPerType == 0 {
pagesPerType = 1
}
if pagesPerType > MaxAllowedPagesPerType {
pagesPerType = MaxAllowedPagesPerType
}
}
}
totalTasks := len(SupportedCloudTypes) * pagesPerType
if DebugLog {
fmt.Printf("[%s] 将分别搜索 %d 种网盘类型,每种 %d 页,总计 %d 个并发任务\n",
p.Name(), len(SupportedCloudTypes), pagesPerType, totalTasks)
}
// 2. 并发请求多个网盘类型的多页数据
var wg sync.WaitGroup
resultsChan := make(chan PageResult, totalTasks)
// 启动并发任务:遍历网盘类型和页数
for _, cloudType := range SupportedCloudTypes {
for pageNo := 1; pageNo <= pagesPerType; pageNo++ {
wg.Add(1)
go func(fromType string, page int) {
defer wg.Done()
results, err := p.fetchSinglePageWithType(client, keyword, page, fromType)
resultsChan <- PageResult{
pageNo: page,
fromType: fromType,
results: results,
err: err,
}
}(cloudType, pageNo)
}
}
// 等待所有任务完成
go func() {
wg.Wait()
close(resultsChan)
}()
// 3. 收集所有页面结果
var allResults []model.SearchResult
successTasks := 0
errorTasks := 0
resultsByType := make(map[string]int) // 统计各网盘类型的结果数
for pageResult := range resultsChan {
if pageResult.err != nil {
errorTasks++
if DebugLog {
fmt.Printf("[%s] %s网盘第%d页请求失败: %v\n", p.Name(), pageResult.fromType, pageResult.pageNo, pageResult.err)
}
continue
}
successTasks++
allResults = append(allResults, pageResult.results...)
resultsByType[pageResult.fromType] += len(pageResult.results)
if DebugLog {
fmt.Printf("[%s] %s网盘第%d页成功获取 %d 个结果\n", p.Name(), pageResult.fromType, pageResult.pageNo, len(pageResult.results))
}
}
if DebugLog {
fmt.Printf("[%s] 分类搜索完成: 成功%d任务, 失败%d任务, 总结果%d个\n",
p.Name(), successTasks, errorTasks, len(allResults))
for cloudType, count := range resultsByType {
fmt.Printf("[%s] - %s网盘: %d个结果\n", p.Name(), cloudType, count)
}
}
// 4. 如果所有任务都失败,返回错误
if successTasks == 0 {
return nil, fmt.Errorf("[%s] 所有搜索任务都失败", p.Name())
}
// 5. 关键词过滤
beforeFilterCount := len(allResults)
filteredResults := plugin.FilterResultsByKeyword(allResults, keyword)
if DebugLog {
fmt.Printf("[%s] 关键词过滤: 过滤前%d项 -> 过滤后%d项\n",
p.Name(), beforeFilterCount, len(filteredResults))
}
return filteredResults, nil
}
// fetchSinglePageWithType 获取指定网盘类型的单页数据
func (p *SDSOPlugin) fetchSinglePageWithType(client *http.Client, keyword string, pageNo int, fromType string) ([]model.SearchResult, error) {
// 1. 构建搜索URL添加from参数指定网盘类型
searchURL := fmt.Sprintf("https://sdso.top/api/sd/search?name=%s&pageNo=%d&from=%s",
url.QueryEscape(keyword), pageNo, fromType)
if DebugLog {
fmt.Printf("[%s] 请求%s网盘第%d页: %s\n", p.Name(), fromType, pageNo, searchURL)
}
// 2. 创建带超时的上下文
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 3. 创建请求对象
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("[%s] %s网盘第%d页创建请求失败: %w", p.Name(), fromType, pageNo, err)
}
// 4. 设置请求头
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://sdso.top/")
// 5. 发送HTTP请求带重试机制
resp, err := p.doRequestWithRetry(req, client)
if err != nil {
return nil, fmt.Errorf("[%s] %s网盘第%d页请求失败: %w", p.Name(), fromType, pageNo, err)
}
defer resp.Body.Close()
// 6. 检查状态码
if resp.StatusCode != 200 {
return nil, fmt.Errorf("[%s] %s网盘第%d页返回状态码: %d", p.Name(), fromType, pageNo, resp.StatusCode)
}
// 7. 解析响应
var apiResp APIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("[%s] %s网盘第%d页JSON解析失败: %w", p.Name(), fromType, pageNo, err)
}
// 8. 检查API响应状态
if apiResp.Code != 200 {
return nil, fmt.Errorf("[%s] %s网盘第%d页API错误: %s", p.Name(), fromType, pageNo, apiResp.Msg)
}
if DebugLog {
fmt.Printf("[%s] %s网盘第%d页获取到 %d 个原始结果\n", p.Name(), fromType, pageNo, len(apiResp.Data.List))
}
// 9. 转换为标准格式
results := make([]model.SearchResult, 0, len(apiResp.Data.List))
processedCount := 0
skippedCount := 0
for i, item := range apiResp.Data.List {
// 解密网盘链接
decryptedURL, err := DecryptURL(item.URL)
if err != nil {
if DebugLog {
fmt.Printf("[%s] %s网盘第%d页第%d项解密失败: %v\n", p.Name(), fromType, pageNo, i+1, err)
}
skippedCount++
continue
}
// 验证是否为有效的网盘链接
if !isValidPanURL(decryptedURL) {
if DebugLog {
fmt.Printf("[%s] %s网盘第%d页第%d项无效链接: %s\n", p.Name(), fromType, pageNo, i+1, decryptedURL)
}
skippedCount++
continue
}
// 映射网盘类型
panType := mapPanType(item.From)
if panType == "others" {
skippedCount++
continue
}
// 创建链接对象
link := model.Link{
Type: panType,
URL: decryptedURL,
Password: "", // SDSO返回的链接通常不包含密码
}
// 解析时间
datetime, err := time.Parse("2006-01-02 15:04:05", item.GmtShare)
if err != nil {
datetime = time.Now() // 如果解析失败,使用当前时间
}
// 清理标题中的HTML标签
title := cleanHTMLTags(item.Name)
// 构建搜索结果UniqueID包含网盘类型和页码避免重复
result := model.SearchResult{
UniqueID: fmt.Sprintf("%s-%s-%s-%d", p.Name(), item.ID, fromType, pageNo),
Title: title,
Content: fmt.Sprintf("分享者: %s | 文件数量: %d | 网盘类型: %s", item.CreatorName, item.FileCount, fromType),
Links: []model.Link{link},
Tags: []string{item.From, item.Type},
Channel: "", // 插件搜索结果必须为空字符串
Datetime: datetime,
}
results = append(results, result)
processedCount++
}
if DebugLog {
fmt.Printf("[%s] %s网盘第%d页处理完成: 原始%d项 -> 有效%d项 -> 跳过%d项\n",
p.Name(), fromType, pageNo, len(apiResp.Data.List), processedCount, skippedCount)
}
return results, nil
}
// doRequestWithRetry 带重试机制的HTTP请求
func (p *SDSOPlugin) 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)
}
// DecryptURL 解密SDSO网站返回的加密URL
// 输入: Base64编码的密文
// 输出: 解密后的原始网盘链接
func DecryptURL(encryptedURL string) (string, error) {
if encryptedURL == "" {
return "", fmt.Errorf("加密URL不能为空")
}
// Base64解码
ciphertext, err := base64.StdEncoding.DecodeString(encryptedURL)
if err != nil {
return "", fmt.Errorf("Base64解码失败: %w", err)
}
// 检查密文长度
if len(ciphertext) == 0 {
return "", fmt.Errorf("密文长度为0")
}
// 检查密文长度是否为16的倍数
if len(ciphertext)%aes.BlockSize != 0 {
return "", fmt.Errorf("密文长度不是AES块大小的倍数")
}
// 创建AES块加密器
block, err := aes.NewCipher([]byte(AESKey))
if err != nil {
return "", fmt.Errorf("创建AES加密器失败: %w", err)
}
// 创建CBC模式解密器
iv := []byte(AESIV)
if len(iv) != aes.BlockSize {
return "", fmt.Errorf("IV长度不正确: 期望%d实际%d", aes.BlockSize, len(iv))
}
mode := cipher.NewCBCDecrypter(block, iv)
// 解密
plaintext := make([]byte, len(ciphertext))
mode.CryptBlocks(plaintext, ciphertext)
// 去除PKCS7填充
unpaddedText, err := removePKCS7Padding(plaintext)
if err != nil {
return "", fmt.Errorf("去除填充失败: %w", err)
}
return string(unpaddedText), nil
}
// removePKCS7Padding 去除PKCS7填充
func removePKCS7Padding(data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, fmt.Errorf("数据为空")
}
// 获取填充长度
paddingLen := int(data[len(data)-1])
// 验证填充长度
if paddingLen == 0 || paddingLen > len(data) || paddingLen > aes.BlockSize {
return nil, fmt.Errorf("无效的填充长度: %d", paddingLen)
}
// 验证填充字节
for i := len(data) - paddingLen; i < len(data); i++ {
if data[i] != byte(paddingLen) {
return nil, fmt.Errorf("无效的填充字节")
}
}
// 返回去除填充后的数据
return data[:len(data)-paddingLen], nil
}
// cleanHTMLTags 清理HTML标签
func cleanHTMLTags(text string) string {
// 移除高亮标签 <span style="color: red;">...</span>
re := regexp.MustCompile(`<span[^>]*>(.*?)</span>`)
cleaned := re.ReplaceAllString(text, "$1")
// 移除其他可能的HTML标签
re2 := regexp.MustCompile(`<[^>]*>`)
cleaned = re2.ReplaceAllString(cleaned, "")
return strings.TrimSpace(cleaned)
}
// mapPanType 映射网盘类型
func mapPanType(from string) string {
switch strings.ToLower(from) {
case "quark":
return "quark"
case "xunlei":
return "xunlei"
case "aliyun", "ali":
return "aliyun" // PanSou内部仍使用aliyun标识
case "baidu":
return "baidu"
default:
return "others"
}
}
// isValidPanURL 验证是否为有效的网盘链接
func isValidPanURL(url string) bool {
if url == "" {
return false
}
// 检查是否包含网盘域名特征
validDomains := []string{
"pan.quark.cn",
"pan.xunlei.com",
"aliyundrive.com",
"alipan.com",
"pan.baidu.com",
}
urlLower := strings.ToLower(url)
for _, domain := range validDomains {
if strings.Contains(urlLower, domain) {
return true
}
}
return false
}