新增插件jutoushe

This commit is contained in:
www.xueximeng.com
2025-09-01 16:04:19 +08:00
parent b999f3d38f
commit 7b2c9ef9bd
3 changed files with 548 additions and 0 deletions

View File

@@ -63,6 +63,7 @@ import (
_ "pansou/plugin/yuhuage"
_ "pansou/plugin/u3c3"
_ "pansou/plugin/clxiong"
_ "pansou/plugin/jutoushe"
)
// 全局缓存写入管理器

View File

@@ -0,0 +1,199 @@
# JUTOUSHE剧透社HTML结构分析
## 网站信息
- **网站名称**: 剧透社
- **域名**: https://1.star2.cn/
- **类型**: 网盘资源分享站,主要提供电视剧、电影、短剧、综艺等影视资源
- **特点**: 提供多种网盘下载链接(夸克、百度、阿里等)
## 搜索页面结构
### 1. 搜索URL模式
```
https://1.star2.cn/search/?keyword={关键词}
示例:
https://1.star2.cn/search/?keyword=%E7%91%9E%E5%85%8B%E5%92%8C%E8%8E%AB%E8%92%82
https://1.star2.cn/search/?keyword=%E4%B8%8E%E6%99%8B%E9%95%BF%E5%AE%89
参数说明:
- keyword: URL编码的搜索关键词
```
### 2. 搜索结果容器
- **父容器**: `<ul class="erx-list">`
- **结果项**: `<li class="item">` (每个搜索结果)
### 3. 单个搜索结果结构
#### 标题和链接区域 (.a)
```html
<div class="a">
<a href="/dm/8100.html" class="main">【动漫】瑞克和莫蒂8.全集</a>
<span class="tags"></span>
</div>
提取要素:
- 详情页链接: a.main 的 href 属性 (需要拼接完整域名)
- 标题: a.main 的文本内容 (包含分类标签)
```
#### 时间信息区域 (.i)
```html
<div class="i">
<span class="erx-num-font time">2025-05-26</span>
</div>
提取要素:
- 发布时间: span.time 的文本内容 (格式: YYYY-MM-DD)
```
## 详情页面结构
### 1. 详情页URL模式
```
https://1.star2.cn/{分类}/{ID}.html
示例:
https://1.star2.cn/dm/8100.html (动漫类别)
https://1.star2.cn/ju/8737.html (国剧类别)
分类说明:
- /dm/ : 动漫
- /ju/ : 国剧
- /dj/ : 短剧
- /zy/ : 综艺
- /mv/ : 电影
- /rh/ : 韩日剧
- /ym/ : 英美剧
- /wj/ : 外剧
- /qt/ : 其他
```
### 2. 详情页面关键区域
#### 标题区域
```html
<h1>【动漫】瑞克和莫蒂8.全集</h1>
提取要素:
- 标题: h1 的文本内容
```
#### 元信息区域
```html
<section class="erx-tct i">
<span class="time">2025-05-26</span>
<span class="view">823次浏览</span>
</section>
提取要素:
- 发布时间: span.time 的文本内容
- 浏览次数: span.view 的文本内容 (可选)
```
#### 下载链接区域
```html
<div class="dlipp-cont-wp">
<div class="dlipp-cont-inner">
<div class="dlipp-cont-hd">
<img src="/skin/images/tv.png" alt="影片地址">
<span>影片地址</span>
</div>
<div class="dlipp-cont-bd">
<a class="dlipp-dl-btn j-wbdlbtn-dlipp" href="https://pan.quark.cn/s/2b941bc45d86" target="_blank">
<img src="/skin/images/kk.png" alt="夸克网盘">
<span>夸克网盘</span>
</a>
<a class="dlipp-dl-btn j-wbdlbtn-dlipp" href="https://pan.baidu.com/s/1E92Hy50UxJnTTrU3qD9jqQ?pwd=8888" target="_blank">
<img src="/skin/images/bd.png" alt="百度网盘">
<span>百度网盘</span>
</a>
</div>
</div>
</div>
提取要素:
- 网盘链接: .dlipp-cont-bd a.dlipp-dl-btn 的 href 属性
- 网盘类型: 从链接URL自动识别 (quark.cn, baidu.com 等)
- 提取码: 从URL参数中提取 (如 ?pwd=8888)
```
## CSS选择器总结
| 数据项 | 页面类型 | CSS选择器 | 提取方式 |
|--------|----------|-----------|----------|
| 搜索结果列表 | 搜索页 | `ul.erx-list li.item` | 遍历所有结果项 |
| 标题 | 搜索页 | `.a a.main` | 文本内容 |
| 详情页链接 | 搜索页 | `.a a.main` | href 属性 |
| 发布时间 | 搜索页 | `.i span.time` | 文本内容 |
| 详情页标题 | 详情页 | `h1` | 文本内容 |
| 详情页时间 | 详情页 | `section.i span.time` | 文本内容 |
| 浏览次数 | 详情页 | `section.i span.view` | 文本内容 |
| 下载链接 | 详情页 | `.dlipp-cont-bd a.dlipp-dl-btn` | href 属性 |
## 实现要点
### 1. 网盘类型自动识别
根据链接URL自动识别网盘类型
```
pan.quark.cn → quark (夸克网盘)
pan.baidu.com → baidu (百度网盘)
aliyundrive.com → aliyun (阿里云盘)
alipan.com → aliyun (阿里云盘新域名)
cloud.189.cn → tianyi (天翼云盘)
pan.xunlei.com → xunlei (迅雷网盘)
115.com → 115 (115网盘)
123pan.com → 123 (123网盘)
caiyun.139.com → mobile (移动云盘)
```
### 2. 提取码处理
- 百度网盘: `?pwd=1234` 格式
- 其他网盘: 一般无需提取码或在URL中已包含
### 3. 标题清理
- 保留分类标签如 `【动漫】``【国剧】`
- 去除多余空格和特殊字符
### 4. 时间格式处理
- 原格式: `2025-05-26`
- 需转换为标准时间对象
### 5. 内容描述
- 可以从标题中提取分类信息作为描述
- 或使用固定描述如 "剧透社影视资源"
## 支持的分类
| 分类代码 | 中文名称 | 路径 | 说明 |
|----------|----------|------|------|
| dm | 动漫 | /dm/ | 动画、动漫作品 |
| ju | 国剧 | /ju/ | 国产电视剧 |
| dj | 短剧 | /dj/ | 短视频剧集 |
| zy | 综艺 | /zy/ | 综艺节目 |
| mv | 电影 | /mv/ | 电影作品 |
| rh | 韩日 | /rh/ | 韩国、日本影视剧 |
| ym | 英美 | /ym/ | 英美影视剧 |
| wj | 外剧 | /wj/ | 其他外国影视剧 |
| qt | 其他 | /qt/ | 其他类型内容 |
## 错误处理
1. **网络超时**: 设置合理的超时时间,实现重试机制
2. **解析失败**: 对于解析失败的页面,记录日志但不中断流程
3. **空结果**: 搜索无结果时返回空数组
4. **链接失效**: 验证链接格式,过滤掉明显无效的链接
## 反爬虫处理
1. **请求头设置**: 使用标准浏览器User-Agent
2. **请求频率**: 控制请求间隔避免被封IP
3. **错误重试**: 遇到403/429等状态码时适当延迟重试
## 特殊说明
1. **域名**: 网站可能使用多个域名或动态域名,需要灵活处理
2. **编码**: 确保中文关键词正确URL编码
3. **链接拼接**: 详情页链接为相对路径需要拼接完整URL
4. **缓存**: 建议缓存搜索结果,避免重复请求

348
plugin/jutoushe/jutoushe.go Normal file
View File

@@ -0,0 +1,348 @@
package jutoushe
import (
"context"
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"pansou/model"
"pansou/plugin"
)
type JutoushePlugin struct {
*plugin.BaseAsyncPlugin
}
func init() {
p := &JutoushePlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("jutoushe", 1),
}
plugin.RegisterGlobalPlugin(p)
}
// Search 执行搜索并返回结果(兼容性方法)
func (p *JutoushePlugin) 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 *JutoushePlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)
}
// searchImpl 实现搜索逻辑
func (p *JutoushePlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
// 1. 构建搜索URL
baseURL := "https://1.star2.cn"
searchURL := fmt.Sprintf("%s/search/?keyword=%s", baseURL, url.QueryEscape(keyword))
// 2. 创建带超时的上下文
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 3. 创建请求对象
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err)
}
// 4. 设置请求头,避免反爬虫检测
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
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+"/")
// 5. 发送HTTP请求带重试机制
resp, err := p.doRequestWithRetry(req, client)
if err != nil {
return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err)
}
defer resp.Body.Close()
// 6. 检查状态码
if resp.StatusCode != 200 {
return nil, fmt.Errorf("[%s] 请求返回状态码: %d", p.Name(), resp.StatusCode)
}
// 7. 解析搜索结果页面
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("[%s] HTML解析失败: %w", p.Name(), err)
}
// 8. 提取搜索结果
var results []model.SearchResult
doc.Find("ul.erx-list li.item").Each(func(i int, s *goquery.Selection) {
// 提取标题和链接
linkElem := s.Find(".a a.main")
title := strings.TrimSpace(linkElem.Text())
detailPath, exists := linkElem.Attr("href")
if !exists || title == "" {
return // 跳过无效项
}
// 构建完整的详情页URL
detailURL := baseURL + detailPath
// 提取发布时间
timeStr := strings.TrimSpace(s.Find(".i span.time").Text())
publishTime := p.parseDate(timeStr)
// 构建唯一ID
uniqueID := fmt.Sprintf("%s-%s", p.Name(), p.extractIDFromURL(detailPath))
// 创建搜索结果(先不获取下载链接)
result := model.SearchResult{
UniqueID: uniqueID,
Title: title,
Content: fmt.Sprintf("剧透社影视资源:%s", title),
Datetime: publishTime,
Tags: p.extractTags(title),
Links: []model.Link{}, // 稍后从详情页获取
Channel: "", // 插件搜索结果必须为空字符串
}
// 异步获取详情页的下载链接
if links := p.getDetailLinks(client, detailURL); len(links) > 0 {
result.Links = links
results = append(results, result)
}
})
// 9. 关键词过滤
filteredResults := plugin.FilterResultsByKeyword(results, keyword)
return filteredResults, nil
}
// doRequestWithRetry 带重试机制的HTTP请求
func (p *JutoushePlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {
maxRetries := 3
var lastErr error
for i := 0; i < maxRetries; i++ {
if i > 0 {
// 指数退避重试
backoff := time.Duration(1<<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)
}
// getDetailLinks 获取详情页的下载链接
func (p *JutoushePlugin) getDetailLinks(client *http.Client, detailURL string) []model.Link {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", detailURL, nil)
if err != nil {
return nil
}
// 设置请求头
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Referer", "https://1.star2.cn/")
resp, err := client.Do(req)
if err != nil || resp.StatusCode != 200 {
if resp != nil {
resp.Body.Close()
}
return nil
}
defer resp.Body.Close()
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil
}
var links []model.Link
// 提取下载链接
doc.Find(".dlipp-cont-bd a.dlipp-dl-btn").Each(func(i int, s *goquery.Selection) {
href, exists := s.Attr("href")
if !exists || href == "" {
return
}
// 过滤掉无效链接
if !p.isValidNetworkDriveURL(href) {
return
}
// 确定网盘类型和提取提取码
cloudType := p.determineCloudType(href)
password := p.extractPassword(href)
link := model.Link{
Type: cloudType,
URL: href,
Password: password,
}
links = append(links, link)
})
return links
}
// determineCloudType 根据URL确定网盘类型
func (p *JutoushePlugin) determineCloudType(url string) string {
switch {
case strings.Contains(url, "pan.quark.cn"):
return "quark"
case strings.Contains(url, "drive.uc.cn"):
return "uc"
case strings.Contains(url, "pan.baidu.com"):
return "baidu"
case strings.Contains(url, "aliyundrive.com") || strings.Contains(url, "alipan.com"):
return "aliyun"
case strings.Contains(url, "pan.xunlei.com"):
return "xunlei"
case strings.Contains(url, "cloud.189.cn"):
return "tianyi"
case strings.Contains(url, "115.com"):
return "115"
case strings.Contains(url, "123pan.com"):
return "123"
case strings.Contains(url, "caiyun.139.com"):
return "mobile"
case strings.Contains(url, "mypikpak.com"):
return "pikpak"
default:
return "others"
}
}
// extractPassword 从URL中提取提取码
func (p *JutoushePlugin) extractPassword(url string) string {
// 处理百度网盘的pwd参数
if strings.Contains(url, "pan.baidu.com") && strings.Contains(url, "pwd=") {
re := regexp.MustCompile(`pwd=([^&]+)`)
matches := re.FindStringSubmatch(url)
if len(matches) > 1 {
return matches[1]
}
}
// 其他网盘暂不处理提取码
return ""
}
// isValidNetworkDriveURL 验证是否为有效的网盘链接
func (p *JutoushePlugin) isValidNetworkDriveURL(url string) bool {
if url == "" {
return false
}
// 检查是否为HTTP/HTTPS链接
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return false
}
// 检查是否包含已知网盘域名
knownDomains := []string{
"pan.quark.cn", "drive.uc.cn", "pan.baidu.com",
"aliyundrive.com", "alipan.com", "pan.xunlei.com",
"cloud.189.cn", "115.com", "123pan.com",
"caiyun.139.com", "mypikpak.com",
}
for _, domain := range knownDomains {
if strings.Contains(url, domain) {
return true
}
}
return false
}
// extractIDFromURL 从URL路径中提取ID
func (p *JutoushePlugin) extractIDFromURL(urlPath string) string {
// 从 /dm/8100.html 提取 8100
re := regexp.MustCompile(`/([^/]+)/(\d+)\.html`)
matches := re.FindStringSubmatch(urlPath)
if len(matches) > 2 {
return matches[2]
}
// 如果无法提取使用完整路径作为ID
return strings.ReplaceAll(urlPath, "/", "_")
}
// extractTags 从标题中提取标签
func (p *JutoushePlugin) extractTags(title string) []string {
var tags []string
// 提取分类标签
categoryPattern := regexp.MustCompile(`【([^】]+)】`)
matches := categoryPattern.FindAllStringSubmatch(title, -1)
for _, match := range matches {
if len(match) > 1 {
tags = append(tags, match[1])
}
}
// 如果没有提取到分类,添加默认标签
if len(tags) == 0 {
tags = append(tags, "影视资源")
}
return tags
}
// parseDate 解析日期字符串
func (p *JutoushePlugin) parseDate(dateStr string) time.Time {
if dateStr == "" {
return time.Now()
}
// 尝试解析 YYYY-MM-DD 格式
if t, err := time.Parse("2006-01-02", dateStr); err == nil {
return t
}
// 尝试解析 YYYY年MM月DD日 格式
re := regexp.MustCompile(`(\d{4})年(\d{1,2})月(\d{1,2})日`)
matches := re.FindStringSubmatch(dateStr)
if len(matches) == 4 {
year, _ := strconv.Atoi(matches[1])
month, _ := strconv.Atoi(matches[2])
day, _ := strconv.Atoi(matches[3])
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.Local)
}
// 解析失败,返回当前时间
return time.Now()
}