mirror of
https://github.com/fish2018/pansou.git
synced 2025-11-25 03:14:59 +08:00
新增插件pianku,clmao,wuji
This commit is contained in:
5
main.go
5
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"
|
||||
)
|
||||
|
||||
// 全局缓存写入管理器
|
||||
|
||||
328
plugin/clmao/clmao.go
Normal file
328
plugin/clmao/clmao.go
Normal 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())
|
||||
}
|
||||
155
plugin/clmao/html结构分析.md
Normal file
155
plugin/clmao/html结构分析.md
Normal 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 <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. 网站域名可能会变更,需要支持域名更新
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
469
plugin/pianku/html结构分析.md
Normal file
469
plugin/pianku/html结构分析.md
Normal 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="【国剧】凡人修仙传(2025)4K 持续更新中奇幻 古装 杨洋 金晨 4K60FPS"
|
||||
class="folder">
|
||||
【国剧】凡人修仙传(2025)4K 持续更新中奇幻 古装 杨洋 金晨 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, ®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结构,搜索结果以列表形式展示,每个结果包含基本的影片信息。详情页提供更完整的信息和播放源。在实现插件时需要注意处理各种边界情况和数据清理工作。
|
||||
522
plugin/pianku/pianku.go
Normal file
522
plugin/pianku/pianku.go
Normal 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 ""
|
||||
}
|
||||
206
plugin/wuji/html结构分析.md
Normal file
206
plugin/wuji/html结构分析.md
Normal 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
411
plugin/wuji/wuji.go
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user