mirror of
https://github.com/fish2018/pansou.git
synced 2025-11-25 03:14:59 +08:00
新增插件sdso
This commit is contained in:
@@ -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命令
|
||||
|
||||
|
||||
3
main.go
3
main.go
@@ -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
503
plugin/sdso/sdso.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user