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 历史
[](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 失望太久。人们已经尝试过了!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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
+
+```
+
+### 数据提取选择器
+
+#### 结果列表
+- **选择器**: `.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 失望太久。人们已经尝试过了!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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
+
+```
+
+### 数据提取选择器
+
+#### 结果列表
+- **选择器**: `.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 失望太久。人们已经尝试过了!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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
+
+```
+
+### 数据提取选择器
+
+#### 结果列表
+- **选择器**: `.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
+
+```
+
+### 数据提取选择器
+
+#### 结果列表
+- **选择器**: `.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<