From 9877d17ac8dce691102641f75f5d84bcf542bc36 Mon Sep 17 00:00:00 2001 From: "www.xueximeng.com" Date: Wed, 20 Aug 2025 17:25:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8F=92=E4=BB=B6pianku,clma?= =?UTF-8?q?o,wuji?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.go | 5 +- plugin/clmao/clmao.go | 328 +++++++++++++++++++++ plugin/clmao/html结构分析.md | 155 ++++++++++ plugin/panyq/panyq.go | 2 +- plugin/pianku/html结构分析.md | 469 ++++++++++++++++++++++++++++++ plugin/pianku/pianku.go | 522 ++++++++++++++++++++++++++++++++++ plugin/wuji/html结构分析.md | 206 ++++++++++++++ plugin/wuji/wuji.go | 411 ++++++++++++++++++++++++++ 8 files changed, 2096 insertions(+), 2 deletions(-) create mode 100644 plugin/clmao/clmao.go create mode 100644 plugin/clmao/html结构分析.md create mode 100644 plugin/pianku/html结构分析.md create mode 100644 plugin/pianku/pianku.go create mode 100644 plugin/wuji/html结构分析.md create mode 100644 plugin/wuji/wuji.go diff --git a/main.go b/main.go index 15a4a6c..77880cf 100644 --- a/main.go +++ b/main.go @@ -43,10 +43,13 @@ import ( _ "pansou/plugin/shandian" _ "pansou/plugin/duoduo" _ "pansou/plugin/huban" - _ "pansou/plugin/fox4k" _ "pansou/plugin/cyg" _ "pansou/plugin/erxiao" _ "pansou/plugin/miaoso" + _ "pansou/plugin/fox4k" + _ "pansou/plugin/pianku" + _ "pansou/plugin/clmao" + _ "pansou/plugin/wuji" ) // 全局缓存写入管理器 diff --git a/plugin/clmao/clmao.go b/plugin/clmao/clmao.go new file mode 100644 index 0000000..dd8f7f2 --- /dev/null +++ b/plugin/clmao/clmao.go @@ -0,0 +1,328 @@ +package clmao + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + "pansou/model" + "pansou/plugin" +) + +// 常量定义 +const ( + // 基础URL + BaseURL = "https://www.8800492.xyz" + + // 搜索URL格式:/search-{keyword}-{category}-{sort}-{page}.html + SearchURL = BaseURL + "/search-%s-0-2-1.html" + + // 默认参数 + MaxRetries = 3 + TimeoutSeconds = 30 +) + +// 预编译的正则表达式 +var ( + // 磁力链接正则 + magnetLinkRegex = regexp.MustCompile(`magnet:\?xt=urn:btih:[0-9a-fA-F]{40}[^"'\s]*`) + + // 文件大小正则 + fileSizeRegex = regexp.MustCompile(`(\d+\.?\d*)\s*(B|KB|MB|GB|TB)`) + + // 数字提取正则 + numberRegex = regexp.MustCompile(`\d+`) +) + +// 常用UA列表 +var userAgents = []string{ + "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", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0", +} + +// ClmaoPlugin 磁力猫搜索插件 +type ClmaoPlugin struct { + *plugin.BaseAsyncPlugin +} + +// NewClmaoPlugin 创建新的磁力猫插件实例 +func NewClmaoPlugin() *ClmaoPlugin { + return &ClmaoPlugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("clmao", 60), + } +} + +// Name 返回插件名称 +func (p *ClmaoPlugin) Name() string { + return "clmao" +} + +// DisplayName 返回插件显示名称 +func (p *ClmaoPlugin) DisplayName() string { + return "磁力猫" +} + +// Description 返回插件描述 +func (p *ClmaoPlugin) Description() string { + return "磁力猫 - 磁力链接搜索引擎" +} + +// Search 执行搜索并返回结果(兼容性方法) +func (p *ClmaoPlugin) 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 *ClmaoPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { + return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) +} + +// searchImpl 实际的搜索实现 +func (p *ClmaoPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { + // URL编码关键词 + encodedKeyword := url.QueryEscape(keyword) + searchURL := fmt.Sprintf(SearchURL, encodedKeyword) + + // 创建带超时的上下文 + ctx, cancel := context.WithTimeout(context.Background(), TimeoutSeconds*time.Second) + defer cancel() + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err) + } + + // 设置请求头 + p.setRequestHeaders(req) + + // 发送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 != 200 { + return nil, fmt.Errorf("[%s] 请求返回状态码: %d", p.Name(), resp.StatusCode) + } + + // 读取响应体内容 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("[%s] 读取响应失败: %w", p.Name(), err) + } + + // 调试输出前500个字符 + if len(body) > 500 { + fmt.Printf("[%s] 响应内容前500字符: %s\n", p.Name(), string(body[:500])) + } else { + fmt.Printf("[%s] 完整响应内容: %s\n", p.Name(), string(body)) + } + + // 解析HTML + doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body))) + if err != nil { + return nil, fmt.Errorf("[%s] HTML解析失败: %w", p.Name(), err) + } + + // 提取搜索结果 + searchResults := p.extractSearchResults(doc) + fmt.Printf("[%s] 找到搜索结果数量: %d\n", p.Name(), len(searchResults)) + + // 关键词过滤 + searchKeyword := keyword + if searchParam, ok := ext["search"]; ok { + if searchStr, ok := searchParam.(string); ok && searchStr != "" { + searchKeyword = searchStr + } + } + return plugin.FilterResultsByKeyword(searchResults, searchKeyword), nil +} + +// extractSearchResults 提取搜索结果 +func (p *ClmaoPlugin) extractSearchResults(doc *goquery.Document) []model.SearchResult { + var results []model.SearchResult + + // 查找所有搜索结果 + doc.Find(".tbox .ssbox").Each(func(i int, s *goquery.Selection) { + result := p.parseSearchResult(s) + if result.Title != "" && len(result.Links) > 0 { + results = append(results, result) + } + }) + + return results +} + +// parseSearchResult 解析单个搜索结果 +func (p *ClmaoPlugin) parseSearchResult(s *goquery.Selection) model.SearchResult { + result := model.SearchResult{ + Channel: p.Name(), + Datetime: time.Now(), + } + + // 提取标题 + titleSection := s.Find(".title h3") + titleLink := titleSection.Find("a") + title := strings.TrimSpace(titleLink.Text()) + result.Title = p.cleanTitle(title) + + // 提取分类作为标签 + category := strings.TrimSpace(titleSection.Find("span").Text()) + if category != "" { + result.Tags = []string{p.mapCategory(category)} + } + + // 提取磁力链接和元数据 + p.extractMagnetInfo(s, &result) + + // 提取文件列表作为内容 + p.extractFileList(s, &result) + + // 生成唯一ID + result.UniqueID = fmt.Sprintf("%s_%d", p.Name(), time.Now().UnixNano()) + + return result +} + +// extractMagnetInfo 提取磁力链接和元数据 +func (p *ClmaoPlugin) extractMagnetInfo(s *goquery.Selection, result *model.SearchResult) { + sbar := s.Find(".sbar") + + // 提取磁力链接 + magnetLink, _ := sbar.Find("a[href^='magnet:']").Attr("href") + if magnetLink != "" { + link := model.Link{ + Type: "magnet", + URL: magnetLink, + } + result.Links = []model.Link{link} + } + + // 提取元数据并添加到内容中 + var metadata []string + sbar.Find("span").Each(func(i int, span *goquery.Selection) { + text := strings.TrimSpace(span.Text()) + + if strings.Contains(text, "添加时间:") || + strings.Contains(text, "大小:") || + strings.Contains(text, "热度:") { + metadata = append(metadata, text) + } + }) + + if len(metadata) > 0 { + if result.Content != "" { + result.Content += "\n\n" + } + result.Content += strings.Join(metadata, " | ") + } +} + +// extractFileList 提取文件列表 +func (p *ClmaoPlugin) extractFileList(s *goquery.Selection, result *model.SearchResult) { + var files []string + + s.Find(".slist ul li").Each(func(i int, li *goquery.Selection) { + text := strings.TrimSpace(li.Text()) + if text != "" { + files = append(files, text) + } + }) + + if len(files) > 0 { + if result.Content != "" { + result.Content += "\n\n文件列表:\n" + } else { + result.Content = "文件列表:\n" + } + result.Content += strings.Join(files, "\n") + } +} + +// mapCategory 映射分类 +func (p *ClmaoPlugin) mapCategory(category string) string { + switch category { + case "[影视]": + return "video" + case "[音乐]": + return "music" + case "[图像]": + return "image" + case "[文档书籍]": + return "document" + case "[压缩文件]": + return "archive" + case "[安装包]": + return "software" + case "[其他]": + return "others" + default: + return "others" + } +} + +// cleanTitle 清理标题 +func (p *ClmaoPlugin) cleanTitle(title string) string { + // 移除【】之间的广告内容 + title = regexp.MustCompile(`【[^】]*】`).ReplaceAllString(title, "") + // 移除[]之间的内容(如有需要) + title = regexp.MustCompile(`\[[^\]]*\]`).ReplaceAllString(title, "") + // 移除多余的空格 + title = regexp.MustCompile(`\s+`).ReplaceAllString(title, " ") + return strings.TrimSpace(title) +} + +// setRequestHeaders 设置请求头 +func (p *ClmaoPlugin) setRequestHeaders(req *http.Request) { + // 使用第一个稳定的UA + ua := userAgents[0] + req.Header.Set("User-Agent", ua) + 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("Accept-Encoding", "gzip, deflate") + req.Header.Set("Connection", "keep-alive") + req.Header.Set("Upgrade-Insecure-Requests", "1") + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Pragma", "no-cache") +} + +// doRequestWithRetry 带重试的HTTP请求 +func (p *ClmaoPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) { + var lastErr error + + for i := 0; i < MaxRetries; i++ { + resp, err := client.Do(req) + if err == nil { + return resp, nil + } + + lastErr = err + if i < MaxRetries-1 { + time.Sleep(time.Duration(i+1) * time.Second) + } + } + + return nil, fmt.Errorf("请求失败,已重试%d次: %w", MaxRetries, lastErr) +} + + + +// init 注册插件 +func init() { + plugin.RegisterGlobalPlugin(NewClmaoPlugin()) +} \ No newline at end of file diff --git a/plugin/clmao/html结构分析.md b/plugin/clmao/html结构分析.md new file mode 100644 index 0000000..ce8820c --- /dev/null +++ b/plugin/clmao/html结构分析.md @@ -0,0 +1,155 @@ +# Clmao (磁力猫) HTML结构分析 + +## 网站信息 + +- **网站名称**: 磁力猫 - 磁力搜索引擎 +- **基础URL**: https://www.8800492.xyz/ +- **功能**: BT种子磁力链接搜索 +- **搜索URL格式**: `/search-{keyword}-{category}-{sort}-{page}.html` + +## 搜索页面结构 + +### 1. 搜索URL参数说明 + +``` +https://www.8800492.xyz/search-%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0-0-0-1.html + ^关键词(URL编码) ^分类 ^排序 ^页码 +``` + +**参数说明**: +- `keyword`: URL编码的搜索关键词 +- `category`: 分类筛选 (0=全部, 1=影视, 2=音乐, 3=图像, 4=文档书籍, 5=压缩文件, 6=安装包, 7=其他) +- `sort`: 排序方式 (0=相关程度, 1=文件大小, 2=添加时间, 3=热度, 4=最近访问) +- `page`: 页码 (从1开始) + +### 2. 搜索结果容器 + +```html +
+
+ +
+ +
+``` + +### 3. 单个搜索结果结构 + +#### 标题区域 +```html +
+

+ [影视] + + 19凡人修仙传20凡人修仙传21天龙八部... + +

+
+``` + +#### 文件列表区域 +```html +
+ +
+``` + +#### 信息栏区域 +```html +
+ [磁力链接] + 添加时间:2022-06-28 + 大小:145.5 MB + 最近下载:2025-08-19 + 热度:2348 +
+``` + +### 4. 分页区域 + +```html +
+ 共61页 + 上一页 + 1 + 2 + + 下一页 +
+``` + +## 数据提取要点 + +### 需要提取的信息 + +1. **搜索结果基本信息**: + - 标题: `.title h3 a` 的文本内容 + - 分类: `.title h3 span` 的文本内容 + - 详情页链接: `.title h3 a` 的 `href` 属性 + +2. **磁力链接信息**: + - 磁力链接: `.sbar a[href^="magnet:"]` 的 `href` 属性 + - 文件大小: `.sbar .cpill` 的文本内容 + - 添加时间: `.sbar` 中 "添加时间:" 后的 `` 标签内容 + - 热度: `.sbar` 中 "热度:" 后的 `` 标签内容 + +3. **文件列表**: + - 文件名和大小: `.slist ul li` 的文本内容 + +### CSS选择器 + +```css +/* 搜索结果容器 */ +.tbox .ssbox + +/* 标题和分类 */ +.title h3 span /* 分类 */ +.title h3 a /* 标题和详情链接 */ + +/* 磁力链接 */ +.sbar a[href^="magnet:"] + +/* 文件信息 */ +.slist ul li + +/* 元数据 */ +.sbar span b /* 时间、大小、热度等 */ +``` + +## 特殊处理 + +### 1. 关键词高亮 +搜索关键词在结果中用 `` 标签高亮显示 + +### 2. 文件大小格式 +文件大小格式多样: `145.5 MB`、`854.2 MB`、`41.5 GB` 等 + +### 3. 磁力链接格式 +标准磁力链接格式: `magnet:?xt=urn:btih:{40位哈希值}` + +### 4. 分类映射 +- [影视] → movie/video +- [音乐] → music +- [图像] → image +- [文档书籍] → document +- [压缩文件] → archive +- [安装包] → software +- [其他] → others + +## 请求头要求 + +建议设置常见的浏览器请求头: +- User-Agent: 现代浏览器UA +- Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 +- Accept-Language: zh-CN,zh;q=0.9,en;q=0.8 + +## 注意事项 + +1. 网站可能有反爬虫机制,需要适当的请求间隔 +2. 搜索关键词需要进行URL编码 +3. 磁力链接是直接可用的,无需额外处理 +4. 部分结果可能包含大量无关文件,需要进行过滤 +5. 网站域名可能会变更,需要支持域名更新 \ No newline at end of file diff --git a/plugin/panyq/panyq.go b/plugin/panyq/panyq.go index edc40e8..74438f8 100644 --- a/plugin/panyq/panyq.go +++ b/plugin/panyq/panyq.go @@ -312,7 +312,7 @@ func (p *PanyqPlugin) doSearch(client *http.Client, keyword string, ext map[stri // 步骤4: 获取最终链接 finalLink, err := p.getFinalLink(actionIDs[ActionIDKeys[2]], item.EID, client) if err != nil { - fmt.Println("panyq: get final link failed for", item.EID, ":", err) + // fmt.Println("panyq: get final link failed for", item.EID, ":", err) return } diff --git a/plugin/pianku/html结构分析.md b/plugin/pianku/html结构分析.md new file mode 100644 index 0000000..41e6258 --- /dev/null +++ b/plugin/pianku/html结构分析.md @@ -0,0 +1,469 @@ +# 片库网 (btnull.pro) 网站搜索结果HTML结构分析 + +## 网站信息 + +- **网站名称**: 片库网 BTNULL +- **网站域名**: btnull.pro +- **搜索URL格式**: `https://btnull.pro/search/-------------.html?wd={关键词}` +- **详情页URL格式**: `https://btnull.pro/movie/{ID}.html` +- **播放页URL格式**: `https://btnull.pro/play/{ID}-{源ID}-{集ID}.html` +- **主要特点**: 提供电影、剧集、动漫等多类型影视资源,支持在线播放 + +## 搜索结果页面结构 + +搜索结果页面的主要内容位于`.sr_lists`元素内,每个搜索结果项包含在`dl`元素中。 + +```html +
+
+
+
+ +
+
+ +
+``` + +### 单个搜索结果结构 + +每个搜索结果包含以下主要元素: + +#### 1. 封面图片和详情页链接 + +封面图片和链接位于`dt`元素中: + +```html +
+ + + +
+``` + +- 详情页链接:`dt > a`的`href`属性,格式为`/movie/{ID}.html` +- 封面图片:`dt > a > img`的`src`属性 +- ID提取:从链接URL中提取数字ID(如63114) + +#### 2. 详细信息 + +详细信息位于`dd`元素中,包含多个`p`元素: + +```html +
+

名称:凡人修仙传(2025) [剧集][30集全]

+

又名:The Immortal Ascension

+

地区:大陆  类型:奇幻,古装

+

主演:杨洋,金晨,汪铎,赵小棠,赵晴,...

+

简介:《凡人修仙传》讲述的是:该剧改编自忘语的同名小说,...

+
+``` + +##### 字段解析 + +| 字段类型 | 选择器 | 说明 | 示例 | +|---------|--------|------|------| +| **标题** | `dd > p:first-child strong a` | 影片名称和详情页链接 | `凡人修仙传(2025)` | +| **状态标签** | `dd > p:first-child span.ss1` | 影片状态和类型 | `[剧集][30集全]` | +| **又名** | `dd > p.p0:contains('又名:')` | 影片别名(可能不存在) | `The Immortal Ascension` | +| **地区类型** | `dd > p:contains('地区:')` | 地区和类型信息 | `地区:大陆  类型:奇幻,古装` | +| **主演** | `dd > p.p0:contains('主演:')` | 主要演员列表 | `主演:杨洋,金晨,汪铎,...` | +| **简介** | `dd > p:last-child` | 影片简介描述 | `《凡人修仙传》讲述的是:...` | + +##### 数据处理说明 + +1. **标题提取**: 从`strong > a`的文本内容中提取,通常包含年份 +2. **状态解析**: 从`span.ss1`中提取类型(剧集/电影/动漫)和状态信息 +3. **地区类型分离**: 需要解析"地区:xxx  类型:xxx"格式的文本 +4. **主演处理**: 从以"主演:"开头的段落中提取,多个演员用逗号分隔 +5. **简介清理**: 提取纯文本内容,去除HTML标签 + +## 详情页面结构 + +详情页面包含更完整的影片信息、播放源链接和下载资源。 + +### 1. 基本信息 + +详情页的基本信息位于`.main-ui-meta`元素中: + +```html +
+

凡人修仙传(2025)

+
当前为 30集全 资源,最后更新于 23小时前
+
导演:杨阳
+
主演:杨洋...
+
类型:奇幻...
+
地区:大陆
+
语言:国语
+
上映:2025-07-27(中国大陆)
+
时长:45分钟
+
又名:The Immortal Ascension
+
+``` + +### 2. 播放源信息 + +播放源信息位于`.sBox`元素中: + +```html +
+

在线播放 +
+
    +
  • 量子源
  • +
  • 如意源
  • +
+
+

+
+ + +
+
+``` + +#### 播放链接解析 + +- **播放源切换**: `.py-tabs li`元素,通过`class="on"`识别当前选中源 +- **播放链接**: `.player li a`的`href`属性 +- **链接格式**: `/play/{ID}-{源ID}-{集ID}.html` +- **集数标题**: `a`元素的文本内容 + +### 3. 磁力&网盘下载部分 ⭐ 重要 + +这是详情页最有价值的部分,位于`#donLink`元素中: + +```html +
+

磁力&网盘

+ +
+``` + +#### 下载链接分类 + +| 标签页类型 | 说明 | 内容 | +|-----------|------|------| +| **中字1080P** | 磁力链接 | 1080P分辨率的磁力资源 | +| **中字4K** | 磁力链接 | 4K分辨率的磁力资源 | +| **百度网盘** | 网盘链接 | 百度网盘分享链接 | +| **迅雷网盘** | 网盘链接 | 迅雷网盘分享链接 | +| **夸克网盘** | 网盘链接 | 夸克网盘分享链接 | +| **阿里网盘** | 网盘链接 | 阿里云盘分享链接 | +| **天翼网盘** | 网盘链接 | 天翼云盘分享链接 | +| **115网盘** | 网盘链接 | 115网盘分享链接 | +| **UC网盘** | 网盘链接 | UC网盘分享链接 | + +#### 单个下载链接结构 + +每个下载项都采用统一的HTML结构: + +```html + +``` + +#### 链接类型和格式 + +##### 磁力链接格式 + +```html + + The.Immortal.Ascension.2025.EP0130.HD1080P.X264.AAC.Mandarin.CHS.XLYS[12.28G] + +``` + +##### 网盘链接格式 + +**百度网盘**: +```html + + 【国剧】凡人修仙传(2025)4K 持续更新中奇幻 古装 杨洋 金晨 4K60FPS + +``` + +**迅雷网盘**: +```html + + . ⊙o⊙【全30集.已完结】 【凡人修仙传2025】【4K高码】【国语中字】【类型:奇幻 古装】【主演:杨洋 金晨 汪铎】 + +``` + +**夸克网盘**: +```html + + ⊙o⊙【全30集已完结】【凡人修仙传2025】【4K高码率】【国语中字】【类型:奇幻 古装】【主演:杨洋金晨汪铎.】【纯净分享】 + +``` + +#### 下载链接提取策略 + +```go +// 提取所有下载链接 +func extractDownloadLinks(doc *goquery.Document) map[string][]DownloadLink { + links := make(map[string][]DownloadLink) + + // 遍历每个标签页 + doc.Find("#donLink .nav-tabs .title").Each(func(i int, title *goquery.Selection) { + tabName := strings.TrimSpace(title.Text()) + + // 找到对应的内容区域 + contentArea := doc.Find("#donLink .tab-content").Eq(i) + + var tabLinks []DownloadLink + contentArea.Find(".down-list2").Each(func(j int, item *goquery.Selection) { + link, exists := item.Find(".down-list3 a").Attr("href") + if !exists { + return + } + + title := item.Find(".down-list3 a").Text() + fullTitle, _ := item.Find(".down-list3 a").Attr("title") + + linkType := determineLinkType(link) + password := extractPassword(link, title) + + downloadLink := DownloadLink{ + Type: linkType, + URL: link, + Title: strings.TrimSpace(title), + FullTitle: fullTitle, + Password: password, + } + + tabLinks = append(tabLinks, downloadLink) + }) + + if len(tabLinks) > 0 { + links[tabName] = tabLinks + } + }) + + return links +} + +// 判断链接类型 +func determineLinkType(url string) string { + switch { + case strings.Contains(url, "magnet:"): + return "magnet" + case strings.Contains(url, "pan.baidu.com"): + return "baidu" + case strings.Contains(url, "pan.xunlei.com"): + return "xunlei" + case strings.Contains(url, "pan.quark.cn"): + return "quark" + case strings.Contains(url, "aliyundrive.com"), strings.Contains(url, "alipan.com"): + return "aliyun" + case strings.Contains(url, "cloud.189.cn"): + return "tianyi" + case strings.Contains(url, "115.com"): + return "115" + case strings.Contains(url, "drive.uc.cn"): + return "uc" + default: + return "others" + } +} + +// 提取密码 +func extractPassword(url, title string) string { + // 从URL中提取 + if match := regexp.MustCompile(`[?&]pwd=([^&]+)`).FindStringSubmatch(url); len(match) > 1 { + return match[1] + } + + // 从标题中提取 + patterns := []string{ + `提取码[::]\s*([0-9a-zA-Z]+)`, + `密码[::]\s*([0-9a-zA-Z]+)`, + `pwd[::]\s*([0-9a-zA-Z]+)`, + } + + for _, pattern := range patterns { + if match := regexp.MustCompile(pattern).FindStringSubmatch(title); len(match) > 1 { + return match[1] + } + } + + return "" +} +``` + +## 分页结构 + +由于提供的HTML示例中没有明显的分页结构,可能需要进一步分析或该网站采用Ajax加载更多结果的方式。 + +## 请求头要求 + +根据搜索请求信息,建议设置以下请求头: + +```http +GET /search/-------------.html?wd={关键词} HTTP/1.1 +Host: btnull.pro +Referer: https://btnull.pro/ +User-Agent: 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 +Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 +Accept-Language: zh-CN,zh;q=0.9,en;q=0.8 +Connection: keep-alive +``` + +## 数据提取策略 + +### 1. 搜索结果提取 + +```go +// 伪代码示例 +func extractSearchResults(doc *goquery.Document) []SearchResult { + var results []SearchResult + + doc.Find(".sr_lists dl").Each(func(i int, s *goquery.Selection) { + // 提取链接和ID + link, _ := s.Find("dt a").Attr("href") + id := extractIDFromURL(link) // 从 /movie/63114.html 提取 63114 + + // 提取封面图片 + image, _ := s.Find("dt a img").Attr("src") + + // 提取标题 + title := s.Find("dd p:first-child strong a").Text() + + // 提取状态标签 + status := s.Find("dd p:first-child span.ss1").Text() + + // 提取其他信息 + var actors, description, region, types string + s.Find("dd p").Each(func(j int, p *goquery.Selection) { + text := p.Text() + if strings.Contains(text, "主演:") { + actors = strings.TrimPrefix(text, "主演:") + } else if strings.Contains(text, "地区:") { + // 解析地区和类型 + parseRegionAndTypes(text, ®ion, &types) + } else if j == s.Find("dd p").Length()-1 { + // 最后一个p元素通常是简介 + description = strings.TrimPrefix(text, "简介:") + } + }) + + result := SearchResult{ + ID: id, + Title: title, + Status: status, + Image: image, + Link: link, + Actors: actors, + Description: description, + Region: region, + Types: types, + } + results = append(results, result) + }) + + return results +} +``` + +### 2. 详情页信息提取 + +详情页可以提取更完整的信息,包括: +- 导演信息 +- 完整的演员列表 +- 上映时间 +- 影片时长 +- 播放源和集数列表 + +### 3. 播放源提取 + +```go +func extractPlaySources(doc *goquery.Document) []PlaySource { + var sources []PlaySource + + // 提取播放源名称 + sourceNames := []string{} + doc.Find(".py-tabs li").Each(func(i int, s *goquery.Selection) { + sourceNames = append(sourceNames, s.Text()) + }) + + // 提取每个播放源的集数链接 + doc.Find(".player").Each(func(i int, player *goquery.Selection) { + source := PlaySource{ + Name: sourceNames[i], + Episodes: []Episode{}, + } + + player.Find("li a").Each(func(j int, a *goquery.Selection) { + href, _ := a.Attr("href") + title := a.Text() + + episode := Episode{ + Title: title, + URL: href, + } + source.Episodes = append(source.Episodes, episode) + }) + + sources = append(sources, source) + }) + + return sources +} +``` + +## 注意事项 + +1. **图片防盗链**: 图片标签包含`referrerpolicy="no-referrer"`属性,需要注意请求头设置 +2. **URL编码**: 搜索关键词需要进行URL编码 +3. **容错处理**: 某些字段(如又名、主演)可能不存在,需要进行空值检查 +4. **ID提取**: 需要从URL路径中正确提取数字ID +5. **文本清理**: 需要去除多余的空格、换行符等字符 +6. **播放源**: 不同播放源可能有不同的集数,需要分别处理 + +## 总结 + +片库网采用较为标准的HTML结构,搜索结果以列表形式展示,每个结果包含基本的影片信息。详情页提供更完整的信息和播放源。在实现插件时需要注意处理各种边界情况和数据清理工作。 \ No newline at end of file diff --git a/plugin/pianku/pianku.go b/plugin/pianku/pianku.go new file mode 100644 index 0000000..53f7163 --- /dev/null +++ b/plugin/pianku/pianku.go @@ -0,0 +1,522 @@ +package pianku + +import ( + "context" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "pansou/model" + "pansou/plugin" + + "github.com/PuerkitoBio/goquery" +) + +// 在init函数中注册插件 +func init() { + plugin.RegisterGlobalPlugin(NewPiankuPlugin()) +} + +const ( + // 基础URL + BaseURL = "https://btnull.pro" + SearchPath = "/search/-------------.html" + + // 默认参数 + MaxRetries = 3 + TimeoutSeconds = 30 +) + +// 预编译的正则表达式 +var ( + // 提取电影ID的正则表达式 + movieIDRegex = regexp.MustCompile(`/movie/(\d+)\.html`) + + // 年份提取正则 + yearRegex = regexp.MustCompile(`\((\d{4})\)`) + + // 地区和类型分离正则 + regionTypeRegex = regexp.MustCompile(`地区:([^ ]*?) +类型:(.*)`) + + // 磁力链接正则 + magnetLinkRegex = regexp.MustCompile(`magnet:\?xt=urn:btih:[0-9a-fA-F]{40}[^"'\s]*`) + + // ED2K链接正则 + ed2kLinkRegex = regexp.MustCompile(`ed2k://\|file\|[^|]+\|[^|]+\|[^|]+\|/?`) + + // 网盘链接正则表达式 + panLinkRegexes = map[string]*regexp.Regexp{ + "baidu": regexp.MustCompile(`https?://pan\.baidu\.com/s/[0-9a-zA-Z_-]+(?:\?pwd=[0-9a-zA-Z]+)?(?:&v=\d+)?`), + "aliyun": regexp.MustCompile(`https?://(?:www\.)?alipan\.com/s/[0-9a-zA-Z_-]+`), + "tianyi": regexp.MustCompile(`https?://cloud\.189\.cn/t/[0-9a-zA-Z_-]+(?:\([^)]*\))?`), + "uc": regexp.MustCompile(`https?://drive\.uc\.cn/s/[0-9a-fA-F]+(?:\?[^"\s]*)?`), + "mobile": regexp.MustCompile(`https?://caiyun\.139\.com/[^"\s]+`), + "115": regexp.MustCompile(`https?://(?:115\.com|115cdn\.com)/s/[0-9a-zA-Z_-]+(?:\?[^"\s]*)?`), + "pikpak": regexp.MustCompile(`https?://mypikpak\.com/s/[0-9a-zA-Z_-]+`), + "xunlei": regexp.MustCompile(`https?://pan\.xunlei\.com/s/[0-9a-zA-Z_-]+(?:\?pwd=[0-9a-zA-Z]+)?`), + "123": regexp.MustCompile(`https?://(?:www\.)?(?:123pan\.com|123684\.com)/s/[0-9a-zA-Z_-]+(?:\?[^"\s]*)?`), + "quark": regexp.MustCompile(`https?://pan\.quark\.cn/s/[0-9a-fA-F]+(?:\?pwd=[0-9a-zA-Z]+)?`), + } + + // 密码提取正则表达式 + passwordRegexes = []*regexp.Regexp{ + regexp.MustCompile(`[?&]pwd=([0-9a-zA-Z]+)`), // URL中的pwd参数 + regexp.MustCompile(`[?&]password=([0-9a-zA-Z]+)`), // URL中的password参数 + regexp.MustCompile(`提取码[::]\s*([0-9a-zA-Z]+)`), // 提取码:xxxx + regexp.MustCompile(`访问码[::]\s*([0-9a-zA-Z]+)`), // 访问码:xxxx + regexp.MustCompile(`密码[::]\s*([0-9a-zA-Z]+)`), // 密码:xxxx + regexp.MustCompile(`验证码[::]\s*([0-9a-zA-Z]+)`), // 验证码:xxxx + regexp.MustCompile(`口令[::]\s*([0-9a-zA-Z]+)`), // 口令:xxxx + regexp.MustCompile(`(访问码[::]\s*([0-9a-zA-Z]+))`), // (访问码:xxxx) + } +) + +// 常用UA列表 +var userAgents = []string{ + "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", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", +} + +// PiankuPlugin 片库网搜索插件 +type PiankuPlugin struct { + *plugin.BaseAsyncPlugin +} + +// NewPiankuPlugin 创建新的片库网插件 +func NewPiankuPlugin() *PiankuPlugin { + return &PiankuPlugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("pianku", 3), // 优先级3,标准质量数据源 + } +} + +// Search 执行搜索并返回结果(兼容性方法) +func (p *PiankuPlugin) 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 *PiankuPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { + return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) +} + +// searchImpl 实际的搜索实现 +func (p *PiankuPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { + // 处理扩展参数 + searchKeyword := keyword + if ext != nil { + if titleEn, exists := ext["title_en"]; exists { + if titleEnStr, ok := titleEn.(string); ok && titleEnStr != "" { + searchKeyword = titleEnStr + } + } + } + + // 构建请求URL + searchURL := fmt.Sprintf("%s%s?wd=%s", BaseURL, SearchPath, url.QueryEscape(searchKeyword)) + + // 创建带超时的上下文 + ctx, cancel := context.WithTimeout(context.Background(), TimeoutSeconds*time.Second) + defer cancel() + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err) + } + + // 设置请求头 + p.setRequestHeaders(req) + + // 发送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 != 200 { + return nil, fmt.Errorf("[%s] 请求返回状态码: %d", p.Name(), resp.StatusCode) + } + + // 解析HTML + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return nil, fmt.Errorf("[%s] HTML解析失败: %w", p.Name(), err) + } + + // 提取搜索结果基本信息 + searchResults := p.extractSearchResults(doc) + + // 为每个搜索结果获取详情页的下载链接 + var finalResults []model.SearchResult + for _, result := range searchResults { + // 获取详情页链接 + if len(result.Links) == 0 { + continue + } + detailURL := result.Links[0].URL + + // 请求详情页并解析下载链接 + downloadLinks, err := p.fetchDetailPageLinks(client, detailURL) + if err != nil { + // 如果获取详情页失败,仍然保留原始结果 + finalResults = append(finalResults, result) + continue + } + + // 更新结果的链接为真正的下载链接 + if len(downloadLinks) > 0 { + result.Links = downloadLinks + finalResults = append(finalResults, result) + } + } + + // 关键词过滤 + return plugin.FilterResultsByKeyword(finalResults, searchKeyword), nil +} + +// setRequestHeaders 设置请求头 +func (p *PiankuPlugin) setRequestHeaders(req *http.Request) { + req.Header.Set("User-Agent", userAgents[0]) + 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+"/") +} + +// doRequestWithRetry 带重试机制的HTTP请求 +func (p *PiankuPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) { + var lastErr error + + for i := 0; i < MaxRetries; i++ { + if i > 0 { + // 指数退避重试 + backoff := time.Duration(1< 0 { + results = append(results, result) + } + }) + + return results +} + +// extractSingleResult 提取单个搜索结果 +func (p *PiankuPlugin) extractSingleResult(s *goquery.Selection) model.SearchResult { + // 提取链接和ID + link, exists := s.Find("dt a").Attr("href") + if !exists { + return model.SearchResult{} // 返回空结果 + } + + // 提取电影ID + movieID := p.extractMovieID(link) + if movieID == "" { + return model.SearchResult{} + } + + // 提取封面图片(暂时不使用,但保留用于未来扩展) + _, _ = s.Find("dt a img").Attr("src") + + // 提取标题 + title := strings.TrimSpace(s.Find("dd p:first-child strong a").Text()) + if title == "" { + return model.SearchResult{} + } + + // 提取状态标签 + status := strings.TrimSpace(s.Find("dd p:first-child span.ss1").Text()) + + // 解析详细信息 + var actors, description, region, types, altName string + + s.Find("dd p").Each(func(j int, p *goquery.Selection) { + text := strings.TrimSpace(p.Text()) + + if strings.HasPrefix(text, "又名:") { + altName = strings.TrimPrefix(text, "又名:") + } else if strings.Contains(text, "地区:") && strings.Contains(text, "类型:") { + // 解析地区和类型 + region, types = parseRegionAndTypes(text) + } else if strings.HasPrefix(text, "主演:") { + actors = strings.TrimPrefix(text, "主演:") + } else if strings.HasPrefix(text, "简介:") { + description = strings.TrimPrefix(text, "简介:") + } else if !strings.Contains(text, "名称:") && !strings.Contains(text, "又名:") && + !strings.Contains(text, "地区:") && !strings.Contains(text, "主演:") && text != "" { + // 可能是简介(没有"简介:"前缀的情况) + if description == "" && len(text) > 10 { + description = text + } + } + }) + + // 构建完整的详情页URL + fullLink := p.buildFullURL(link) + + // 设置标签 + tags := []string{} + if region != "" { + tags = append(tags, region) + } + if types != "" { + // 分割类型标签 + typeList := strings.Split(types, ",") + for _, t := range typeList { + t = strings.TrimSpace(t) + if t != "" { + tags = append(tags, t) + } + } + } + if status != "" { + tags = append(tags, status) + } + + // 构建内容描述 + content := description + if actors != "" && content != "" { + content = fmt.Sprintf("主演:%s\n%s", actors, content) + } else if actors != "" { + content = fmt.Sprintf("主演:%s", actors) + } + + if altName != "" { + if content != "" { + content = fmt.Sprintf("又名:%s\n%s", altName, content) + } else { + content = fmt.Sprintf("又名:%s", altName) + } + } + + // 创建链接(使用详情页作为主要链接) + links := []model.Link{ + { + Type: "others", // 详情页链接 + URL: fullLink, + }, + } + + result := model.SearchResult{ + UniqueID: fmt.Sprintf("%s-%s", p.Name(), movieID), + Title: title, + Content: content, + Datetime: time.Now(), // 无法从搜索结果获取准确时间,使用当前时间 + Tags: tags, + Links: links, + Channel: "", // 插件搜索结果必须为空字符串 + } + + return result +} + +// extractMovieID 从URL中提取电影ID +func (p *PiankuPlugin) extractMovieID(url string) string { + matches := movieIDRegex.FindStringSubmatch(url) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// parseRegionAndTypes 解析地区和类型信息 +func parseRegionAndTypes(text string) (region, types string) { + matches := regionTypeRegex.FindStringSubmatch(text) + if len(matches) > 2 { + region = strings.TrimSpace(matches[1]) + types = strings.TrimSpace(matches[2]) + } + return +} + +// buildFullURL 构建完整的URL +func (p *PiankuPlugin) buildFullURL(path string) string { + if strings.HasPrefix(path, "http") { + return path + } + return BaseURL + path +} + +// fetchDetailPageLinks 获取详情页的下载链接 +func (p *PiankuPlugin) fetchDetailPageLinks(client *http.Client, detailURL string) ([]model.Link, error) { + // 创建带超时的上下文 + ctx, cancel := context.WithTimeout(context.Background(), TimeoutSeconds*time.Second) + defer cancel() + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "GET", detailURL, nil) + if err != nil { + return nil, fmt.Errorf("创建详情页请求失败: %w", err) + } + + // 设置请求头 + p.setRequestHeaders(req) + + // 发送HTTP请求 + resp, err := p.doRequestWithRetry(req, client) + if err != nil { + return nil, fmt.Errorf("详情页请求失败: %w", err) + } + defer resp.Body.Close() + + // 检查状态码 + if resp.StatusCode != 200 { + return nil, fmt.Errorf("详情页请求返回状态码: %d", resp.StatusCode) + } + + // 解析HTML + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return nil, fmt.Errorf("详情页HTML解析失败: %w", err) + } + + // 提取下载链接 + return p.extractDownloadLinks(doc), nil +} + +// extractDownloadLinks 提取详情页中的下载链接 +func (p *PiankuPlugin) extractDownloadLinks(doc *goquery.Document) []model.Link { + var links []model.Link + seenURLs := make(map[string]bool) // 用于去重 + + // 查找下载链接区域 + doc.Find("#donLink .down-list2").Each(func(i int, s *goquery.Selection) { + linkURL, exists := s.Find(".down-list3 a").Attr("href") + if !exists || linkURL == "" { + return + } + + // 获取链接标题 + title := strings.TrimSpace(s.Find(".down-list3 a").Text()) + if title == "" { + return + } + + // 验证链接有效性 + if !p.isValidLink(linkURL) { + return + } + + // 去重检查 + if seenURLs[linkURL] { + return + } + seenURLs[linkURL] = true + + // 判断链接类型 + linkType := p.determineLinkType(linkURL) + + // 提取密码 + password := p.extractPassword(linkURL, title) + + // 创建链接对象 + link := model.Link{ + Type: linkType, + URL: linkURL, + Password: password, + } + + links = append(links, link) + }) + + return links +} + +// isValidLink 验证链接是否有效 +func (p *PiankuPlugin) isValidLink(url string) bool { + // 检查是否为磁力链接 + if magnetLinkRegex.MatchString(url) { + return true + } + + // 检查是否为ED2K链接 + if ed2kLinkRegex.MatchString(url) { + return true + } + + // 检查是否为有效的网盘链接 + for _, regex := range panLinkRegexes { + if regex.MatchString(url) { + return true + } + } + + // 如果都不匹配,则不是有效链接 + return false +} + +// determineLinkType 判断链接类型 +func (p *PiankuPlugin) determineLinkType(url string) string { + // 检查磁力链接 + if magnetLinkRegex.MatchString(url) { + return "magnet" + } + + // 检查ED2K链接 + if ed2kLinkRegex.MatchString(url) { + return "ed2k" + } + + // 检查网盘链接 + for panType, regex := range panLinkRegexes { + if regex.MatchString(url) { + return panType + } + } + + return "others" +} + +// extractPassword 提取密码 +func (p *PiankuPlugin) extractPassword(url, title string) string { + // 首先从链接URL中提取密码 + for _, regex := range passwordRegexes { + if matches := regex.FindStringSubmatch(url); len(matches) > 1 { + return matches[1] + } + } + + // 然后从标题文本中提取密码 + for _, regex := range passwordRegexes { + if matches := regex.FindStringSubmatch(title); len(matches) > 1 { + return matches[1] + } + } + + return "" +} \ No newline at end of file diff --git a/plugin/wuji/html结构分析.md b/plugin/wuji/html结构分析.md new file mode 100644 index 0000000..6c30173 --- /dev/null +++ b/plugin/wuji/html结构分析.md @@ -0,0 +1,206 @@ +# Wuji (无极磁链) HTML结构分析 + +## 网站信息 + +- **网站名称**: ØMagnet 无极磁链 +- **基础URL**: https://xcili.net/ +- **功能**: 磁力链接搜索引擎 +- **搜索URL格式**: `/search?q={keyword}` + +## 搜索页面结构 + +### 1. 搜索URL参数说明 + +``` +https://xcili.net/search?q=%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0 + ^关键词(URL编码) +``` + +**参数说明**: +- `q`: URL编码的搜索关键词 + +### 2. 搜索结果容器 + +```html + + + + + + + +
+``` + +### 3. 单个搜索结果结构 + +```html + + + + 【高清剧集网发布 www.DDHDTV.com】凡人修仙传:星海飞驰篇[第103集][国语配音+中文字幕]... +

凡人修仙传.A.Mortal's.Journey.2020.E103.2160p.WEB-DL.H264.AAC-ColorWEB.mp4

+
+ + 2.02GB + +``` + +**提取要点**: +- 详情页链接:`td a` 的 `href` 属性(如 `/!k5mO`) +- 标题:`td a` 的直接文本内容(不包括 `

`) +- 文件名:`p.sample` 的文本内容 +- 文件大小:`td.td-size` 的文本内容 + +## 详情页面结构 + +### 1. 详情页URL格式 +``` +https://xcili.net/!k5mO + ^资源ID +``` + +### 2. 详情页关键元素 + +#### 标题区域 +```html +

凡人修仙传156

+``` + +#### 磁力链接区域 +```html +
+ +
+``` + +#### 文件信息区域 +```html +
+
种子特征码 :
+
73fb26f819ac2582c56ec9089c85cad4b0d42545
+ +
文件大小 :
+
288.6 MB
+ +
发布日期 :
+
2025-08-16 14:51:15
+
+``` + +#### 文件列表区域 +```html + + + + + + + + + + + + + + + + + +
文件 ( 2 )大小
专属高速VPN介绍.txt470 B
凡人修仙传156.mp4288.6 MB
+``` + +## 数据提取要点 + +### 搜索页面提取信息 + +1. **基本信息**: + - 标题: `tr td a` 的直接文本内容(移除子元素文本) + - 详情页链接: `tr td a` 的 `href` 属性 + - 文件大小: `tr td.td-size` 的文本内容 + - 文件名预览: `tr td a p.sample` 的文本内容 + +### 详情页面提取信息 + +1. **磁力链接**: + - 磁力链接: `input#input-magnet` 的 `value` 属性 + +2. **元数据**: + - 标题: `h2.magnet-title` 的文本内容 + - 种子哈希: `dl.torrent-info` 中 "种子特征码" 对应的 `dd` 内容 + - 文件大小: `dl.torrent-info` 中 "文件大小" 对应的 `dd` 内容 + - 发布日期: `dl.torrent-info` 中 "发布日期" 对应的 `dd` 内容 + +3. **文件列表**: + - 文件列表: `table.file-list tbody tr` 中的文件名和大小 + +### CSS选择器 + +```css +/* 搜索页面 */ +table.file-list tbody tr /* 搜索结果行 */ +tr td a /* 标题链接 */ +tr td a p.sample /* 文件名预览 */ +tr td.td-size /* 文件大小 */ + +/* 详情页面 */ +h2.magnet-title /* 标题 */ +input#input-magnet /* 磁力链接 */ +dl.torrent-info /* 元数据信息 */ +table.file-list tbody tr /* 文件列表 */ +``` + +## 广告内容清理 + +### 需要清理的广告格式 + +1. **【】格式广告**: + - `【高清剧集网发布 www.DDHDTV.com】` + - `【不太灵影视 www.3BT0.com】` + - `【8i2.fit】名称:` + +2. **其他格式**: + - 数字+【xxx】格式: `48【孩子你要相信光】` + +### 清理规则 + +```javascript +// 移除【】及其内容 +title = title.replace(/【[^】]*】/g, ''); + +// 移除数字+【】格式 +title = title.replace(/^\d+【[^】]*】/, ''); + +// 移除多余空格 +title = title.replace(/\s+/g, ' ').trim(); +``` + +## 插件流程设计 + +### 1. 搜索流程 +1. 构造搜索URL: `https://xcili.net/search?q={keyword}` +2. 解析搜索结果页面,提取基本信息和详情页链接 +3. 对每个结果访问详情页获取磁力链接 +4. 合并信息并返回最终结果 + +### 2. 详情页处理 +1. 访问详情页URL +2. 提取磁力链接和详细信息 +3. 解析文件列表 + +## 请求头要求 + +建议设置常见的浏览器请求头: +- User-Agent: 现代浏览器UA +- Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 +- Accept-Language: zh-CN,zh;q=0.9,en;q=0.8 + +## 注意事项 + +1. 需要进行两次请求:搜索页面 + 详情页面 +2. 磁力链接在详情页面,不在搜索页面 +3. 标题需要清理多种格式的广告内容 +4. 文件大小格式多样:B, KB, MB, GB +5. 详情页链接格式: `/!{resourceId}` +6. 需要适当的请求间隔避免被限制 \ No newline at end of file diff --git a/plugin/wuji/wuji.go b/plugin/wuji/wuji.go new file mode 100644 index 0000000..379f429 --- /dev/null +++ b/plugin/wuji/wuji.go @@ -0,0 +1,411 @@ +package wuji + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" + + "github.com/PuerkitoBio/goquery" + "pansou/model" + "pansou/plugin" +) + +// 常量定义 +const ( + // 基础URL + BaseURL = "https://xcili.net" + + // 搜索URL格式:/search?q={keyword} + SearchURL = BaseURL + "/search?q=%s" + + // 默认参数 + MaxRetries = 3 + TimeoutSeconds = 30 + + // 并发控制参数 + MaxConcurrency = 20 // 最大并发数 +) + +// 预编译的正则表达式 +var ( + // 磁力链接正则 + magnetLinkRegex = regexp.MustCompile(`magnet:\?xt=urn:btih:[0-9a-fA-F]{40}[^"'\s]*`) + + // 磁力链接缓存,键为详情页URL,值为磁力链接 + magnetCache = sync.Map{} + cacheTTL = 1 * time.Hour // 缓存1小时 +) + +// 缓存的磁力链接响应 +type magnetCacheEntry struct { + MagnetLink string + Timestamp time.Time +} + +// 常用UA列表 +var userAgents = []string{ + "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", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0", +} + +// WujiPlugin 无极磁链搜索插件 +type WujiPlugin struct { + *plugin.BaseAsyncPlugin +} + +// NewWujiPlugin 创建新的无极磁链插件实例 +func NewWujiPlugin() *WujiPlugin { + return &WujiPlugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("wuji", 3), + } +} + +// Name 返回插件名称 +func (p *WujiPlugin) Name() string { + return "wuji" +} + +// DisplayName 返回插件显示名称 +func (p *WujiPlugin) DisplayName() string { + return "无极磁链" +} + +// Description 返回插件描述 +func (p *WujiPlugin) Description() string { + return "ØMagnet 无极磁链 - 磁力链接搜索引擎" +} + +// Search 执行搜索并返回结果(兼容性方法) +func (p *WujiPlugin) 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 *WujiPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { + return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) +} + +// searchImpl 实际的搜索实现 +func (p *WujiPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { + // URL编码关键词 + encodedKeyword := url.QueryEscape(keyword) + searchURL := fmt.Sprintf(SearchURL, encodedKeyword) + + // 创建带超时的上下文 + ctx, cancel := context.WithTimeout(context.Background(), TimeoutSeconds*time.Second) + defer cancel() + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err) + } + + // 设置请求头 + p.setRequestHeaders(req) + + // 发送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 != 200 { + return nil, fmt.Errorf("[%s] 请求返回状态码: %d", p.Name(), resp.StatusCode) + } + + // 读取响应体内容 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("[%s] 读取响应失败: %w", p.Name(), err) + } + + // 解析HTML + doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body))) + if err != nil { + return nil, fmt.Errorf("[%s] HTML解析失败: %w", p.Name(), err) + } + + // 提取搜索结果 + searchResults := p.extractSearchResults(doc) + + // 并发获取每个结果的详情页磁力链接 + finalResults := p.enrichWithMagnetLinks(searchResults, client) + + // 关键词过滤 + searchKeyword := keyword + if searchParam, ok := ext["search"]; ok { + if searchStr, ok := searchParam.(string); ok && searchStr != "" { + searchKeyword = searchStr + } + } + + return plugin.FilterResultsByKeyword(finalResults, searchKeyword), nil +} + +// extractSearchResults 提取搜索结果 +func (p *WujiPlugin) extractSearchResults(doc *goquery.Document) []model.SearchResult { + var results []model.SearchResult + + // 查找所有搜索结果 + doc.Find("table.file-list tbody tr").Each(func(i int, s *goquery.Selection) { + result := p.parseSearchResult(s) + if result.Title != "" { + results = append(results, result) + } + }) + + return results +} + +// parseSearchResult 解析单个搜索结果 +func (p *WujiPlugin) parseSearchResult(s *goquery.Selection) model.SearchResult { + result := model.SearchResult{ + Channel: p.Name(), + Datetime: time.Now(), + } + + // 提取标题和详情页链接 + titleCell := s.Find("td").First() + titleLink := titleCell.Find("a") + + // 详情页链接 + detailPath, exists := titleLink.Attr("href") + if !exists || detailPath == "" { + return result + } + + // 构造完整的详情页URL + detailURL := BaseURL + detailPath + + // 提取标题(排除 p.sample 的内容) + titleText := titleLink.Clone() + titleText.Find("p.sample").Remove() + title := strings.TrimSpace(titleText.Text()) + result.Title = p.cleanTitle(title) + + // 提取文件名预览 + sampleText := strings.TrimSpace(titleLink.Find("p.sample").Text()) + + // 提取文件大小 + sizeText := strings.TrimSpace(s.Find("td.td-size").Text()) + + // 构造内容 + var contentParts []string + if sampleText != "" { + contentParts = append(contentParts, "文件: "+sampleText) + } + if sizeText != "" { + contentParts = append(contentParts, "大小: "+sizeText) + } + result.Content = strings.Join(contentParts, "\n") + + // 暂时将详情页链接作为占位符(后续会被磁力链接替换) + result.Links = []model.Link{{ + Type: "detail", + URL: detailURL, + }} + + // 生成唯一ID + result.UniqueID = fmt.Sprintf("%s_%d", p.Name(), time.Now().UnixNano()) + + // 添加标签 + result.Tags = []string{"magnet"} + + return result +} + +// fetchMagnetLink 获取详情页的磁力链接(带缓存) +func (p *WujiPlugin) fetchMagnetLink(client *http.Client, detailURL string) (string, error) { + // 检查缓存 + if cached, ok := magnetCache.Load(detailURL); ok { + if entry, ok := cached.(magnetCacheEntry); ok { + if time.Since(entry.Timestamp) < cacheTTL { + // 缓存命中 + return entry.MagnetLink, nil + } + // 缓存过期,删除 + magnetCache.Delete(detailURL) + } + } + // 创建带超时的上下文 + ctx, cancel := context.WithTimeout(context.Background(), TimeoutSeconds*time.Second) + defer cancel() + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "GET", detailURL, nil) + if err != nil { + return "", fmt.Errorf("创建详情页请求失败: %w", err) + } + + // 设置请求头 + p.setRequestHeaders(req) + + // 发送HTTP请求 + resp, err := p.doRequestWithRetry(req, client) + if err != nil { + return "", fmt.Errorf("详情页请求失败: %w", err) + } + defer resp.Body.Close() + + // 检查状态码 + if resp.StatusCode != 200 { + return "", fmt.Errorf("详情页返回状态码: %d", resp.StatusCode) + } + + // 读取响应体内容 + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取详情页响应失败: %w", err) + } + + // 解析HTML + doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body))) + if err != nil { + return "", fmt.Errorf("详情页HTML解析失败: %w", err) + } + + // 提取磁力链接 + magnetInput := doc.Find("input#input-magnet") + if magnetInput.Length() == 0 { + return "", fmt.Errorf("未找到磁力链接输入框") + } + + magnetLink, exists := magnetInput.Attr("value") + if !exists || magnetLink == "" { + return "", fmt.Errorf("磁力链接为空") + } + + // 存入缓存 + magnetCache.Store(detailURL, magnetCacheEntry{ + MagnetLink: magnetLink, + Timestamp: time.Now(), + }) + + return magnetLink, nil +} + +// cleanTitle 清理标题中的广告内容 +func (p *WujiPlugin) cleanTitle(title string) string { + // 移除【】之间的广告内容 + title = regexp.MustCompile(`【[^】]*】`).ReplaceAllString(title, "") + // 移除数字+【】格式的广告 + title = regexp.MustCompile(`^\d+【[^】]*】`).ReplaceAllString(title, "") + // 移除[]之间的内容(如有需要) + title = regexp.MustCompile(`\[[^\]]*\]`).ReplaceAllString(title, "") + // 移除多余的空格 + title = regexp.MustCompile(`\s+`).ReplaceAllString(title, " ") + return strings.TrimSpace(title) +} + +// setRequestHeaders 设置请求头 +func (p *WujiPlugin) setRequestHeaders(req *http.Request) { + // 使用第一个稳定的UA + ua := userAgents[0] + req.Header.Set("User-Agent", ua) + 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", "no-cache") + req.Header.Set("Pragma", "no-cache") +} + +// doRequestWithRetry 带重试的HTTP请求 +func (p *WujiPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) { + var lastErr error + + for i := 0; i < MaxRetries; i++ { + resp, err := client.Do(req) + if err == nil { + return resp, nil + } + + lastErr = err + if i < MaxRetries-1 { + time.Sleep(time.Duration(i+1) * time.Second) + } + } + + return nil, fmt.Errorf("请求失败,已重试%d次: %w", MaxRetries, lastErr) +} + +// enrichWithMagnetLinks 并发获取磁力链接并丰富搜索结果 +func (p *WujiPlugin) enrichWithMagnetLinks(results []model.SearchResult, client *http.Client) []model.SearchResult { + if len(results) == 0 { + return results + } + + // 使用信号量控制并发数 + semaphore := make(chan struct{}, MaxConcurrency) + var wg sync.WaitGroup + var mutex sync.Mutex + + enrichedResults := make([]model.SearchResult, len(results)) + copy(enrichedResults, results) + + for i := range enrichedResults { + // 检查是否有详情页链接 + if len(enrichedResults[i].Links) == 0 { + continue + } + + wg.Add(1) + go func(index int) { + defer wg.Done() + + // 获取信号量 + semaphore <- struct{}{} + defer func() { <-semaphore }() + + // 获取详情页URL + detailURL := enrichedResults[index].Links[0].URL + + // 添加适当的间隔避免请求过于频繁 + time.Sleep(time.Duration(index%5) * 100 * time.Millisecond) + + // 请求详情页并解析磁力链接 + magnetLink, err := p.fetchMagnetLink(client, detailURL) + if err == nil && magnetLink != "" { + mutex.Lock() + enrichedResults[index].Links = []model.Link{{ + Type: "magnet", + URL: magnetLink, + }} + mutex.Unlock() + } else if err != nil { + fmt.Printf("[%s] 获取磁力链接失败 [%d]: %v\n", p.Name(), index, err) + } + }(i) + } + + wg.Wait() + + // 过滤掉没有有效磁力链接的结果 + var validResults []model.SearchResult + for _, result := range enrichedResults { + if len(result.Links) > 0 && result.Links[0].Type == "magnet" { + validResults = append(validResults, result) + } + } + + return validResults +} + +// init 注册插件 +func init() { + plugin.RegisterGlobalPlugin(NewWujiPlugin()) +} \ No newline at end of file