diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4d677a5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 fish2018 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 5db6ec3..5f7f4be 100644 --- a/README.md +++ b/README.md @@ -384,6 +384,10 @@ GET /api/search?kw=速度与激情&channels=tgsearchers3,xxx&conc=2&refresh=true } ``` +## 📄 许可证 + +本项目采用 MIT 许可证。详情请见 [LICENSE](LICENSE) 文件。 + ## ⭐ Star 历史 [![Star History Chart](https://api.star-history.com/svg?repos=fish2018/pansou&type=Date)](https://star-history.com/#fish2018/pansou&Date) diff --git a/main.go b/main.go index 64e8c4a..b04ccb9 100644 --- a/main.go +++ b/main.go @@ -59,6 +59,10 @@ import ( _ "pansou/plugin/xys" _ "pansou/plugin/ddys" _ "pansou/plugin/hdmoli" + _ "pansou/plugin/javdb" + _ "pansou/plugin/yuhuage" + _ "pansou/plugin/u3c3" + _ "pansou/plugin/clxiong" ) // 全局缓存写入管理器 diff --git a/plugin/clxiong copy 2/1.txt b/plugin/clxiong copy 2/1.txt new file mode 100644 index 0000000..f0fb9b4 --- /dev/null +++ b/plugin/clxiong copy 2/1.txt @@ -0,0 +1,301 @@ +1.获取 location +post https://www.cilixiong.org/e/search/index.php +content-type application/x-www-form-urlencoded +referer https://www.cilixiong.org/ + +classid=1%2C2&show=title&tempid=1&keyboard=%E7%91%9E%E5%85%8B%E5%92%8C%E8%8E%AB%E8%92%82 + +返回值: +从返回的headers取location值 +location result/?searchid=7549 + +2.搜索 +get https://www.cilixiong.org/e/search/result/?searchid=7549 +返回值: + + + + +瑞克和莫蒂 搜索结果 - 磁力熊 + + + + + + + + + +
+
+
+
+
+ + +
+ + + + +
+
+
+
+
+
找到 8 条符合搜索条件 "瑞克和莫蒂" 的结果
+
+ + + + + + + + + + + + + + + + + +
+
+ +
+
+
+// 激情小视频在线观看 +
+ + + + +3.详情页 +get https://www.cilixiong.org/drama/4466.html +referer https://www.cilixiong.org/e/search/result/?searchid=7549 +返回值: + + + + +瑞克和莫蒂(2025) 美国电视剧1080P下载在线观看 - 磁力熊 + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + +
+ + + + +
+
+
+
+
+
+
+ 瑞克和莫蒂 +
+
+

瑞克和莫蒂

+

豆瓣评分: 8.9

+

又名:Rick and Morty Season 8

+

上映日期:2025-05-25(美国)

+

类型:|喜剧|冒险|科幻|动画|

+

单集片长:22分钟

+

上映地区:美国

+

主演:伊恩·卡多尼 / 哈利·贝尔登 / 克里斯·帕内尔 / 斯宾瑟·格拉默 / 萨拉·乔克

+

最后更新于:2025-08-16

+

+
+
+

瑞克和莫蒂剧情简介:

+
瑞克和莫蒂第八季回来了!生活又有了意义!一切皆有可能!留意 Summer、Jerry、Beth 和其他 Beth 的冒险。也许 Butter Bot 会得到一个新的任务?无论发生什么,你都不能让 Rick 和 Morty 失望太久。人们已经尝试过了!
+
+
+
+ +
+
+

剧集仅提供第一集在线播放预览。

+
+ +
+
+

瑞克和莫蒂磁力下载地址

+ +
+
为保证质量优先选择原声版本,下载字幕请前往 Subhd字幕Zimuku字幕库
+
+
+
+ +
+
+// 激情小视频在线观看 +
+ + + \ No newline at end of file diff --git a/plugin/clxiong copy 2/clxiong.go b/plugin/clxiong copy 2/clxiong.go new file mode 100644 index 0000000..9ae9a9d --- /dev/null +++ b/plugin/clxiong copy 2/clxiong.go @@ -0,0 +1,506 @@ +package clxiong + +import ( + "fmt" + "io" + "log" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" + + "github.com/PuerkitoBio/goquery" + "pansou/model" + "pansou/plugin" +) + +const ( + BaseURL = "https://www.cilixiong.org" + SearchURL = "https://www.cilixiong.org/e/search/index.php" + UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + MaxRetries = 3 + RetryDelay = 2 * time.Second + MaxResults = 30 +) + +// ClxiongPlugin 磁力熊插件 +type ClxiongPlugin struct { + *plugin.BaseAsyncPlugin + debugMode bool +} + +func init() { + p := &ClxiongPlugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("clxiong", 1, true), + debugMode: true, + } + plugin.RegisterGlobalPlugin(p) +} + +// Search 搜索接口实现 +func (p *ClxiongPlugin) 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 搜索并返回详细结果 +func (p *ClxiongPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (*model.PluginSearchResult, error) { + if p.debugMode { + log.Printf("[CLXIONG] 开始搜索: %s", keyword) + } + + // 第一步:POST搜索获取searchid + searchID, err := p.getSearchID(keyword) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 获取searchid失败: %v", err) + } + return nil, fmt.Errorf("获取searchid失败: %v", err) + } + + // 第二步:GET搜索结果 + results, err := p.getSearchResults(searchID, keyword) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 获取搜索结果失败: %v", err) + } + return nil, err + } + + // 第三步:同步获取详情页磁力链接 + p.fetchDetailLinksSync(results) + + if p.debugMode { + log.Printf("[CLXIONG] 搜索完成,获得 %d 个结果", len(results)) + } + + // 应用关键词过滤 + filteredResults := plugin.FilterResultsByKeyword(results, keyword) + + return &model.PluginSearchResult{ + Results: filteredResults, + IsFinal: true, + Timestamp: time.Now(), + Source: p.Name(), + Message: fmt.Sprintf("找到 %d 个结果", len(filteredResults)), + }, nil +} + +// getSearchID 第一步:POST搜索获取searchid +func (p *ClxiongPlugin) getSearchID(keyword string) (string, error) { + if p.debugMode { + log.Printf("[CLXIONG] 正在获取searchid...") + } + + client := &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // 不自动跟随重定向,我们需要手动处理 + return http.ErrUseLastResponse + }, + } + + // 准备POST数据 + formData := url.Values{} + formData.Set("classid", "1,2") // 1=电影,2=剧集 + formData.Set("show", "title") // 搜索字段 + formData.Set("tempid", "1") // 模板ID + formData.Set("keyboard", keyword) // 搜索关键词 + + req, err := http.NewRequest("POST", SearchURL, strings.NewReader(formData.Encode())) + if err != nil { + return "", err + } + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Referer", BaseURL+"/") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + + var resp *http.Response + var lastErr error + + // 重试机制 + for i := 0; i < MaxRetries; i++ { + resp, lastErr = client.Do(req) + if lastErr == nil && (resp.StatusCode == 302 || resp.StatusCode == 301) { + break + } + if resp != nil { + resp.Body.Close() + } + if i < MaxRetries-1 { + time.Sleep(RetryDelay) + } + } + + if lastErr != nil { + return "", lastErr + } + defer resp.Body.Close() + + // 检查重定向响应 + if resp.StatusCode != 302 && resp.StatusCode != 301 { + return "", fmt.Errorf("期望302重定向,但得到状态码: %d", resp.StatusCode) + } + + // 从Location头部提取searchid + location := resp.Header.Get("Location") + if location == "" { + return "", fmt.Errorf("重定向响应中没有Location头部") + } + + // 解析searchid + searchID := p.extractSearchIDFromLocation(location) + if searchID == "" { + return "", fmt.Errorf("无法从Location中提取searchid: %s", location) + } + + if p.debugMode { + log.Printf("[CLXIONG] 获取到searchid: %s", searchID) + } + + return searchID, nil +} + +// extractSearchIDFromLocation 从Location头部提取searchid +func (p *ClxiongPlugin) extractSearchIDFromLocation(location string) string { + // location格式: "result/?searchid=7549" + re := regexp.MustCompile(`searchid=(\d+)`) + matches := re.FindStringSubmatch(location) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// getSearchResults 第二步:GET搜索结果 +func (p *ClxiongPlugin) getSearchResults(searchID, keyword string) ([]model.SearchResult, error) { + if p.debugMode { + log.Printf("[CLXIONG] 正在获取搜索结果,searchid: %s", searchID) + } + + // 构建结果页URL + resultURL := fmt.Sprintf("%s/e/search/result/?searchid=%s", BaseURL, searchID) + + client := &http.Client{Timeout: 30 * time.Second} + + req, err := http.NewRequest("GET", resultURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Referer", BaseURL+"/") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + + var resp *http.Response + var lastErr error + + // 重试机制 + for i := 0; i < MaxRetries; i++ { + resp, lastErr = client.Do(req) + if lastErr == nil && resp.StatusCode == 200 { + break + } + if resp != nil { + resp.Body.Close() + } + if i < MaxRetries-1 { + time.Sleep(RetryDelay) + } + } + + if lastErr != nil { + return nil, lastErr + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("搜索结果请求失败,状态码: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return p.parseSearchResults(string(body)) +} + +// parseSearchResults 解析搜索结果页面 +func (p *ClxiongPlugin) parseSearchResults(html string) ([]model.SearchResult, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return nil, err + } + + var results []model.SearchResult + + // 查找搜索结果项 + doc.Find(".row.row-cols-2.row-cols-lg-4 .col").Each(func(i int, s *goquery.Selection) { + if i >= MaxResults { + return // 限制结果数量 + } + + // 提取详情页链接 + linkEl := s.Find("a[href*='/drama/'], a[href*='/movie/']") + if linkEl.Length() == 0 { + return // 跳过无链接的项 + } + + detailPath, exists := linkEl.Attr("href") + if !exists || detailPath == "" { + return + } + + // 构建完整的详情页URL + detailURL := BaseURL + detailPath + + // 提取标题 + title := strings.TrimSpace(linkEl.Find("h2.h4").Text()) + if title == "" { + return // 跳过无标题的项 + } + + // 提取评分 + rating := strings.TrimSpace(s.Find(".rank").Text()) + + // 提取年份 + year := strings.TrimSpace(s.Find(".small").Last().Text()) + + // 提取海报图片 + poster := "" + cardImg := s.Find(".card-img") + if cardImg.Length() > 0 { + if style, exists := cardImg.Attr("style"); exists { + poster = p.extractImageFromStyle(style) + } + } + + // 构建内容信息 + var contentParts []string + if rating != "" { + contentParts = append(contentParts, "评分: "+rating) + } + if year != "" { + contentParts = append(contentParts, "年份: "+year) + } + if poster != "" { + contentParts = append(contentParts, "海报: "+poster) + } + // 添加详情页链接到content中,供后续提取磁力链接使用 + contentParts = append(contentParts, "详情页: "+detailURL) + + content := strings.Join(contentParts, " | ") + + // 生成唯一ID + uniqueID := p.generateUniqueID(detailPath) + + result := model.SearchResult{ + Title: title, + Content: content, + Channel: "", // 插件搜索结果必须为空 + Tags: []string{"磁力链接", "影视"}, + Datetime: time.Now(), // 搜索时间 + Links: []model.Link{}, // 初始为空,后续异步获取 + UniqueID: uniqueID, + } + + results = append(results, result) + }) + + if p.debugMode { + log.Printf("[CLXIONG] 解析到 %d 个搜索结果", len(results)) + } + + return results, nil +} + +// extractImageFromStyle 从style属性中提取背景图片URL +func (p *ClxiongPlugin) extractImageFromStyle(style string) string { + // style格式: "background-image: url('https://i.nacloud.cc/2024/12154.webp');" + re := regexp.MustCompile(`url\(['"]?([^'"]+)['"]?\)`) + matches := re.FindStringSubmatch(style) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// fetchDetailLinksSync 同步获取详情页磁力链接 +func (p *ClxiongPlugin) fetchDetailLinksSync(results []model.SearchResult) { + if len(results) == 0 { + return + } + + if p.debugMode { + log.Printf("[CLXIONG] 开始同步获取 %d 个详情页的磁力链接", len(results)) + } + + // 使用WaitGroup确保所有请求完成后再返回 + var wg sync.WaitGroup + + // 限制并发数,避免过多请求 + semaphore := make(chan struct{}, 5) // 最多5个并发请求 + + for i := range results { + wg.Add(1) + go func(index int) { + defer wg.Done() + + // 获取信号量 + semaphore <- struct{}{} + defer func() { <-semaphore }() + + detailURL := p.extractDetailURLFromContent(results[index].Content) + if detailURL != "" { + magnetLinks := p.fetchDetailPageMagnetLinks(detailURL) + if len(magnetLinks) > 0 { + results[index].Links = magnetLinks + if p.debugMode { + log.Printf("[CLXIONG] 为结果 %d 获取到 %d 个磁力链接", index+1, len(magnetLinks)) + } + } + } + }(i) + } + + // 等待所有goroutine完成 + wg.Wait() + + if p.debugMode { + totalLinks := 0 + for _, result := range results { + totalLinks += len(result.Links) + } + log.Printf("[CLXIONG] 所有磁力链接获取完成,共获得 %d 个磁力链接", totalLinks) + } +} + +// extractDetailURLFromContent 从content中提取详情页URL +func (p *ClxiongPlugin) extractDetailURLFromContent(content string) string { + // 查找"详情页: URL"模式 + re := regexp.MustCompile(`详情页: (https?://[^\s|]+)`) + matches := re.FindStringSubmatch(content) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// fetchDetailPageMagnetLinks 获取详情页的磁力链接 +func (p *ClxiongPlugin) fetchDetailPageMagnetLinks(detailURL string) []model.Link { + if p.debugMode { + log.Printf("[CLXIONG] 正在获取详情页磁力链接: %s", detailURL) + } + + client := &http.Client{Timeout: 20 * time.Second} + + req, err := http.NewRequest("GET", detailURL, nil) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 创建详情页请求失败: %v", err) + } + return nil + } + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Referer", BaseURL+"/") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + + resp, err := client.Do(req) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 详情页请求失败: %v", err) + } + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + if p.debugMode { + log.Printf("[CLXIONG] 详情页HTTP状态错误: %d", resp.StatusCode) + } + return nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 读取详情页响应失败: %v", err) + } + return nil + } + + return p.parseMagnetLinksFromDetail(string(body)) +} + +// parseMagnetLinksFromDetail 从详情页HTML中解析磁力链接 +func (p *ClxiongPlugin) parseMagnetLinksFromDetail(html string) []model.Link { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 解析详情页HTML失败: %v", err) + } + return nil + } + + var links []model.Link + + // 查找磁力链接 + doc.Find(".mv_down a[href^='magnet:']").Each(func(i int, s *goquery.Selection) { + href, exists := s.Attr("href") + if exists && href != "" { + // 获取文件名(链接文本) + fileName := strings.TrimSpace(s.Text()) + + link := model.Link{ + URL: href, + Type: "magnet", + } + + // 如果文件名包含大小信息,可以存储在Password字段中作为备注 + if fileName != "" && strings.Contains(fileName, "[") { + link.Password = fileName // 临时存储文件名和大小信息 + } + + links = append(links, link) + + if p.debugMode { + log.Printf("[CLXIONG] 找到磁力链接: %s %s", fileName, href) + } + } + }) + + if p.debugMode { + log.Printf("[CLXIONG] 详情页共找到 %d 个磁力链接", len(links)) + } + + return links +} + +// generateUniqueID 生成唯一ID +func (p *ClxiongPlugin) generateUniqueID(detailPath string) string { + // 从路径中提取ID,如 "/drama/4466.html" -> "4466" + re := regexp.MustCompile(`/(?:drama|movie)/(\d+)\.html`) + matches := re.FindStringSubmatch(detailPath) + if len(matches) > 1 { + return fmt.Sprintf("clxiong-%s", matches[1]) + } + + // 备用方案:使用完整路径生成哈希 + hash := 0 + for _, char := range detailPath { + hash = hash*31 + int(char) + } + if hash < 0 { + hash = -hash + } + return fmt.Sprintf("clxiong-%d", hash) +} \ No newline at end of file diff --git a/plugin/clxiong copy 2/html结构分析.md b/plugin/clxiong copy 2/html结构分析.md new file mode 100644 index 0000000..bf54be1 --- /dev/null +++ b/plugin/clxiong copy 2/html结构分析.md @@ -0,0 +1,168 @@ +# 磁力熊(CiLiXiong) HTML结构分析文档 + +## 网站信息 +- **域名**: `www.cilixiong.org` +- **名称**: 磁力熊 +- **类型**: 影视磁力链接搜索网站 +- **特点**: 两步式搜索流程,需要先POST获取searchid,再GET搜索结果 + +## 搜索流程分析 + +### 第一步:提交搜索请求 +#### 请求信息 +- **URL**: `https://www.cilixiong.org/e/search/index.php` +- **方法**: POST +- **Content-Type**: `application/x-www-form-urlencoded` +- **Referer**: `https://www.cilixiong.org/` + +#### POST参数 +``` +classid=1%2C2&show=title&tempid=1&keyboard={URL编码的关键词} +``` +参数说明: +- `classid=1,2` - 搜索分类(1=电影,2=剧集) +- `show=title` - 搜索字段 +- `tempid=1` - 模板ID +- `keyboard` - 搜索关键词(需URL编码) + +#### 响应处理 +- **状态码**: 302重定向 +- **关键信息**: 从响应头`Location`字段获取searchid +- **格式**: `result/?searchid=7549` + +### 第二步:获取搜索结果 +#### 请求信息 +- **URL**: `https://www.cilixiong.org/e/search/result/?searchid={searchid}` +- **方法**: GET +- **Referer**: `https://www.cilixiong.org/` + +## 搜索结果页面结构 + +### 页面布局 +- **容器**: `.container` +- **结果提示**: `.text-white.py-3` - 显示"找到 X 条符合搜索条件" +- **结果网格**: `.row.row-cols-2.row-cols-lg-4.align-items-stretch.g-4.py-2` + +### 单个结果项结构 +```html +
+
+ +
+
+

影片标题

+
    +
  • 8.9
  • +
  • 2025
  • +
+
+
+
+
+``` + +### 数据提取选择器 + +#### 结果列表 +- **选择器**: `.row.row-cols-2.row-cols-lg-4 .col` +- **排除**: 空白或无效的卡片 + +#### 单项数据提取 +1. **详情链接**: `.col a[href*="/drama/"]` 或 `.col a[href*="/movie/"]` +2. **标题**: `.col h2.h4` +3. **评分**: `.col .rank` +4. **年份**: `.col .small`(最后一个li元素) +5. **海报**: `.col .card-img[style*="background-image"]` - 从style属性提取url + +#### 链接格式 +- 电影:`/movie/ID.html` +- 剧集:`/drama/ID.html` +- 需补全为绝对URL:`https://www.cilixiong.org/drama/ID.html` + +## 详情页面结构 + +### 基本信息区域 +```html +
+

影片标题

+

豆瓣评分: 8.9

+

又名:英文名称

+

上映日期:2025-05-25(美国)

+

类型:|喜剧|冒险|科幻|动画|

+

单集片长:22分钟

+

上映地区:美国

+

主演:演员列表

+
+``` + +### 磁力链接区域 +```html +
+

影片名磁力下载地址

+
+ +
+
+``` + +### 磁力链接提取 +- **容器**: `.mv_down .container` +- **链接项**: `.border-bottom` +- **磁力链接**: `a[href^="magnet:"]` +- **文件名**: 链接的文本内容 +- **大小信息**: 通常包含在文件名的方括号中 + +## 错误处理 + +### 常见问题 +1. **搜索无结果**: 页面会显示"找到 0 条符合搜索条件" +2. **searchid失效**: 可能需要重新发起搜索请求 +3. **详情页无磁力链接**: 某些内容可能暂时无下载资源 + +### 限流检测 +- **状态码**: 检测429或403状态码 +- **页面内容**: 检测是否包含"访问频繁"等提示 + +## 实现要点 + +### 请求头设置 +```http +User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 +Content-Type: application/x-www-form-urlencoded (POST请求) +Referer: https://www.cilixiong.org/ +``` + +### Cookie处理 +- 网站可能需要维持会话状态 +- 建议在客户端中启用Cookie存储 + +### 搜索策略 +1. **首次搜索**: POST提交 → 解析Location → GET结果页 +2. **结果解析**: 提取基本信息,构建搜索结果 +3. **详情获取**: 可选,异步获取磁力链接 + +### 数据字段映射 +- **Title**: 影片中文标题 +- **Content**: 评分、年份、类型等信息组合 +- **UniqueID**: 使用详情页URL的ID部分 +- **Links**: 磁力链接数组 +- **Tags**: 影片类型标签 + +## 技术注意事项 + +### URL编码 +- 搜索关键词必须进行URL编码 +- 中文字符使用UTF-8编码 + +### 重定向处理 +- POST请求会返回302重定向 +- 需要从响应头提取Location信息 +- 不要自动跟随重定向,需要手动解析 + +### 异步处理 +- 搜索结果可以先返回基本信息 +- 磁力链接通过异步请求详情页获取 +- 设置合理的并发限制和超时时间 \ No newline at end of file diff --git a/plugin/clxiong copy 3/1.txt b/plugin/clxiong copy 3/1.txt new file mode 100644 index 0000000..f0fb9b4 --- /dev/null +++ b/plugin/clxiong copy 3/1.txt @@ -0,0 +1,301 @@ +1.获取 location +post https://www.cilixiong.org/e/search/index.php +content-type application/x-www-form-urlencoded +referer https://www.cilixiong.org/ + +classid=1%2C2&show=title&tempid=1&keyboard=%E7%91%9E%E5%85%8B%E5%92%8C%E8%8E%AB%E8%92%82 + +返回值: +从返回的headers取location值 +location result/?searchid=7549 + +2.搜索 +get https://www.cilixiong.org/e/search/result/?searchid=7549 +返回值: + + + + +瑞克和莫蒂 搜索结果 - 磁力熊 + + + + + + + + + +
+
+
+
+
+ + +
+ + + + +
+
+
+
+
+
找到 8 条符合搜索条件 "瑞克和莫蒂" 的结果
+
+ + + + + + + + + + + + + + + + + +
+
+ +
+
+
+// 激情小视频在线观看 +
+ + + + +3.详情页 +get https://www.cilixiong.org/drama/4466.html +referer https://www.cilixiong.org/e/search/result/?searchid=7549 +返回值: + + + + +瑞克和莫蒂(2025) 美国电视剧1080P下载在线观看 - 磁力熊 + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + +
+ + + + +
+
+
+
+
+
+
+ 瑞克和莫蒂 +
+
+

瑞克和莫蒂

+

豆瓣评分: 8.9

+

又名:Rick and Morty Season 8

+

上映日期:2025-05-25(美国)

+

类型:|喜剧|冒险|科幻|动画|

+

单集片长:22分钟

+

上映地区:美国

+

主演:伊恩·卡多尼 / 哈利·贝尔登 / 克里斯·帕内尔 / 斯宾瑟·格拉默 / 萨拉·乔克

+

最后更新于:2025-08-16

+

+
+
+

瑞克和莫蒂剧情简介:

+
瑞克和莫蒂第八季回来了!生活又有了意义!一切皆有可能!留意 Summer、Jerry、Beth 和其他 Beth 的冒险。也许 Butter Bot 会得到一个新的任务?无论发生什么,你都不能让 Rick 和 Morty 失望太久。人们已经尝试过了!
+
+
+
+ +
+
+

剧集仅提供第一集在线播放预览。

+
+ +
+
+

瑞克和莫蒂磁力下载地址

+ +
+
为保证质量优先选择原声版本,下载字幕请前往 Subhd字幕Zimuku字幕库
+
+
+
+ +
+
+// 激情小视频在线观看 +
+ + + \ No newline at end of file diff --git a/plugin/clxiong copy 3/clxiong.go b/plugin/clxiong copy 3/clxiong.go new file mode 100644 index 0000000..6d88343 --- /dev/null +++ b/plugin/clxiong copy 3/clxiong.go @@ -0,0 +1,587 @@ +package clxiong + +import ( + "fmt" + "io" + "log" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" + + "github.com/PuerkitoBio/goquery" + "pansou/model" + "pansou/plugin" +) + +const ( + BaseURL = "https://www.cilixiong.org" + SearchURL = "https://www.cilixiong.org/e/search/index.php" + UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + MaxRetries = 3 + RetryDelay = 2 * time.Second + MaxResults = 30 +) + +// DetailPageInfo 详情页信息结构体 +type DetailPageInfo struct { + MagnetLinks []model.Link + UpdateTime time.Time + Title string + FirstFileName string // 第一个文件的名称,用于生成note +} + +// ClxiongPlugin 磁力熊插件 +type ClxiongPlugin struct { + *plugin.BaseAsyncPlugin + debugMode bool +} + +func init() { + p := &ClxiongPlugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("clxiong", 1, true), + debugMode: false, + } + plugin.RegisterGlobalPlugin(p) +} + +// Search 搜索接口实现 +func (p *ClxiongPlugin) 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 搜索并返回详细结果 +func (p *ClxiongPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (*model.PluginSearchResult, error) { + if p.debugMode { + log.Printf("[CLXIONG] 开始搜索: %s", keyword) + } + + // 第一步:POST搜索获取searchid + searchID, err := p.getSearchID(keyword) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 获取searchid失败: %v", err) + } + return nil, fmt.Errorf("获取searchid失败: %v", err) + } + + // 第二步:GET搜索结果 + results, err := p.getSearchResults(searchID, keyword) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 获取搜索结果失败: %v", err) + } + return nil, err + } + + // 第三步:同步获取详情页磁力链接 + p.fetchDetailLinksSync(results) + + if p.debugMode { + log.Printf("[CLXIONG] 搜索完成,获得 %d 个结果", len(results)) + } + + // 应用关键词过滤 + filteredResults := plugin.FilterResultsByKeyword(results, keyword) + + return &model.PluginSearchResult{ + Results: filteredResults, + IsFinal: true, + Timestamp: time.Now(), + Source: p.Name(), + Message: fmt.Sprintf("找到 %d 个结果", len(filteredResults)), + }, nil +} + +// getSearchID 第一步:POST搜索获取searchid +func (p *ClxiongPlugin) getSearchID(keyword string) (string, error) { + if p.debugMode { + log.Printf("[CLXIONG] 正在获取searchid...") + } + + client := &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // 不自动跟随重定向,我们需要手动处理 + return http.ErrUseLastResponse + }, + } + + // 准备POST数据 + formData := url.Values{} + formData.Set("classid", "1,2") // 1=电影,2=剧集 + formData.Set("show", "title") // 搜索字段 + formData.Set("tempid", "1") // 模板ID + formData.Set("keyboard", keyword) // 搜索关键词 + + req, err := http.NewRequest("POST", SearchURL, strings.NewReader(formData.Encode())) + if err != nil { + return "", err + } + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Referer", BaseURL+"/") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + + var resp *http.Response + var lastErr error + + // 重试机制 + for i := 0; i < MaxRetries; i++ { + resp, lastErr = client.Do(req) + if lastErr == nil && (resp.StatusCode == 302 || resp.StatusCode == 301) { + break + } + if resp != nil { + resp.Body.Close() + } + if i < MaxRetries-1 { + time.Sleep(RetryDelay) + } + } + + if lastErr != nil { + return "", lastErr + } + defer resp.Body.Close() + + // 检查重定向响应 + if resp.StatusCode != 302 && resp.StatusCode != 301 { + return "", fmt.Errorf("期望302重定向,但得到状态码: %d", resp.StatusCode) + } + + // 从Location头部提取searchid + location := resp.Header.Get("Location") + if location == "" { + return "", fmt.Errorf("重定向响应中没有Location头部") + } + + // 解析searchid + searchID := p.extractSearchIDFromLocation(location) + if searchID == "" { + return "", fmt.Errorf("无法从Location中提取searchid: %s", location) + } + + if p.debugMode { + log.Printf("[CLXIONG] 获取到searchid: %s", searchID) + } + + return searchID, nil +} + +// extractSearchIDFromLocation 从Location头部提取searchid +func (p *ClxiongPlugin) extractSearchIDFromLocation(location string) string { + // location格式: "result/?searchid=7549" + re := regexp.MustCompile(`searchid=(\d+)`) + matches := re.FindStringSubmatch(location) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// getSearchResults 第二步:GET搜索结果 +func (p *ClxiongPlugin) getSearchResults(searchID, keyword string) ([]model.SearchResult, error) { + if p.debugMode { + log.Printf("[CLXIONG] 正在获取搜索结果,searchid: %s", searchID) + } + + // 构建结果页URL + resultURL := fmt.Sprintf("%s/e/search/result/?searchid=%s", BaseURL, searchID) + + client := &http.Client{Timeout: 30 * time.Second} + + req, err := http.NewRequest("GET", resultURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Referer", BaseURL+"/") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + + var resp *http.Response + var lastErr error + + // 重试机制 + for i := 0; i < MaxRetries; i++ { + resp, lastErr = client.Do(req) + if lastErr == nil && resp.StatusCode == 200 { + break + } + if resp != nil { + resp.Body.Close() + } + if i < MaxRetries-1 { + time.Sleep(RetryDelay) + } + } + + if lastErr != nil { + return nil, lastErr + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("搜索结果请求失败,状态码: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return p.parseSearchResults(string(body)) +} + +// parseSearchResults 解析搜索结果页面 +func (p *ClxiongPlugin) parseSearchResults(html string) ([]model.SearchResult, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return nil, err + } + + var results []model.SearchResult + + // 查找搜索结果项 + doc.Find(".row.row-cols-2.row-cols-lg-4 .col").Each(func(i int, s *goquery.Selection) { + if i >= MaxResults { + return // 限制结果数量 + } + + // 提取详情页链接 + linkEl := s.Find("a[href*='/drama/'], a[href*='/movie/']") + if linkEl.Length() == 0 { + return // 跳过无链接的项 + } + + detailPath, exists := linkEl.Attr("href") + if !exists || detailPath == "" { + return + } + + // 构建完整的详情页URL + detailURL := BaseURL + detailPath + + // 提取标题 + title := strings.TrimSpace(linkEl.Find("h2.h4").Text()) + if title == "" { + return // 跳过无标题的项 + } + + // 提取评分 + rating := strings.TrimSpace(s.Find(".rank").Text()) + + // 提取年份 + year := strings.TrimSpace(s.Find(".small").Last().Text()) + + // 提取海报图片 + poster := "" + cardImg := s.Find(".card-img") + if cardImg.Length() > 0 { + if style, exists := cardImg.Attr("style"); exists { + poster = p.extractImageFromStyle(style) + } + } + + // 构建内容信息 + var contentParts []string + if rating != "" { + contentParts = append(contentParts, "评分: "+rating) + } + if year != "" { + contentParts = append(contentParts, "年份: "+year) + } + if poster != "" { + contentParts = append(contentParts, "海报: "+poster) + } + // 添加详情页链接到content中,供后续提取磁力链接使用 + contentParts = append(contentParts, "详情页: "+detailURL) + + content := strings.Join(contentParts, " | ") + + // 生成唯一ID + uniqueID := p.generateUniqueID(detailPath) + + result := model.SearchResult{ + Title: title, + Content: content, + Channel: "", // 插件搜索结果必须为空 + Tags: []string{"磁力链接", "影视"}, + Datetime: time.Now(), // 搜索时间 + Links: []model.Link{}, // 初始为空,后续异步获取 + UniqueID: uniqueID, + } + + results = append(results, result) + }) + + if p.debugMode { + log.Printf("[CLXIONG] 解析到 %d 个搜索结果", len(results)) + } + + return results, nil +} + +// extractImageFromStyle 从style属性中提取背景图片URL +func (p *ClxiongPlugin) extractImageFromStyle(style string) string { + // style格式: "background-image: url('https://i.nacloud.cc/2024/12154.webp');" + re := regexp.MustCompile(`url\(['"]?([^'"]+)['"]?\)`) + matches := re.FindStringSubmatch(style) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// fetchDetailLinksSync 同步获取详情页磁力链接 +func (p *ClxiongPlugin) fetchDetailLinksSync(results []model.SearchResult) { + if len(results) == 0 { + return + } + + if p.debugMode { + log.Printf("[CLXIONG] 开始同步获取 %d 个详情页的磁力链接", len(results)) + } + + // 使用WaitGroup确保所有请求完成后再返回 + var wg sync.WaitGroup + + // 限制并发数,避免过多请求 + semaphore := make(chan struct{}, 5) // 最多5个并发请求 + + for i := range results { + wg.Add(1) + go func(index int) { + defer wg.Done() + + // 获取信号量 + semaphore <- struct{}{} + defer func() { <-semaphore }() + + detailURL := p.extractDetailURLFromContent(results[index].Content) + if detailURL != "" { + detailInfo := p.fetchDetailPageInfo(detailURL, results[index].Title) + if detailInfo != nil && len(detailInfo.MagnetLinks) > 0 { + results[index].Links = detailInfo.MagnetLinks + // 更新日期时间为详情页的更新时间 + if !detailInfo.UpdateTime.IsZero() { + results[index].Datetime = detailInfo.UpdateTime + } + // 更新标题,使其包含第一个文件信息,用于生成正确的note + if detailInfo.FirstFileName != "" { + results[index].Title = fmt.Sprintf("%s-%s", results[index].Title, detailInfo.FirstFileName) + } + if p.debugMode { + log.Printf("[CLXIONG] 为结果 %d 获取到 %d 个磁力链接", index+1, len(detailInfo.MagnetLinks)) + } + } + } + }(i) + } + + // 等待所有goroutine完成 + wg.Wait() + + if p.debugMode { + totalLinks := 0 + for _, result := range results { + totalLinks += len(result.Links) + } + log.Printf("[CLXIONG] 所有磁力链接获取完成,共获得 %d 个磁力链接", totalLinks) + } +} + +// extractDetailURLFromContent 从content中提取详情页URL +func (p *ClxiongPlugin) extractDetailURLFromContent(content string) string { + // 查找"详情页: URL"模式 + re := regexp.MustCompile(`详情页: (https?://[^\s|]+)`) + matches := re.FindStringSubmatch(content) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// fetchDetailPageInfo 获取详情页的完整信息 +func (p *ClxiongPlugin) fetchDetailPageInfo(detailURL string, movieTitle string) *DetailPageInfo { + if p.debugMode { + log.Printf("[CLXIONG] 正在获取详情页信息: %s", detailURL) + } + + client := &http.Client{Timeout: 20 * time.Second} + + req, err := http.NewRequest("GET", detailURL, nil) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 创建详情页请求失败: %v", err) + } + return nil + } + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Referer", BaseURL+"/") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + + resp, err := client.Do(req) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 详情页请求失败: %v", err) + } + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + if p.debugMode { + log.Printf("[CLXIONG] 详情页HTTP状态错误: %d", resp.StatusCode) + } + return nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 读取详情页响应失败: %v", err) + } + return nil + } + + return p.parseDetailPageInfo(string(body), movieTitle) +} + +// parseDetailPageInfo 从详情页HTML中解析完整信息 +func (p *ClxiongPlugin) parseDetailPageInfo(html string, movieTitle string) *DetailPageInfo { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 解析详情页HTML失败: %v", err) + } + return nil + } + + detailInfo := &DetailPageInfo{ + Title: movieTitle, + } + + // 解析更新时间 + detailInfo.UpdateTime = p.parseUpdateTimeFromDetail(doc) + + // 解析磁力链接 + magnetLinks, firstFileName := p.parseMagnetLinksFromDetailDoc(doc, movieTitle) + detailInfo.MagnetLinks = magnetLinks + detailInfo.FirstFileName = firstFileName + + if p.debugMode { + log.Printf("[CLXIONG] 详情页解析完成: 磁力链接 %d 个,更新时间: %v", + len(detailInfo.MagnetLinks), detailInfo.UpdateTime) + } + + return detailInfo +} + +// parseUpdateTimeFromDetail 从详情页解析更新时间 +func (p *ClxiongPlugin) parseUpdateTimeFromDetail(doc *goquery.Document) time.Time { + // 查找"最后更新于:2025-08-16"这样的文本 + var updateTime time.Time + + doc.Find(".mv_detail p").Each(func(i int, s *goquery.Selection) { + text := strings.TrimSpace(s.Text()) + if strings.Contains(text, "最后更新于:") { + // 提取日期部分 + dateStr := strings.Replace(text, "最后更新于:", "", 1) + dateStr = strings.TrimSpace(dateStr) + + // 解析日期,支持多种格式 + layouts := []string{ + "2006-01-02", + "2006-1-2", + "2006/01/02", + "2006/1/2", + } + + for _, layout := range layouts { + if t, err := time.Parse(layout, dateStr); err == nil { + updateTime = t + if p.debugMode { + log.Printf("[CLXIONG] 解析到更新时间: %s -> %v", dateStr, updateTime) + } + return + } + } + + if p.debugMode { + log.Printf("[CLXIONG] 无法解析更新时间: %s", dateStr) + } + } + }) + + return updateTime +} + +// parseMagnetLinksFromDetailDoc 从详情页DOM解析磁力链接 +func (p *ClxiongPlugin) parseMagnetLinksFromDetailDoc(doc *goquery.Document, movieTitle string) ([]model.Link, string) { + var links []model.Link + var firstFileName string + + // 查找磁力链接 + doc.Find(".mv_down a[href^='magnet:']").Each(func(i int, s *goquery.Selection) { + href, exists := s.Attr("href") + if exists && href != "" { + // 获取文件名(链接文本) + fileName := strings.TrimSpace(s.Text()) + + // 记录第一个文件名 + if i == 0 && fileName != "" { + firstFileName = fileName + } + + link := model.Link{ + URL: href, + Type: "magnet", + } + + // 磁力链接密码字段设置为空(按用户要求) + link.Password = "" + + links = append(links, link) + + if p.debugMode { + log.Printf("[CLXIONG] 找到磁力链接: %s", fileName) + } + } + }) + + if p.debugMode { + log.Printf("[CLXIONG] 详情页共找到 %d 个磁力链接", len(links)) + } + + return links, firstFileName +} + +// generateUniqueID 生成唯一ID +func (p *ClxiongPlugin) generateUniqueID(detailPath string) string { + // 从路径中提取ID,如 "/drama/4466.html" -> "4466" + re := regexp.MustCompile(`/(?:drama|movie)/(\d+)\.html`) + matches := re.FindStringSubmatch(detailPath) + if len(matches) > 1 { + return fmt.Sprintf("clxiong-%s", matches[1]) + } + + // 备用方案:使用完整路径生成哈希 + hash := 0 + for _, char := range detailPath { + hash = hash*31 + int(char) + } + if hash < 0 { + hash = -hash + } + return fmt.Sprintf("clxiong-%d", hash) +} \ No newline at end of file diff --git a/plugin/clxiong copy 3/html结构分析.md b/plugin/clxiong copy 3/html结构分析.md new file mode 100644 index 0000000..bf54be1 --- /dev/null +++ b/plugin/clxiong copy 3/html结构分析.md @@ -0,0 +1,168 @@ +# 磁力熊(CiLiXiong) HTML结构分析文档 + +## 网站信息 +- **域名**: `www.cilixiong.org` +- **名称**: 磁力熊 +- **类型**: 影视磁力链接搜索网站 +- **特点**: 两步式搜索流程,需要先POST获取searchid,再GET搜索结果 + +## 搜索流程分析 + +### 第一步:提交搜索请求 +#### 请求信息 +- **URL**: `https://www.cilixiong.org/e/search/index.php` +- **方法**: POST +- **Content-Type**: `application/x-www-form-urlencoded` +- **Referer**: `https://www.cilixiong.org/` + +#### POST参数 +``` +classid=1%2C2&show=title&tempid=1&keyboard={URL编码的关键词} +``` +参数说明: +- `classid=1,2` - 搜索分类(1=电影,2=剧集) +- `show=title` - 搜索字段 +- `tempid=1` - 模板ID +- `keyboard` - 搜索关键词(需URL编码) + +#### 响应处理 +- **状态码**: 302重定向 +- **关键信息**: 从响应头`Location`字段获取searchid +- **格式**: `result/?searchid=7549` + +### 第二步:获取搜索结果 +#### 请求信息 +- **URL**: `https://www.cilixiong.org/e/search/result/?searchid={searchid}` +- **方法**: GET +- **Referer**: `https://www.cilixiong.org/` + +## 搜索结果页面结构 + +### 页面布局 +- **容器**: `.container` +- **结果提示**: `.text-white.py-3` - 显示"找到 X 条符合搜索条件" +- **结果网格**: `.row.row-cols-2.row-cols-lg-4.align-items-stretch.g-4.py-2` + +### 单个结果项结构 +```html +
+
+ +
+
+

影片标题

+
    +
  • 8.9
  • +
  • 2025
  • +
+
+
+
+
+``` + +### 数据提取选择器 + +#### 结果列表 +- **选择器**: `.row.row-cols-2.row-cols-lg-4 .col` +- **排除**: 空白或无效的卡片 + +#### 单项数据提取 +1. **详情链接**: `.col a[href*="/drama/"]` 或 `.col a[href*="/movie/"]` +2. **标题**: `.col h2.h4` +3. **评分**: `.col .rank` +4. **年份**: `.col .small`(最后一个li元素) +5. **海报**: `.col .card-img[style*="background-image"]` - 从style属性提取url + +#### 链接格式 +- 电影:`/movie/ID.html` +- 剧集:`/drama/ID.html` +- 需补全为绝对URL:`https://www.cilixiong.org/drama/ID.html` + +## 详情页面结构 + +### 基本信息区域 +```html +
+

影片标题

+

豆瓣评分: 8.9

+

又名:英文名称

+

上映日期:2025-05-25(美国)

+

类型:|喜剧|冒险|科幻|动画|

+

单集片长:22分钟

+

上映地区:美国

+

主演:演员列表

+
+``` + +### 磁力链接区域 +```html +
+

影片名磁力下载地址

+
+ +
+
+``` + +### 磁力链接提取 +- **容器**: `.mv_down .container` +- **链接项**: `.border-bottom` +- **磁力链接**: `a[href^="magnet:"]` +- **文件名**: 链接的文本内容 +- **大小信息**: 通常包含在文件名的方括号中 + +## 错误处理 + +### 常见问题 +1. **搜索无结果**: 页面会显示"找到 0 条符合搜索条件" +2. **searchid失效**: 可能需要重新发起搜索请求 +3. **详情页无磁力链接**: 某些内容可能暂时无下载资源 + +### 限流检测 +- **状态码**: 检测429或403状态码 +- **页面内容**: 检测是否包含"访问频繁"等提示 + +## 实现要点 + +### 请求头设置 +```http +User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 +Content-Type: application/x-www-form-urlencoded (POST请求) +Referer: https://www.cilixiong.org/ +``` + +### Cookie处理 +- 网站可能需要维持会话状态 +- 建议在客户端中启用Cookie存储 + +### 搜索策略 +1. **首次搜索**: POST提交 → 解析Location → GET结果页 +2. **结果解析**: 提取基本信息,构建搜索结果 +3. **详情获取**: 可选,异步获取磁力链接 + +### 数据字段映射 +- **Title**: 影片中文标题 +- **Content**: 评分、年份、类型等信息组合 +- **UniqueID**: 使用详情页URL的ID部分 +- **Links**: 磁力链接数组 +- **Tags**: 影片类型标签 + +## 技术注意事项 + +### URL编码 +- 搜索关键词必须进行URL编码 +- 中文字符使用UTF-8编码 + +### 重定向处理 +- POST请求会返回302重定向 +- 需要从响应头提取Location信息 +- 不要自动跟随重定向,需要手动解析 + +### 异步处理 +- 搜索结果可以先返回基本信息 +- 磁力链接通过异步请求详情页获取 +- 设置合理的并发限制和超时时间 \ No newline at end of file diff --git a/plugin/clxiong copy/1.txt b/plugin/clxiong copy/1.txt new file mode 100644 index 0000000..f0fb9b4 --- /dev/null +++ b/plugin/clxiong copy/1.txt @@ -0,0 +1,301 @@ +1.获取 location +post https://www.cilixiong.org/e/search/index.php +content-type application/x-www-form-urlencoded +referer https://www.cilixiong.org/ + +classid=1%2C2&show=title&tempid=1&keyboard=%E7%91%9E%E5%85%8B%E5%92%8C%E8%8E%AB%E8%92%82 + +返回值: +从返回的headers取location值 +location result/?searchid=7549 + +2.搜索 +get https://www.cilixiong.org/e/search/result/?searchid=7549 +返回值: + + + + +瑞克和莫蒂 搜索结果 - 磁力熊 + + + + + + + + + +
+
+
+
+
+ + +
+ + + + +
+
+
+
+
+
找到 8 条符合搜索条件 "瑞克和莫蒂" 的结果
+
+ + + + + + + + + + + + + + + + + +
+
+ +
+
+
+// 激情小视频在线观看 +
+ + + + +3.详情页 +get https://www.cilixiong.org/drama/4466.html +referer https://www.cilixiong.org/e/search/result/?searchid=7549 +返回值: + + + + +瑞克和莫蒂(2025) 美国电视剧1080P下载在线观看 - 磁力熊 + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + +
+ + + + +
+
+
+
+
+
+
+ 瑞克和莫蒂 +
+
+

瑞克和莫蒂

+

豆瓣评分: 8.9

+

又名:Rick and Morty Season 8

+

上映日期:2025-05-25(美国)

+

类型:|喜剧|冒险|科幻|动画|

+

单集片长:22分钟

+

上映地区:美国

+

主演:伊恩·卡多尼 / 哈利·贝尔登 / 克里斯·帕内尔 / 斯宾瑟·格拉默 / 萨拉·乔克

+

最后更新于:2025-08-16

+

+
+
+

瑞克和莫蒂剧情简介:

+
瑞克和莫蒂第八季回来了!生活又有了意义!一切皆有可能!留意 Summer、Jerry、Beth 和其他 Beth 的冒险。也许 Butter Bot 会得到一个新的任务?无论发生什么,你都不能让 Rick 和 Morty 失望太久。人们已经尝试过了!
+
+
+
+ +
+
+

剧集仅提供第一集在线播放预览。

+
+ +
+
+

瑞克和莫蒂磁力下载地址

+ +
+
为保证质量优先选择原声版本,下载字幕请前往 Subhd字幕Zimuku字幕库
+
+
+
+ +
+
+// 激情小视频在线观看 +
+ + + \ No newline at end of file diff --git a/plugin/clxiong copy/clxiong.go b/plugin/clxiong copy/clxiong.go new file mode 100644 index 0000000..c53db31 --- /dev/null +++ b/plugin/clxiong copy/clxiong.go @@ -0,0 +1,483 @@ +package clxiong + +import ( + "fmt" + "io" + "log" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + "pansou/model" + "pansou/plugin" +) + +const ( + BaseURL = "https://www.cilixiong.org" + SearchURL = "https://www.cilixiong.org/e/search/index.php" + UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + MaxRetries = 3 + RetryDelay = 2 * time.Second + MaxResults = 30 +) + +// ClxiongPlugin 磁力熊插件 +type ClxiongPlugin struct { + *plugin.BaseAsyncPlugin + debugMode bool +} + +func init() { + p := &ClxiongPlugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("clxiong", 1, true), + debugMode: true, + } + plugin.RegisterGlobalPlugin(p) +} + +// Search 搜索接口实现 +func (p *ClxiongPlugin) 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 搜索并返回详细结果 +func (p *ClxiongPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (*model.PluginSearchResult, error) { + if p.debugMode { + log.Printf("[CLXIONG] 开始搜索: %s", keyword) + } + + // 第一步:POST搜索获取searchid + searchID, err := p.getSearchID(keyword) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 获取searchid失败: %v", err) + } + return nil, fmt.Errorf("获取searchid失败: %v", err) + } + + // 第二步:GET搜索结果 + results, err := p.getSearchResults(searchID, keyword) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 获取搜索结果失败: %v", err) + } + return nil, err + } + + // 第三步:异步获取详情页磁力链接 + p.fetchDetailLinksAsync(results) + + if p.debugMode { + log.Printf("[CLXIONG] 搜索完成,获得 %d 个结果", len(results)) + } + + // 应用关键词过滤 + fmt.Printf("results: %v\n", results) + filteredResults := plugin.FilterResultsByKeyword(results, keyword) + + return &model.PluginSearchResult{ + Results: filteredResults, + IsFinal: true, + Timestamp: time.Now(), + Source: p.Name(), + Message: fmt.Sprintf("找到 %d 个结果", len(filteredResults)), + }, nil +} + +// getSearchID 第一步:POST搜索获取searchid +func (p *ClxiongPlugin) getSearchID(keyword string) (string, error) { + if p.debugMode { + log.Printf("[CLXIONG] 正在获取searchid...") + } + + client := &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // 不自动跟随重定向,我们需要手动处理 + return http.ErrUseLastResponse + }, + } + + // 准备POST数据 + formData := url.Values{} + formData.Set("classid", "1,2") // 1=电影,2=剧集 + formData.Set("show", "title") // 搜索字段 + formData.Set("tempid", "1") // 模板ID + formData.Set("keyboard", keyword) // 搜索关键词 + + req, err := http.NewRequest("POST", SearchURL, strings.NewReader(formData.Encode())) + if err != nil { + return "", err + } + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Referer", BaseURL+"/") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + + var resp *http.Response + var lastErr error + + // 重试机制 + for i := 0; i < MaxRetries; i++ { + resp, lastErr = client.Do(req) + if lastErr == nil && (resp.StatusCode == 302 || resp.StatusCode == 301) { + break + } + if resp != nil { + resp.Body.Close() + } + if i < MaxRetries-1 { + time.Sleep(RetryDelay) + } + } + + if lastErr != nil { + return "", lastErr + } + defer resp.Body.Close() + + // 检查重定向响应 + if resp.StatusCode != 302 && resp.StatusCode != 301 { + return "", fmt.Errorf("期望302重定向,但得到状态码: %d", resp.StatusCode) + } + + // 从Location头部提取searchid + location := resp.Header.Get("Location") + if location == "" { + return "", fmt.Errorf("重定向响应中没有Location头部") + } + + // 解析searchid + searchID := p.extractSearchIDFromLocation(location) + if searchID == "" { + return "", fmt.Errorf("无法从Location中提取searchid: %s", location) + } + + if p.debugMode { + log.Printf("[CLXIONG] 获取到searchid: %s", searchID) + } + + return searchID, nil +} + +// extractSearchIDFromLocation 从Location头部提取searchid +func (p *ClxiongPlugin) extractSearchIDFromLocation(location string) string { + // location格式: "result/?searchid=7549" + re := regexp.MustCompile(`searchid=(\d+)`) + matches := re.FindStringSubmatch(location) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// getSearchResults 第二步:GET搜索结果 +func (p *ClxiongPlugin) getSearchResults(searchID, keyword string) ([]model.SearchResult, error) { + if p.debugMode { + log.Printf("[CLXIONG] 正在获取搜索结果,searchid: %s", searchID) + } + + // 构建结果页URL + resultURL := fmt.Sprintf("%s/e/search/result/?searchid=%s", BaseURL, searchID) + + client := &http.Client{Timeout: 30 * time.Second} + + req, err := http.NewRequest("GET", resultURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Referer", BaseURL+"/") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + + var resp *http.Response + var lastErr error + + // 重试机制 + for i := 0; i < MaxRetries; i++ { + resp, lastErr = client.Do(req) + if lastErr == nil && resp.StatusCode == 200 { + break + } + if resp != nil { + resp.Body.Close() + } + if i < MaxRetries-1 { + time.Sleep(RetryDelay) + } + } + + if lastErr != nil { + return nil, lastErr + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("搜索结果请求失败,状态码: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return p.parseSearchResults(string(body)) +} + +// parseSearchResults 解析搜索结果页面 +func (p *ClxiongPlugin) parseSearchResults(html string) ([]model.SearchResult, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return nil, err + } + + var results []model.SearchResult + + // 查找搜索结果项 + doc.Find(".row.row-cols-2.row-cols-lg-4 .col").Each(func(i int, s *goquery.Selection) { + if i >= MaxResults { + return // 限制结果数量 + } + + // 提取详情页链接 + linkEl := s.Find("a[href*='/drama/'], a[href*='/movie/']") + if linkEl.Length() == 0 { + return // 跳过无链接的项 + } + + detailPath, exists := linkEl.Attr("href") + if !exists || detailPath == "" { + return + } + + // 构建完整的详情页URL + detailURL := BaseURL + detailPath + + // 提取标题 + title := strings.TrimSpace(linkEl.Find("h2.h4").Text()) + if title == "" { + return // 跳过无标题的项 + } + + // 提取评分 + rating := strings.TrimSpace(s.Find(".rank").Text()) + + // 提取年份 + year := strings.TrimSpace(s.Find(".small").Last().Text()) + + // 提取海报图片 + poster := "" + cardImg := s.Find(".card-img") + if cardImg.Length() > 0 { + if style, exists := cardImg.Attr("style"); exists { + poster = p.extractImageFromStyle(style) + } + } + + // 构建内容信息 + var contentParts []string + if rating != "" { + contentParts = append(contentParts, "评分: "+rating) + } + if year != "" { + contentParts = append(contentParts, "年份: "+year) + } + if poster != "" { + contentParts = append(contentParts, "海报: "+poster) + } + // 添加详情页链接到content中,供后续提取磁力链接使用 + contentParts = append(contentParts, "详情页: "+detailURL) + + content := strings.Join(contentParts, " | ") + + // 生成唯一ID + uniqueID := p.generateUniqueID(detailPath) + + result := model.SearchResult{ + Title: title, + Content: content, + Channel: "", // 插件搜索结果必须为空 + Tags: []string{"磁力链接", "影视"}, + Datetime: time.Now(), // 搜索时间 + Links: []model.Link{}, // 初始为空,后续异步获取 + UniqueID: uniqueID, + } + + results = append(results, result) + }) + + if p.debugMode { + log.Printf("[CLXIONG] 解析到 %d 个搜索结果", len(results)) + } + + return results, nil +} + +// extractImageFromStyle 从style属性中提取背景图片URL +func (p *ClxiongPlugin) extractImageFromStyle(style string) string { + // style格式: "background-image: url('https://i.nacloud.cc/2024/12154.webp');" + re := regexp.MustCompile(`url\(['"]?([^'"]+)['"]?\)`) + matches := re.FindStringSubmatch(style) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// fetchDetailLinksAsync 异步获取详情页磁力链接 +func (p *ClxiongPlugin) fetchDetailLinksAsync(results []model.SearchResult) { + if len(results) == 0 { + return + } + + if p.debugMode { + log.Printf("[CLXIONG] 开始异步获取 %d 个详情页的磁力链接", len(results)) + } + + // 使用goroutine异步获取,避免阻塞主搜索流程 + for i := range results { + go func(index int) { + detailURL := p.extractDetailURLFromContent(results[index].Content) + if detailURL != "" { + magnetLinks := p.fetchDetailPageMagnetLinks(detailURL) + if len(magnetLinks) > 0 { + results[index].Links = magnetLinks + if p.debugMode { + log.Printf("[CLXIONG] 为结果 %d 获取到 %d 个磁力链接", index+1, len(magnetLinks)) + } + } + } + }(i) + } +} + +// extractDetailURLFromContent 从content中提取详情页URL +func (p *ClxiongPlugin) extractDetailURLFromContent(content string) string { + // 查找"详情页: URL"模式 + re := regexp.MustCompile(`详情页: (https?://[^\s|]+)`) + matches := re.FindStringSubmatch(content) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// fetchDetailPageMagnetLinks 获取详情页的磁力链接 +func (p *ClxiongPlugin) fetchDetailPageMagnetLinks(detailURL string) []model.Link { + if p.debugMode { + log.Printf("[CLXIONG] 正在获取详情页磁力链接: %s", detailURL) + } + + client := &http.Client{Timeout: 20 * time.Second} + + req, err := http.NewRequest("GET", detailURL, nil) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 创建详情页请求失败: %v", err) + } + return nil + } + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Referer", BaseURL+"/") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + + resp, err := client.Do(req) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 详情页请求失败: %v", err) + } + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + if p.debugMode { + log.Printf("[CLXIONG] 详情页HTTP状态错误: %d", resp.StatusCode) + } + return nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 读取详情页响应失败: %v", err) + } + return nil + } + + return p.parseMagnetLinksFromDetail(string(body)) +} + +// parseMagnetLinksFromDetail 从详情页HTML中解析磁力链接 +func (p *ClxiongPlugin) parseMagnetLinksFromDetail(html string) []model.Link { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 解析详情页HTML失败: %v", err) + } + return nil + } + + var links []model.Link + + // 查找磁力链接 + doc.Find(".mv_down a[href^='magnet:']").Each(func(i int, s *goquery.Selection) { + href, exists := s.Attr("href") + if exists && href != "" { + // 获取文件名(链接文本) + fileName := strings.TrimSpace(s.Text()) + + link := model.Link{ + URL: href, + Type: "magnet", + } + + // 如果文件名包含大小信息,可以存储在Password字段中作为备注 + if fileName != "" && strings.Contains(fileName, "[") { + link.Password = fileName // 临时存储文件名和大小信息 + } + + links = append(links, link) + + if p.debugMode { + log.Printf("[CLXIONG] 找到磁力链接: %s %s", fileName, href) + } + } + }) + + if p.debugMode { + log.Printf("[CLXIONG] 详情页共找到 %d 个磁力链接", len(links)) + } + + return links +} + +// generateUniqueID 生成唯一ID +func (p *ClxiongPlugin) generateUniqueID(detailPath string) string { + // 从路径中提取ID,如 "/drama/4466.html" -> "4466" + re := regexp.MustCompile(`/(?:drama|movie)/(\d+)\.html`) + matches := re.FindStringSubmatch(detailPath) + if len(matches) > 1 { + return fmt.Sprintf("clxiong-%s", matches[1]) + } + + // 备用方案:使用完整路径生成哈希 + hash := 0 + for _, char := range detailPath { + hash = hash*31 + int(char) + } + if hash < 0 { + hash = -hash + } + return fmt.Sprintf("clxiong-%d", hash) +} \ No newline at end of file diff --git a/plugin/clxiong copy/html结构分析.md b/plugin/clxiong copy/html结构分析.md new file mode 100644 index 0000000..bf54be1 --- /dev/null +++ b/plugin/clxiong copy/html结构分析.md @@ -0,0 +1,168 @@ +# 磁力熊(CiLiXiong) HTML结构分析文档 + +## 网站信息 +- **域名**: `www.cilixiong.org` +- **名称**: 磁力熊 +- **类型**: 影视磁力链接搜索网站 +- **特点**: 两步式搜索流程,需要先POST获取searchid,再GET搜索结果 + +## 搜索流程分析 + +### 第一步:提交搜索请求 +#### 请求信息 +- **URL**: `https://www.cilixiong.org/e/search/index.php` +- **方法**: POST +- **Content-Type**: `application/x-www-form-urlencoded` +- **Referer**: `https://www.cilixiong.org/` + +#### POST参数 +``` +classid=1%2C2&show=title&tempid=1&keyboard={URL编码的关键词} +``` +参数说明: +- `classid=1,2` - 搜索分类(1=电影,2=剧集) +- `show=title` - 搜索字段 +- `tempid=1` - 模板ID +- `keyboard` - 搜索关键词(需URL编码) + +#### 响应处理 +- **状态码**: 302重定向 +- **关键信息**: 从响应头`Location`字段获取searchid +- **格式**: `result/?searchid=7549` + +### 第二步:获取搜索结果 +#### 请求信息 +- **URL**: `https://www.cilixiong.org/e/search/result/?searchid={searchid}` +- **方法**: GET +- **Referer**: `https://www.cilixiong.org/` + +## 搜索结果页面结构 + +### 页面布局 +- **容器**: `.container` +- **结果提示**: `.text-white.py-3` - 显示"找到 X 条符合搜索条件" +- **结果网格**: `.row.row-cols-2.row-cols-lg-4.align-items-stretch.g-4.py-2` + +### 单个结果项结构 +```html +
+
+ +
+
+

影片标题

+
    +
  • 8.9
  • +
  • 2025
  • +
+
+
+
+
+``` + +### 数据提取选择器 + +#### 结果列表 +- **选择器**: `.row.row-cols-2.row-cols-lg-4 .col` +- **排除**: 空白或无效的卡片 + +#### 单项数据提取 +1. **详情链接**: `.col a[href*="/drama/"]` 或 `.col a[href*="/movie/"]` +2. **标题**: `.col h2.h4` +3. **评分**: `.col .rank` +4. **年份**: `.col .small`(最后一个li元素) +5. **海报**: `.col .card-img[style*="background-image"]` - 从style属性提取url + +#### 链接格式 +- 电影:`/movie/ID.html` +- 剧集:`/drama/ID.html` +- 需补全为绝对URL:`https://www.cilixiong.org/drama/ID.html` + +## 详情页面结构 + +### 基本信息区域 +```html +
+

影片标题

+

豆瓣评分: 8.9

+

又名:英文名称

+

上映日期:2025-05-25(美国)

+

类型:|喜剧|冒险|科幻|动画|

+

单集片长:22分钟

+

上映地区:美国

+

主演:演员列表

+
+``` + +### 磁力链接区域 +```html +
+

影片名磁力下载地址

+
+ +
+
+``` + +### 磁力链接提取 +- **容器**: `.mv_down .container` +- **链接项**: `.border-bottom` +- **磁力链接**: `a[href^="magnet:"]` +- **文件名**: 链接的文本内容 +- **大小信息**: 通常包含在文件名的方括号中 + +## 错误处理 + +### 常见问题 +1. **搜索无结果**: 页面会显示"找到 0 条符合搜索条件" +2. **searchid失效**: 可能需要重新发起搜索请求 +3. **详情页无磁力链接**: 某些内容可能暂时无下载资源 + +### 限流检测 +- **状态码**: 检测429或403状态码 +- **页面内容**: 检测是否包含"访问频繁"等提示 + +## 实现要点 + +### 请求头设置 +```http +User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 +Content-Type: application/x-www-form-urlencoded (POST请求) +Referer: https://www.cilixiong.org/ +``` + +### Cookie处理 +- 网站可能需要维持会话状态 +- 建议在客户端中启用Cookie存储 + +### 搜索策略 +1. **首次搜索**: POST提交 → 解析Location → GET结果页 +2. **结果解析**: 提取基本信息,构建搜索结果 +3. **详情获取**: 可选,异步获取磁力链接 + +### 数据字段映射 +- **Title**: 影片中文标题 +- **Content**: 评分、年份、类型等信息组合 +- **UniqueID**: 使用详情页URL的ID部分 +- **Links**: 磁力链接数组 +- **Tags**: 影片类型标签 + +## 技术注意事项 + +### URL编码 +- 搜索关键词必须进行URL编码 +- 中文字符使用UTF-8编码 + +### 重定向处理 +- POST请求会返回302重定向 +- 需要从响应头提取Location信息 +- 不要自动跟随重定向,需要手动解析 + +### 异步处理 +- 搜索结果可以先返回基本信息 +- 磁力链接通过异步请求详情页获取 +- 设置合理的并发限制和超时时间 \ No newline at end of file diff --git a/plugin/clxiong/clxiong.go b/plugin/clxiong/clxiong.go new file mode 100644 index 0000000..1f7d6bd --- /dev/null +++ b/plugin/clxiong/clxiong.go @@ -0,0 +1,645 @@ +package clxiong + +import ( + "fmt" + "io" + "log" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" + + "github.com/PuerkitoBio/goquery" + "pansou/model" + "pansou/plugin" +) + +const ( + BaseURL = "https://www.cilixiong.org" + SearchURL = "https://www.cilixiong.org/e/search/index.php" + UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + MaxRetries = 3 + RetryDelay = 2 * time.Second + MaxResults = 30 +) + +// DetailPageInfo 详情页信息结构体 +type DetailPageInfo struct { + MagnetLinks []model.Link + UpdateTime time.Time + Title string + FileNames []string // 所有文件的名称,与磁力链接对应 +} + +// ClxiongPlugin 磁力熊插件 +type ClxiongPlugin struct { + *plugin.BaseAsyncPlugin + debugMode bool +} + +func init() { + p := &ClxiongPlugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("clxiong", 2, true), + debugMode: false, // 开启调试模式检查磁力链接提取问题 + } + plugin.RegisterGlobalPlugin(p) +} + +// Search 搜索接口实现 +func (p *ClxiongPlugin) 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 搜索并返回详细结果 +func (p *ClxiongPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (*model.PluginSearchResult, error) { + if p.debugMode { + log.Printf("[CLXIONG] 开始搜索: %s", keyword) + } + + // 第一步:POST搜索获取searchid + searchID, err := p.getSearchID(keyword) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 获取searchid失败: %v", err) + } + return nil, fmt.Errorf("获取searchid失败: %v", err) + } + + // 第二步:GET搜索结果 + results, err := p.getSearchResults(searchID, keyword) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 获取搜索结果失败: %v", err) + } + return nil, err + } + + // 第三步:同步获取详情页磁力链接 + results = p.fetchDetailLinksSync(results) + + if p.debugMode { + log.Printf("[CLXIONG] 搜索完成,获得 %d 个结果", len(results)) + } + + // 应用关键词过滤 + filteredResults := plugin.FilterResultsByKeyword(results, keyword) + + return &model.PluginSearchResult{ + Results: filteredResults, + IsFinal: true, + Timestamp: time.Now(), + Source: p.Name(), + Message: fmt.Sprintf("找到 %d 个结果", len(filteredResults)), + }, nil +} + +// getSearchID 第一步:POST搜索获取searchid +func (p *ClxiongPlugin) getSearchID(keyword string) (string, error) { + if p.debugMode { + log.Printf("[CLXIONG] 正在获取searchid...") + } + + client := &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // 不自动跟随重定向,我们需要手动处理 + return http.ErrUseLastResponse + }, + } + + // 准备POST数据 + formData := url.Values{} + formData.Set("classid", "1,2") // 1=电影,2=剧集 + formData.Set("show", "title") // 搜索字段 + formData.Set("tempid", "1") // 模板ID + formData.Set("keyboard", keyword) // 搜索关键词 + + req, err := http.NewRequest("POST", SearchURL, strings.NewReader(formData.Encode())) + if err != nil { + return "", err + } + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Referer", BaseURL+"/") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + + var resp *http.Response + var lastErr error + + // 重试机制 + for i := 0; i < MaxRetries; i++ { + resp, lastErr = client.Do(req) + if lastErr == nil && (resp.StatusCode == 302 || resp.StatusCode == 301) { + break + } + if resp != nil { + resp.Body.Close() + } + if i < MaxRetries-1 { + time.Sleep(RetryDelay) + } + } + + if lastErr != nil { + return "", lastErr + } + defer resp.Body.Close() + + // 检查重定向响应 + if resp.StatusCode != 302 && resp.StatusCode != 301 { + return "", fmt.Errorf("期望302重定向,但得到状态码: %d", resp.StatusCode) + } + + // 从Location头部提取searchid + location := resp.Header.Get("Location") + if location == "" { + return "", fmt.Errorf("重定向响应中没有Location头部") + } + + // 解析searchid + searchID := p.extractSearchIDFromLocation(location) + if searchID == "" { + return "", fmt.Errorf("无法从Location中提取searchid: %s", location) + } + + if p.debugMode { + log.Printf("[CLXIONG] 获取到searchid: %s", searchID) + } + + return searchID, nil +} + +// extractSearchIDFromLocation 从Location头部提取searchid +func (p *ClxiongPlugin) extractSearchIDFromLocation(location string) string { + // location格式: "result/?searchid=7549" + re := regexp.MustCompile(`searchid=(\d+)`) + matches := re.FindStringSubmatch(location) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// getSearchResults 第二步:GET搜索结果 +func (p *ClxiongPlugin) getSearchResults(searchID, keyword string) ([]model.SearchResult, error) { + if p.debugMode { + log.Printf("[CLXIONG] 正在获取搜索结果,searchid: %s", searchID) + } + + // 构建结果页URL + resultURL := fmt.Sprintf("%s/e/search/result/?searchid=%s", BaseURL, searchID) + + client := &http.Client{Timeout: 30 * time.Second} + + req, err := http.NewRequest("GET", resultURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Referer", BaseURL+"/") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + + var resp *http.Response + var lastErr error + + // 重试机制 + for i := 0; i < MaxRetries; i++ { + resp, lastErr = client.Do(req) + if lastErr == nil && resp.StatusCode == 200 { + break + } + if resp != nil { + resp.Body.Close() + } + if i < MaxRetries-1 { + time.Sleep(RetryDelay) + } + } + + if lastErr != nil { + return nil, lastErr + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("搜索结果请求失败,状态码: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return p.parseSearchResults(string(body)) +} + +// parseSearchResults 解析搜索结果页面 +func (p *ClxiongPlugin) parseSearchResults(html string) ([]model.SearchResult, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return nil, err + } + + var results []model.SearchResult + + // 查找搜索结果项 + doc.Find(".row.row-cols-2.row-cols-lg-4 .col").Each(func(i int, s *goquery.Selection) { + if i >= MaxResults { + return // 限制结果数量 + } + + // 提取详情页链接 + linkEl := s.Find("a[href*='/drama/'], a[href*='/movie/']") + if linkEl.Length() == 0 { + return // 跳过无链接的项 + } + + detailPath, exists := linkEl.Attr("href") + if !exists || detailPath == "" { + return + } + + // 构建完整的详情页URL + detailURL := BaseURL + detailPath + + // 提取标题 + title := strings.TrimSpace(linkEl.Find("h2.h4").Text()) + if title == "" { + return // 跳过无标题的项 + } + + // 提取评分 + rating := strings.TrimSpace(s.Find(".rank").Text()) + + // 提取年份 + year := strings.TrimSpace(s.Find(".small").Last().Text()) + + // 提取海报图片 + poster := "" + cardImg := s.Find(".card-img") + if cardImg.Length() > 0 { + if style, exists := cardImg.Attr("style"); exists { + poster = p.extractImageFromStyle(style) + } + } + + // 构建内容信息 + var contentParts []string + if rating != "" { + contentParts = append(contentParts, "评分: "+rating) + } + if year != "" { + contentParts = append(contentParts, "年份: "+year) + } + if poster != "" { + contentParts = append(contentParts, "海报: "+poster) + } + // 添加详情页链接到content中,供后续提取磁力链接使用 + contentParts = append(contentParts, "详情页: "+detailURL) + + content := strings.Join(contentParts, " | ") + + // 生成唯一ID + uniqueID := p.generateUniqueID(detailPath) + + result := model.SearchResult{ + Title: title, + Content: content, + Channel: "", // 插件搜索结果必须为空 + Tags: []string{"磁力链接", "影视"}, + Datetime: time.Now(), // 搜索时间 + Links: []model.Link{}, // 初始为空,后续异步获取 + UniqueID: uniqueID, + } + + results = append(results, result) + }) + + if p.debugMode { + log.Printf("[CLXIONG] 解析到 %d 个搜索结果", len(results)) + } + + return results, nil +} + +// extractImageFromStyle 从style属性中提取背景图片URL +func (p *ClxiongPlugin) extractImageFromStyle(style string) string { + // style格式: "background-image: url('https://i.nacloud.cc/2024/12154.webp');" + re := regexp.MustCompile(`url\(['"]?([^'"]+)['"]?\)`) + matches := re.FindStringSubmatch(style) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// fetchDetailLinksSync 同步获取详情页磁力链接 +func (p *ClxiongPlugin) fetchDetailLinksSync(results []model.SearchResult) []model.SearchResult { + if len(results) == 0 { + return results + } + + if p.debugMode { + log.Printf("[CLXIONG] 开始同步获取 %d 个详情页的磁力链接", len(results)) + } + + // 使用WaitGroup确保所有请求完成后再返回 + var wg sync.WaitGroup + var mu sync.Mutex // 保护results切片的互斥锁 + var additionalResults []model.SearchResult // 存储额外创建的搜索结果 + + // 限制并发数,避免过多请求 + semaphore := make(chan struct{}, 5) // 最多5个并发请求 + + for i := range results { + wg.Add(1) + go func(index int) { + defer wg.Done() + + // 获取信号量 + semaphore <- struct{}{} + defer func() { <-semaphore }() + + detailURL := p.extractDetailURLFromContent(results[index].Content) + if detailURL != "" { + detailInfo := p.fetchDetailPageInfo(detailURL, results[index].Title) + if detailInfo != nil && len(detailInfo.MagnetLinks) > 0 { + // 为每个磁力链接创建独立的搜索结果,这样每个链接都有自己的note + baseResult := results[index] + + // 第一个链接更新原结果 + if len(detailInfo.FileNames) > 0 { + results[index].Title = fmt.Sprintf("%s-%s", baseResult.Title, detailInfo.FileNames[0]) + } + results[index].Links = []model.Link{detailInfo.MagnetLinks[0]} + if !detailInfo.UpdateTime.IsZero() { + results[index].Datetime = detailInfo.UpdateTime + } + + // 其他链接创建新的搜索结果 + var newResults []model.SearchResult + for i := 1; i < len(detailInfo.MagnetLinks); i++ { + newResult := model.SearchResult{ + MessageID: fmt.Sprintf("%s-%d", baseResult.MessageID, i+1), + UniqueID: fmt.Sprintf("%s-%d", baseResult.UniqueID, i+1), + Channel: baseResult.Channel, + Content: baseResult.Content, + Tags: baseResult.Tags, + Images: baseResult.Images, + Links: []model.Link{detailInfo.MagnetLinks[i]}, + } + + // 设置独特的标题和时间 + if i < len(detailInfo.FileNames) { + newResult.Title = fmt.Sprintf("%s-%s", baseResult.Title, detailInfo.FileNames[i]) + } else { + newResult.Title = baseResult.Title + } + + if !detailInfo.UpdateTime.IsZero() { + newResult.Datetime = detailInfo.UpdateTime + } else { + newResult.Datetime = baseResult.Datetime + } + + newResults = append(newResults, newResult) + } + + // 使用锁保护切片的修改 + if len(newResults) > 0 { + mu.Lock() + additionalResults = append(additionalResults, newResults...) + mu.Unlock() + } + + if p.debugMode { + log.Printf("[CLXIONG] 为结果 %d 获取到 %d 个磁力链接,创建了 %d 个搜索结果", index+1, len(detailInfo.MagnetLinks), len(detailInfo.MagnetLinks)) + } + } + } + }(i) + } + + // 等待所有goroutine完成 + wg.Wait() + + // 合并额外创建的搜索结果 + results = append(results, additionalResults...) + + if p.debugMode { + totalLinks := 0 + for _, result := range results { + totalLinks += len(result.Links) + } + log.Printf("[CLXIONG] 所有磁力链接获取完成,共获得 %d 个磁力链接,总搜索结果 %d 个", totalLinks, len(results)) + } + + return results +} + +// extractDetailURLFromContent 从content中提取详情页URL +func (p *ClxiongPlugin) extractDetailURLFromContent(content string) string { + // 查找"详情页: URL"模式 + re := regexp.MustCompile(`详情页: (https?://[^\s|]+)`) + matches := re.FindStringSubmatch(content) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// fetchDetailPageInfo 获取详情页的完整信息 +func (p *ClxiongPlugin) fetchDetailPageInfo(detailURL string, movieTitle string) *DetailPageInfo { + if p.debugMode { + log.Printf("[CLXIONG] 正在获取详情页信息: %s", detailURL) + } + + client := &http.Client{Timeout: 20 * time.Second} + + req, err := http.NewRequest("GET", detailURL, nil) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 创建详情页请求失败: %v", err) + } + return nil + } + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Referer", BaseURL+"/") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + + resp, err := client.Do(req) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 详情页请求失败: %v", err) + } + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + if p.debugMode { + log.Printf("[CLXIONG] 详情页HTTP状态错误: %d", resp.StatusCode) + } + return nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 读取详情页响应失败: %v", err) + } + return nil + } + + return p.parseDetailPageInfo(string(body), movieTitle) +} + +// parseDetailPageInfo 从详情页HTML中解析完整信息 +func (p *ClxiongPlugin) parseDetailPageInfo(html string, movieTitle string) *DetailPageInfo { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + if p.debugMode { + log.Printf("[CLXIONG] 解析详情页HTML失败: %v", err) + } + return nil + } + + detailInfo := &DetailPageInfo{ + Title: movieTitle, + } + + // 解析更新时间 + detailInfo.UpdateTime = p.parseUpdateTimeFromDetail(doc) + + // 解析磁力链接 + magnetLinks, fileNames := p.parseMagnetLinksFromDetailDoc(doc, movieTitle) + detailInfo.MagnetLinks = magnetLinks + detailInfo.FileNames = fileNames + + if p.debugMode { + log.Printf("[CLXIONG] 详情页解析完成: 磁力链接 %d 个,更新时间: %v", + len(detailInfo.MagnetLinks), detailInfo.UpdateTime) + } + + return detailInfo +} + +// parseUpdateTimeFromDetail 从详情页解析更新时间 +func (p *ClxiongPlugin) parseUpdateTimeFromDetail(doc *goquery.Document) time.Time { + // 查找"最后更新于:2025-08-16"这样的文本 + var updateTime time.Time + + doc.Find(".mv_detail p").Each(func(i int, s *goquery.Selection) { + text := strings.TrimSpace(s.Text()) + if strings.Contains(text, "最后更新于:") { + // 提取日期部分 + dateStr := strings.Replace(text, "最后更新于:", "", 1) + dateStr = strings.TrimSpace(dateStr) + + // 解析日期,支持多种格式 + layouts := []string{ + "2006-01-02", + "2006-1-2", + "2006/01/02", + "2006/1/2", + } + + for _, layout := range layouts { + if t, err := time.Parse(layout, dateStr); err == nil { + updateTime = t + if p.debugMode { + log.Printf("[CLXIONG] 解析到更新时间: %s -> %v", dateStr, updateTime) + } + return + } + } + + if p.debugMode { + log.Printf("[CLXIONG] 无法解析更新时间: %s", dateStr) + } + } + }) + + return updateTime +} + +// parseMagnetLinksFromDetailDoc 从详情页DOM解析磁力链接 +func (p *ClxiongPlugin) parseMagnetLinksFromDetailDoc(doc *goquery.Document, movieTitle string) ([]model.Link, []string) { + var links []model.Link + var fileNames []string + + if p.debugMode { + // 调试:检查是否找到磁力下载区域 + mvDown := doc.Find(".mv_down") + log.Printf("[CLXIONG] 找到 .mv_down 区域数量: %d", mvDown.Length()) + + // 调试:检查磁力链接数量 + magnetLinks := doc.Find(".mv_down a[href^='magnet:']") + log.Printf("[CLXIONG] 找到磁力链接数量: %d", magnetLinks.Length()) + + // 如果没找到,尝试其他可能的选择器 + if magnetLinks.Length() == 0 { + allMagnetLinks := doc.Find("a[href^='magnet:']") + log.Printf("[CLXIONG] 页面总磁力链接数量: %d", allMagnetLinks.Length()) + } + } + + // 查找磁力链接 + doc.Find(".mv_down a[href^='magnet:']").Each(func(i int, s *goquery.Selection) { + href, exists := s.Attr("href") + if exists && href != "" { + // 获取文件名(链接文本) + fileName := strings.TrimSpace(s.Text()) + + link := model.Link{ + URL: href, + Type: "magnet", + } + + // 磁力链接密码字段设置为空(按用户要求) + link.Password = "" + + links = append(links, link) + fileNames = append(fileNames, fileName) + + if p.debugMode { + log.Printf("[CLXIONG] 找到磁力链接: %s", fileName) + } + } + }) + + if p.debugMode { + log.Printf("[CLXIONG] 详情页共找到 %d 个磁力链接", len(links)) + } + + return links, fileNames +} + +// generateUniqueID 生成唯一ID +func (p *ClxiongPlugin) generateUniqueID(detailPath string) string { + // 从路径中提取ID,如 "/drama/4466.html" -> "4466" + re := regexp.MustCompile(`/(?:drama|movie)/(\d+)\.html`) + matches := re.FindStringSubmatch(detailPath) + if len(matches) > 1 { + return fmt.Sprintf("clxiong-%s", matches[1]) + } + + // 备用方案:使用完整路径生成哈希 + hash := 0 + for _, char := range detailPath { + hash = hash*31 + int(char) + } + if hash < 0 { + hash = -hash + } + return fmt.Sprintf("clxiong-%d", hash) +} \ No newline at end of file diff --git a/plugin/clxiong/html结构分析.md b/plugin/clxiong/html结构分析.md new file mode 100644 index 0000000..bf54be1 --- /dev/null +++ b/plugin/clxiong/html结构分析.md @@ -0,0 +1,168 @@ +# 磁力熊(CiLiXiong) HTML结构分析文档 + +## 网站信息 +- **域名**: `www.cilixiong.org` +- **名称**: 磁力熊 +- **类型**: 影视磁力链接搜索网站 +- **特点**: 两步式搜索流程,需要先POST获取searchid,再GET搜索结果 + +## 搜索流程分析 + +### 第一步:提交搜索请求 +#### 请求信息 +- **URL**: `https://www.cilixiong.org/e/search/index.php` +- **方法**: POST +- **Content-Type**: `application/x-www-form-urlencoded` +- **Referer**: `https://www.cilixiong.org/` + +#### POST参数 +``` +classid=1%2C2&show=title&tempid=1&keyboard={URL编码的关键词} +``` +参数说明: +- `classid=1,2` - 搜索分类(1=电影,2=剧集) +- `show=title` - 搜索字段 +- `tempid=1` - 模板ID +- `keyboard` - 搜索关键词(需URL编码) + +#### 响应处理 +- **状态码**: 302重定向 +- **关键信息**: 从响应头`Location`字段获取searchid +- **格式**: `result/?searchid=7549` + +### 第二步:获取搜索结果 +#### 请求信息 +- **URL**: `https://www.cilixiong.org/e/search/result/?searchid={searchid}` +- **方法**: GET +- **Referer**: `https://www.cilixiong.org/` + +## 搜索结果页面结构 + +### 页面布局 +- **容器**: `.container` +- **结果提示**: `.text-white.py-3` - 显示"找到 X 条符合搜索条件" +- **结果网格**: `.row.row-cols-2.row-cols-lg-4.align-items-stretch.g-4.py-2` + +### 单个结果项结构 +```html +
+
+ +
+
+

影片标题

+
    +
  • 8.9
  • +
  • 2025
  • +
+
+
+
+
+``` + +### 数据提取选择器 + +#### 结果列表 +- **选择器**: `.row.row-cols-2.row-cols-lg-4 .col` +- **排除**: 空白或无效的卡片 + +#### 单项数据提取 +1. **详情链接**: `.col a[href*="/drama/"]` 或 `.col a[href*="/movie/"]` +2. **标题**: `.col h2.h4` +3. **评分**: `.col .rank` +4. **年份**: `.col .small`(最后一个li元素) +5. **海报**: `.col .card-img[style*="background-image"]` - 从style属性提取url + +#### 链接格式 +- 电影:`/movie/ID.html` +- 剧集:`/drama/ID.html` +- 需补全为绝对URL:`https://www.cilixiong.org/drama/ID.html` + +## 详情页面结构 + +### 基本信息区域 +```html +
+

影片标题

+

豆瓣评分: 8.9

+

又名:英文名称

+

上映日期:2025-05-25(美国)

+

类型:|喜剧|冒险|科幻|动画|

+

单集片长:22分钟

+

上映地区:美国

+

主演:演员列表

+
+``` + +### 磁力链接区域 +```html +
+

影片名磁力下载地址

+
+ +
+
+``` + +### 磁力链接提取 +- **容器**: `.mv_down .container` +- **链接项**: `.border-bottom` +- **磁力链接**: `a[href^="magnet:"]` +- **文件名**: 链接的文本内容 +- **大小信息**: 通常包含在文件名的方括号中 + +## 错误处理 + +### 常见问题 +1. **搜索无结果**: 页面会显示"找到 0 条符合搜索条件" +2. **searchid失效**: 可能需要重新发起搜索请求 +3. **详情页无磁力链接**: 某些内容可能暂时无下载资源 + +### 限流检测 +- **状态码**: 检测429或403状态码 +- **页面内容**: 检测是否包含"访问频繁"等提示 + +## 实现要点 + +### 请求头设置 +```http +User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 +Content-Type: application/x-www-form-urlencoded (POST请求) +Referer: https://www.cilixiong.org/ +``` + +### Cookie处理 +- 网站可能需要维持会话状态 +- 建议在客户端中启用Cookie存储 + +### 搜索策略 +1. **首次搜索**: POST提交 → 解析Location → GET结果页 +2. **结果解析**: 提取基本信息,构建搜索结果 +3. **详情获取**: 可选,异步获取磁力链接 + +### 数据字段映射 +- **Title**: 影片中文标题 +- **Content**: 评分、年份、类型等信息组合 +- **UniqueID**: 使用详情页URL的ID部分 +- **Links**: 磁力链接数组 +- **Tags**: 影片类型标签 + +## 技术注意事项 + +### URL编码 +- 搜索关键词必须进行URL编码 +- 中文字符使用UTF-8编码 + +### 重定向处理 +- POST请求会返回302重定向 +- 需要从响应头提取Location信息 +- 不要自动跟随重定向,需要手动解析 + +### 异步处理 +- 搜索结果可以先返回基本信息 +- 磁力链接通过异步请求详情页获取 +- 设置合理的并发限制和超时时间 \ No newline at end of file diff --git a/plugin/cyg/cyg.go b/plugin/cyg/cyg.go index c5bdb42..913194a 100644 --- a/plugin/cyg/cyg.go +++ b/plugin/cyg/cyg.go @@ -27,7 +27,7 @@ var ( xunleiLinkRegex = regexp.MustCompile(`https?://pan\.xunlei\.com/s/[0-9a-zA-Z_\-]+`) tianyiLinkRegex = regexp.MustCompile(`https?://cloud\.189\.cn/t/[0-9a-zA-Z]+`) link115Regex = regexp.MustCompile(`https?://115\.com/s/[0-9a-zA-Z]+`) - mobileLinkRegex = regexp.MustCompile(`https?://caiyun\.feixin\.10086\.cn/[0-9a-zA-Z]+`) + mobileLinkRegex = regexp.MustCompile(`https?://(caiyun\.feixin\.10086\.cn|caiyun\.139\.com|yun\.139\.com|cloud\.139\.com|pan\.139\.com)/.*`) weiyunLinkRegex = regexp.MustCompile(`https?://share\.weiyun\.com/[0-9a-zA-Z]+`) lanzouLinkRegex = regexp.MustCompile(`https?://(www\.)?(lanzou[uixys]*|lan[zs]o[ux])\.(com|net|org)/[0-9a-zA-Z]+`) jianguoyunLinkRegex = regexp.MustCompile(`https?://(www\.)?jianguoyun\.com/p/[0-9a-zA-Z]+`) @@ -350,7 +350,7 @@ func (p *CygPlugin) determineCloudType(name string) string { return "tianyi" case "115", "115网盘": return "115" - case "移动云盘", "移动", "mobile", "和彩云": + case "移动云盘", "移动", "mobile", "和彩云", "139云盘", "139", "中国移动云盘": return "mobile" case "微云", "腾讯微云", "weiyun": return "weiyun" diff --git a/plugin/hdr4k/设计文档.md b/plugin/hdr4k/设计文档.md index c108d98..df8dec3 100644 --- a/plugin/hdr4k/设计文档.md +++ b/plugin/hdr4k/设计文档.md @@ -473,9 +473,9 @@ if err != nil { ``` **处理策略**: -- 📝 详细错误日志 -- 🔄 降级处理机制 -- 📊 监控告警 +- 详细错误日志 +- 降级处理机制 +- 监控告警 #### 3. 数据错误 @@ -880,7 +880,7 @@ log.Debug("缓存命中", "key", cacheKey, "type", "detail_page") --- -## 📝 开发指南 +## 开发指南 ### 代码规范 diff --git a/plugin/javdb/javdb.go b/plugin/javdb/javdb.go new file mode 100644 index 0000000..8ddccb7 --- /dev/null +++ b/plugin/javdb/javdb.go @@ -0,0 +1,1008 @@ +package javdb + +import ( + "context" + "crypto/md5" + "fmt" + "io" + "log" + "math/rand" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/PuerkitoBio/goquery" + "pansou/model" + "pansou/plugin" +) + +const ( + PluginName = "javdb" + DisplayName = "JavDB" + Description = "JavDB - 影片数据库,专门提供磁力链接搜索" + BaseURL = "https://javdb.com" + SearchPath = "/search?q=%s&f=all" + UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" + MaxResults = 50 + MaxConcurrency = 10 + + // 429限流重试配置 + MaxRetryOnRateLimit = 0 // 遇到429时的最大重试次数,设为0则不重试 + MinRetryDelay = 4 // 最小延迟秒数 + MaxRetryDelay = 8 // 最大延迟秒数 +) + +// JavdbPlugin JavDB插件 +type JavdbPlugin struct { + *plugin.BaseAsyncPlugin + debugMode bool + detailCache sync.Map // 缓存详情页结果 + cacheTTL time.Duration + rateLimited int32 // 429限流标志位,使用atomic操作 + rateLimitCount int32 // 429错误计数 +} + +// init 注册插件 +func init() { + plugin.RegisterGlobalPlugin(NewJavdbPlugin()) +} + +// NewJavdbPlugin 创建新的JavDB插件实例 +func NewJavdbPlugin() *JavdbPlugin { + debugMode := false + + // 初始化随机种子 + rand.Seed(time.Now().UnixNano()) + + p := &JavdbPlugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(PluginName, 5, true), + debugMode: debugMode, + cacheTTL: 30 * time.Minute, // 详情页缓存30分钟 + } + + return p +} + +// Name 插件名称 +func (p *JavdbPlugin) Name() string { + return PluginName +} + +// DisplayName 插件显示名称 +func (p *JavdbPlugin) DisplayName() string { + return DisplayName +} + +// Description 插件描述 +func (p *JavdbPlugin) Description() string { + return Description +} + +// SkipServiceFilter 磁力搜索插件,跳过Service层过滤 +func (p *JavdbPlugin) SkipServiceFilter() bool { + return true // 磁力搜索,跳过网盘服务过滤 +} + +// Search 搜索接口(兼容性方法) +func (p *JavdbPlugin) 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 *JavdbPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { + return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) +} + +// searchImpl 搜索实现 +func (p *JavdbPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { + if p.debugMode { + log.Printf("[JAVDB] 开始搜索: %s", keyword) + } + + if p.debugMode { + log.Printf("[JAVDB] 开始搜索,客户端超时: %v", client.Timeout) + } + + // 第一步:执行搜索获取结果列表 + searchResults, err, isRateLimited := p.executeSearchWithRateLimit(client, keyword) + if err != nil && !isRateLimited { + return nil, fmt.Errorf("[%s] 执行搜索失败: %w", p.Name(), err) + } + + if p.debugMode { + if isRateLimited { + log.Printf("[JAVDB] ⚡ 遇到429限流,但继续处理已获取的 %d 个结果", len(searchResults)) + } else { + log.Printf("[JAVDB] 搜索获取到 %d 个结果", len(searchResults)) + } + } + + // 如果没有搜索结果,直接返回 + if len(searchResults) == 0 { + if p.debugMode { + log.Printf("[JAVDB] 无搜索结果,直接返回") + } + return []model.SearchResult{}, nil + } + + // 第二步:并发获取详情页磁力链接(设定合理超时) + finalResults := p.fetchDetailMagnetLinks(client, searchResults, keyword) + + if p.debugMode { + log.Printf("[JAVDB] 最终获取到 %d 个有效结果", len(finalResults)) + if isRateLimited { + log.Printf("[JAVDB] ⚡ 由于429限流,结果可能不完整,系统将在后台继续获取") + } + } + + return finalResults, nil +} + +// executeSearchWithRateLimit 执行搜索请求,支持限流检测 +func (p *JavdbPlugin) executeSearchWithRateLimit(client *http.Client, keyword string) ([]model.SearchResult, error, bool) { + // 重置限流状态,每次新搜索都重新尝试 + atomic.StoreInt32(&p.rateLimited, 0) + + // 构建搜索URL + searchURL := fmt.Sprintf("%s%s", BaseURL, fmt.Sprintf(SearchPath, url.QueryEscape(keyword))) + + if p.debugMode { + log.Printf("[JAVDB] 搜索URL: %s", searchURL) + // 显示重试配置信息 + if MaxRetryOnRateLimit > 0 { + log.Printf("[JAVDB] 429重试配置: 最大%d次,延迟%d-%d秒", MaxRetryOnRateLimit, MinRetryDelay, MaxRetryDelay) + } else { + log.Printf("[JAVDB] 429重试配置: 禁用重试") + } + // 如果之前有限流,显示统计信息 + if count := atomic.LoadInt32(&p.rateLimitCount); count > 0 { + log.Printf("[JAVDB] 历史429限流次数: %d", count) + } + } + + // 创建带超时的上下文 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return nil, fmt.Errorf("[%s] 创建搜索请求失败: %w", p.Name(), err), false + } + + // 设置完整的请求头 + req.Header.Set("User-Agent", UserAgent) + 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", BaseURL+"/") + + if p.debugMode { + log.Printf("[JAVDB] 发送搜索请求...") + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err), false + } + defer resp.Body.Close() + + if p.debugMode { + log.Printf("[JAVDB] 搜索请求响应状态: %d", resp.StatusCode) + } + + // 检测429限流 - 立即返回,不延迟 + if resp.StatusCode == 429 { + atomic.StoreInt32(&p.rateLimited, 1) + atomic.AddInt32(&p.rateLimitCount, 1) + if p.debugMode { + log.Printf("[JAVDB] ⚡ 检测到429限流,立即返回空结果") + } + return []model.SearchResult{}, nil, true // 返回空结果和限流标志 + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("[%s] 搜索请求HTTP状态错误: %d", p.Name(), resp.StatusCode), false + } + + // 读取响应体用于调试 + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("[%s] 读取搜索结果失败: %w", p.Name(), err), false + } + + if p.debugMode { + bodyStr := string(bodyBytes) + log.Printf("[JAVDB] 响应体长度: %d", len(bodyStr)) + // 输出前500个字符用于调试 + if len(bodyStr) > 500 { + log.Printf("[JAVDB] 响应体前500字符: %s", bodyStr[:500]) + } else { + log.Printf("[JAVDB] 完整响应体: %s", bodyStr) + } + } + + // 解析HTML提取搜索结果 + doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(bodyBytes))) + if err != nil { + return nil, fmt.Errorf("[%s] 解析搜索结果HTML失败: %w", p.Name(), err), false + } + + results, err := p.parseSearchResults(doc) + return results, err, false +} + + +// doRequestWithRetry 带重试机制的HTTP请求 +func (p *JavdbPlugin) 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< 0 { + // 随机延迟,避免同时重试造成更大压力 + delaySeconds := rand.Intn(MaxRetryDelay-MinRetryDelay+1) + MinRetryDelay + if p.debugMode { + log.Printf("[JAVDB] 429重试 %d/%d,随机延迟 %d 秒", attempt, MaxRetryOnRateLimit, delaySeconds) + } + time.Sleep(time.Duration(delaySeconds) * time.Second) + } + + // 克隆请求避免并发问题 + reqClone := req.Clone(req.Context()) + + resp, err := client.Do(reqClone) + if err != nil { + lastErr = err + if resp != nil { + resp.Body.Close() + } + continue + } + + // 如果不是429,直接返回(无论成功还是其他错误) + if resp.StatusCode != 429 { + return resp, nil + } + + // 遇到429 + atomic.AddInt32(&p.rateLimitCount, 1) + if p.debugMode { + log.Printf("[JAVDB] 遇到429限流,尝试 %d/%d", attempt+1, MaxRetryOnRateLimit+1) + } + + // 如果不允许重试或已达到最大重试次数 + if MaxRetryOnRateLimit == 0 || attempt >= MaxRetryOnRateLimit { + atomic.StoreInt32(&p.rateLimited, 1) + resp.Body.Close() + return nil, fmt.Errorf("[%s] 429限流,%s", p.Name(), + func() string { + if MaxRetryOnRateLimit == 0 { + return "不重试" + } + return fmt.Sprintf("重试%d次后仍然限流", MaxRetryOnRateLimit) + }()) + } + + resp.Body.Close() + lastErr = fmt.Errorf("429 Too Many Requests") + } + + return nil, lastErr +} + +// parseSearchResults 解析搜索结果HTML +func (p *JavdbPlugin) parseSearchResults(doc *goquery.Document) ([]model.SearchResult, error) { + var results []model.SearchResult + + if p.debugMode { + // 检查是否找到了.movie-list元素 + movieListEl := doc.Find(".movie-list") + log.Printf("[JAVDB] 找到.movie-list元素数量: %d", movieListEl.Length()) + + // 检查是否找到了.item元素 + itemEls := doc.Find(".movie-list .item") + log.Printf("[JAVDB] 找到.movie-list .item元素数量: %d", itemEls.Length()) + + // 如果没有找到预期元素,尝试其他可能的选择器 + if itemEls.Length() == 0 { + log.Printf("[JAVDB] 尝试查找其他可能的结果元素...") + + // 尝试其他可能的选择器 + altSelectors := []string{ + ".movie-list > div", + ".movie-list div.item", + "[class*='movie'] [class*='item']", + ".video-list .item", + ".search-results .item", + } + + for _, selector := range altSelectors { + altEls := doc.Find(selector) + if altEls.Length() > 0 { + log.Printf("[JAVDB] 找到替代选择器 '%s' 的元素数量: %d", selector, altEls.Length()) + } + } + + // 输出页面的主要结构用于调试 + doc.Find("div[class*='movie'], div[class*='video'], div[class*='search'], div[class*='result']").Each(func(i int, s *goquery.Selection) { + className, _ := s.Attr("class") + log.Printf("[JAVDB] 找到可能相关的div元素: class='%s'", className) + }) + } + } + + // 查找搜索结果项: .movie-list .item + doc.Find(".movie-list .item").Each(func(i int, s *goquery.Selection) { + if len(results) >= MaxResults { + return + } + + if p.debugMode { + log.Printf("[JAVDB] 开始解析第 %d 个结果项", i+1) + } + + result := p.parseResultItem(s, i+1) + if result != nil { + results = append(results, *result) + if p.debugMode { + log.Printf("[JAVDB] 成功解析第 %d 个结果项: %s", i+1, result.Title) + } + } else if p.debugMode { + log.Printf("[JAVDB] 第 %d 个结果项解析失败", i+1) + } + }) + + if p.debugMode { + log.Printf("[JAVDB] 解析到 %d 个原始结果", len(results)) + } + + return results, nil +} + +// parseResultItem 解析单个搜索结果项 +func (p *JavdbPlugin) parseResultItem(s *goquery.Selection, index int) *model.SearchResult { + if p.debugMode { + // 输出当前结果项的HTML结构用于调试 + itemHTML, _ := s.Html() + if len(itemHTML) > 300 { + log.Printf("[JAVDB] 结果项 %d HTML前300字符: %s", index, itemHTML[:300]) + } else { + log.Printf("[JAVDB] 结果项 %d 完整HTML: %s", index, itemHTML) + } + } + + // 提取详情页链接 + linkEl := s.Find("a.box") + if p.debugMode { + log.Printf("[JAVDB] 结果项 %d 找到a.box元素数量: %d", index, linkEl.Length()) + + // 如果没有找到a.box,尝试其他可能的链接选择器 + if linkEl.Length() == 0 { + altLinkSelectors := []string{"a", "a[href*='/v/']", ".box", "[href*='/v/']"} + for _, selector := range altLinkSelectors { + altLinks := s.Find(selector) + if altLinks.Length() > 0 { + log.Printf("[JAVDB] 结果项 %d 找到替代链接选择器 '%s' 的元素数量: %d", index, selector, altLinks.Length()) + } + } + } + } + + if linkEl.Length() == 0 { + if p.debugMode { + log.Printf("[JAVDB] 跳过无链接的结果") + } + return nil + } + + detailURL, _ := linkEl.Attr("href") + title, _ := linkEl.Attr("title") + + if p.debugMode { + log.Printf("[JAVDB] 结果项 %d 详情页URL: %s", index, detailURL) + log.Printf("[JAVDB] 结果项 %d 标题: %s", index, title) + } + + if detailURL == "" || title == "" { + if p.debugMode { + log.Printf("[JAVDB] 跳过无效链接或标题的结果") + } + return nil + } + + // 处理相对路径 + if strings.HasPrefix(detailURL, "/") { + detailURL = BaseURL + detailURL + } + + // 提取番号和标题 + videoNumber, _ := p.extractVideoInfo(s) + + // 提取评分 + rating := p.extractRating(s) + + // 提取发布日期 + releaseDate := p.extractReleaseDate(s) + + // 提取标签 + tags := p.extractTags(s) + + // 构建内容 + var contentParts []string + if videoNumber != "" { + contentParts = append(contentParts, fmt.Sprintf("番號:%s", videoNumber)) + } + if rating != "" { + contentParts = append(contentParts, fmt.Sprintf("評分:%s", rating)) + } + if releaseDate != "" { + contentParts = append(contentParts, fmt.Sprintf("發布日期:%s", releaseDate)) + } + if len(tags) > 0 { + contentParts = append(contentParts, fmt.Sprintf("標籤:%s", strings.Join(tags, " "))) + } + + content := strings.Join(contentParts, "\n") + + // 解析时间 + datetime := p.parseTime(releaseDate) + + // 构建初始结果对象(磁力链接稍后获取) + result := model.SearchResult{ + Title: p.cleanTitle(title), + Content: content, + Channel: "", // 插件搜索结果必须为空字符串 + MessageID: fmt.Sprintf("%s-%d-%d", p.Name(), index, time.Now().Unix()), + UniqueID: fmt.Sprintf("%s-%d", p.Name(), index), + Datetime: datetime, + Links: []model.Link{}, // 先为空,详情页处理后添加 + Tags: tags, + } + + // 添加详情页URL到临时字段(用于后续处理) + result.Content += fmt.Sprintf("\n详情页URL: %s", detailURL) + + if p.debugMode { + log.Printf("[JAVDB] 解析结果: %s (%s)", title, videoNumber) + } + + return &result +} + +// extractVideoInfo 提取番号和标题信息 +func (p *JavdbPlugin) extractVideoInfo(s *goquery.Selection) (videoNumber, videoTitle string) { + videoTitleEl := s.Find(".video-title") + if videoTitleEl.Length() > 0 { + fullTitle := strings.TrimSpace(videoTitleEl.Text()) + + // 提取番号 (在标签中) + strongEl := videoTitleEl.Find("strong") + if strongEl.Length() > 0 { + videoNumber = strings.TrimSpace(strongEl.Text()) + // 从完整标题中移除番号,得到作品标题 + videoTitle = strings.TrimSpace(strings.Replace(fullTitle, videoNumber, "", 1)) + } else { + videoTitle = fullTitle + } + } + return videoNumber, videoTitle +} + +// extractRating 提取评分 +func (p *JavdbPlugin) extractRating(s *goquery.Selection) string { + ratingEl := s.Find(".score .value") + if ratingEl.Length() > 0 { + rating := strings.TrimSpace(ratingEl.Text()) + // 清理评分文本,只保留主要信息 + rating = strings.ReplaceAll(rating, "\n", " ") + rating = regexp.MustCompile(`\s+`).ReplaceAllString(rating, " ") + return rating + } + return "" +} + +// extractReleaseDate 提取发布日期 +func (p *JavdbPlugin) extractReleaseDate(s *goquery.Selection) string { + metaEl := s.Find(".meta") + if metaEl.Length() > 0 { + date := strings.TrimSpace(metaEl.Text()) + return date + } + return "" +} + +// extractTags 提取标签 +func (p *JavdbPlugin) extractTags(s *goquery.Selection) []string { + var tags []string + s.Find(".tags .tag").Each(func(i int, tagEl *goquery.Selection) { + tag := strings.TrimSpace(tagEl.Text()) + if tag != "" { + tags = append(tags, tag) + } + }) + return tags +} + +// cleanTitle 清理标题 +func (p *JavdbPlugin) cleanTitle(title string) string { + title = strings.TrimSpace(title) + // 移除多余的空格 + title = regexp.MustCompile(`\s+`).ReplaceAllString(title, " ") + return title +} + +// parseTime 解析时间字符串 +func (p *JavdbPlugin) parseTime(dateStr string) time.Time { + if dateStr == "" { + return time.Now() + } + + // 常见的日期格式 + layouts := []string{ + "2006-01-02", + "2006/01/02", + "01-02-2006", + "01/02/2006", + } + + for _, layout := range layouts { + if t, err := time.Parse(layout, dateStr); err == nil { + return t + } + } + + return time.Now() +} + +// fetchDetailMagnetLinks 并发获取详情页磁力链接 +func (p *JavdbPlugin) fetchDetailMagnetLinks(client *http.Client, searchResults []model.SearchResult, keyword string) []model.SearchResult { + if len(searchResults) == 0 { + if p.debugMode { + log.Printf("[JAVDB] 无搜索结果需要获取详情页") + } + return []model.SearchResult{} + } + + if p.debugMode { + log.Printf("[JAVDB] 开始获取 %d 个搜索结果的详情页磁力链接", len(searchResults)) + } + + // 使用通道控制并发数 + semaphore := make(chan struct{}, MaxConcurrency) + var wg sync.WaitGroup + resultsChan := make(chan []model.SearchResult, len(searchResults)) + + // 根据客户端超时调整策略 + var finalResults []model.SearchResult + useTimeout := client.Timeout <= 5*time.Second // 短超时客户端使用超时机制 + + for i, result := range searchResults { + // 检查是否已经被限流,如果是则停止启动新的goroutine + if atomic.LoadInt32(&p.rateLimited) == 1 { + if p.debugMode { + log.Printf("[JAVDB] 检测到限流状态,停止启动新的详情页请求") + } + break + } + + wg.Add(1) + go func(r model.SearchResult, index int) { + defer wg.Done() + semaphore <- struct{}{} // 获取信号量 + defer func() { <-semaphore }() // 释放信号量 + + // 在goroutine内部再次检查限流状态 + if atomic.LoadInt32(&p.rateLimited) == 1 { + if p.debugMode { + log.Printf("[JAVDB] goroutine内检测到限流状态,跳过详情页请求: %s", r.Title) + } + return + } + + if p.debugMode { + log.Printf("[JAVDB] 开始处理第 %d 个搜索结果: %s", index+1, r.Title) + } + + // 从Content中提取详情页URL + detailURL := p.extractDetailURLFromContent(r.Content) + if detailURL == "" { + if p.debugMode { + log.Printf("[JAVDB] 跳过无详情页URL的结果: %s", r.Title) + log.Printf("[JAVDB] Content内容: %s", r.Content) + } + return + } + + if p.debugMode { + log.Printf("[JAVDB] 第 %d 个结果详情页URL: %s", index+1, detailURL) + } + + // 获取详情页磁力链接 + magnetLinks := p.fetchDetailPageMagnetLinks(client, detailURL) + if p.debugMode { + log.Printf("[JAVDB] 第 %d 个结果获取到 %d 个磁力链接", index+1, len(magnetLinks)) + } + + if len(magnetLinks) > 0 { + // 为每个磁力链接创建一个SearchResult + var results []model.SearchResult + for _, link := range magnetLinks { + // 复制基础结果 + newResult := r + // 清理Content中的详情页URL + newResult.Content = p.cleanContent(r.Content) + // 设置磁力链接 + newResult.Links = []model.Link{link} + // 更新唯一ID - 基于磁力链接URL哈希确保一致性 + linkHash := fmt.Sprintf("%x", md5.Sum([]byte(link.URL)))[:8] + newResult.UniqueID = fmt.Sprintf("%s-magnet-%s", newResult.UniqueID, linkHash) + newResult.MessageID = newResult.UniqueID + results = append(results, newResult) + } + resultsChan <- results + if p.debugMode { + log.Printf("[JAVDB] 第 %d 个结果成功创建 %d 个最终结果", index+1, len(results)) + } + } else if p.debugMode { + log.Printf("[JAVDB] 详情页无磁力链接: %s", r.Title) + } + }(result, i) + } + + // 等待所有goroutine完成的信号 + done := make(chan struct{}) + go func() { + wg.Wait() + close(resultsChan) + close(done) + }() + + // 收集结果 + if useTimeout { + // 短超时客户端:4秒超时机制,快速返回部分结果 + timeout := time.After(4 * time.Second) + collectLoop: + for { + select { + case results, ok := <-resultsChan: + if !ok { + break collectLoop + } + finalResults = append(finalResults, results...) + if p.debugMode { + log.Printf("[JAVDB] 收集到一批结果,数量: %d,总数: %d", len(results), len(finalResults)) + } + case <-timeout: + if p.debugMode { + log.Printf("[JAVDB] ⏰ 4秒超时,返回已获取的 %d 个结果", len(finalResults)) + } + break collectLoop + case <-done: + if p.debugMode { + log.Printf("[JAVDB] 所有详情页请求完成") + } + break collectLoop + } + } + } else { + // 长超时客户端:等待所有结果完成 + for { + select { + case results, ok := <-resultsChan: + if !ok { + goto finished + } + finalResults = append(finalResults, results...) + if p.debugMode { + log.Printf("[JAVDB] 收集到一批结果,数量: %d,总数: %d", len(results), len(finalResults)) + } + case <-done: + if p.debugMode { + log.Printf("[JAVDB] 所有详情页请求完成") + } + goto finished + } + } + finished: + } + + if p.debugMode { + log.Printf("[JAVDB] 最终收集到 %d 个结果", len(finalResults)) + // 如果遇到了限流,提示用户 + if atomic.LoadInt32(&p.rateLimited) == 1 { + log.Printf("[JAVDB] 本次搜索遇到429限流,结果可能不完整") + } + } + + return finalResults +} + + + +// extractDetailURLFromContent 从Content中提取详情页URL +func (p *JavdbPlugin) extractDetailURLFromContent(content string) string { + lines := strings.Split(content, "\n") + for _, line := range lines { + if strings.HasPrefix(line, "详情页URL: ") { + return strings.TrimPrefix(line, "详情页URL: ") + } + } + return "" +} + +// cleanContent 清理Content,移除详情页URL行 +func (p *JavdbPlugin) cleanContent(content string) string { + lines := strings.Split(content, "\n") + var cleanedLines []string + for _, line := range lines { + if !strings.HasPrefix(line, "详情页URL: ") { + cleanedLines = append(cleanedLines, line) + } + } + return strings.Join(cleanedLines, "\n") +} + +// fetchDetailPageMagnetLinks 获取详情页的磁力链接 +func (p *JavdbPlugin) fetchDetailPageMagnetLinks(client *http.Client, detailURL string) []model.Link { + if p.debugMode { + log.Printf("[JAVDB] 开始获取详情页磁力链接: %s", detailURL) + } + + // 检查缓存 + if cached, found := p.detailCache.Load(detailURL); found { + if links, ok := cached.([]model.Link); ok { + if p.debugMode { + log.Printf("[JAVDB] 使用缓存的详情页链接: %s", detailURL) + } + return links + } + } + + // 创建带超时的上下文 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", detailURL, nil) + if err != nil { + if p.debugMode { + log.Printf("[JAVDB] 创建详情页请求失败: %v", err) + } + return []model.Link{} + } + + // 设置请求头 + req.Header.Set("User-Agent", UserAgent) + 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("Referer", BaseURL+"/") + + if p.debugMode { + log.Printf("[JAVDB] 发送详情页请求...") + } + + resp, err := p.doRequestWithRateLimitRetry(req, client) + if err != nil { + if p.debugMode { + log.Printf("[JAVDB] 详情页请求失败: %v", err) + } + return []model.Link{} + } + defer resp.Body.Close() + + if p.debugMode { + log.Printf("[JAVDB] 详情页请求响应状态: %d", resp.StatusCode) + } + + if resp.StatusCode != 200 { + if p.debugMode { + log.Printf("[JAVDB] 详情页HTTP状态错误: %d", resp.StatusCode) + } + return []model.Link{} + } + + // 读取响应体 + body, err := io.ReadAll(resp.Body) + if err != nil { + if p.debugMode { + log.Printf("[JAVDB] 读取详情页响应失败: %v", err) + } + return []model.Link{} + } + + if p.debugMode { + bodyStr := string(body) + log.Printf("[JAVDB] 详情页响应体长度: %d", len(bodyStr)) + // 检查页面是否包含磁力链接相关内容 + if strings.Contains(bodyStr, "magnet:") { + magnetCount := strings.Count(bodyStr, "magnet:") + log.Printf("[JAVDB] 详情页包含 %d 个magnet字符串", magnetCount) + } else { + log.Printf("[JAVDB] 详情页不包含magnet字符串") + } + + // 检查是否包含预期的磁力链接容器元素 + if strings.Contains(bodyStr, "magnets-content") { + log.Printf("[JAVDB] 找到magnets-content容器") + } else { + log.Printf("[JAVDB] 未找到magnets-content容器") + } + + if strings.Contains(bodyStr, "magnet-links") { + log.Printf("[JAVDB] 找到magnet-links容器") + } else { + log.Printf("[JAVDB] 未找到magnet-links容器") + } + } + + // 解析磁力链接 + links := p.parseMagnetLinks(string(body)) + + // 缓存结果 + if len(links) > 0 { + p.detailCache.Store(detailURL, links) + } + + if p.debugMode { + log.Printf("[JAVDB] 从详情页提取到 %d 个磁力链接: %s", len(links), detailURL) + } + + return links +} + +// parseMagnetLinks 解析磁力链接 +func (p *JavdbPlugin) parseMagnetLinks(htmlContent string) []model.Link { + var links []model.Link + + if p.debugMode { + log.Printf("[JAVDB] 开始解析磁力链接") + } + + // 使用goquery解析HTML + doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent)) + if err != nil { + if p.debugMode { + log.Printf("[JAVDB] 解析详情页HTML失败: %v", err) + } + return links + } + + if p.debugMode { + // 检查关键容器元素 + magnetsContentEl := doc.Find("#magnets-content") + log.Printf("[JAVDB] 找到#magnets-content元素数量: %d", magnetsContentEl.Length()) + + magnetLinksEl := doc.Find("#magnets-content .magnet-links") + log.Printf("[JAVDB] 找到#magnets-content .magnet-links元素数量: %d", magnetLinksEl.Length()) + + magnetItemsEl := doc.Find("#magnets-content .magnet-links .item") + log.Printf("[JAVDB] 找到#magnets-content .magnet-links .item元素数量: %d", magnetItemsEl.Length()) + + // 如果没有找到预期元素,尝试其他可能的选择器 + if magnetItemsEl.Length() == 0 { + log.Printf("[JAVDB] 尝试其他可能的磁力链接选择器...") + + altSelectors := []string{ + ".magnet-links .item", + "[href^='magnet:']", + "a[href*='magnet:']", + ".item [href^='magnet:']", + } + + for _, selector := range altSelectors { + altEls := doc.Find(selector) + if altEls.Length() > 0 { + log.Printf("[JAVDB] 找到替代选择器 '%s' 的元素数量: %d", selector, altEls.Length()) + } + } + } + } + + // 查找磁力链接区域: .magnet-links .item (因为#magnets-content本身就有magnet-links类) + doc.Find(".magnet-links .item").Each(func(i int, s *goquery.Selection) { + if p.debugMode { + log.Printf("[JAVDB] 开始解析第 %d 个磁力链接项", i+1) + } + + // 提取磁力链接URL - 从.magnet-name下的a标签获取href + magnetEl := s.Find(".magnet-name a") + if p.debugMode { + log.Printf("[JAVDB] 第 %d 个项找到.magnet-name a元素数量: %d", i+1, magnetEl.Length()) + } + + if magnetEl.Length() == 0 { + if p.debugMode { + log.Printf("[JAVDB] 第 %d 个项无.magnet-name a元素,跳过", i+1) + } + return + } + + magnetURL, _ := magnetEl.Attr("href") + if magnetURL == "" { + if p.debugMode { + log.Printf("[JAVDB] 第 %d 个项磁力链接URL为空,跳过", i+1) + } + return + } + + // 验证是否为磁力链接 + if !strings.HasPrefix(magnetURL, "magnet:") { + if p.debugMode { + log.Printf("[JAVDB] 第 %d 个项不是磁力链接: %s,跳过", i+1, magnetURL) + } + return + } + + if p.debugMode { + log.Printf("[JAVDB] 第 %d 个项原始磁力URL: %s", i+1, magnetURL) + } + + // 解码HTML实体 + magnetURL = strings.ReplaceAll(magnetURL, "&", "&") + + if p.debugMode { + log.Printf("[JAVDB] 第 %d 个项解码后磁力URL: %s", i+1, magnetURL) + } + + link := model.Link{ + Type: "magnet", + URL: magnetURL, + Password: "", // 磁力链接无需密码 + } + + links = append(links, link) + + if p.debugMode { + // 提取资源名称用于调试日志 + nameEl := s.Find(".magnet-name .name") + resourceName := strings.TrimSpace(nameEl.Text()) + // 提取文件信息用于调试日志 + metaEl := s.Find(".magnet-name .meta") + fileInfo := strings.TrimSpace(metaEl.Text()) + log.Printf("[JAVDB] 成功提取第 %d 个磁力链接: %s (%s)", i+1, resourceName, fileInfo) + } + }) + + if p.debugMode { + log.Printf("[JAVDB] 磁力链接解析完成,共找到 %d 个链接", len(links)) + } + + return links +} \ No newline at end of file diff --git a/plugin/u3c3/.DS_Store b/plugin/u3c3/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/plugin/u3c3/.DS_Store differ diff --git a/plugin/u3c3/u3c3.go b/plugin/u3c3/u3c3.go new file mode 100644 index 0000000..6c99cb6 --- /dev/null +++ b/plugin/u3c3/u3c3.go @@ -0,0 +1,422 @@ +package u3c3 + +import ( + "fmt" + "io" + "log" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + "pansou/model" + "pansou/plugin" +) + +const ( + BaseURL = "https://u3c3u3c3.u3c3u3c3u3c3.com" + UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + MaxRetries = 3 + RetryDelay = 2 * time.Second +) + +// U3c3Plugin U3C3插件 +type U3c3Plugin struct { + *plugin.BaseAsyncPlugin + debugMode bool + search2 string // 缓存的search2参数 + lastSync time.Time +} + +func init() { + p := &U3c3Plugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("u3c3", 5, true), + debugMode: false, + } + plugin.RegisterGlobalPlugin(p) +} + +// Search 搜索接口实现 +func (p *U3c3Plugin) 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 搜索并返回详细结果 +func (p *U3c3Plugin) SearchWithResult(keyword string, ext map[string]interface{}) (*model.PluginSearchResult, error) { + if p.debugMode { + log.Printf("[U3C3] 开始搜索: %s", keyword) + } + + // 第一步:获取search2参数 + search2, err := p.getSearch2Parameter() + if err != nil { + if p.debugMode { + log.Printf("[U3C3] 获取search2参数失败: %v", err) + } + return nil, fmt.Errorf("获取search2参数失败: %v", err) + } + + // 第二步:执行搜索 + results, err := p.doSearch(keyword, search2) + if err != nil { + if p.debugMode { + log.Printf("[U3C3] 搜索失败: %v", err) + } + return nil, err + } + + if p.debugMode { + log.Printf("[U3C3] 搜索完成,获得 %d 个结果", len(results)) + } + + // 应用关键词过滤 + filteredResults := plugin.FilterResultsByKeyword(results, keyword) + + return &model.PluginSearchResult{ + Results: filteredResults, + IsFinal: true, + Timestamp: time.Now(), + Source: p.Name(), + Message: fmt.Sprintf("找到 %d 个结果", len(filteredResults)), + }, nil +} + +// getSearch2Parameter 获取search2参数 +func (p *U3c3Plugin) getSearch2Parameter() (string, error) { + // 如果缓存有效(1小时内),直接返回 + if p.search2 != "" && time.Since(p.lastSync) < time.Hour { + return p.search2, nil + } + + if p.debugMode { + log.Printf("[U3C3] 正在获取search2参数...") + } + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + req, err := http.NewRequest("GET", BaseURL, nil) + if err != nil { + return "", err + } + + req.Header.Set("User-Agent", UserAgent) + 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") + + var resp *http.Response + var lastErr error + + // 重试机制 + for i := 0; i < MaxRetries; i++ { + resp, lastErr = client.Do(req) + if lastErr == nil && resp.StatusCode == 200 { + break + } + if resp != nil { + resp.Body.Close() + } + if i < MaxRetries-1 { + time.Sleep(RetryDelay) + } + } + + if lastErr != nil { + return "", lastErr + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("HTTP状态码错误: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + // 从JavaScript中提取search2参数 + search2 := p.extractSearch2FromHTML(string(body)) + if search2 == "" { + return "", fmt.Errorf("无法从首页提取search2参数") + } + + // 缓存参数 + p.search2 = search2 + p.lastSync = time.Now() + + if p.debugMode { + log.Printf("[U3C3] 获取到search2参数: %s", search2) + } + + return search2, nil +} + +// extractSearch2FromHTML 从HTML中提取search2参数 +func (p *U3c3Plugin) extractSearch2FromHTML(html string) string { + // 按行处理,排除注释行 + lines := strings.Split(html, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + + // 跳过注释行 + if strings.HasPrefix(line, "//") { + continue + } + + // 查找包含nmefafej的行 + if strings.Contains(line, "nmefafej") && strings.Contains(line, `"`) { + // 使用正则提取引号内的值 + re := regexp.MustCompile(`var\s+nmefafej\s*=\s*"([^"]+)"`) + matches := re.FindStringSubmatch(line) + if len(matches) > 1 && len(matches[1]) > 5 { + if p.debugMode { + log.Printf("[U3C3] 提取到search2参数: %s (来自行: %s)", matches[1], line) + } + return matches[1] + } + + // 备用方案:直接提取引号内容 + start := strings.Index(line, `"`) + if start != -1 { + end := strings.Index(line[start+1:], `"`) + if end != -1 && end > 5 { + candidate := line[start+1 : start+1+end] + if len(candidate) > 5 { + if p.debugMode { + log.Printf("[U3C3] 备用方案提取search2: %s (来自行: %s)", candidate, line) + } + return candidate + } + } + } + } + } + + if p.debugMode { + log.Printf("[U3C3] 未能找到search2参数") + } + return "" +} + +// doSearch 执行搜索 +func (p *U3c3Plugin) doSearch(keyword, search2 string) ([]model.SearchResult, error) { + // 构建搜索URL + encodedKeyword := url.QueryEscape(keyword) + searchURL := fmt.Sprintf("%s/?search2=%s&search=%s", BaseURL, search2, encodedKeyword) + + if p.debugMode { + log.Printf("[U3C3] 搜索URL: %s", searchURL) + } + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Referer", BaseURL+"/") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + + var resp *http.Response + var lastErr error + + // 重试机制 + for i := 0; i < MaxRetries; i++ { + resp, lastErr = client.Do(req) + if lastErr == nil && resp.StatusCode == 200 { + break + } + if resp != nil { + resp.Body.Close() + } + if i < MaxRetries-1 { + time.Sleep(RetryDelay) + } + } + + if lastErr != nil { + return nil, lastErr + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("搜索请求失败,状态码: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return p.parseSearchResults(string(body)) +} + +// parseSearchResults 解析搜索结果 +func (p *U3c3Plugin) parseSearchResults(html string) ([]model.SearchResult, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return nil, err + } + + var results []model.SearchResult + + // 查找搜索结果表格行 + doc.Find("tbody tr.default").Each(func(i int, s *goquery.Selection) { + // 跳过广告行(通常包含置顶标识) + titleCell := s.Find("td:nth-child(2)") + titleText := titleCell.Text() + if strings.Contains(titleText, "[置顶]") { + return // 跳过置顶广告 + } + + // 提取标题和详情链接 + titleLink := titleCell.Find("a") + title := strings.TrimSpace(titleLink.Text()) + if title == "" { + return // 跳过空标题 + } + + // 清理标题中的HTML标签和特殊字符 + title = p.cleanTitle(title) + + // 提取详情页链接(可选,用于后续扩展) + detailURL, _ := titleLink.Attr("href") + if detailURL != "" && !strings.HasPrefix(detailURL, "http") { + detailURL = BaseURL + detailURL + } + + // 提取链接信息 + linkCell := s.Find("td:nth-child(3)") + var links []model.Link + + // 磁力链接 + linkCell.Find("a[href^='magnet:']").Each(func(j int, link *goquery.Selection) { + href, exists := link.Attr("href") + if exists && href != "" { + links = append(links, model.Link{ + URL: href, + Type: "magnet", + }) + } + }) + + // 种子文件链接 + linkCell.Find("a[href$='.torrent']").Each(func(j int, link *goquery.Selection) { + href, exists := link.Attr("href") + if exists && href != "" { + if !strings.HasPrefix(href, "http") { + href = BaseURL + href + } + links = append(links, model.Link{ + URL: href, + Type: "torrent", + }) + } + }) + + // 提取文件大小 + sizeText := strings.TrimSpace(s.Find("td:nth-child(4)").Text()) + + // 提取上传时间 + dateText := strings.TrimSpace(s.Find("td:nth-child(5)").Text()) + + // 提取分类 + categoryText := s.Find("td:nth-child(1) a").AttrOr("title", "") + + // 构建内容信息 + var contentParts []string + if categoryText != "" { + contentParts = append(contentParts, "分类: "+categoryText) + } + if sizeText != "" { + contentParts = append(contentParts, "大小: "+sizeText) + } + if dateText != "" { + contentParts = append(contentParts, "时间: "+dateText) + } + + content := strings.Join(contentParts, " | ") + + // 生成唯一ID + uniqueID := p.generateUniqueID(title, sizeText) + + result := model.SearchResult{ + Title: title, + Content: content, + Channel: "", // 插件搜索结果必须为空 + Tags: []string{"种子", "磁力链接"}, + Datetime: p.parseDateTime(dateText), + Links: links, + UniqueID: uniqueID, + } + + results = append(results, result) + }) + + if p.debugMode { + log.Printf("[U3C3] 解析到 %d 个搜索结果", len(results)) + } + + return results, nil +} + +// cleanTitle 清理标题文本 +func (p *U3c3Plugin) cleanTitle(title string) string { + // 移除HTML标签 + title = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(title, "") + // 移除多余的空白字符 + title = regexp.MustCompile(`\s+`).ReplaceAllString(title, " ") + // 移除前后空白 + title = strings.TrimSpace(title) + return title +} + +// parseDateTime 解析日期时间 +func (p *U3c3Plugin) parseDateTime(dateStr string) time.Time { + if dateStr == "" { + return time.Time{} + } + + // 尝试解析常见的日期格式 + formats := []string{ + "2006-01-02 15:04:05", + "2006-01-02", + "01-02 15:04", + } + + for _, format := range formats { + if t, err := time.Parse(format, dateStr); err == nil { + return t + } + } + + // 如果解析失败,返回零值 + return time.Time{} +} + +// generateUniqueID 生成唯一ID +func (p *U3c3Plugin) generateUniqueID(title, size string) string { + // 使用插件名、标题和大小生成唯一ID + source := fmt.Sprintf("%s-%s-%s", p.Name(), title, size) + // 简单的哈希处理(实际项目中可使用更复杂的哈希算法) + hash := 0 + for _, char := range source { + hash = hash*31 + int(char) + } + if hash < 0 { + hash = -hash + } + return fmt.Sprintf("u3c3-%d", hash) +} \ No newline at end of file diff --git a/plugin/yuhuage/.DS_Store b/plugin/yuhuage/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/plugin/yuhuage/.DS_Store differ diff --git a/plugin/yuhuage/yuhuage.go b/plugin/yuhuage/yuhuage.go new file mode 100644 index 0000000..f779599 --- /dev/null +++ b/plugin/yuhuage/yuhuage.go @@ -0,0 +1,415 @@ +package yuhuage + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/PuerkitoBio/goquery" + "pansou/model" + "pansou/plugin" +) + +const ( + BaseURL = "https://www.iyuhuage.fun" + SearchPath = "/search/" + UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + MaxConcurrency = 5 // 详情页最大并发数 + MaxRetryCount = 2 // 最大重试次数 +) + +// YuhuagePlugin 雨花阁插件 +type YuhuagePlugin struct { + *plugin.BaseAsyncPlugin + debugMode bool + detailCache sync.Map // 缓存详情页结果 + cacheTTL time.Duration + rateLimited int32 // 429限流标志位 +} + +func init() { + p := &YuhuagePlugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("yuhuage", 3, true), + debugMode: false, + cacheTTL: 30 * time.Minute, + } + plugin.RegisterGlobalPlugin(p) +} + +// Search 搜索接口实现 +func (p *YuhuagePlugin) 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 *YuhuagePlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { + return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) +} + +// searchImpl 搜索实现方法 +func (p *YuhuagePlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { + if p.debugMode { + log.Printf("[YUHUAGE] 开始搜索: %s", keyword) + } + + // 检查限流状态 + if atomic.LoadInt32(&p.rateLimited) == 1 { + if p.debugMode { + log.Printf("[YUHUAGE] 当前处于限流状态,跳过搜索") + } + return nil, fmt.Errorf("rate limited") + } + + // 构建搜索URL + encodedQuery := url.QueryEscape(keyword) + searchURL := fmt.Sprintf("%s%s%s-%d-time.html", BaseURL, SearchPath, encodedQuery, 1) + + // 创建带超时的上下文 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // 创建请求对象 + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err) + } + + // 设置请求头 + req.Header.Set("User-Agent", UserAgent) + 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", BaseURL+"/") + + // 发送HTTP请求 + resp, err := p.doRequestWithRetry(req, client) + if err != nil { + return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err) + } + defer resp.Body.Close() + + if resp.StatusCode == 429 { + atomic.StoreInt32(&p.rateLimited, 1) + go func() { + time.Sleep(60 * time.Second) + atomic.StoreInt32(&p.rateLimited, 0) + }() + return nil, fmt.Errorf("[%s] 请求被限流", p.Name()) + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("[%s] HTTP错误: %d", p.Name(), resp.StatusCode) + } + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("[%s] 读取响应失败: %w", p.Name(), err) + } + + // 解析搜索结果 + results, err := p.parseSearchResults(string(body)) + if err != nil { + return nil, err + } + + if p.debugMode { + log.Printf("[YUHUAGE] 搜索完成,获得 %d 个结果", len(results)) + } + + // 关键词过滤 + return plugin.FilterResultsByKeyword(results, keyword), nil +} + +// parseSearchResults 解析搜索结果 +func (p *YuhuagePlugin) parseSearchResults(html string) ([]model.SearchResult, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return nil, err + } + + var results []model.SearchResult + var detailURLs []string + + // 提取搜索结果 + doc.Find(".search-item.detail-width").Each(func(i int, s *goquery.Selection) { + title := strings.TrimSpace(p.cleanTitle(s.Find(".item-title h3 a").Text())) + detailHref, exists := s.Find(".item-title h3 a").Attr("href") + + if !exists || title == "" { + return + } + + detailURL := BaseURL + detailHref + detailURLs = append(detailURLs, detailURL) + + // 提取基本信息 + createTime := strings.TrimSpace(s.Find(".item-bar span:contains('创建时间') b").Text()) + size := strings.TrimSpace(s.Find(".item-bar .cpill.blue-pill").Text()) + fileCount := strings.TrimSpace(s.Find(".item-bar .cpill.yellow-pill").Text()) + hot := strings.TrimSpace(s.Find(".item-bar span:contains('热度') b").Text()) + lastDownload := strings.TrimSpace(s.Find(".item-bar span:contains('最近下载') b").Text()) + + // 构建内容描述 + content := fmt.Sprintf("创建时间: %s | 大小: %s | 文件数: %s | 热度: %s", + createTime, size, fileCount, hot) + if lastDownload != "" { + content += fmt.Sprintf(" | 最近下载: %s", lastDownload) + } + + result := model.SearchResult{ + Title: title, + Content: content, + Channel: "", // 插件搜索结果必须为空字符串 + Tags: []string{"磁力链接"}, + Datetime: p.parseDateTime(createTime), + UniqueID: fmt.Sprintf("%s-%s", p.Name(), p.extractHashFromURL(detailURL)), + } + + results = append(results, result) + }) + + if p.debugMode { + log.Printf("[YUHUAGE] 解析到 %d 个搜索结果,准备获取详情", len(results)) + } + + // 同步获取详情页链接 + p.fetchDetailsSync(detailURLs, results) + + return results, nil +} + +// fetchDetailsSync 同步获取详情页信息 +func (p *YuhuagePlugin) fetchDetailsSync(detailURLs []string, results []model.SearchResult) { + if len(detailURLs) == 0 { + return + } + + semaphore := make(chan struct{}, MaxConcurrency) + var wg sync.WaitGroup + + for i, detailURL := range detailURLs { + if i >= len(results) { + break + } + + wg.Add(1) + go func(url string, result *model.SearchResult) { + defer wg.Done() + semaphore <- struct{}{} + defer func() { <-semaphore }() + + links := p.fetchDetailLinks(url) + if len(links) > 0 { + result.Links = links + if p.debugMode { + log.Printf("[YUHUAGE] 为结果设置了 %d 个链接", len(links)) + } + } else if p.debugMode { + log.Printf("[YUHUAGE] 详情页没有找到有效链接: %s", url) + } + }(detailURL, &results[i]) + } + + wg.Wait() + if p.debugMode { + log.Printf("[YUHUAGE] 详情页获取完成") + } +} + +// fetchDetailLinks 获取详情页链接 +func (p *YuhuagePlugin) fetchDetailLinks(detailURL string) []model.Link { + // 检查缓存 + if cached, exists := p.detailCache.Load(detailURL); exists { + if links, ok := cached.([]model.Link); ok { + return links + } + } + + client := &http.Client{Timeout: 15 * time.Second} + + for retry := 0; retry <= MaxRetryCount; retry++ { + req, err := http.NewRequest("GET", detailURL, nil) + if err != nil { + continue + } + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Referer", BaseURL+"/") + + resp, err := client.Do(req) + if err != nil { + if retry < MaxRetryCount { + time.Sleep(time.Duration(retry+1) * time.Second) + continue + } + break + } + + if resp.StatusCode != 200 { + resp.Body.Close() + if retry < MaxRetryCount { + time.Sleep(time.Duration(retry+1) * time.Second) + continue + } + break + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + if retry < MaxRetryCount { + time.Sleep(time.Duration(retry+1) * time.Second) + continue + } + break + } + + links := p.parseDetailLinks(string(body)) + + // 缓存结果 + if len(links) > 0 { + p.detailCache.Store(detailURL, links) + // 设置缓存过期 + go func() { + time.Sleep(p.cacheTTL) + p.detailCache.Delete(detailURL) + }() + } + + return links + } + + return nil +} + +// parseDetailLinks 解析详情页链接 +func (p *YuhuagePlugin) parseDetailLinks(html string) []model.Link { + var links []model.Link + + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return links + } + + // 提取磁力链接 + doc.Find("a.download[href^='magnet:']").Each(func(i int, s *goquery.Selection) { + href, exists := s.Attr("href") + if exists && href != "" { + if p.debugMode { + log.Printf("[YUHUAGE] 找到磁力链接: %s", href) + } + links = append(links, model.Link{ + URL: href, + Type: "magnet", + }) + } + }) + + // 提取迅雷链接 + doc.Find("a.download[href^='thunder:']").Each(func(i int, s *goquery.Selection) { + href, exists := s.Attr("href") + if exists && href != "" { + if p.debugMode { + log.Printf("[YUHUAGE] 找到迅雷链接: %s", href) + } + links = append(links, model.Link{ + URL: href, + Type: "thunder", + }) + } + }) + + if p.debugMode && len(links) > 0 { + log.Printf("[YUHUAGE] 从详情页解析到 %d 个链接", len(links)) + } + + return links +} + +// extractHashFromURL 从URL中提取哈希ID +func (p *YuhuagePlugin) extractHashFromURL(detailURL string) string { + re := regexp.MustCompile(`/hash/(\d+)\.html`) + matches := re.FindStringSubmatch(detailURL) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// cleanTitle 清理标题 +func (p *YuhuagePlugin) cleanTitle(title string) string { + title = strings.TrimSpace(title) + // 移除HTML标签(如标签) + re := regexp.MustCompile(`<[^>]*>`) + title = re.ReplaceAllString(title, "") + // 移除多余的空格 + re = regexp.MustCompile(`\s+`) + title = re.ReplaceAllString(title, " ") + return strings.TrimSpace(title) +} + +// parseDateTime 解析时间字符串 +func (p *YuhuagePlugin) parseDateTime(timeStr string) time.Time { + if timeStr == "" { + return time.Time{} + } + + // 尝试不同的时间格式 + formats := []string{ + "2006-01-02 15:04:05", + "2006-01-02", + "2006/01/02 15:04:05", + "2006/01/02", + } + + for _, format := range formats { + if t, err := time.Parse(format, timeStr); err == nil { + return t + } + } + + return time.Time{} +} + +// doRequestWithRetry 带重试机制的HTTP请求 +func (p *YuhuagePlugin) 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<