新增插件pianku,clmao,wuji

This commit is contained in:
www.xueximeng.com
2025-08-20 17:25:45 +08:00
parent e2292e3610
commit 9877d17ac8
8 changed files with 2096 additions and 2 deletions

View File

@@ -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"
)
// 全局缓存写入管理器

328
plugin/clmao/clmao.go Normal file
View File

@@ -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())
}

View File

@@ -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
<div class="tbox">
<div class="ssbox">
<!-- 单个搜索结果 -->
</div>
<!-- 更多结果... -->
</div>
```
### 3. 单个搜索结果结构
#### 标题区域
```html
<div class="title">
<h3>
<span>[影视]</span> <!-- 分类标签 -->
<a href="/hash/a6cfa78f3c36e78c7f6342ff12de9590a25db441.html" target="_blank">
19<span class="red">凡人修仙传</span>20<span class="red">凡人修仙传</span>21天龙八部...
</a>
</h3>
</div>
```
#### 文件列表区域
```html
<div class="slist">
<ul>
<li>rw.mp4&nbsp;<span class="lightColor">145.5 MB</span></li>
<!-- 更多文件... -->
</ul>
</div>
```
#### 信息栏区域
```html
<div class="sbar">
<span><a href="magnet:?xt=urn:btih:A6CFA78F3C36E78C7F6342FF12DE9590A25DB441" target="_blank">[磁力链接]</a></span>
<span>添加时间:<b>2022-06-28</b></span>
<span>大小:<b class="cpill yellow-pill">145.5 MB</b></span>
<span>最近下载:<b>2025-08-19</b></span>
<span>热度:<b>2348</b></span>
</div>
```
### 4. 分页区域
```html
<div class="pager">
<span>共61页</span>
<a href="#">上一页</a>
<span>1</span> <!-- 当前页 -->
<a href="/search-%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0-0-0-2.html">2</a>
<!-- 更多页码... -->
<a href="/search-%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0-0-0-2.html">下一页</a>
</div>
```
## 数据提取要点
### 需要提取的信息
1. **搜索结果基本信息**:
- 标题: `.title h3 a` 的文本内容
- 分类: `.title h3 span` 的文本内容
- 详情页链接: `.title h3 a``href` 属性
2. **磁力链接信息**:
- 磁力链接: `.sbar a[href^="magnet:"]``href` 属性
- 文件大小: `.sbar .cpill` 的文本内容
- 添加时间: `.sbar` 中 "添加时间:" 后的 `<b>` 标签内容
- 热度: `.sbar` 中 "热度:" 后的 `<b>` 标签内容
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. 关键词高亮
搜索关键词在结果中用 `<span class="red">` 标签高亮显示
### 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. 网站域名可能会变更,需要支持域名更新

View File

@@ -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
}

View File

@@ -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
<div class="sr_lists">
<dl>
<dt><a href="/movie/63114.html"><img src="..." referrerpolicy="no-referrer"></a></dt>
<dd>
<!-- 详细信息 -->
</dd>
</dl>
<!-- 更多搜索结果... -->
</div>
```
### 单个搜索结果结构
每个搜索结果包含以下主要元素:
#### 1. 封面图片和详情页链接
封面图片和链接位于`dt`元素中:
```html
<dt>
<a href="/movie/63114.html">
<img src="https://www.4kfox.com/upload/vod/20250727-1/a75d775236aec4128ef805c6461ef07a.jpg" referrerpolicy="no-referrer">
</a>
</dt>
```
- 详情页链接:`dt > a``href`属性,格式为`/movie/{ID}.html`
- 封面图片:`dt > a > img``src`属性
- ID提取从链接URL中提取数字ID如63114
#### 2. 详细信息
详细信息位于`dd`元素中,包含多个`p`元素:
```html
<dd>
<p>名称:<strong><a href="/movie/63114.html">凡人修仙传(2025)</a></strong><span class="ss1"> [剧集][30集全]</span></p>
<p class="p0">又名The Immortal Ascension</p>
<p>地区:大陆  类型:奇幻,古装</p>
<p class="p0">主演:杨洋,金晨,汪铎,赵小棠,赵晴,...</p>
<p>简介:《凡人修仙传》讲述的是:该剧改编自忘语的同名小说,...</p>
</dd>
```
##### 字段解析
| 字段类型 | 选择器 | 说明 | 示例 |
|---------|--------|------|------|
| **标题** | `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
<div class="main-ui-meta">
<h1>凡人修仙传<span class="year">(2025)</span></h1>
<div class="otherbox">当前为 30集全 资源,最后更新于 23小时前</div>
<div><span>导演:</span><a href="..." target="_blank">杨阳</a></div>
<div class="text-overflow"><span>主演:</span><a href="..." target="_blank">杨洋</a>...</div>
<div><span>类型:</span><a href="..." target="_blank">奇幻</a>...</div>
<div><span>地区:</span>大陆</div>
<div><span>语言:</span>国语</div>
<div><span>上映:</span>2025-07-27(中国大陆)</div>
<div><span>时长:</span>45分钟</div>
<div><span>又名:</span>The Immortal Ascension</div>
</div>
```
### 2. 播放源信息
播放源信息位于`.sBox`元素中:
```html
<div class="sBox wrap row">
<h2>在线播放
<div class="hd right">
<ul class="py-tabs">
<li class="on">量子源</li>
<li class="">如意源</li>
</ul>
</div>
</h2>
<div class="bd">
<ul class="player ckp gdt bf-w">
<li><a href="/play/63114-1-1.html">第01集</a></li>
<li><a href="/play/63114-1-2.html">第02集</a></li>
<!-- 更多集数... -->
</ul>
<ul class="player ckp gdt bf-w">
<li><a href="/play/63114-2-1.html">第01集</a></li>
<li><a href="/play/63114-2-2.html">第02集</a></li>
<!-- 其他播放源... -->
</ul>
</div>
</div>
```
#### 播放链接解析
- **播放源切换**: `.py-tabs li`元素,通过`class="on"`识别当前选中源
- **播放链接**: `.player li a``href`属性
- **链接格式**: `/play/{ID}-{源ID}-{集ID}.html`
- **集数标题**: `a`元素的文本内容
### 3. 磁力&网盘下载部分 ⭐ 重要
这是详情页最有价值的部分,位于`#donLink`元素中:
```html
<div class="wrap row">
<h2>磁力&网盘</h2>
<div class="down-link" id="donLink">
<div class="hd">
<ul class="nav-tabs tab-title">
<li class="title">中字1080P</li>
<li class="title">中字4K</li>
<li class="title">百度网盘</li>
<li class="title">迅雷网盘</li>
<li class="title">夸克网盘</li>
<li class="title">阿里网盘</li>
<li class="title">天翼网盘</li>
<li class="title">115网盘</li>
<li class="title">UC网盘</li>
</ul>
</div>
<div class="down-list tab-content">
<!-- 各个标签页的内容 -->
</div>
</div>
</div>
```
#### 下载链接分类
| 标签页类型 | 说明 | 内容 |
|-----------|------|------|
| **中字1080P** | 磁力链接 | 1080P分辨率的磁力资源 |
| **中字4K** | 磁力链接 | 4K分辨率的磁力资源 |
| **百度网盘** | 网盘链接 | 百度网盘分享链接 |
| **迅雷网盘** | 网盘链接 | 迅雷网盘分享链接 |
| **夸克网盘** | 网盘链接 | 夸克网盘分享链接 |
| **阿里网盘** | 网盘链接 | 阿里云盘分享链接 |
| **天翼网盘** | 网盘链接 | 天翼云盘分享链接 |
| **115网盘** | 网盘链接 | 115网盘分享链接 |
| **UC网盘** | 网盘链接 | UC网盘分享链接 |
#### 单个下载链接结构
每个下载项都采用统一的HTML结构
```html
<ul class="gdt content">
<li class="down-list2">
<p class="down-list3">
<a href="实际链接" title="完整标题" class="folder">
显示标题
</a>
</p>
<span>
<a href="javascript:void(0);" class="copy-btn" data-clipboard-text="实际链接">
<i class="far fa-copy"></i> 复制
</a>
</span>
</li>
</ul>
```
#### 链接类型和格式
##### 磁力链接格式
```html
<a href="magnet:?xt=urn:btih:dde51e7d23800702e9d946f103b5c54c93d538a8&dn=The.Immortal.Ascension.2025.EP01-30.HD1080P.X264.AAC.Mandarin.CHS.XLYS"
title="The.Immortal.Ascension.2025.EP0130.HD1080P.X264.AAC.Mandarin.CHS.XLYS[12.28G]"
class="folder">
The.Immortal.Ascension.2025.EP0130.HD1080P.X264.AAC.Mandarin.CHS.XLYS[12.28G]
</a>
```
##### 网盘链接格式
**百度网盘**:
```html
<a href="https://pan.baidu.com/s/1qg5KF7J-guvt8-jCORPf0w?pwd=1234&v=918"
title="【国剧】凡人修仙传20254K 持续更新中奇幻 古装 杨洋 金晨 4K60FPS"
class="folder">
【国剧】凡人修仙传20254K 持续更新中奇幻 古装 杨洋 金晨 4K60FPS
</a>
```
**迅雷网盘**:
```html
<a href="https://pan.xunlei.com/s/VOW_0D7L3HlSe9g4m5XN-c8XA1?pwd=3suf"
title=". ⊙o⊙【全30集.已完结】 【凡人修仙传2025】【4K高码】【国语中字】【类型奇幻 古装】【主演:杨洋 金晨 汪铎】"
class="folder">
. ⊙o⊙【全30集.已完结】 【凡人修仙传2025】【4K高码】【国语中字】【类型奇幻 古装】【主演:杨洋 金晨 汪铎】
</a>
```
**夸克网盘**:
```html
<a href="https://pan.quark.cn/s/914548c6f323"
title="⊙o⊙【全30集已完结】【凡人修仙传2025】【4K高码率】【国语中字】【类型奇幻 古装】【主演:杨洋金晨汪铎.】【纯净分享】"
class="folder">
⊙o⊙【全30集已完结】【凡人修仙传2025】【4K高码率】【国语中字】【类型奇幻 古装】【主演:杨洋金晨汪铎.】【纯净分享】
</a>
```
#### 下载链接提取策略
```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, &region, &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结构搜索结果以列表形式展示每个结果包含基本的影片信息。详情页提供更完整的信息和播放源。在实现插件时需要注意处理各种边界情况和数据清理工作。

522
plugin/pianku/pianku.go Normal file
View File

@@ -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<<uint(i-1)) * 200 * time.Millisecond
time.Sleep(backoff)
}
// 克隆请求避免并发问题
reqClone := req.Clone(req.Context())
resp, err := client.Do(reqClone)
if err == nil && resp.StatusCode == 200 {
return resp, nil
}
if resp != nil {
resp.Body.Close()
}
lastErr = err
}
return nil, fmt.Errorf("重试 %d 次后仍然失败: %w", MaxRetries, lastErr)
}
// extractSearchResults 提取搜索结果
func (p *PiankuPlugin) extractSearchResults(doc *goquery.Document) []model.SearchResult {
var results []model.SearchResult
// 查找搜索结果容器
doc.Find(".sr_lists dl").Each(func(i int, s *goquery.Selection) {
result := p.extractSingleResult(s)
if result.UniqueID != "" && len(result.Links) > 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 ""
}

View File

@@ -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
<table class="table table-hover file-list">
<tbody>
<tr>
<!-- 单个搜索结果 -->
</tr>
<!-- 更多结果... -->
</tbody>
</table>
```
### 3. 单个搜索结果结构
```html
<tr>
<td>
<a href="/!k5mO">
【高清剧集网发布 www.DDHDTV.com】<b>凡人修仙传</b>:星海飞驰篇[第103集][国语配音+中文字幕]...
<p class="sample"><b>凡人修仙传</b>.A.Mortal's.Journey.2020.E103.2160p.WEB-DL.H264.AAC-ColorWEB.mp4</p>
</a>
</td>
<td class="td-size">2.02GB</td>
</tr>
```
**提取要点**:
- 详情页链接:`td a``href` 属性(如 `/!k5mO`
- 标题:`td a` 的直接文本内容(不包括 `<p class="sample">`
- 文件名:`p.sample` 的文本内容
- 文件大小:`td.td-size` 的文本内容
## 详情页面结构
### 1. 详情页URL格式
```
https://xcili.net/!k5mO
^资源ID
```
### 2. 详情页关键元素
#### 标题区域
```html
<h2 class="magnet-title">凡人修仙传156</h2>
```
#### 磁力链接区域
```html
<div class="input-group magnet-box">
<input id="input-magnet" class="form-control" type="text"
value="magnet:?xt=urn:btih:73fb26f819ac2582c56ec9089c85cad4b0d42545&dn=..." />
</div>
```
#### 文件信息区域
```html
<dl class="dl-horizontal torrent-info col-sm-9">
<dt>种子特征码 :</dt>
<dd>73fb26f819ac2582c56ec9089c85cad4b0d42545</dd>
<dt>文件大小 :</dt>
<dd>288.6 MB</dd>
<dt>发布日期 :</dt>
<dd>2025-08-16 14:51:15</dd>
</dl>
```
#### 文件列表区域
```html
<table class="table table-hover file-list">
<thead>
<tr>
<th>文件 ( 2 )</th>
<th class="th-size">大小</th>
</tr>
</thead>
<tbody>
<tr>
<td>专属高速VPN介绍.txt</td>
<td class="td-size">470 B</td>
</tr>
<tr>
<td>凡人修仙传156.mp4</td>
<td class="td-size">288.6 MB</td>
</tr>
</tbody>
</table>
```
## 数据提取要点
### 搜索页面提取信息
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. 需要适当的请求间隔避免被限制

411
plugin/wuji/wuji.go Normal file
View File

@@ -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())
}