From 3d61e6e33ed1a9d207731b3c9895f75c22fda192 Mon Sep 17 00:00:00 2001 From: "www.xueximeng.com" Date: Tue, 18 Nov 2025 18:11:53 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8F=92=E4=BB=B6feikuai?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/插件开发指南.md | 384 +++++++++++++++++++++++++++- main.go | 1 + plugin/feikuai/feikuai.go | 422 +++++++++++++++++++++++++++++++ plugin/feikuai/html结构分析.md | 446 +++++++++++++++++++++++++++++++++ plugin/feikuai/json结构分析.md | 299 ++++++++++++++++++++++ 5 files changed, 1550 insertions(+), 2 deletions(-) create mode 100644 plugin/feikuai/feikuai.go create mode 100644 plugin/feikuai/html结构分析.md create mode 100644 plugin/feikuai/json结构分析.md diff --git a/docs/插件开发指南.md b/docs/插件开发指南.md index 64cd730..1d363a0 100644 --- a/docs/插件开发指南.md +++ b/docs/插件开发指南.md @@ -309,7 +309,111 @@ func (p *MyPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (* } ``` -### 3. 链接转换 +### 3. 链接转换与 work_title 字段 + +#### Link 结构定义 + +```go +type Link struct { + Type string `json:"type"` // 网盘类型 + URL string `json:"url"` // 链接地址 + Password string `json:"password"` // 提取码/密码 + Datetime time.Time `json:"datetime,omitempty"` // 链接更新时间(可选) + WorkTitle string `json:"work_title,omitempty"` // 作品标题(重要:用于区分同一消息中多个作品的链接) +} +``` + +#### work_title 字段详解 + +**字段作用**: +- 用于区分**同一条消息/结果中包含的多个不同作品**的链接 +- 特别适用于论坛帖子、TG频道消息等一次性发布多部影视资源的场景 + +**使用场景示例**: + +``` +📺 TG频道消息示例: +【今日更新】多部热门剧集 +1. 凡人修仙传 第30集 + 夸克:https://pan.quark.cn/s/abc123 +2. 唐朝诡事录 第20集 + 夸克:https://pan.quark.cn/s/def456 +3. 庆余年2 全集 + 百度:https://pan.baidu.com/s/xyz789?pwd=abcd +``` + +**不使用 work_title 的问题**: +- 所有链接的标题都是 "【今日更新】多部热门剧集" +- 用户无法区分哪个链接对应哪部剧集 + +**使用 work_title 后的效果**: +```go +links := []model.Link{ + { + Type: "quark", + URL: "https://pan.quark.cn/s/abc123", + WorkTitle: "凡人修仙传 第30集", // 独立作品标题 + }, + { + Type: "quark", + URL: "https://pan.quark.cn/s/def456", + WorkTitle: "唐朝诡事录 第20集", // 独立作品标题 + }, + { + Type: "baidu", + URL: "https://pan.baidu.com/s/xyz789?pwd=abcd", + Password: "abcd", + WorkTitle: "庆余年2 全集", // 独立作品标题 + }, +} +``` + +**PanSou系统的智能处理**: + +PanSou 会根据消息中的链接数量自动决定是否提取 work_title: + +1. **链接数量 ≤ 4**:所有链接使用相同的 work_title(即消息标题) + ```go + // 示例:一条消息只包含同一部剧的不同网盘链接 + // 消息标题:"凡人修仙传 第30集" + // 链接1(夸克)、链接2(百度) → work_title 都是 "凡人修仙传 第30集" + ``` + +2. **链接数量 > 4**:系统智能识别每个链接对应的作品标题 + ```go + // 示例:一条消息包含5个不同作品的链接 + // 系统会分析消息文本,为每个链接提取独立的 work_title + ``` + +**插件实现 work_title 的两种方式**: + +**方式1: 依赖系统自动提取**(适用于TG频道、论坛等) +```go +// 直接返回链接,系统会自动调用 extractWorkTitlesForLinks 进行处理 +links := []model.Link{ + {Type: "quark", URL: "https://pan.quark.cn/s/abc123"}, + {Type: "baidu", URL: "https://pan.baidu.com/s/xyz789"}, +} +// PanSou会根据消息文本自动为每个链接提取work_title +``` + +**方式2: 插件手动设置**(适用于API插件、磁力搜索等) +```go +// 插件直接设置 work_title(如feikuai、thepiratebay等) +links := []model.Link{ + { + Type: "magnet", + URL: magnetURL, + WorkTitle: buildWorkTitle(keyword, fileName), // 插件自己构建 + Datetime: publishedTime, + }, +} +``` + +**插件开发建议**: +- **网盘API插件**: 如果API直接返回单一作品,可以不设置 work_title(留空) +- **磁力搜索插件**: 建议设置 work_title,特别是文件名不含中文时需要拼接关键词 +- **爬虫插件**: 如果能从页面提取每个链接的独立标题,建议设置 work_title #### 支持的网盘类型 @@ -400,7 +504,283 @@ func convertAPILinks(apiLinks []APILink) []model.Link { ## 高级特性 -### 1. Service层过滤控制详解 +### 1. 插件Web路由注册(自定义HTTP接口) + +#### 概述 + +PanSou 支持插件注册自定义的 HTTP 路由,用于实现插件专属的管理页面、配置接口或其他Web功能。 + +**典型应用场景**: +- 插件配置管理界面(如 QQPD 的用户登录和频道管理) +- 插件数据查询接口 +- 插件状态监控页面 +- OAuth回调接口 + +#### 接口定义 + +```go +// PluginWithWebHandler 支持Web路由的插件接口 +// 插件可以选择实现此接口来注册自定义的HTTP路由 +type PluginWithWebHandler interface { + AsyncSearchPlugin // 继承搜索插件接口 + + // RegisterWebRoutes 注册Web路由 + // router: gin的路由组,插件可以在此注册自己的路由 + RegisterWebRoutes(router *gin.RouterGroup) +} +``` + +#### 实现步骤 + +**步骤1: 插件结构实现接口** + +```go +package myplugin + +import ( + "github.com/gin-gonic/gin" + "pansou/plugin" + "pansou/model" +) + +type MyPlugin struct { + *plugin.BaseAsyncPlugin + // ... 其他字段 +} + +// 确保插件实现了 PluginWithWebHandler 接口 +var _ plugin.PluginWithWebHandler = (*MyPlugin)(nil) +``` + +**步骤2: 实现 RegisterWebRoutes 方法** + +```go +// RegisterWebRoutes 注册Web路由 +func (p *MyPlugin) RegisterWebRoutes(router *gin.RouterGroup) { + // 创建插件专属的路由组 + myGroup := router.Group("/myplugin") + + // 注册GET路由 + myGroup.GET("/status", p.handleGetStatus) + + // 注册POST路由 + myGroup.POST("/config", p.handleUpdateConfig) + + // 支持动态路径参数 + myGroup.GET("/:id", p.handleGetByID) + myGroup.POST("/:id/action", p.handleAction) +} +``` + +**步骤3: 实现路由处理函数** + +```go +// handleGetStatus 获取插件状态 +func (p *MyPlugin) handleGetStatus(c *gin.Context) { + c.JSON(200, gin.H{ + "status": "ok", + "plugin": p.Name(), + "version": "1.0.0", + }) +} + +// handleUpdateConfig 更新插件配置 +func (p *MyPlugin) handleUpdateConfig(c *gin.Context) { + var config map[string]interface{} + + if err := c.BindJSON(&config); err != nil { + c.JSON(400, gin.H{"error": "Invalid JSON"}) + return + } + + // 处理配置更新逻辑 + // ... + + c.JSON(200, gin.H{ + "success": true, + "message": "配置已更新", + }) +} + +// handleGetByID 根据ID获取数据 +func (p *MyPlugin) handleGetByID(c *gin.Context) { + id := c.Param("id") + + // 根据ID查询数据 + // ... + + c.JSON(200, gin.H{ + "id": id, + "data": "...", + }) +} +``` + +#### 实际案例: QQPD 插件 + +QQPD 插件实现了完整的用户管理和频道配置功能: + +```go +// RegisterWebRoutes 注册Web路由 +func (p *QQPDPlugin) RegisterWebRoutes(router *gin.RouterGroup) { + qqpd := router.Group("/qqpd") + + // GET /:param - 显示管理页面(HTML) + qqpd.GET("/:param", p.handleManagePage) + + // POST /:param - 处理管理操作(JSON API) + qqpd.POST("/:param", p.handleManagePagePOST) +} + +// handleManagePage 渲染管理页面 +func (p *QQPDPlugin) handleManagePage(c *gin.Context) { + param := c.Param("param") + + // 生成用户专属的管理页面 + html := strings.ReplaceAll(HTMLTemplate, "HASH_PLACEHOLDER", param) + + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(200, html) +} + +// handleManagePagePOST 处理管理操作 +func (p *QQPDPlugin) handleManagePagePOST(c *gin.Context) { + param := c.Param("param") + + var req struct { + Action string `json:"action"` + Channels []string `json:"channels,omitempty"` + Keyword string `json:"keyword,omitempty"` + } + + if err := c.BindJSON(&req); err != nil { + respondError(c, "无效的请求格式") + return + } + + // 根据不同的 action 执行不同的操作 + switch req.Action { + case "get_status": + p.handleGetStatus(c, param) + case "set_channels": + p.handleSetChannels(c, param, req.Channels) + case "test_search": + p.handleTestSearch(c, param, req.Keyword) + case "logout": + p.handleLogout(c, param) + default: + respondError(c, "未知的操作") + } +} +``` + +#### 实际案例: Gying 插件 + +```go +// RegisterWebRoutes 注册Web路由 +func (p *GyingPlugin) RegisterWebRoutes(router *gin.RouterGroup) { + gying := router.Group("/gying") + gying.GET("/:param", p.handleManagePage) + gying.POST("/:param", p.handleManagePagePOST) +} +``` + +#### 路由访问示例 + +插件注册的路由可以通过以下方式访问: + +```bash +# QQPD 插件管理页面 +GET http://localhost:8888/qqpd/user123 + +# QQPD 插件配置接口 +POST http://localhost:8888/qqpd/user123 +Content-Type: application/json +{ + "action": "set_channels", + "channels": ["pd97631607", "kuake12345"] +} + +# 自定义插件接口 +GET http://localhost:8888/myplugin/status +POST http://localhost:8888/myplugin/config +GET http://localhost:8888/myplugin/resource123 +``` + +#### 系统集成 + +PanSou 在启动时会自动扫描并注册所有实现了 `PluginWithWebHandler` 接口的插件路由: + +```go +// api/router.go 中的自动注册逻辑 +func SetupRouter(searchService *service.SearchService) *gin.Engine { + r := gin.Default() + + // ... 其他路由配置 ... + + // 注册插件的Web路由(如果插件实现了PluginWithWebHandler接口) + allPlugins := plugin.GetRegisteredPlugins() + for _, p := range allPlugins { + if webPlugin, ok := p.(plugin.PluginWithWebHandler); ok { + webPlugin.RegisterWebRoutes(r.Group("")) + } + } + + return r +} +``` + +#### 开发建议 + +1. **路由命名规范**: 使用插件名作为路由前缀,避免与其他插件冲突 + ```go + // ✅ 推荐 + router.Group("/myplugin") + + // ❌ 避免 + router.Group("/config") // 可能与其他插件冲突 + ``` + +2. **安全考虑**: + - 对敏感操作进行身份验证 + - 验证用户输入,防止注入攻击 + - 使用哈希或加密保护敏感参数 + +3. **错误处理**: 统一错误响应格式 + ```go + func respondError(c *gin.Context, message string) { + c.JSON(400, gin.H{ + "success": false, + "message": message, + }) + } + + func respondSuccess(c *gin.Context, message string, data interface{}) { + c.JSON(200, gin.H{ + "success": true, + "message": message, + "data": data, + }) + } + ``` + +4. **HTML模板**: 可以内嵌HTML模板提供管理界面 + ```go + const HTMLTemplate = ` + + + 插件管理 + + +

{{ .PluginName }} 管理界面

+ + + ` + ``` + +5. **可选实现**: Web路由是**可选功能**,只有需要自定义HTTP接口的插件才需要实现 + +### 2. Service层过滤控制详解 #### 构造函数选择 diff --git a/main.go b/main.go index 0b5134c..df4cfb3 100644 --- a/main.go +++ b/main.go @@ -87,6 +87,7 @@ import ( _ "pansou/plugin/ash" _ "pansou/plugin/qqpd" _ "pansou/plugin/weibo" + _ "pansou/plugin/feikuai" ) // 全局缓存写入管理器 diff --git a/plugin/feikuai/feikuai.go b/plugin/feikuai/feikuai.go new file mode 100644 index 0000000..f006d66 --- /dev/null +++ b/plugin/feikuai/feikuai.go @@ -0,0 +1,422 @@ +package feikuai + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "pansou/model" + "pansou/plugin" + "pansou/util/json" +) + +const ( + // API URL格式 + SearchAPIURL = "https://feikuai.tv/t_search/bm_search.php?kw=%s" + + // 默认超时时间 + DefaultTimeout = 15 * time.Second + + // HTTP连接池配置 + MaxIdleConns = 100 + MaxIdleConnsPerHost = 30 + MaxConnsPerHost = 50 + IdleConnTimeout = 90 * time.Second +) + +// 预编译正则表达式 +var ( + // 文件扩展名正则 + fileExtRegex = regexp.MustCompile(`\.(mkv|mp4|avi|rmvb|wmv|flv|mov|ts|m2ts|iso)$`) + + // 文件大小信息正则 + fileSizeRegex = regexp.MustCompile(`\s*·\s*[\d.]+\s*[KMGT]B\s*$`) + + // 日期时间提取正则 + dateTimeRegex = regexp.MustCompile(`@[^-]+-(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})`) +) + +// FeikuaiPlugin Feikuai磁力搜索插件 +type FeikuaiPlugin struct { + *plugin.BaseAsyncPlugin + optimizedClient *http.Client +} + +// createOptimizedHTTPClient 创建优化的HTTP客户端 +func createOptimizedHTTPClient() *http.Client { + transport := &http.Transport{ + MaxIdleConns: MaxIdleConns, + MaxIdleConnsPerHost: MaxIdleConnsPerHost, + MaxConnsPerHost: MaxConnsPerHost, + IdleConnTimeout: IdleConnTimeout, + DisableKeepAlives: false, + } + + return &http.Client{ + Transport: transport, + Timeout: DefaultTimeout, + } +} + +// NewFeikuaiPlugin 创建新的Feikuai插件 +func NewFeikuaiPlugin() *FeikuaiPlugin { + return &FeikuaiPlugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("feikuai", 3, true), // 跳过Service层过滤 + optimizedClient: createOptimizedHTTPClient(), + } +} + +func init() { + plugin.RegisterGlobalPlugin(NewFeikuaiPlugin()) +} + +// Search 同步搜索接口 +func (p *FeikuaiPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { + result, err := p.SearchWithResult(keyword, ext) + if err != nil { + return nil, err + } + return result.Results, nil +} + +// SearchWithResult 带结果统计的搜索接口 +func (p *FeikuaiPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { + return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) +} + +// searchImpl 搜索实现 +func (p *FeikuaiPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { + // 使用优化的客户端 + if p.optimizedClient != nil { + client = p.optimizedClient + } + + // 构建API搜索URL + searchURL := fmt.Sprintf(SearchAPIURL, url.QueryEscape(keyword)) + + // 创建带超时的上下文 + ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancel() + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err) + } + + // 设置请求头 + req.Header.Set("User-Agent", "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", "application/json, text/plain, */*") + 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", "https://feikuai.tv/") + + // 发送请求(带重试) + 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) + } + + // 读取并解析JSON响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("[%s] 读取响应失败: %w", p.Name(), err) + } + + var apiResp FeikuaiAPIResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("[%s] JSON解析失败: %w", p.Name(), err) + } + + // 检查API响应状态 + if apiResp.Code != 0 { + return nil, fmt.Errorf("[%s] API返回错误: %s (code: %d)", p.Name(), apiResp.Msg, apiResp.Code) + } + + // 解析搜索结果 + var results []model.SearchResult + for _, item := range apiResp.Items { + // 每个item可能包含多个种子 + for _, torrent := range item.Torrents { + result := p.parseTorrent(keyword, item, torrent) + if result.Title != "" && len(result.Links) > 0 { + results = append(results, result) + } + } + } + + // 使用关键词过滤结果 + return plugin.FilterResultsByKeyword(results, keyword), nil +} + +// FeikuaiAPIResponse API响应结构 +type FeikuaiAPIResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Keyword string `json:"keyword"` + Count int `json:"count"` + Items []FeikuaiAPIItem `json:"items"` +} + +// FeikuaiAPIItem API数据项 +type FeikuaiAPIItem struct { + ContentID *string `json:"content_id"` + Title string `json:"title"` + Type string `json:"type"` + Year *int `json:"year"` + Torrents []FeikuaiTorrent `json:"torrents"` +} + +// FeikuaiTorrent 种子数据 +type FeikuaiTorrent struct { + InfoHash string `json:"info_hash"` + Magnet string `json:"magnet"` + Name string `json:"name"` + SizeBytes int64 `json:"size_bytes"` + SizeGB float64 `json:"size_gb"` + Seeders int `json:"seeders"` + Leechers int `json:"leechers"` + PublishedAt string `json:"published_at"` + PublishedAgo string `json:"published_ago"` + FilePath string `json:"file_path"` + FileExt string `json:"file_ext"` +} + +// parseTorrent 解析种子数据为SearchResult +func (p *FeikuaiPlugin) parseTorrent(keyword string, item FeikuaiAPIItem, torrent FeikuaiTorrent) model.SearchResult { + // 构建唯一ID + uniqueID := fmt.Sprintf("%s-%s", p.Name(), torrent.InfoHash) + + // 构建work_title + workTitle := p.buildWorkTitle(keyword, torrent.Name) + + // 构建描述信息 + content := p.buildContent(item, torrent) + + // 解析发布时间 + datetime := p.parsePublishedTime(torrent.PublishedAt) + + // 构建标签 + tags := p.extractTags(item.Title, torrent.Name) + + // 构建磁力链接 + links := []model.Link{ + { + Type: "magnet", + URL: torrent.Magnet, + Password: "", // 磁力链接无密码 + Datetime: datetime, + WorkTitle: workTitle, + }, + } + + return model.SearchResult{ + UniqueID: uniqueID, + Title: workTitle, // 使用处理后的work_title作为标题 + Content: content, + Links: links, + Tags: tags, + Channel: "", // 插件搜索结果Channel为空 + Datetime: datetime, + } +} + +// buildWorkTitle 构建work_title(核心功能) +func (p *FeikuaiPlugin) buildWorkTitle(keyword, fileName string) string { + // 1. 清洗文件名 + cleanedName := p.cleanFileName(fileName) + + // 2. 检查是否包含关键词 + if p.containsKeywords(keyword, cleanedName) { + return cleanedName + } + + // 3. 不包含关键词,拼接中文关键词 + return fmt.Sprintf("%s-%s", keyword, cleanedName) +} + +// cleanFileName 清洗文件名 +func (p *FeikuaiPlugin) cleanFileName(fileName string) string { + // 去除文件扩展名 + fileName = fileExtRegex.ReplaceAllString(fileName, "") + + // 去除文件大小信息 + fileName = fileSizeRegex.ReplaceAllString(fileName, "") + + // 去除日期时间部分(@来源-日期 时间) + if idx := strings.Index(fileName, "@"); idx != -1 { + fileName = fileName[:idx] + } + + return strings.TrimSpace(fileName) +} + +// containsKeywords 检查文本是否包含关键词 +func (p *FeikuaiPlugin) containsKeywords(keyword, text string) bool { + // 简化处理:分词并检查 + keywords := p.splitKeywords(keyword) + lowerText := strings.ToLower(text) + + for _, kw := range keywords { + if strings.Contains(lowerText, strings.ToLower(kw)) { + return true + } + } + + return false +} + +// splitKeywords 分词提取关键词 +func (p *FeikuaiPlugin) splitKeywords(keyword string) []string { + // 移除标点符号和空格 + keyword = strings.TrimSpace(keyword) + + // 简单按空格、中文标点分割 + separators := []string{" ", " ", ",", "。", "、", ";", ":", "!", "?", "-", "_"} + + parts := []string{keyword} + for _, sep := range separators { + var newParts []string + for _, part := range parts { + if strings.Contains(part, sep) { + newParts = append(newParts, strings.Split(part, sep)...) + } else { + newParts = append(newParts, part) + } + } + parts = newParts + } + + // 过滤空字符串和过短的词 + var result []string + for _, part := range parts { + part = strings.TrimSpace(part) + if len(part) >= 2 { // 至少2个字符 + result = append(result, part) + } + } + + return result +} + +// buildContent 构建内容描述 +func (p *FeikuaiPlugin) buildContent(item FeikuaiAPIItem, torrent FeikuaiTorrent) string { + var contentParts []string + + // 文件名 + contentParts = append(contentParts, fmt.Sprintf("文件名: %s", torrent.Name)) + + // 文件大小 + contentParts = append(contentParts, fmt.Sprintf("大小: %.2f GB", torrent.SizeGB)) + + // 做种数和下载数 + contentParts = append(contentParts, fmt.Sprintf("做种: %d", torrent.Seeders)) + contentParts = append(contentParts, fmt.Sprintf("下载: %d", torrent.Leechers)) + + // 发布时间(人类可读格式) + if torrent.PublishedAgo != "" { + contentParts = append(contentParts, fmt.Sprintf("发布: %s", torrent.PublishedAgo)) + } + + return strings.Join(contentParts, " | ") +} + +// extractTags 提取标签 +func (p *FeikuaiPlugin) extractTags(title, fileName string) []string { + var tags []string + combinedText := strings.ToUpper(title + " " + fileName) + + // 分辨率标签 + if strings.Contains(combinedText, "2160P") || strings.Contains(combinedText, "4K") { + tags = append(tags, "4K") + } else if strings.Contains(combinedText, "1080P") { + tags = append(tags, "1080P") + } else if strings.Contains(combinedText, "720P") { + tags = append(tags, "720P") + } + + // 编码格式 + if strings.Contains(combinedText, "H265") || strings.Contains(combinedText, "HEVC") { + tags = append(tags, "H265") + } else if strings.Contains(combinedText, "H264") || strings.Contains(combinedText, "AVC") { + tags = append(tags, "H264") + } + + // HDR标签 + if strings.Contains(combinedText, "HDR") { + tags = append(tags, "HDR") + } + + // 60帧 + if strings.Contains(combinedText, "60FPS") || strings.Contains(combinedText, "60HZ") { + tags = append(tags, "60fps") + } + + return tags +} + +// parsePublishedTime 解析发布时间 +func (p *FeikuaiPlugin) parsePublishedTime(timeStr string) time.Time { + if timeStr == "" { + return time.Now() + } + + // 解析ISO 8601格式: "2025-11-18 00:54:20.659664+00" + layouts := []string{ + "2006-01-02 15:04:05.999999-07", + "2006-01-02 15:04:05.999999+07", + "2006-01-02 15:04:05-07", + "2006-01-02 15:04:05+07", + "2006-01-02 15:04:05", + } + + for _, layout := range layouts { + if t, err := time.Parse(layout, timeStr); err == nil { + return t + } + } + + // 解析失败,返回当前时间 + return time.Now() +} + +// doRequestWithRetry 带重试的HTTP请求 +func (p *FeikuaiPlugin) 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< +
+
+ +
+
+ +``` + +### 单个搜索结果结构 + +每个搜索结果包含以下主要元素: + +#### 1. 分类标签 + +```html +
剧集
+``` + +- 类型:电影、剧集、综艺、动漫 + +#### 2. 封面图片和详情页链接 + +```html + +
+
30集完结
+
豆瓣:9.3分
+
+ 凡人修仙传 +
+
+
+``` + +- **详情页链接**: 从 `` 标签的 `href` 属性提取 +- **资源ID**: 从URL中提取(如 `157546`) +- **更新状态**: `.module-item-note` 包含集数信息 +- **豆瓣评分**: `.module-item-douban` 包含评分(可选) +- **封面图片**: `img` 标签的 `data-original` 属性 + +#### 3. 标题和基本信息 + +```html +
+ +
+
2025 /中国大陆 / 奇幻,古装
+
+
+
杨洋,金晨,汪铎,赵小棠,...
+
+
+``` + +- **标题**: `.module-card-item-title strong` 的文本内容 +- **年份/地区/类型**: 第一个 `.module-info-item-content` 包含,用 `/` 分隔 +- **演员信息**: 第二个 `.module-info-item-content` 包含演员列表 + +#### 4. 操作按钮 + +```html + +``` + +### 搜索结果数量 + +```html +
+ 搜索 "凡人修仙传", + 找到 26 部影片 +
+``` + +- **搜索关键词**: `.module-heading-search-result strong` (第一个) +- **结果数量**: `.mac_total` 的文本内容 + +### 分页结构 + +```html +
+
+ 首页 + 1 + 2 + 下一页 +
+
+``` + +## 详情页面结构 + +### 1. 基本信息区域 + +```html +
+
+
+
+
+ 凡人修仙传 +
+
+
+
+
+

凡人修仙传

+
+ + + +
+
+
+
+
+``` + +- **标题**: `h1` 标签的文本内容 +- **年份**: 第一个 `.module-info-tag-link a` 的 `title` 属性 +- **地区**: 第二个 `.module-info-tag-link a` 的 `title` 属性 +- **类型**: 第三个 `.module-info-tag-link` 内的所有 `a` 标签文本 + +### 2. 详细信息 + +```html +
+
+
+
+

该剧改编自忘语的同名小说...

+
+
+
+ 导演: +
+ 杨阳/ +
+
+
+ 主演: +
+ 杨洋/ + 金晨/ + ... +
+
+
+
+``` + +- **剧情简介**: `.module-info-introduction-content p` 的文本内容 +- **导演**: 查找包含 "导演:" 的 `.module-info-item-title`,然后提取 `.module-info-item-content` 中的演员链接 +- **主演**: 查找包含 "主演:" 的 `.module-info-item-title`,然后提取 `.module-info-item-content` 中的演员链接 + +### 3. 网盘下载链接区域 ⭐ 核心 + +```html +
+
+

影片下载

+
+
+
+
+ 百度网盘 + 1 +
+
+ 夸克网盘 + 1 +
+ +
+
+
+
+
+``` + +#### 网盘类型标签 + +- **网盘类型**: `.module-tab-item span` 的文本内容 +- **数量**: `.module-tab-item small` 的文本内容 +- **网盘标识**: `data-dropdown-value` 属性或 `span` 文本 + +支持的网盘/链接类型: +- 百度网盘 (`baidu`) +- 夸克网盘 (`quark`) +- 迅雷云盘 (`xunlei`) +- 阿里云盘 (`aliyun`) +- 天翼云盘 (`tianyi`) +- UC网盘 (`uc`) +- 115网盘 (`115`) +- 123云盘 (`123`) +- 移动云盘 (`mobile`) +- 磁力链接 (`magnet`) + +#### 下载链接列表 + +```html + +``` + +##### 链接数据提取 + +- **下载链接URL**: `.module-row-text` 的 `href` 属性 或 `.module-row-title-dlist p` 的文本内容 +- **网盘/链接类型**: 根据链接URL自动识别 + - 网盘链接:`baidu`, `quark`, `aliyun`, `xunlei`, `tianyi`, `uc`, `115`, `123`, `mobile` + - 磁力链接:`magnet:?xt=urn:btih:` 开头识别为 `magnet` + +- **独立标题** (⭐ 重要 - 对应API的 `work_title` 字段): + - **基础提取**: 从 `.module-row-title-dlist h4` 提取文本内容 + - **清洗处理**: + 1. 去除末尾的日期时间部分(`@来源-日期 时间`) + 2. 去除文件扩展名(如 `.mkv`, `.mp4` 等) + 3. 去除文件大小信息(如 `· 2.29GB`) + - **标题拼接规则** (关键): + - 检查清洗后的独立标题是否包含详情页主标题的关键词 + - **判断方法**: 将详情页标题分词,检查独立标题中是否包含任一关键词(忽略标点和空格) + - **需要拼接**: 如果不包含关键词,则拼接格式为 `{详情页主标题}-{独立标题}` + - **无需拼接**: 如果包含关键词,直接使用独立标题 + - **示例**: + - 网盘链接:`凡人修仙传(2025)4K 高码率 更至EP169@一键搜片-2025-11-16 18:55:25` + → 清洗后:`凡人修仙传(2025)4K 高码率 更至EP169` + → 包含关键词"凡人修仙传",无需拼接 + - 磁力链接:`Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv · 2.29GB@一键搜片-2025-11-18 17:09:52` + → 详情页标题:`唐朝诡事录之长安` + → 清洗后:`Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV` + → 不包含关键词,需要拼接 + → 最终:`唐朝诡事录之长安-Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV` + +- **日期提取** (对应API的 `datetime` 字段): + - 从独立标题中提取日期时间信息 + - 日期格式:`@来源-YYYY-MM-DD HH:mm:ss` + - 正则表达式:`@[^-]+-(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})` + - 示例:从 `@一键搜片-2025-11-16 18:55:25` 提取 `2025-11-16 18:55:25` + +## 提取逻辑 + +### 搜索结果页面提取逻辑 + +1. 定位所有的 `.module-card-item.module-item` 元素 +2. 对于每个元素: + - 从 `.module-card-item-poster` 的 `href` 属性提取详情页链接 + - 从链接中提取资源ID(如 `157546`) + - 从 `.module-card-item-title strong` 提取标题 + - 从 `.module-card-item-class` 提取分类 + - 从 `.module-item-note` 提取更新状态 + - 从 `.module-item-douban` 提取豆瓣评分(可选) + - 从第一个 `.module-info-item-content` 提取年份/地区/类型 + - 从第二个 `.module-info-item-content` 提取演员列表 + - 从 `img` 的 `data-original` 属性提取封面图片URL + +### 详情页面提取逻辑 + +1. 获取资源基本信息: + - 标题:`h1` 的文本内容 + - 年份:第一个 `.module-info-tag-link a[title]` 的 `title` 属性 + - 地区:第二个 `.module-info-tag-link a[title]` 的 `title` 属性 + - 类型:第三个 `.module-info-tag-link` 内的所有 `a` 标签文本 + - 封面图片:`.module-info-poster img` 的 `data-original` 属性 + +2. 提取详细信息: + - 剧情简介:`.module-info-introduction-content p` 的文本内容 + - 导演:查找包含 "导演:" 的 `.module-info-item`,提取其中的 `a` 标签文本 + - 主演:查找包含 "主演:" 的 `.module-info-item`,提取其中的 `a` 标签文本 + +3. 提取下载链接(⭐ 核心): + - 遍历所有 `.module-tab-item`,获取网盘类型和数量 + - 对应每个 `.tab-content`,提取其中的 `.module-row-info` + - 对每个 `.module-row-info`: + - **链接URL**: 从 `.module-row-text` 的 `href` 属性或 `.module-row-title-dlist p` 提取 + - **链接类型**: 根据链接URL自动识别(网盘类型或 `magnet`) + - **原始标题**: 从 `.module-row-title-dlist h4` 提取完整文本 + - **独立标题** (`work_title`): + 1. 清洗原始标题(去除日期、扩展名、文件大小) + 2. 检查是否包含详情页主标题关键词 + 3. 如不包含,拼接为 `{详情页主标题}-{清洗后标题}` + - **日期时间** (`datetime`): 从原始标题中提取日期,使用正则 `@[^-]+-(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})` + - **密码**: 从URL参数中提取(如 `?pwd=xxx` 或 `?password=xxx`,仅适用于部分网盘) + +## 网盘链接和磁力链接格式 + +| 类型 | URL特征 | 密码格式 | +|---------|---------|---------| +| 百度网盘 | `pan.baidu.com` | `?pwd=` 参数 | +| 夸克网盘 | `pan.quark.cn` | 无密码或单独提供 | +| 阿里云盘 | `alipan.com` 或 `aliyundrive.com` | 无密码 | +| 迅雷网盘 | `pan.xunlei.com` | `?pwd=` 参数 | +| 天翼云盘 | `cloud.189.cn` | 无密码 | +| UC网盘 | `drive.uc.cn` | 无密码 | +| 115网盘 | `115cdn.com` | `?password=` 参数 | +| 123网盘 | `123684.com`, `123685.com`, `123912.com` | 无密码 | +| 移动云盘 | `caiyun.139.com` | 无密码 | +| 磁力链接 | `magnet:?xt=urn:btih:` | 无密码 | + +## API字段映射 + +根据README的API文档,Link对象字段映射关系: + +| API字段 | HTML提取位置 | 提取方法 | 示例 | +|---------|------------|---------|------| +| `type` | 链接URL | 自动识别URL特征 | `baidu`, `quark`, `tianyi`, `magnet` 等 | +| `url` | `.module-row-title-dlist p` 或 `href` | 文本内容或属性值 | `https://pan.baidu.com/s/xxx` 或 `magnet:?xt=...` | +| `password` | 链接URL参数 | 提取 `?pwd=` 或 `?password=` | `B5B3`, `yyds` (仅部分网盘) | +| `datetime` | `.module-row-title-dlist h4` | 正则提取日期时间 | `2025-11-16 18:55:25` | +| `work_title` | `.module-row-title-dlist h4` + 详情页主标题 | 清洗+关键词检查+拼接 | 见下方详细说明 | + +**`work_title` 字段详细处理流程**: + +1. **提取原始标题**: 从 `.module-row-title-dlist h4` 获取完整文本 + - 示例1: `凡人修仙传(2025)4K 高码率 更至EP169@一键搜片-2025-11-16 18:55:25` + - 示例2: `Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv · 2.29GB@一键搜片-2025-11-18 17:09:52` + +2. **清洗标题**: + - 去除日期时间部分: 删除 `@来源-日期 时间` 格式的后缀 + - 去除文件扩展名: 删除 `.mkv`, `.mp4`, `.avi` 等 + - 去除文件大小: 删除 `· 2.29GB` 等文件大小信息 + - 清洗结果1: `凡人修仙传(2025)4K 高码率 更至EP169` + - 清洗结果2: `Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV` + +3. **关键词检查与拼接**: + - 获取详情页主标题(如 `唐朝诡事录之长安`) + - 将主标题分词,提取关键词(忽略标点符号和空格) + - 检查清洗后的独立标题是否包含任一关键词 + - **包含关键词**: 直接使用清洗后的标题 + - 示例: `凡人修仙传(2025)4K 高码率 更至EP169` (包含"凡人修仙传") + - **不包含关键词**: 拼接格式为 `{详情页主标题}-{清洗后标题}` + - 示例: `唐朝诡事录之长安-Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV` + +**其他字段说明**: +- `datetime`: 从原始 `h4` 标题中提取的时间戳,格式为 `YYYY-MM-DD HH:mm:ss` +- `password`: 部分网盘(百度、迅雷、115)的密码在URL参数中,需要单独提取;磁力链接无密码 + +## 注意事项 + +1. **图片延迟加载**: 封面图片使用了 `lazy lazyload` 类,实际图片URL在 `data-original` 属性中 + +2. **资源ID提取**: 从URL中提取ID的正则表达式:`/voddetail/(\d+)\.html` + +3. **链接类型识别**: + - 网盘链接:通过域名识别(`pan.baidu.com`, `pan.quark.cn` 等) + - 磁力链接:通过 `magnet:?xt=urn:btih:` 前缀识别 + +4. **网盘链接密码**: 某些网盘的密码包含在URL参数中(如 `?pwd=B5B3`),需要分离链接和密码;磁力链接无密码 + +5. **独立标题处理** (⭐ 核心重点): + - 每个链接都有独立的 `h4` 标题,必须单独提取 + - 需要清洗标题(去除日期、扩展名、文件大小) + - **关键词检查**: 必须检查清洗后标题是否包含详情页主标题的关键词 + - **拼接规则**: 不包含关键词时,需拼接为 `{详情页主标题}-{清洗后标题}` + - 特别注意磁力链接的标题通常是英文文件名,大概率需要拼接中文标题 + +6. **日期时间提取** (重要): + - 从 `h4` 标题末尾提取日期时间 + - 格式为 `@来源-YYYY-MM-DD HH:mm:ss` + - 正则表达式: `@[^-]+-(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})` + +7. **多链接支持**: 一个资源可能有多个网盘和磁力链接,每个链接都有独立的标题、时间和密码 + +8. **分页处理**: 搜索结果有分页,URL格式为 `/vodsearch/{关键词}----------{页码}---.html` + +9. **AJAX加载**: 网站使用AJAX动态加载搜索结果,需要注意异步请求处理 + +10. **反爬虫**: 图片设置了 `referrerpolicy="no-referrer"`,需要在请求头中处理 diff --git a/plugin/feikuai/json结构分析.md b/plugin/feikuai/json结构分析.md new file mode 100644 index 0000000..99ce9a2 --- /dev/null +++ b/plugin/feikuai/json结构分析.md @@ -0,0 +1,299 @@ +# Feikuai API 数据结构分析 + +## 基本信息 +- **数据源类型**: JSON API +- **网站名称**: 飞快TV (feikuai.tv) +- **API URL格式**: `https://feikuai.tv/t_search/bm_search.php?kw={URL编码的关键词}` +- **数据特点**: 磁力链接搜索API,提供结构化的BT/磁力资源数据 +- **特殊说明**: 专注于磁力链接,包含详细的种子信息(做种数、下载数等) + +## API响应结构 + +### 顶层结构 +```json +{ + "code": 0, // 状态码:0表示成功 + "msg": "ok", // 响应消息 + "keyword": "唐朝诡事录之长安", // 搜索关键词 + "count": 8, // 搜索结果总数 + "items": [] // 数据列表数组 +} +``` + +### `items`数组中的数据项结构 +```json +{ + "content_id": null, // 内容ID(通常为null) + "title": "【高清剧集网发布 www.BPHDTV.com】唐朝诡事录之长安[第07-08集][国语音轨+简繁英字幕].2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV", + "type": "movie", // 资源类型(通常为movie) + "year": null, // 年份(通常为null) + "torrents": [] // 磁力链接数组 +} +``` + +### `torrents`数组中的种子数据结构 +```json +{ + "info_hash": "c3a3a53c2408396d64450046361f00650cb9e53e", // 种子哈希值 + "magnet": "magnet:?xt=urn:btih:C3A3A53C2408396D64450046361F00650CB9E53E&dn=Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv&xl=2458041664", + "name": "Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv", + "size_bytes": 2458041664, // 文件大小(字节) + "size_gb": 2.29, // 文件大小(GB) + "seeders": 4, // 做种数 + "leechers": 4, // 下载数 + "published_at": "2025-11-18 00:54:20.659664+00", // 发布时间(带时区) + "published_ago": "约 8 小时前", // 发布时间(人类可读) + "file_path": "Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv", + "file_ext": "mkv" // 文件扩展名 +} +``` + +## 插件所需字段映射 + +| 源字段 | 目标字段 | 说明 | +|--------|----------|------| +| `content_id` 或基于 `info_hash` | `UniqueID` | 格式: `feikuai-{info_hash}` 或 `feikuai-{index}` | +| `title` | `Title` | 资源标题(包含发布组信息) | +| `title` + `name` + `size_gb` + `seeders` + `leechers` | `Content` | 组合描述信息 | +| 从 `title` 或 `name` 提取 | `Tags` | 标签数组(如分辨率、格式等) | +| `torrents` | `Links` | 解析为Link数组,每个种子对应一个Link | +| `""` | `Channel` | 插件搜索结果Channel为空 | +| `published_at` | `Datetime` | 磁力链接发布时间 | + +## 下载链接解析 + +### 磁力链接特点 +- **链接类型**: 全部为 `magnet` 类型 +- **无需密码**: 磁力链接不需要提取码 +- **多种子支持**: 一个资源(item)可能包含多个种子(torrents) + +### 磁力链接格式 +``` +magnet:?xt=urn:btih:{INFO_HASH}&dn={URL编码的文件名}&xl={文件大小} +``` + +**示例**: +``` +magnet:?xt=urn:btih:C3A3A53C2408396D64450046361F00650CB9E53E&dn=Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv&xl=2458041664 +``` + +### 种子信息提取 +从 `torrents` 数组中,每个种子可提取以下信息: +- **磁力链接**: `magnet` 字段 +- **文件名**: `name` 或 `file_path` 字段 +- **文件大小**: `size_gb` (GB) 或 `size_bytes` (字节) +- **做种/下载数**: `seeders` / `leechers` +- **发布时间**: `published_at` 或 `published_ago` + +## work_title 处理规则 + +根据HTML结构分析中的规则,需要对每个磁力链接的标题进行处理: + +### 处理流程 +1. **提取标题**: 从 `name` 或 `file_path` 字段获取文件名 +2. **清洗标题**: + - 去除文件扩展名(`.mkv`, `.mp4` 等) + - 去除文件大小信息(如果在文件名中) +3. **关键词检查**: + - 检查清洗后的文件名是否包含搜索关键词 + - 或检查是否包含 `title` 字段中的关键词 +4. **拼接规则**: + - **包含关键词**: 直接使用清洗后的文件名 + - **不包含关键词**: 拼接为 `{搜索关键词}-{清洗后文件名}` + +### 示例 + +**场景1: 英文文件名,不包含中文关键词** +``` +搜索关键词: "唐朝诡事录之长安" +文件名: "Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv" +清洗后: "Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV" +work_title: "唐朝诡事录之长安-Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV" +``` + +**场景2: 中文文件名,包含关键词** +``` +搜索关键词: "唐朝诡事录之长安" +文件名: "唐朝诡事录之长安.Horror.Stories.of.Tang.Dynasty.S03E05.2022.2160p.WEB-DL.H265.DDP5.1-ColorTV.mkv" +清洗后: "唐朝诡事录之长安.Horror.Stories.of.Tang.Dynasty.S03E05.2022.2160p.WEB-DL.H265.DDP5.1-ColorTV" +work_title: "唐朝诡事录之长安.Horror.Stories.of.Tang.Dynasty.S03E05.2022.2160p.WEB-DL.H265.DDP5.1-ColorTV" +(包含关键词,无需拼接) +``` + +## 插件开发指导 + +### 请求示例 +```go +searchURL := fmt.Sprintf("https://feikuai.tv/t_search/bm_search.php?kw=%s", url.QueryEscape(keyword)) +``` + +### SearchResult构建示例 +```go +// 遍历items +for _, item := range apiResponse.Items { + // 遍历每个item的torrents + for _, torrent := range item.Torrents { + // 清洗文件名 + cleanedName := cleanFileName(torrent.Name) + + // 检查是否包含关键词并拼接 + workTitle := buildWorkTitle(keyword, cleanedName) + + // 构建SearchResult + result := model.SearchResult{ + UniqueID: fmt.Sprintf("feikuai-%s", torrent.InfoHash), + Title: item.Title, // 或使用workTitle + Content: buildContent(item, torrent), + Links: []model.Link{ + { + Type: "magnet", + URL: torrent.Magnet, + Password: "", // 磁力链接无密码 + Datetime: parseTime(torrent.PublishedAt), + WorkTitle: workTitle, // ⭐ 重要:独立标题 + }, + }, + Tags: extractTags(item.Title, torrent.Name), + Channel: "", // 插件搜索结果Channel为空 + Datetime: parseTime(torrent.PublishedAt), + } + results = append(results, result) + } +} +``` + +### 关键函数示例 + +#### 1. 清洗文件名 +```go +func cleanFileName(fileName string) string { + // 去除文件扩展名 + ext := filepath.Ext(fileName) + if ext != "" { + fileName = strings.TrimSuffix(fileName, ext) + } + + // 去除文件大小信息(如果存在) + fileName = regexp.MustCompile(`\s*·\s*[\d.]+\s*[KMGT]B\s*$`).ReplaceAllString(fileName, "") + + return strings.TrimSpace(fileName) +} +``` + +#### 2. 构建work_title +```go +func buildWorkTitle(keyword, cleanedName string) string { + // 检查是否包含关键词(忽略大小写和标点) + if containsKeywords(keyword, cleanedName) { + return cleanedName + } + + // 不包含关键词,需要拼接 + return fmt.Sprintf("%s-%s", keyword, cleanedName) +} + +func containsKeywords(keyword, text string) bool { + // 简单实现:分词后检查 + keywords := splitKeywords(keyword) + for _, kw := range keywords { + if strings.Contains(strings.ToLower(text), strings.ToLower(kw)) { + return true + } + } + return false +} +``` + +#### 3. 构建内容描述 +```go +func buildContent(item FeikuaiAPIItem, torrent Torrent) string { + var contentParts []string + + contentParts = append(contentParts, fmt.Sprintf("文件名: %s", torrent.Name)) + contentParts = append(contentParts, fmt.Sprintf("大小: %.2f GB", torrent.SizeGB)) + contentParts = append(contentParts, fmt.Sprintf("做种: %d", torrent.Seeders)) + contentParts = append(contentParts, fmt.Sprintf("下载: %d", torrent.Leechers)) + contentParts = append(contentParts, fmt.Sprintf("发布: %s", torrent.PublishedAgo)) + + return strings.Join(contentParts, " | ") +} +``` + +#### 4. 提取标签 +```go +func extractTags(title, fileName string) []string { + var tags []string + + // 提取分辨率 + if strings.Contains(title, "2160p") || strings.Contains(fileName, "2160p") { + tags = append(tags, "4K") + } else if strings.Contains(title, "1080p") || strings.Contains(fileName, "1080p") { + tags = append(tags, "1080p") + } + + // 提取编码格式 + if strings.Contains(title, "H265") || strings.Contains(fileName, "H265") { + tags = append(tags, "H265") + } + + // 提取HDR + if strings.Contains(title, "HDR") || strings.Contains(fileName, "HDR") { + tags = append(tags, "HDR") + } + + return tags +} +``` + +#### 5. 时间解析 +```go +func parseTime(timeStr string) time.Time { + // 解析ISO 8601格式: "2025-11-18 00:54:20.659664+00" + t, err := time.Parse("2006-01-02 15:04:05.999999-07", timeStr) + if err != nil { + // 解析失败,返回当前时间 + return time.Now() + } + return t +} +``` + +## API字段映射表 + +| API字段 | Link对象字段 | 提取方法 | 示例 | +|---------|-------------|---------|------| +| `magnet` | `URL` | 直接使用 | `magnet:?xt=urn:btih:...` | +| - | `Type` | 固定值 | `magnet` | +| - | `Password` | 固定值 | `""` (空字符串) | +| `published_at` | `Datetime` | 时间解析 | `2025-11-18T00:54:20Z` | +| `name` | `WorkTitle` | 清洗+关键词检查+拼接 | `唐朝诡事录之长安-Strange.Tales...` | + +## 与其他插件的差异 + +| 特性 | feikuai | wanou/ouge/zhizhen | huban | 说明 | +|------|---------|-------------------|-------|------| +| **链接类型** | 仅磁力链接 | 网盘链接 | 网盘链接 | 专注BT资源 | +| **多链接** | 一对多 | 多对一 | 多对多 | 一个资源多个种子 | +| **种子信息** | 详细 | 无 | 无 | 包含做种数等 | +| **work_title** | 必需拼接 | 可选 | 可选 | 文件名通常不含中文 | +| **时间信息** | 精确 | 当前时间 | 当前时间 | API提供发布时间 | + +## 注意事项 + +1. **磁力链接专用**: 此API仅返回磁力链接,不包含网盘链接 +2. **多种子处理**: 一个资源可能有多个种子,需要全部提取 +3. **文件名处理**: 文件名通常是英文,需要拼接中文关键词 +4. **时区处理**: `published_at` 包含时区信息(+00),需要正确解析 +5. **做种数排序**: 建议按做种数(seeders)降序排序,优先显示热门资源 +6. **空值处理**: `content_id` 和 `year` 通常为 null,需要处理 +7. **标题清洗**: `title` 字段包含发布组信息(如【高清剧集网发布 www.BPHDTV.com】),可选择性去除 + +## 开发建议 + +1. **独立实现**: 不能复用网盘类插件的代码,需要专门处理磁力链接 +2. **work_title关键**: 文件名拼接中文关键词是核心功能 +3. **种子排序**: 实现按做种数排序,提升用户体验 +4. **时间解析**: 正确解析带时区的ISO 8601时间格式 +5. **内容丰富**: 充分利用API提供的文件大小、做种数等信息 +6. **错误处理**: API可能返回 `code != 0` 的错误状态 +7. **测试覆盖**: 重点测试中英文文件名的work_title拼接逻辑