mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
5d1ae91419
- site_adapter.go: SiteConfig 扩展 UserAgent/Timeout/Extra/FlareSolverrURL 字段 - site_adapter.go: doRequest() GET 请求支持 FlareSolverr 代理绕过 Cloudflare/WAF - site_adapter.go: buildRequest() 使用 cfg.UserAgent(覆盖默认 UA) - site_adapter.go: MTeam api_key 认证改为 Authorization: Bearer 格式 - site_adapter.go: 修复 2 个 go vet 冗余 StatusFound 警告 - site.go: Search() 重构为 sync.WaitGroup 并发执行,提升多站搜索性能 - site.go: siteModelToConfig() 改为 SiteService 方法,按 BrowserEmulation 填充 FlareSolverrURL - site.go: Search() 空关键词返回 [] 而非 error,nil slice 防护 - 新增 system_handler.go: 系统工具探测接口 - 新增 ffmpeg_auto_install.go: FFmpeg 自动安装服务 - 新增 scripts/install-ffmpeg.ps1: Windows FFmpeg 安装脚本
1426 lines
42 KiB
Go
1426 lines
42 KiB
Go
// Package service — PT 站点适配器接口及 6 种适配器实现。
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/ShukeBta/MediaStationGo/internal/helper"
|
||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||
)
|
||
|
||
// SiteConfig 站点配置(从 model.Site 解密后的纯文本)。
|
||
type SiteConfig struct {
|
||
Name string
|
||
Type string
|
||
URL string
|
||
AuthType string
|
||
Cookie string
|
||
APIKey string
|
||
AuthHeader string
|
||
UserAgent string // 自定义 User-Agent
|
||
Timeout time.Duration // 请求超时
|
||
Extra map[string]string // JSON 扩展配置
|
||
FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF)
|
||
}
|
||
|
||
// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。
|
||
type SiteSearchResult struct {
|
||
SiteName string `json:"site_name"`
|
||
Items []TorrentItem `json:"items"`
|
||
Total int `json:"total"`
|
||
Page int `json:"page"`
|
||
}
|
||
|
||
// TorrentItem 种子条目。
|
||
type TorrentItem struct {
|
||
ID string `json:"id"`
|
||
Title string `json:"title"`
|
||
Subtitle string `json:"subtitle"`
|
||
Category string `json:"category"`
|
||
Size int64 `json:"size"`
|
||
Seeders int `json:"seeders"`
|
||
Leechers int `json:"leechers"`
|
||
Snatched int `json:"snatched"`
|
||
Free bool `json:"free"`
|
||
FreeEndAt *time.Time `json:"free_end_at"`
|
||
UploadTime time.Time `json:"upload_time"`
|
||
DetailURL string `json:"detail_url"`
|
||
DownloadURL string `json:"download_url"`
|
||
}
|
||
|
||
// TorrentDetail 种子详情。
|
||
type TorrentDetail struct {
|
||
ID string `json:"id"`
|
||
Title string `json:"title"`
|
||
Subtitle string `json:"subtitle"`
|
||
Category string `json:"category"`
|
||
Size int64 `json:"size"`
|
||
Seeders int `json:"seeders"`
|
||
Leechers int `json:"leechers"`
|
||
Snatched int `json:"snatched"`
|
||
Free bool `json:"free"`
|
||
FreeEndAt *time.Time `json:"free_end_at"`
|
||
UploadTime time.Time `json:"upload_time"`
|
||
DetailURL string `json:"detail_url"`
|
||
DownloadURL string `json:"download_url"`
|
||
InfoHash string `json:"info_hash,omitempty"`
|
||
ImdbID string `json:"imdb_id,omitempty"`
|
||
Description string `json:"description,omitempty"`
|
||
Files []string `json:"files,omitempty"`
|
||
}
|
||
|
||
// SiteAdapter 站点适配器接口。
|
||
type SiteAdapter interface {
|
||
// Authenticate 测试站点认证是否有效。
|
||
Authenticate(ctx context.Context, cfg SiteConfig) error
|
||
|
||
// Search 搜索种子。
|
||
Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error)
|
||
|
||
// Browse 浏览种子列表。
|
||
Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error)
|
||
|
||
// GetDetail 获取种子详情。
|
||
GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error)
|
||
|
||
// GetDownloadURL 获取下载链接。
|
||
GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error)
|
||
}
|
||
|
||
// newHTTPClient 创建带有认证头的 HTTP 客户端。
|
||
func newHTTPClient(cfg SiteConfig, timeout time.Duration) *http.Client {
|
||
return &http.Client{Timeout: timeout}
|
||
}
|
||
|
||
// buildRequest 构建带认证的 HTTP 请求。
|
||
func buildRequest(ctx context.Context, method, rawURL string, cfg SiteConfig, body io.Reader) (*http.Request, error) {
|
||
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
switch cfg.AuthType {
|
||
case "cookie":
|
||
if cfg.Cookie != "" {
|
||
req.Header.Set("Cookie", cfg.Cookie)
|
||
}
|
||
case "api_key":
|
||
// MTeam 使用 Authorization: Bearer 格式
|
||
if cfg.Type == "mteam" {
|
||
if cfg.APIKey != "" {
|
||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||
}
|
||
} else if cfg.APIKey != "" {
|
||
req.Header.Set("X-API-Key", cfg.APIKey)
|
||
}
|
||
case "auth_header":
|
||
if cfg.AuthHeader != "" {
|
||
parts := strings.SplitN(cfg.AuthHeader, ":", 2)
|
||
if len(parts) == 2 {
|
||
req.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
|
||
} else {
|
||
req.Header.Set("Authorization", "Bearer "+cfg.AuthHeader)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 使用 SiteConfig 中的 UserAgent(如果提供),否则使用默认值
|
||
userAgent := cfg.UserAgent
|
||
if userAgent == "" {
|
||
userAgent = model.DefaultUserAgent
|
||
}
|
||
req.Header.Set("User-Agent", userAgent)
|
||
return req, nil
|
||
}
|
||
|
||
// doRequest 执行 HTTP 请求并返回响应体。
|
||
// 当 cfg.FlareSolverrURL 已配置且方法为 GET 时,通过 FlareSolverr 代理请求
|
||
// 以绕过 Cloudflare/WAF 挑战验证。
|
||
func doRequest(ctx context.Context, client *http.Client, method, rawURL string, cfg SiteConfig, body io.Reader) ([]byte, int, error) {
|
||
// ── FlareSolverr 浏览器模拟路径(仅 GET) ──────────────────────────
|
||
if cfg.FlareSolverrURL != "" && method == "GET" {
|
||
timeout := int(cfg.Timeout.Seconds())
|
||
if timeout <= 0 {
|
||
timeout = 30
|
||
}
|
||
pageBody, err := helper.FetchURLWithFlareSolverr(
|
||
cfg.FlareSolverrURL, rawURL, cfg.Cookie, timeout, "", nil)
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("flareSolverr: %w", err)
|
||
}
|
||
return []byte(pageBody), http.StatusOK, nil
|
||
}
|
||
|
||
// ── 直接 HTTP 请求路径 ─────────────────────────────────────────────
|
||
req, err := buildRequest(ctx, method, rawURL, cfg, body)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if body != nil {
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
}
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
data, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, resp.StatusCode, err
|
||
}
|
||
return data, resp.StatusCode, nil
|
||
}
|
||
|
||
// ─── NexusPHP 适配器 ─────────────────────────────────────────────────────────
|
||
|
||
// NexusPHPAdapter NexusPHP 框架适配器(馒头、HDHome、CHDBits 等)。
|
||
type NexusPHPAdapter struct {
|
||
client *http.Client
|
||
}
|
||
|
||
// NewNexusPHPAdapter 创建 NexusPHP 适配器。
|
||
func NewNexusPHPAdapter() *NexusPHPAdapter {
|
||
return &NexusPHPAdapter{
|
||
client: &http.Client{Timeout: 30 * time.Second},
|
||
}
|
||
}
|
||
|
||
func (a *NexusPHPAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
||
resp, err := buildRequest(ctx, "GET", cfg.URL+"/index.php", cfg, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("build request: %w", err)
|
||
}
|
||
httpResp, err := a.client.Do(resp)
|
||
if err != nil {
|
||
return fmt.Errorf("request failed: %w", err)
|
||
}
|
||
defer httpResp.Body.Close()
|
||
|
||
if httpResp.StatusCode == http.StatusFound {
|
||
return fmt.Errorf("authentication failed: redirected to login page")
|
||
}
|
||
if httpResp.StatusCode != http.StatusOK {
|
||
return fmt.Errorf("authentication failed: status %d", httpResp.StatusCode)
|
||
}
|
||
|
||
body, _ := io.ReadAll(httpResp.Body)
|
||
bodyStr := string(body)
|
||
// NexusPHP 登录页面通常包含 logout 或 userdetails
|
||
if strings.Contains(bodyStr, "userdetails") || strings.Contains(bodyStr, "logout") {
|
||
return nil
|
||
}
|
||
// Check for common login indicators
|
||
if strings.Contains(bodyStr, "login") && !strings.Contains(bodyStr, "userdetails") {
|
||
return fmt.Errorf("authentication failed: not logged in")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *NexusPHPAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||
params := url.Values{}
|
||
params.Set("search", keyword)
|
||
params.Set("page", strconv.Itoa(page))
|
||
params.Set("inclbookmarked", "0")
|
||
params.Set("incldead", "0")
|
||
|
||
u := cfg.URL + "/torrents.php?" + params.Encode()
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("search request: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("search failed: status %d", status)
|
||
}
|
||
|
||
return parseNexusPHPHTML(string(data), cfg.Name, cfg.URL)
|
||
}
|
||
|
||
func (a *NexusPHPAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||
params := url.Values{}
|
||
if category != "" {
|
||
params.Set("cat", category)
|
||
}
|
||
params.Set("page", strconv.Itoa(page))
|
||
|
||
u := cfg.URL + "/torrents.php?" + params.Encode()
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("browse request: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("browse failed: status %d", status)
|
||
}
|
||
|
||
return parseNexusPHPHTML(string(data), cfg.Name, cfg.URL)
|
||
}
|
||
|
||
func (a *NexusPHPAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
|
||
u := cfg.URL + "/details.php?id=" + id
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("detail request: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("detail failed: status %d", status)
|
||
}
|
||
|
||
return parseNexusPHPDetailHTML(string(data), id, cfg.URL)
|
||
}
|
||
|
||
func (a *NexusPHPAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
|
||
return cfg.URL + "/download.php?id=" + id, nil
|
||
}
|
||
|
||
// parseNexusPHPHTML 解析 NexusPHP 种子列表 HTML。
|
||
func parseNexusPHPHTML(html, siteName, baseURL string) (*SiteSearchResult, error) {
|
||
result := &SiteSearchResult{
|
||
SiteName: siteName,
|
||
Items: []TorrentItem{},
|
||
Page: 1,
|
||
}
|
||
|
||
// Extract table rows from torrent table
|
||
rowRegex := regexp.MustCompile(`<tr[^>]*>\s*<td[^>]*class="rowfollow"[^>]*>.*?</tr>`)
|
||
matches := rowRegex.FindAllString(html, -1)
|
||
|
||
for _, row := range matches {
|
||
item := parseNexusPHPRow(row, baseURL)
|
||
if item.ID != "" {
|
||
result.Items = append(result.Items, item)
|
||
}
|
||
}
|
||
|
||
result.Total = len(result.Items)
|
||
return result, nil
|
||
}
|
||
|
||
// parseNexusPHPRow 解析单行种子条目。
|
||
func parseNexusPHPRow(row, baseURL string) TorrentItem {
|
||
item := TorrentItem{}
|
||
|
||
// Extract torrent ID and title
|
||
idRegex := regexp.MustCompile(`details\.php\?id=(\d+)[^"]*"[^>]*>([^<]+)`)
|
||
idMatches := idRegex.FindStringSubmatch(row)
|
||
if len(idMatches) >= 3 {
|
||
item.ID = idMatches[1]
|
||
item.Title = strings.TrimSpace(idMatches[2])
|
||
item.DetailURL = baseURL + "/details.php?id=" + item.ID
|
||
}
|
||
|
||
// Extract download link
|
||
dlRegex := regexp.MustCompile(`download\.php\?id=(\d+)`)
|
||
if dlMatches := dlRegex.FindStringSubmatch(row); len(dlMatches) >= 2 {
|
||
item.DownloadURL = baseURL + "/download.php?id=" + dlMatches[1]
|
||
}
|
||
|
||
// Extract size
|
||
sizeRegex := regexp.MustCompile(`(?i)(\d+\.?\d*)\s*(GB|MB|TB|KB)`)
|
||
if sizeMatches := sizeRegex.FindStringSubmatch(row); len(sizeMatches) >= 3 {
|
||
item.Size = parseSizeString(sizeMatches[1], sizeMatches[2])
|
||
}
|
||
|
||
// Extract seeders and leechers
|
||
seedersRegex := regexp.MustCompile(`seeders[^"]*"[^>]*>(\d+)<`)
|
||
if m := seedersRegex.FindStringSubmatch(row); len(m) >= 2 {
|
||
item.Seeders, _ = strconv.Atoi(m[1])
|
||
}
|
||
leechersRegex := regexp.MustCompile(`leechers[^"]*"[^>]*>(\d+)<`)
|
||
if m := leechersRegex.FindStringSubmatch(row); len(m) >= 2 {
|
||
item.Leechers, _ = strconv.Atoi(m[1])
|
||
}
|
||
|
||
// Extract snatched
|
||
snatchedRegex := regexp.MustCompile(`snatched[^"]*"[^>]*>(\d+)`)
|
||
if m := snatchedRegex.FindStringSubmatch(row); len(m) >= 2 {
|
||
item.Snatched, _ = strconv.Atoi(m[1])
|
||
}
|
||
|
||
// Check for free flag
|
||
freeRegex := regexp.MustCompile(`(?i)(class="free|free2|twoupfree|free_download|促销|免费)`)
|
||
item.Free = freeRegex.MatchString(row)
|
||
|
||
// Extract upload time
|
||
timeRegex := regexp.MustCompile(`(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})`)
|
||
if m := timeRegex.FindStringSubmatch(row); len(m) >= 2 {
|
||
if t, err := time.Parse("2006-01-02 15:04", m[1]); err == nil {
|
||
item.UploadTime = t
|
||
}
|
||
}
|
||
|
||
// Extract category
|
||
catRegex := regexp.MustCompile(`cat=(\d+)[^"]*"[^>]*title="([^"]+)"`)
|
||
if m := catRegex.FindStringSubmatch(row); len(m) >= 3 {
|
||
item.Category = strings.TrimSpace(m[2])
|
||
}
|
||
|
||
return item
|
||
}
|
||
|
||
// parseNexusPHPDetailHTML 解析种子详情页。
|
||
func parseNexusPHPDetailHTML(html, id, baseURL string) (*TorrentDetail, error) {
|
||
detail := &TorrentDetail{
|
||
ID: id,
|
||
DetailURL: baseURL + "/details.php?id=" + id,
|
||
}
|
||
|
||
// Title
|
||
titleRegex := regexp.MustCompile(`<h1[^>]*>([^<]+)</h1>`)
|
||
if m := titleRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||
detail.Title = strings.TrimSpace(m[1])
|
||
}
|
||
|
||
// Subtitle
|
||
subRegex := regexp.MustCompile(`<span[^>]*class="[^"]*sub[^"]*"[^>]*>([^<]+)</span>`)
|
||
if m := subRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||
detail.Subtitle = strings.TrimSpace(m[1])
|
||
}
|
||
|
||
// Info hash
|
||
hashRegex := regexp.MustCompile(`(?i)info_hash[^<]*</td>\s*<td[^>]*>([^<]+)</td>`)
|
||
if m := hashRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||
detail.InfoHash = strings.TrimSpace(m[1])
|
||
}
|
||
|
||
// IMDB ID
|
||
imdbRegex := regexp.MustCompile(`(?i)imdb[^<]*</td>\s*<td[^>]*>[^<]*(tt\d+)`)
|
||
if m := imdbRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||
detail.ImdbID = m[1]
|
||
}
|
||
|
||
// Size
|
||
sizeRegex := regexp.MustCompile(`(?i)size[^<]*</td>\s*<td[^>]*>(\d+\.?\d*)\s*(GB|MB|TB|KB)`)
|
||
if m := sizeRegex.FindStringSubmatch(html); len(m) >= 3 {
|
||
detail.Size = parseSizeString(m[1], m[2])
|
||
}
|
||
|
||
// Seeders / Leechers / Snatched
|
||
slRegex := regexp.MustCompile(`seeders[^<]*</td>\s*<td[^>]*>(\d+)</td>\s*<td[^>]*>\s*</td>\s*<td[^>]*>\s*</td>\s*<td[^>]*>leechers[^<]*</td>\s*<td[^>]*>(\d+)`)
|
||
if m := slRegex.FindStringSubmatch(html); len(m) >= 3 {
|
||
detail.Seeders, _ = strconv.Atoi(m[1])
|
||
detail.Leechers, _ = strconv.Atoi(m[2])
|
||
}
|
||
|
||
snRegex := regexp.MustCompile(`(?i)times completed[^<]*</td>\s*<td[^>]*>(\d+)`)
|
||
if m := snRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||
detail.Snatched, _ = strconv.Atoi(m[1])
|
||
}
|
||
|
||
// Description
|
||
descRegex := regexp.MustCompile(`(?i)<div[^>]*id="kdescr"[^>]*>(.*?)</div>`)
|
||
if m := descRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||
detail.Description = stripHTML(m[1])
|
||
}
|
||
|
||
detail.DownloadURL = baseURL + "/download.php?id=" + id
|
||
detail.Free = strings.Contains(html, "free") || strings.Contains(html, "免费")
|
||
return detail, nil
|
||
}
|
||
|
||
// ─── Gazelle 适配器 ──────────────────────────────────────────────────────────
|
||
|
||
// GazelleAdapter Gazelle 框架适配器(What.cd 开源)。
|
||
type GazelleAdapter struct {
|
||
client *http.Client
|
||
}
|
||
|
||
// NewGazelleAdapter 创建 Gazelle 适配器。
|
||
func NewGazelleAdapter() *GazelleAdapter {
|
||
return &GazelleAdapter{
|
||
client: &http.Client{Timeout: 30 * time.Second},
|
||
}
|
||
}
|
||
|
||
func (a *GazelleAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
||
u := cfg.URL + "/ajax.php?action=index"
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("authenticate: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return fmt.Errorf("authenticate failed: status %d", status)
|
||
}
|
||
var result map[string]interface{}
|
||
if err := json.Unmarshal(data, &result); err != nil {
|
||
return fmt.Errorf("parse response: %w", err)
|
||
}
|
||
if statusMsg, ok := result["status"].(string); ok && statusMsg == "failure" {
|
||
return fmt.Errorf("authentication failed: %v", result["error"])
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *GazelleAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||
params := url.Values{}
|
||
params.Set("action", "browse")
|
||
params.Set("searchstr", keyword)
|
||
params.Set("page", strconv.Itoa(page))
|
||
|
||
u := cfg.URL + "/ajax.php?" + params.Encode()
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("search: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("search failed: status %d", status)
|
||
}
|
||
|
||
return parseGazelleJSON(data, cfg.Name, cfg.URL)
|
||
}
|
||
|
||
func (a *GazelleAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||
params := url.Values{}
|
||
params.Set("action", "browse")
|
||
if category != "" {
|
||
params.Set("filter_cat["+category+"]", "1")
|
||
}
|
||
params.Set("page", strconv.Itoa(page))
|
||
|
||
u := cfg.URL + "/ajax.php?" + params.Encode()
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("browse: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("browse failed: status %d", status)
|
||
}
|
||
|
||
return parseGazelleJSON(data, cfg.Name, cfg.URL)
|
||
}
|
||
|
||
func (a *GazelleAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
|
||
params := url.Values{}
|
||
params.Set("action", "torrent")
|
||
params.Set("id", id)
|
||
|
||
u := cfg.URL + "/ajax.php?" + params.Encode()
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("detail: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("detail failed: status %d", status)
|
||
}
|
||
|
||
var resp map[string]interface{}
|
||
if err := json.Unmarshal(data, &resp); err != nil {
|
||
return nil, fmt.Errorf("parse response: %w", err)
|
||
}
|
||
|
||
torrent, ok := resp["torrent"].(map[string]interface{})
|
||
if !ok {
|
||
return nil, fmt.Errorf("torrent not found")
|
||
}
|
||
|
||
detail := &TorrentDetail{
|
||
ID: id,
|
||
DetailURL: cfg.URL + "/torrents.php?torrentid=" + id,
|
||
DownloadURL: cfg.URL + "/torrents.php?action=download&id=" + id,
|
||
}
|
||
|
||
if v, ok := torrent["groupName"].(string); ok {
|
||
detail.Title = v
|
||
}
|
||
if v, ok := torrent["subName"].(string); ok {
|
||
detail.Subtitle = v
|
||
}
|
||
if v, ok := torrent["size"].(float64); ok {
|
||
detail.Size = int64(v)
|
||
}
|
||
if v, ok := torrent["seeders"].(float64); ok {
|
||
detail.Seeders = int(v)
|
||
}
|
||
if v, ok := torrent["leechers"].(float64); ok {
|
||
detail.Leechers = int(v)
|
||
}
|
||
if v, ok := torrent["snatched"].(float64); ok {
|
||
detail.Snatched = int(v)
|
||
}
|
||
if v, ok := torrent["freeTorrent"].(string); ok && v == "1" {
|
||
detail.Free = true
|
||
}
|
||
if v, ok := torrent["freeTorrent"].(bool); ok {
|
||
detail.Free = v
|
||
}
|
||
if v, ok := torrent["infoHash"].(string); ok {
|
||
detail.InfoHash = v
|
||
}
|
||
if v, ok := torrent["groupDesc"].(string); ok {
|
||
detail.Description = stripHTML(v)
|
||
}
|
||
|
||
return detail, nil
|
||
}
|
||
|
||
func (a *GazelleAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
|
||
return cfg.URL + "/torrents.php?action=download&id=" + id, nil
|
||
}
|
||
|
||
// parseGazelleJSON 解析 Gazelle JSON 响应。
|
||
func parseGazelleJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
|
||
var resp map[string]interface{}
|
||
if err := json.Unmarshal(data, &resp); err != nil {
|
||
return nil, fmt.Errorf("parse JSON: %w", err)
|
||
}
|
||
|
||
result := &SiteSearchResult{
|
||
SiteName: siteName,
|
||
Items: []TorrentItem{},
|
||
}
|
||
|
||
if status, ok := resp["status"].(string); ok && status == "failure" {
|
||
return result, nil
|
||
}
|
||
|
||
results, ok := resp["results"].([]interface{})
|
||
if !ok {
|
||
return result, nil
|
||
}
|
||
|
||
for _, r := range results {
|
||
torrent, ok := r.(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
item := TorrentItem{}
|
||
if v, ok := torrent["torrentId"].(float64); ok {
|
||
item.ID = strconv.Itoa(int(v))
|
||
}
|
||
if v, ok := torrent["groupName"].(string); ok {
|
||
item.Title = v
|
||
}
|
||
if v, ok := torrent["artist"].(string); ok {
|
||
item.Subtitle = v
|
||
}
|
||
if v, ok := torrent["category"].(string); ok {
|
||
item.Category = v
|
||
}
|
||
if v, ok := torrent["size"].(float64); ok {
|
||
item.Size = int64(v)
|
||
}
|
||
if v, ok := torrent["seeders"].(float64); ok {
|
||
item.Seeders = int(v)
|
||
}
|
||
if v, ok := torrent["leechers"].(float64); ok {
|
||
item.Leechers = int(v)
|
||
}
|
||
if v, ok := torrent["snatched"].(float64); ok {
|
||
item.Snatched = int(v)
|
||
}
|
||
if v, ok := torrent["freeTorrent"].(string); ok && v == "1" {
|
||
item.Free = true
|
||
}
|
||
if v, ok := torrent["freeTorrent"].(bool); ok {
|
||
item.Free = v
|
||
}
|
||
if v, ok := torrent["time"].(string); ok {
|
||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||
item.UploadTime = t
|
||
}
|
||
}
|
||
|
||
item.DetailURL = baseURL + "/torrents.php?torrentid=" + item.ID
|
||
item.DownloadURL = baseURL + "/torrents.php?action=download&id=" + item.ID
|
||
result.Items = append(result.Items, item)
|
||
}
|
||
|
||
if total, ok := resp["totalResults"].(float64); ok {
|
||
result.Total = int(total)
|
||
} else {
|
||
result.Total = len(result.Items)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// ─── UNIT3D 适配器 ───────────────────────────────────────────────────────────
|
||
|
||
// UNIT3DAdapter UNIT3D 框架适配器。
|
||
type UNIT3DAdapter struct {
|
||
client *http.Client
|
||
}
|
||
|
||
// NewUNIT3DAdapter 创建 UNIT3D 适配器。
|
||
func NewUNIT3DAdapter() *UNIT3DAdapter {
|
||
return &UNIT3DAdapter{
|
||
client: &http.Client{Timeout: 30 * time.Second},
|
||
}
|
||
}
|
||
|
||
func (a *UNIT3DAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
||
u := cfg.URL + "/api/torrents?limit=1"
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("authenticate: %w", err)
|
||
}
|
||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||
return fmt.Errorf("authentication failed: status %d", status)
|
||
}
|
||
if status != http.StatusOK {
|
||
return fmt.Errorf("authenticate failed: status %d", status)
|
||
}
|
||
var resp map[string]interface{}
|
||
if err := json.Unmarshal(data, &resp); err == nil {
|
||
if errMsg, ok := resp["message"].(string); ok {
|
||
return fmt.Errorf("authentication failed: %s", errMsg)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *UNIT3DAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||
params := url.Values{}
|
||
params.Set("search", keyword)
|
||
params.Set("page", strconv.Itoa(page))
|
||
|
||
u := cfg.URL + "/api/torrents?" + params.Encode()
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("search: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("search failed: status %d", status)
|
||
}
|
||
|
||
return parseUNIT3DJSON(data, cfg.Name, cfg.URL)
|
||
}
|
||
|
||
func (a *UNIT3DAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||
params := url.Values{}
|
||
if category != "" {
|
||
params.Set("category", category)
|
||
}
|
||
params.Set("page", strconv.Itoa(page))
|
||
|
||
u := cfg.URL + "/api/torrents?" + params.Encode()
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("browse: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("browse failed: status %d", status)
|
||
}
|
||
|
||
return parseUNIT3DJSON(data, cfg.Name, cfg.URL)
|
||
}
|
||
|
||
func (a *UNIT3DAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
|
||
u := cfg.URL + "/api/torrents/" + id
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("detail: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("detail failed: status %d", status)
|
||
}
|
||
|
||
var torrent map[string]interface{}
|
||
if err := json.Unmarshal(data, &torrent); err != nil {
|
||
return nil, fmt.Errorf("parse response: %w", err)
|
||
}
|
||
|
||
detail := &TorrentDetail{
|
||
ID: id,
|
||
DetailURL: cfg.URL + "/torrents/" + id,
|
||
}
|
||
|
||
if v, ok := torrent["name"].(string); ok {
|
||
detail.Title = v
|
||
}
|
||
if v, ok := torrent["description"].(string); ok {
|
||
detail.Description = stripHTML(v)
|
||
}
|
||
if v, ok := torrent["size"].(float64); ok {
|
||
detail.Size = int64(v)
|
||
}
|
||
if v, ok := torrent["seeders"].(float64); ok {
|
||
detail.Seeders = int(v)
|
||
}
|
||
if v, ok := torrent["leechers"].(float64); ok {
|
||
detail.Leechers = int(v)
|
||
}
|
||
if v, ok := torrent["times_completed"].(float64); ok {
|
||
detail.Snatched = int(v)
|
||
}
|
||
if v, ok := torrent["free"].(bool); ok {
|
||
detail.Free = v
|
||
}
|
||
if v, ok := torrent["info_hash"].(string); ok {
|
||
detail.InfoHash = v
|
||
}
|
||
|
||
detail.DownloadURL = cfg.URL + "/api/torrents/" + id + "/download"
|
||
return detail, nil
|
||
}
|
||
|
||
func (a *UNIT3DAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
|
||
return cfg.URL + "/api/torrents/" + id + "/download", nil
|
||
}
|
||
|
||
// parseUNIT3DJSON 解析 UNIT3D JSON 响应。
|
||
func parseUNIT3DJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
|
||
var resp struct {
|
||
Data []map[string]interface{} `json:"data"`
|
||
Meta struct {
|
||
Total int `json:"total"`
|
||
CurrentPage int `json:"current_page"`
|
||
} `json:"meta"`
|
||
}
|
||
if err := json.Unmarshal(data, &resp); err != nil {
|
||
return nil, fmt.Errorf("parse JSON: %w", err)
|
||
}
|
||
|
||
result := &SiteSearchResult{
|
||
SiteName: siteName,
|
||
Items: []TorrentItem{},
|
||
Page: resp.Meta.CurrentPage,
|
||
Total: resp.Meta.Total,
|
||
}
|
||
|
||
for _, t := range resp.Data {
|
||
item := TorrentItem{}
|
||
if v, ok := t["id"].(float64); ok {
|
||
item.ID = strconv.Itoa(int(v))
|
||
}
|
||
if v, ok := t["name"].(string); ok {
|
||
item.Title = v
|
||
}
|
||
if v, ok := t["category"].(map[string]interface{}); ok {
|
||
if name, ok := v["name"].(string); ok {
|
||
item.Category = name
|
||
}
|
||
}
|
||
if v, ok := t["size"].(float64); ok {
|
||
item.Size = int64(v)
|
||
}
|
||
if v, ok := t["seeders"].(float64); ok {
|
||
item.Seeders = int(v)
|
||
}
|
||
if v, ok := t["leechers"].(float64); ok {
|
||
item.Leechers = int(v)
|
||
}
|
||
if v, ok := t["times_completed"].(float64); ok {
|
||
item.Snatched = int(v)
|
||
}
|
||
if v, ok := t["free"].(bool); ok {
|
||
item.Free = v
|
||
}
|
||
if v, ok := t["created_at"].(string); ok {
|
||
if t2, err := time.Parse(time.RFC3339, v); err == nil {
|
||
item.UploadTime = t2
|
||
}
|
||
}
|
||
|
||
item.DetailURL = baseURL + "/torrents/" + item.ID
|
||
item.DownloadURL = baseURL + "/api/torrents/" + item.ID + "/download"
|
||
result.Items = append(result.Items, item)
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// ─── MTeam 适配器 ────────────────────────────────────────────────────────────
|
||
|
||
// MTeamAdapter MTeam.cc 独立站适配器。
|
||
type MTeamAdapter struct {
|
||
client *http.Client
|
||
}
|
||
|
||
// NewMTeamAdapter 创建 MTeam 适配器。
|
||
func NewMTeamAdapter() *MTeamAdapter {
|
||
return &MTeamAdapter{
|
||
client: &http.Client{Timeout: 30 * time.Second},
|
||
}
|
||
}
|
||
|
||
func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
||
u := cfg.URL + "/api/torrent/search"
|
||
payload := `{"mode":"search","keyword":"","page":1,"pageSize":1}`
|
||
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload))
|
||
if err != nil {
|
||
return fmt.Errorf("authenticate: %w", err)
|
||
}
|
||
if status == http.StatusUnauthorized {
|
||
return fmt.Errorf("authentication failed: unauthorized")
|
||
}
|
||
if status != http.StatusOK {
|
||
return fmt.Errorf("authenticate failed: status %d", status)
|
||
}
|
||
var resp map[string]interface{}
|
||
if err := json.Unmarshal(data, &resp); err == nil {
|
||
if code, ok := resp["code"].(float64); ok && code != 0 {
|
||
return fmt.Errorf("authentication failed: code %v", code)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||
payload := map[string]interface{}{
|
||
"mode": "search",
|
||
"keyword": keyword,
|
||
"page": page,
|
||
"pageSize": 50,
|
||
}
|
||
body, _ := json.Marshal(payload)
|
||
|
||
u := cfg.URL + "/api/torrent/search"
|
||
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("search: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("search failed: status %d", status)
|
||
}
|
||
|
||
return parseMTeamJSON(data, cfg.Name, cfg.URL)
|
||
}
|
||
|
||
func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||
payload := map[string]interface{}{
|
||
"mode": "browse",
|
||
"category": category,
|
||
"page": page,
|
||
"pageSize": 50,
|
||
}
|
||
body, _ := json.Marshal(payload)
|
||
|
||
u := cfg.URL + "/api/torrent/search"
|
||
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("browse: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("browse failed: status %d", status)
|
||
}
|
||
|
||
return parseMTeamJSON(data, cfg.Name, cfg.URL)
|
||
}
|
||
|
||
func (a *MTeamAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
|
||
u := cfg.URL + "/api/torrent/detail?id=" + id
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("detail: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("detail failed: status %d", status)
|
||
}
|
||
|
||
var resp map[string]interface{}
|
||
if err := json.Unmarshal(data, &resp); err != nil {
|
||
return nil, fmt.Errorf("parse response: %w", err)
|
||
}
|
||
|
||
dataField, ok := resp["data"].(map[string]interface{})
|
||
if !ok {
|
||
return nil, fmt.Errorf("detail not found")
|
||
}
|
||
|
||
detail := &TorrentDetail{
|
||
ID: id,
|
||
DetailURL: cfg.URL + "/detail/" + id,
|
||
}
|
||
|
||
if v, ok := dataField["name"].(string); ok {
|
||
detail.Title = v
|
||
}
|
||
if v, ok := dataField["subtitle"].(string); ok {
|
||
detail.Subtitle = v
|
||
}
|
||
if v, ok := dataField["size"].(float64); ok {
|
||
detail.Size = int64(v)
|
||
}
|
||
if v, ok := dataField["status"].(map[string]interface{}); ok {
|
||
if seeders, ok := v["seeders"].(float64); ok {
|
||
detail.Seeders = int(seeders)
|
||
}
|
||
if leechers, ok := v["leechers"].(float64); ok {
|
||
detail.Leechers = int(leechers)
|
||
}
|
||
if snatched, ok := v["completed"].(float64); ok {
|
||
detail.Snatched = int(snatched)
|
||
}
|
||
}
|
||
if v, ok := dataField["free"].(bool); ok {
|
||
detail.Free = v
|
||
}
|
||
if v, ok := dataField["download"].(string); ok {
|
||
detail.DownloadURL = v
|
||
}
|
||
if v, ok := dataField["description"].(string); ok {
|
||
detail.Description = stripHTML(v)
|
||
}
|
||
|
||
return detail, nil
|
||
}
|
||
|
||
func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
|
||
return cfg.URL + "/api/torrent/detail?id=" + id, nil
|
||
}
|
||
|
||
// parseMTeamJSON 解析 MTeam JSON 响应。
|
||
func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
|
||
var resp struct {
|
||
Code int `json:"code"`
|
||
Data struct {
|
||
Total int `json:"total"`
|
||
Lists []map[string]interface{} `json:"lists"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(data, &resp); err != nil {
|
||
return nil, fmt.Errorf("parse JSON: %w", err)
|
||
}
|
||
|
||
result := &SiteSearchResult{
|
||
SiteName: siteName,
|
||
Items: []TorrentItem{},
|
||
Total: resp.Data.Total,
|
||
}
|
||
|
||
for _, t := range resp.Data.Lists {
|
||
item := TorrentItem{}
|
||
if v, ok := t["id"].(string); ok {
|
||
item.ID = v
|
||
} else if v, ok := t["id"].(float64); ok {
|
||
item.ID = strconv.Itoa(int(v))
|
||
}
|
||
if v, ok := t["name"].(string); ok {
|
||
item.Title = v
|
||
}
|
||
if v, ok := t["subtitle"].(string); ok {
|
||
item.Subtitle = v
|
||
}
|
||
if v, ok := t["category"].(map[string]interface{}); ok {
|
||
if name, ok := v["name"].(string); ok {
|
||
item.Category = name
|
||
}
|
||
}
|
||
if v, ok := t["size"].(float64); ok {
|
||
item.Size = int64(v)
|
||
}
|
||
if v, ok := t["status"].(map[string]interface{}); ok {
|
||
if seeders, ok := v["seeders"].(float64); ok {
|
||
item.Seeders = int(seeders)
|
||
}
|
||
if leechers, ok := v["leechers"].(float64); ok {
|
||
item.Leechers = int(leechers)
|
||
}
|
||
if snatched, ok := v["completed"].(float64); ok {
|
||
item.Snatched = int(snatched)
|
||
}
|
||
}
|
||
if v, ok := t["free"].(bool); ok {
|
||
item.Free = v
|
||
}
|
||
if v, ok := t["uploadTime"].(float64); ok {
|
||
item.UploadTime = time.Unix(int64(v), 0)
|
||
}
|
||
|
||
item.DetailURL = baseURL + "/detail/" + item.ID
|
||
result.Items = append(result.Items, item)
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// ─── Discuz 适配器 ───────────────────────────────────────────────────────────
|
||
|
||
// DiscuzAdapter 基于 Discuz! X 的站点适配器。
|
||
type DiscuzAdapter struct {
|
||
client *http.Client
|
||
}
|
||
|
||
// NewDiscuzAdapter 创建 Discuz 适配器。
|
||
func NewDiscuzAdapter() *DiscuzAdapter {
|
||
return &DiscuzAdapter{
|
||
client: &http.Client{Timeout: 30 * time.Second},
|
||
}
|
||
}
|
||
|
||
func (a *DiscuzAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
||
u := cfg.URL + "/home.php?mod=space"
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("authenticate: %w", err)
|
||
}
|
||
if status == http.StatusFound {
|
||
return fmt.Errorf("authentication failed: redirected to login")
|
||
}
|
||
if status != http.StatusOK {
|
||
return fmt.Errorf("authenticate failed: status %d", status)
|
||
}
|
||
body := string(data)
|
||
if strings.Contains(body, "login") && !strings.Contains(body, "我的空间") {
|
||
return fmt.Errorf("authentication failed: not logged in")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *DiscuzAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||
params := url.Values{}
|
||
params.Set("mod", "forum")
|
||
params.Set("srchtxt", keyword)
|
||
params.Set("searchsubmit", "true")
|
||
params.Set("page", strconv.Itoa(page))
|
||
|
||
u := cfg.URL + "/search.php?" + params.Encode()
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("search: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("search failed: status %d", status)
|
||
}
|
||
|
||
return parseDiscuzHTML(string(data), cfg.Name, cfg.URL)
|
||
}
|
||
|
||
func (a *DiscuzAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||
params := url.Values{}
|
||
if category != "" {
|
||
params.Set("fid", category)
|
||
}
|
||
params.Set("page", strconv.Itoa(page))
|
||
|
||
u := cfg.URL + "/forum.php?" + params.Encode()
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("browse: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("browse failed: status %d", status)
|
||
}
|
||
|
||
return parseDiscuzHTML(string(data), cfg.Name, cfg.URL)
|
||
}
|
||
|
||
func (a *DiscuzAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
|
||
u := cfg.URL + "/forum.php?mod=viewthread&tid=" + id
|
||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("detail: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("detail failed: status %d", status)
|
||
}
|
||
|
||
html := string(data)
|
||
detail := &TorrentDetail{
|
||
ID: id,
|
||
DetailURL: cfg.URL + "/forum.php?mod=viewthread&tid=" + id,
|
||
}
|
||
|
||
// Title
|
||
titleRegex := regexp.MustCompile(`<span[^>]*id="thread_subject"[^>]*>([^<]+)</span>`)
|
||
if m := titleRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||
detail.Title = strings.TrimSpace(m[1])
|
||
}
|
||
|
||
// Extract magnet/torrent links
|
||
magnetRegex := regexp.MustCompile(`magnet:\?[^\s"'<>]+`)
|
||
if m := magnetRegex.FindString(html); m != "" {
|
||
detail.DownloadURL = m
|
||
}
|
||
torrentRegex := regexp.MustCompile(`(attachment\.php\?aid=\d+)`)
|
||
if m := torrentRegex.FindString(html); m != "" && detail.DownloadURL == "" {
|
||
detail.DownloadURL = cfg.URL + "/" + m
|
||
}
|
||
|
||
// Description
|
||
descRegex := regexp.MustCompile(`<div[^>]*class="t_fsz"[^>]*>(.*?)</div>`)
|
||
if m := descRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||
detail.Description = stripHTML(m[1])
|
||
}
|
||
|
||
return detail, nil
|
||
}
|
||
|
||
func (a *DiscuzAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
|
||
return cfg.URL + "/forum.php?mod=viewthread&tid=" + id, nil
|
||
}
|
||
|
||
// parseDiscuzHTML 解析 Discuz HTML 响应。
|
||
func parseDiscuzHTML(html, siteName, baseURL string) (*SiteSearchResult, error) {
|
||
result := &SiteSearchResult{
|
||
SiteName: siteName,
|
||
Items: []TorrentItem{},
|
||
Page: 1,
|
||
}
|
||
|
||
// Extract thread links
|
||
threadRegex := regexp.MustCompile(`<a[^>]*href="(?:forum\.php\?mod=viewthread&tid=|thread-(\d+)-1-1)\.html"[^>]*>([^<]+)</a>`)
|
||
matches := threadRegex.FindAllStringSubmatch(html, -1)
|
||
|
||
for _, m := range matches {
|
||
item := TorrentItem{}
|
||
if m[1] != "" {
|
||
item.ID = m[1]
|
||
} else {
|
||
// Extract tid from URL
|
||
tidRegex := regexp.MustCompile(`tid=(\d+)`)
|
||
if tidM := tidRegex.FindStringSubmatch(m[0]); len(tidM) >= 2 {
|
||
item.ID = tidM[1]
|
||
}
|
||
}
|
||
if item.ID == "" {
|
||
continue
|
||
}
|
||
|
||
item.Title = strings.TrimSpace(m[2])
|
||
item.DetailURL = baseURL + "/forum.php?mod=viewthread&tid=" + item.ID
|
||
item.UploadTime = time.Now()
|
||
|
||
result.Items = append(result.Items, item)
|
||
}
|
||
|
||
result.Total = len(result.Items)
|
||
return result, nil
|
||
}
|
||
|
||
// ─── Custom RSS 适配器 ───────────────────────────────────────────────────────
|
||
|
||
// CustomRSSAdapter 自定义 RSS 源适配器。
|
||
type CustomRSSAdapter struct {
|
||
client *http.Client
|
||
}
|
||
|
||
// NewCustomRSSAdapter 创建 Custom RSS 适配器。
|
||
func NewCustomRSSAdapter() *CustomRSSAdapter {
|
||
return &CustomRSSAdapter{
|
||
client: &http.Client{Timeout: 30 * time.Second},
|
||
}
|
||
}
|
||
|
||
func (a *CustomRSSAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
||
// RSS 源通常不需要认证,或者认证通过 URL 参数
|
||
if cfg.URL == "" {
|
||
return fmt.Errorf("RSS URL is required")
|
||
}
|
||
_, status, err := doRequest(ctx, a.client, "GET", cfg.URL, cfg, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("authenticate: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return fmt.Errorf("authenticate failed: status %d", status)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *CustomRSSAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||
searchURL := cfg.URL
|
||
// If extra has search URL template, use it
|
||
if searchTpl, ok := cfg.Extra["search_url"]; ok && searchTpl != "" {
|
||
searchURL = strings.ReplaceAll(searchTpl, "{keyword}", url.QueryEscape(keyword))
|
||
searchURL = strings.ReplaceAll(searchURL, "{page}", strconv.Itoa(page))
|
||
}
|
||
|
||
data, status, err := doRequest(ctx, a.client, "GET", searchURL, cfg, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("search: %w", err)
|
||
}
|
||
if status != http.StatusOK {
|
||
return nil, fmt.Errorf("search failed: status %d", status)
|
||
}
|
||
|
||
result, err := parseRSSXML(data, cfg.Name, keyword)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse RSS: %w", err)
|
||
}
|
||
|
||
if page > 1 {
|
||
// Simple pagination for RSS: skip items already seen
|
||
start := (page - 1) * 50
|
||
if start < len(result.Items) {
|
||
result.Items = result.Items[start:]
|
||
} else {
|
||
result.Items = []TorrentItem{}
|
||
}
|
||
}
|
||
result.Page = page
|
||
|
||
return result, nil
|
||
}
|
||
|
||
func (a *CustomRSSAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||
// RSS browse is essentially the same as search with empty keyword
|
||
return a.Search(ctx, cfg, "", page)
|
||
}
|
||
|
||
func (a *CustomRSSAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
|
||
// RSS typically doesn't support detail page; return basic info
|
||
return &TorrentDetail{
|
||
ID: id,
|
||
Title: id,
|
||
}, nil
|
||
}
|
||
|
||
func (a *CustomRSSAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
|
||
return id, nil // In RSS, the ID is often the download URL
|
||
}
|
||
|
||
// parseRSSXML 解析 RSS XML 内容。
|
||
func parseRSSXML(data []byte, siteName, keyword string) (*SiteSearchResult, error) {
|
||
result := &SiteSearchResult{
|
||
SiteName: siteName,
|
||
Items: []TorrentItem{},
|
||
}
|
||
|
||
html := string(data)
|
||
// Simple regex-based XML parsing for RSS items
|
||
itemRegex := regexp.MustCompile(`<item>(.*?)</item>`)
|
||
items := itemRegex.FindAllStringSubmatch(html, -1)
|
||
|
||
for i, item := range items {
|
||
ri := TorrentItem{}
|
||
|
||
// Title
|
||
titleRegex := regexp.MustCompile(`<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</title>`)
|
||
if m := titleRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
|
||
ri.Title = strings.TrimSpace(m[1])
|
||
}
|
||
|
||
// Filter by keyword
|
||
if keyword != "" && !strings.Contains(strings.ToLower(ri.Title), strings.ToLower(keyword)) {
|
||
continue
|
||
}
|
||
|
||
ri.ID = strconv.Itoa(i)
|
||
|
||
// Link
|
||
linkRegex := regexp.MustCompile(`<link>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</link>`)
|
||
if m := linkRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
|
||
ri.DetailURL = strings.TrimSpace(m[1])
|
||
ri.DownloadURL = strings.TrimSpace(m[1])
|
||
}
|
||
|
||
// Description
|
||
descRegex := regexp.MustCompile(`<description>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</description>`)
|
||
if m := descRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
|
||
desc := stripHTML(m[1])
|
||
ri.Subtitle = desc
|
||
}
|
||
|
||
// Size from description
|
||
sizeRegex := regexp.MustCompile(`(\d+\.?\d*)\s*(GB|MB|TB|KB)`)
|
||
if m := sizeRegex.FindStringSubmatch(item[1]); len(m) >= 3 {
|
||
ri.Size = parseSizeString(m[1], m[2])
|
||
}
|
||
|
||
// Category
|
||
catRegex := regexp.MustCompile(`<category>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</category>`)
|
||
if m := catRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
|
||
ri.Category = strings.TrimSpace(m[1])
|
||
}
|
||
|
||
// Date
|
||
dateRegex := regexp.MustCompile(`<pubDate>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</pubDate>`)
|
||
if m := dateRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
|
||
for _, layout := range []string{
|
||
time.RFC1123, time.RFC1123Z, time.RFC3339,
|
||
"2006-01-02 15:04:05", "2006-01-02T15:04:05-07:00",
|
||
} {
|
||
if t, err := time.Parse(layout, strings.TrimSpace(m[1])); err == nil {
|
||
ri.UploadTime = t
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
result.Items = append(result.Items, ri)
|
||
}
|
||
|
||
result.Total = len(result.Items)
|
||
return result, nil
|
||
}
|
||
|
||
// TorrentDetail has a Description field used by RSS adapter.
|
||
// (Already defined above)
|
||
|
||
// ─── 辅助函数 ────────────────────────────────────────────────────────────────
|
||
|
||
// doRequestJSON 执行 JSON 请求。
|
||
func doRequestJSON(ctx context.Context, client *http.Client, method, rawURL string, cfg SiteConfig, body []byte) ([]byte, int, error) {
|
||
req, err := buildRequest(ctx, method, rawURL, cfg, nil)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
if body != nil {
|
||
req.Body = io.NopCloser(strings.NewReader(string(body)))
|
||
req.ContentLength = int64(len(body))
|
||
}
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
data, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, resp.StatusCode, err
|
||
}
|
||
return data, resp.StatusCode, nil
|
||
}
|
||
|
||
// parseSizeString 将带单位的字符串转换为字节数。
|
||
func parseSizeString(value string, unit string) int64 {
|
||
v, err := strconv.ParseFloat(value, 64)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
switch strings.ToLower(unit) {
|
||
case "kb":
|
||
return int64(v * 1024)
|
||
case "mb":
|
||
return int64(v * 1024 * 1024)
|
||
case "gb":
|
||
return int64(v * 1024 * 1024 * 1024)
|
||
case "tb":
|
||
return int64(v * 1024 * 1024 * 1024 * 1024)
|
||
default:
|
||
return int64(v)
|
||
}
|
||
}
|
||
|
||
// stripHTML 移除 HTML 标签。
|
||
func stripHTML(s string) string {
|
||
re := regexp.MustCompile(`<[^>]*>`)
|
||
return re.ReplaceAllString(s, "")
|
||
}
|
||
|
||
// GetAdapterForType 根据站点类型返回对应的适配器实例。
|
||
func GetAdapterForType(siteType string) SiteAdapter {
|
||
switch strings.ToLower(siteType) {
|
||
case "nexusphp":
|
||
return NewNexusPHPAdapter()
|
||
case "gazelle":
|
||
return NewGazelleAdapter()
|
||
case "unit3d":
|
||
return NewUNIT3DAdapter()
|
||
case "mteam":
|
||
return NewMTeamAdapter()
|
||
case "discuz":
|
||
return NewDiscuzAdapter()
|
||
case "custom_rss":
|
||
return NewCustomRSSAdapter()
|
||
default:
|
||
return NewNexusPHPAdapter()
|
||
}
|
||
}
|
||
|
||
// NewSiteAdapter 根据站点模型创建对应的适配器。
|
||
func NewSiteAdapter(site *model.Site) SiteAdapter {
|
||
return GetAdapterForType(site.Type)
|
||
}
|