diff --git a/README.md b/README.md index 6058f7c..bff6bf3 100644 --- a/README.md +++ b/README.md @@ -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命令 diff --git a/main.go b/main.go index 1f51e25..77123a6 100644 --- a/main.go +++ b/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" ) // 全局缓存写入管理器 diff --git a/plugin/sdso/sdso.go b/plugin/sdso/sdso.go new file mode 100644 index 0000000..e8903d9 --- /dev/null +++ b/plugin/sdso/sdso.go @@ -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< 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 { + // 移除高亮标签 ... + re := regexp.MustCompile(`]*>(.*?)`) + 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 +}