Files
pansou/plugin/sdso/sdso.go
www.xueximeng.com bc71900fcc 新增插件sdso
2025-09-04 11:46:43 +08:00

504 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}