mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
294 lines
8.9 KiB
Go
294 lines
8.9 KiB
Go
// Package service — PT 站点适配器接口及 6 种适配器实现。
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"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)
|
||
UseProxy bool // 通过 HTTP(S)_PROXY 环境变量出站
|
||
}
|
||
|
||
// 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 客户端。
|
||
// 当 cfg.UseProxy 为 true 时,会读取 HTTP(S)_PROXY 环境变量;
|
||
// 否则忽略环境变量直连。
|
||
func newHTTPClient(cfg SiteConfig, timeout time.Duration) *http.Client {
|
||
secs := int(timeout.Seconds())
|
||
if secs <= 0 {
|
||
secs = 30
|
||
}
|
||
return helper.NewSiteHTTPClient(secs, cfg.UseProxy)
|
||
}
|
||
|
||
// 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":
|
||
// 与旧版参考实现的 ApplySiteAuthHeaders 对齐:
|
||
// M-Team / UNIT3D 等开放 API 的 PT 站点都使用 `x-api-key` 头部,
|
||
// 不要再为 mteam 单独走 Authorization: Bearer,否则服务端会 401。
|
||
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")
|
||
}
|
||
|
||
// 当站点开启了「使用代理」开关时,使用本次请求专用的、读取 HTTP(S)_PROXY
|
||
// 的 client;否则沿用适配器持有的全局 client。这与前端勾选行为对齐。
|
||
httpClient := client
|
||
if cfg.UseProxy {
|
||
httpClient = newHTTPClient(cfg, cfg.Timeout)
|
||
}
|
||
|
||
resp, err := httpClient.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
|
||
}
|
||
|
||
// ─── 辅助函数 ────────────────────────────────────────────────────────────────
|
||
|
||
// 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))
|
||
}
|
||
|
||
httpClient := client
|
||
if cfg.UseProxy {
|
||
httpClient = newHTTPClient(cfg, cfg.Timeout)
|
||
}
|
||
resp, err := httpClient.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
|
||
}
|
||
|
||
func mteamCodeOK(code any) bool {
|
||
codeStr := mteamCodeString(code)
|
||
return codeStr == "0" || codeStr == "200"
|
||
}
|
||
|
||
func mteamCodeString(code any) string {
|
||
switch v := code.(type) {
|
||
case string:
|
||
return strings.TrimSpace(v)
|
||
case float64:
|
||
return strconv.Itoa(int(v))
|
||
case int:
|
||
return strconv.Itoa(v)
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|