diff --git a/internal/service/site_adapter.go b/internal/service/site_adapter.go
index c9b771f..9f575dc 100644
--- a/internal/service/site_adapter.go
+++ b/internal/service/site_adapter.go
@@ -3,11 +3,9 @@ package service
import (
"context"
- "encoding/json"
"fmt"
"io"
"net/http"
- "net/url"
"regexp"
"strconv"
"strings"
@@ -194,964 +192,6 @@ func doRequest(ctx context.Context, client *http.Client, method, rawURL string,
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 {
- // 走 doRequest 以便复用代理 / FlareSolverr / 浏览器头。
- data, status, err := doRequest(ctx, a.client, "GET", cfg.URL+"/index.php", cfg, nil)
- if err != nil {
- return fmt.Errorf("request failed: %w", err)
- }
-
- if status == http.StatusFound {
- return fmt.Errorf("authentication failed: redirected to login page")
- }
- if status == http.StatusUnauthorized || status == http.StatusForbidden {
- return fmt.Errorf("authentication failed: status %d", status)
- }
- if status >= 400 {
- return fmt.Errorf("authentication failed: status %d", status)
- }
-
- body := string(data)
- // NexusPHP 登录后页面通常包含 logout 或 userdetails;
- // 仅当二者都不存在且明确显示登录表单时才判失败。
- if strings.Contains(body, "userdetails") || strings.Contains(body, "logout") || strings.Contains(body, "退出") {
- return nil
- }
- if strings.Contains(body, "takelogin.php") || strings.Contains(body, "id=\"loginform\"") {
- return fmt.Errorf("authentication failed: not logged in")
- }
- // 状态码 OK 但页面不含明显标记时不再武断判失败。
- 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(`
]*>\s*| ]*class="rowfollow"[^>]*>.*? |
`)
- 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(`]*>([^<]+)
`)
- if m := titleRegex.FindStringSubmatch(html); len(m) >= 2 {
- detail.Title = strings.TrimSpace(m[1])
- }
-
- // Subtitle
- subRegex := regexp.MustCompile(`]*class="[^"]*sub[^"]*"[^>]*>([^<]+)`)
- if m := subRegex.FindStringSubmatch(html); len(m) >= 2 {
- detail.Subtitle = strings.TrimSpace(m[1])
- }
-
- // Info hash
- hashRegex := regexp.MustCompile(`(?i)info_hash[^<]*\s*]*>([^<]+) | `)
- if m := hashRegex.FindStringSubmatch(html); len(m) >= 2 {
- detail.InfoHash = strings.TrimSpace(m[1])
- }
-
- // IMDB ID
- imdbRegex := regexp.MustCompile(`(?i)imdb[^<]*\s*]*>[^<]*(tt\d+)`)
- if m := imdbRegex.FindStringSubmatch(html); len(m) >= 2 {
- detail.ImdbID = m[1]
- }
-
- // Size
- sizeRegex := regexp.MustCompile(`(?i)size[^<]* | \s*]*>(\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[^<]* | \s*]*>(\d+) | \s*]*>\s* | \s*]*>\s* | \s*]*>leechers[^<]* | \s*]*>(\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[^<]* | \s*]*>(\d+)`)
- if m := snRegex.FindStringSubmatch(html); len(m) >= 2 {
- detail.Snatched, _ = strconv.Atoi(m[1])
- }
-
- // Description
- descRegex := regexp.MustCompile(`(?i) ]*id="kdescr"[^>]*>(.*?) `)
- 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
-}
-
-// ─── 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(`]*id="thread_subject"[^>]*>([^<]+)`)
- 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(`]*class="t_fsz"[^>]*>(.*?) `)
- 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(`]*href="(?:forum\.php\?mod=viewthread&tid=|thread-(\d+)-1-1)\.html"[^>]*>([^<]+)`)
- 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(`- (.*?)
`)
- items := itemRegex.FindAllStringSubmatch(html, -1)
-
- for i, item := range items {
- ri := TorrentItem{}
-
- // Title
- titleRegex := regexp.MustCompile(`(?:)?`)
- 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(`(?:)?`)
- 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(`(?:)?`)
- 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(`(?:)?`)
- if m := catRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
- ri.Category = strings.TrimSpace(m[1])
- }
-
- // Date
- dateRegex := regexp.MustCompile(`(?:)?`)
- 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 请求。
diff --git a/internal/service/site_adapter_custom_rss.go b/internal/service/site_adapter_custom_rss.go
new file mode 100644
index 0000000..f988a4a
--- /dev/null
+++ b/internal/service/site_adapter_custom_rss.go
@@ -0,0 +1,172 @@
+// Package service — custom RSS site adapter.
+package service
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// ─── 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(`- (.*?)
`)
+ items := itemRegex.FindAllStringSubmatch(html, -1)
+
+ for i, item := range items {
+ ri := TorrentItem{}
+
+ // Title
+ titleRegex := regexp.MustCompile(`(?:)?`)
+ 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(`(?:)?`)
+ 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(`(?:)?`)
+ 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(`(?:)?`)
+ if m := catRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
+ ri.Category = strings.TrimSpace(m[1])
+ }
+
+ // Date
+ dateRegex := regexp.MustCompile(`(?:)?`)
+ 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)
diff --git a/internal/service/site_adapter_discuz.go b/internal/service/site_adapter_discuz.go
new file mode 100644
index 0000000..3c66f2d
--- /dev/null
+++ b/internal/service/site_adapter_discuz.go
@@ -0,0 +1,167 @@
+// Package service — Discuz site adapter.
+package service
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// ─── 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(`]*id="thread_subject"[^>]*>([^<]+)`)
+ 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(`]*class="t_fsz"[^>]*>(.*?) `)
+ 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(`]*href="(?:forum\.php\?mod=viewthread&tid=|thread-(\d+)-1-1)\.html"[^>]*>([^<]+)`)
+ 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
+}
diff --git a/internal/service/site_adapter_gazelle.go b/internal/service/site_adapter_gazelle.go
new file mode 100644
index 0000000..9972561
--- /dev/null
+++ b/internal/service/site_adapter_gazelle.go
@@ -0,0 +1,227 @@
+// Package service — Gazelle site adapter.
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+)
+
+// ─── 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
+}
diff --git a/internal/service/site_adapter_nexusphp.go b/internal/service/site_adapter_nexusphp.go
new file mode 100644
index 0000000..8af6a24
--- /dev/null
+++ b/internal/service/site_adapter_nexusphp.go
@@ -0,0 +1,257 @@
+// Package service — NexusPHP site adapter.
+package service
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// ─── 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 {
+ // 走 doRequest 以便复用代理 / FlareSolverr / 浏览器头。
+ data, status, err := doRequest(ctx, a.client, "GET", cfg.URL+"/index.php", cfg, nil)
+ if err != nil {
+ return fmt.Errorf("request failed: %w", err)
+ }
+
+ if status == http.StatusFound {
+ return fmt.Errorf("authentication failed: redirected to login page")
+ }
+ if status == http.StatusUnauthorized || status == http.StatusForbidden {
+ return fmt.Errorf("authentication failed: status %d", status)
+ }
+ if status >= 400 {
+ return fmt.Errorf("authentication failed: status %d", status)
+ }
+
+ body := string(data)
+ // NexusPHP 登录后页面通常包含 logout 或 userdetails;
+ // 仅当二者都不存在且明确显示登录表单时才判失败。
+ if strings.Contains(body, "userdetails") || strings.Contains(body, "logout") || strings.Contains(body, "退出") {
+ return nil
+ }
+ if strings.Contains(body, "takelogin.php") || strings.Contains(body, "id=\"loginform\"") {
+ return fmt.Errorf("authentication failed: not logged in")
+ }
+ // 状态码 OK 但页面不含明显标记时不再武断判失败。
+ 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(` | ]*>\s*| ]*class="rowfollow"[^>]*>.*? |
`)
+ 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(`]*>([^<]+)
`)
+ if m := titleRegex.FindStringSubmatch(html); len(m) >= 2 {
+ detail.Title = strings.TrimSpace(m[1])
+ }
+
+ // Subtitle
+ subRegex := regexp.MustCompile(`]*class="[^"]*sub[^"]*"[^>]*>([^<]+)`)
+ if m := subRegex.FindStringSubmatch(html); len(m) >= 2 {
+ detail.Subtitle = strings.TrimSpace(m[1])
+ }
+
+ // Info hash
+ hashRegex := regexp.MustCompile(`(?i)info_hash[^<]*\s*]*>([^<]+) | `)
+ if m := hashRegex.FindStringSubmatch(html); len(m) >= 2 {
+ detail.InfoHash = strings.TrimSpace(m[1])
+ }
+
+ // IMDB ID
+ imdbRegex := regexp.MustCompile(`(?i)imdb[^<]*\s*]*>[^<]*(tt\d+)`)
+ if m := imdbRegex.FindStringSubmatch(html); len(m) >= 2 {
+ detail.ImdbID = m[1]
+ }
+
+ // Size
+ sizeRegex := regexp.MustCompile(`(?i)size[^<]* | \s*]*>(\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[^<]* | \s*]*>(\d+) | \s*]*>\s* | \s*]*>\s* | \s*]*>leechers[^<]* | \s*]*>(\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[^<]* | \s*]*>(\d+)`)
+ if m := snRegex.FindStringSubmatch(html); len(m) >= 2 {
+ detail.Snatched, _ = strconv.Atoi(m[1])
+ }
+
+ // Description
+ descRegex := regexp.MustCompile(`(?i) ]*id="kdescr"[^>]*>(.*?) `)
+ 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
+}
diff --git a/internal/service/site_adapter_unit3d.go b/internal/service/site_adapter_unit3d.go
new file mode 100644
index 0000000..f99fb75
--- /dev/null
+++ b/internal/service/site_adapter_unit3d.go
@@ -0,0 +1,198 @@
+// Package service — UNIT3D site adapter.
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+)
+
+// ─── 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
+}
diff --git a/web/src/pages/SiteCard.tsx b/web/src/pages/SiteCard.tsx
new file mode 100644
index 0000000..386f1e8
--- /dev/null
+++ b/web/src/pages/SiteCard.tsx
@@ -0,0 +1,161 @@
+import {
+ CheckCircle,
+ Edit3,
+ HelpCircle,
+ RefreshCw,
+ Trash2,
+ Wifi,
+ XCircle,
+} from "lucide-react";
+
+import type { Site } from "../types";
+import {
+ AUTH_TYPE_LABELS,
+ SITE_TYPE_ABBR,
+ SITE_TYPE_COLORS,
+ SITE_TYPE_LABELS,
+} from "./sitesPageModel";
+
+type SiteCardProps = {
+ site: Site;
+ testing: boolean;
+ onTest: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+};
+
+export function SiteCard({
+ site,
+ testing,
+ onTest,
+ onEdit,
+ onDelete,
+}: SiteCardProps) {
+ return (
+
+ {/* 头部 */}
+
+
+
+ {SITE_TYPE_ABBR[site.type] || "?"}
+
+
+ {site.name}
+
+ {site.url}
+
+
+
+ {/* 状态指示 */}
+
+ {site.last_check_at ? (
+ site.last_error ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+ {!site.enabled && (
+ 已停用
+ )}
+
+
+
+ {/* 标签 */}
+
+
+ {SITE_TYPE_LABELS[site.type] || site.type}
+
+
+ {AUTH_TYPE_LABELS[site.auth_type] || site.auth_type}
+
+ {site.is_default && (
+
+ 默认
+
+ )}
+ {site.use_proxy && (
+
+ 代理
+
+ )}
+ {site.rate_limit && (
+
+ 限流
+
+ )}
+ {site.browser_emulation && (
+
+ 浏览器
+
+ )}
+
+
+ {/* 状态与统计(只读) */}
+
+
+ 状态:
+
+ {site.login_status || "unknown"}
+
+
+ {(site.upload_bytes || 0) > 0 && (
+
+ ↑ {Math.round(((site.upload_bytes ?? 0) / 1073741824) * 100) / 100}{" "}
+ GB / ↓{" "}
+ {Math.round(((site.download_bytes ?? 0) / 1073741824) * 100) / 100}{" "}
+ GB
+
+ )}
+ {site.priority !== 50 && 优先级:{site.priority} }
+
+
+ {/* 操作按钮 */}
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/pages/SiteFormModal.tsx b/web/src/pages/SiteFormModal.tsx
new file mode 100644
index 0000000..354a047
--- /dev/null
+++ b/web/src/pages/SiteFormModal.tsx
@@ -0,0 +1,466 @@
+import type { Dispatch, FormEvent, SetStateAction } from "react";
+import { RefreshCw, X } from "lucide-react";
+
+import type { SiteForm } from "./sitesPageModel";
+
+type SiteFormModalProps = {
+ editingId: string | null;
+ form: SiteForm;
+ setForm: Dispatch>;
+ saving: boolean;
+ advancedOpen: boolean;
+ setAdvancedOpen: Dispatch>;
+ onClose: () => void;
+ onSubmit: (event: FormEvent) => void | Promise;
+ onTypeChange: (type: string) => void;
+};
+
+export function SiteFormModal({
+ editingId,
+ form,
+ setForm,
+ saving,
+ advancedOpen,
+ setAdvancedOpen,
+ onClose,
+ onSubmit,
+ onTypeChange,
+}: SiteFormModalProps) {
+ return (
+
+ event.stopPropagation()}
+ >
+
+
+ {editingId ? "编辑站点" : "添加站点"}
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/pages/SitesPage.tsx b/web/src/pages/SitesPage.tsx
index c9195bd..d46a0c8 100644
--- a/web/src/pages/SitesPage.tsx
+++ b/web/src/pages/SitesPage.tsx
@@ -1,145 +1,149 @@
-import { FormEvent, useEffect, useState } from 'react'
-import toast from 'react-hot-toast'
-import { Globe, Plus, Trash2, Wifi, RefreshCw, X, Edit3, CheckCircle, XCircle, HelpCircle } from 'lucide-react'
+import { type FormEvent, useEffect, useState } from "react";
+import toast from "react-hot-toast";
+import { Globe, Plus, RefreshCw } from "lucide-react";
-import { sitesAPI } from '../api/sites'
-import type { Site } from '../types'
-import { ManagementShortcuts } from '../components/ManagementShortcuts'
-import { confirmAction } from '../components/ConfirmDialog'
+import { sitesAPI } from "../api/sites";
+import type { Site } from "../types";
+import { ManagementShortcuts } from "../components/ManagementShortcuts";
+import { confirmAction } from "../components/ConfirmDialog";
-import { AUTH_TYPE_LABELS, defaultSiteForm, siteFormToPayload, siteToForm, SITE_TYPE_ABBR, SITE_TYPE_COLORS, SITE_TYPE_LABELS } from './sitesPageModel'
+import {
+ defaultSiteForm,
+ siteFormToPayload,
+ siteToForm,
+} from "./sitesPageModel";
+import { SiteCard } from "./SiteCard";
+import { SiteFormModal } from "./SiteFormModal";
export function SitesPage() {
- const [sites, setSites] = useState([])
- const [loading, setLoading] = useState(true)
- const [showModal, setShowModal] = useState(false)
- const [editingId, setEditingId] = useState(null)
- const [form, setForm] = useState(defaultSiteForm())
- const [saving, setSaving] = useState(false)
- const [testingId, setTestingId] = useState(null)
- const [advancedOpen, setAdvancedOpen] = useState(false)
+ const [sites, setSites] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showModal, setShowModal] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [form, setForm] = useState(defaultSiteForm());
+ const [saving, setSaving] = useState(false);
+ const [testingId, setTestingId] = useState(null);
+ const [advancedOpen, setAdvancedOpen] = useState(false);
const loadSites = async () => {
- setLoading(true)
+ setLoading(true);
try {
- const res = await sitesAPI.list()
- setSites(Array.isArray(res.data) ? res.data : [])
+ const response = await sitesAPI.list();
+ setSites(Array.isArray(response.data) ? response.data : []);
} catch {
- toast.error('加载站点列表失败')
+ toast.error("加载站点列表失败");
} finally {
- setLoading(false)
+ setLoading(false);
}
- }
+ };
useEffect(() => {
- loadSites()
- }, [])
+ loadSites();
+ }, []);
- // ── 弹窗操作 ──
const openCreate = () => {
- setEditingId(null)
- setForm(defaultSiteForm())
- setAdvancedOpen(false)
- setShowModal(true)
- }
+ setEditingId(null);
+ setForm(defaultSiteForm());
+ setAdvancedOpen(false);
+ setShowModal(true);
+ };
const openEdit = async (id: string) => {
try {
- const res = await sitesAPI.get(id)
- const s = res.data as Site
- setEditingId(id)
- setForm(siteToForm(s))
- setAdvancedOpen(false)
- setShowModal(true)
+ const response = await sitesAPI.get(id);
+ setEditingId(id);
+ setForm(siteToForm(response.data as Site));
+ setAdvancedOpen(false);
+ setShowModal(true);
} catch {
- toast.error('获取站点详情失败')
+ toast.error("获取站点详情失败");
}
- }
+ };
const closeModal = () => {
- setShowModal(false)
- setEditingId(null)
- }
+ setShowModal(false);
+ setEditingId(null);
+ };
- // ── 保存(支持静默模式)──
const silentSave = async (): Promise => {
- if (!form.name.trim() || !form.url.trim()) return false
- const payload = siteFormToPayload(form, !editingId)
+ if (!form.name.trim() || !form.url.trim()) return false;
+ const payload = siteFormToPayload(form, !editingId);
try {
if (editingId) {
- await sitesAPI.update(editingId, payload)
+ await sitesAPI.update(editingId, payload);
} else {
- const res = await sitesAPI.create(payload)
- setEditingId((res.data as Site)?.id ?? null)
+ const response = await sitesAPI.create(payload);
+ setEditingId((response.data as Site)?.id ?? null);
}
- return true
+ return true;
} catch {
- return false
+ return false;
}
- }
+ };
- const handleSave = async (e: FormEvent) => {
- e.preventDefault()
- setSaving(true)
- const ok = await silentSave()
+ const handleSave = async (event: FormEvent) => {
+ event.preventDefault();
+ setSaving(true);
+ const ok = await silentSave();
if (ok) {
- toast.success(editingId ? '站点已更新' : '站点已添加')
- closeModal()
- await loadSites()
+ toast.success(editingId ? "站点已更新" : "站点已添加");
+ closeModal();
+ await loadSites();
} else {
- const msg = '保存失败'
- toast.error(msg)
+ toast.error("保存失败");
}
- setSaving(false)
- }
+ setSaving(false);
+ };
- // 测试(先自动保存)
const handleTest = async (id: string) => {
- // 如果正在编辑当前站点,先保存
if (editingId === id) {
- setSaving(true)
- const ok = await silentSave()
- setSaving(false)
+ setSaving(true);
+ const ok = await silentSave();
+ setSaving(false);
if (!ok) {
- toast.error('保存失败,无法测试')
- return
+ toast.error("保存失败,无法测试");
+ return;
}
}
- setTestingId(id)
+
+ setTestingId(id);
try {
- const res = await sitesAPI.test(id)
- const msg = res?.message || '连接测试成功'
- toast.success(msg)
+ const response = await sitesAPI.test(id);
+ toast.success(response?.message || "连接测试成功");
} catch (err: unknown) {
- const msg =
- (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
- '连接测试失败'
- toast.error(msg)
+ const message =
+ (err as { response?: { data?: { message?: string } } })?.response?.data
+ ?.message ?? "连接测试失败";
+ toast.error(message);
} finally {
- setTestingId(null)
+ setTestingId(null);
}
- }
+ };
- // ── 删除 ──
const handleDelete = async (site: Site) => {
- if (!(await confirmAction({ title: '删除站点', message: `确定要删除站点「${site.name}」吗?此操作不可撤销。`, confirmText: '删除' }))) return
- try {
- await sitesAPI.remove(site.id)
- toast.success('站点已删除')
- await loadSites()
- } catch {
- toast.error('删除站点失败')
- }
- }
+ const confirmed = await confirmAction({
+ title: "删除站点",
+ message: `确定要删除站点「${site.name}」吗?此操作不可撤销。`,
+ confirmText: "删除",
+ });
+ if (!confirmed) return;
- // ── 站点类型切换时自动切换认证方式 ──
- const handleTypeChange = (t: string) => {
- setForm((f) => ({
- ...f,
- type: t,
- auth_type: t === 'mteam' ? 'api_key' : f.auth_type,
- }))
- }
+ try {
+ await sitesAPI.remove(site.id);
+ toast.success("站点已删除");
+ await loadSites();
+ } catch {
+ toast.error("删除站点失败");
+ }
+ };
+
+ const handleTypeChange = (type: string) => {
+ setForm((current) => ({
+ ...current,
+ type,
+ auth_type: type === "mteam" ? "api_key" : current.auth_type,
+ }));
+ };
return (
@@ -147,139 +151,65 @@ export function SitesPage() {
title="站点与下载链路"
description="把站点、搜索、订阅和下载器放在同一工作流里,避免功能入口被隐藏。"
items={[
- { to: '/download-clients', title: '下载器管理', description: '添加、测试和维护下载器连接', badge: '必需' },
- { to: '/site-search', title: '站点检索', description: '跨 PT 站点搜索资源并创建下载任务' },
- { to: '/subscriptions', title: '订阅管理', description: '管理追剧追番和自动下载规则' },
- { to: '/downloads', title: '下载中心', description: '查看下载任务状态和历史记录' },
+ {
+ to: "/download-clients",
+ title: "下载器管理",
+ description: "添加、测试和维护下载器连接",
+ badge: "必需",
+ },
+ {
+ to: "/site-search",
+ title: "站点检索",
+ description: "跨 PT 站点搜索资源并创建下载任务",
+ },
+ {
+ to: "/subscriptions",
+ title: "订阅管理",
+ description: "管理追剧追番和自动下载规则",
+ },
+ {
+ to: "/downloads",
+ title: "下载中心",
+ description: "查看下载任务状态和历史记录",
+ },
]}
/>
- {/* 页头 */}
- 站点管理
-
- {/* 站点卡片网格 */}
{sites.map((site) => (
-
- {/* 头部 */}
-
-
-
- {SITE_TYPE_ABBR[site.type] || '?'}
-
-
- {site.name}
- {site.url}
-
-
- {/* 状态指示 */}
-
- {site.last_check_at ? (
- site.last_error ? (
-
- ) : (
-
- )
- ) : (
-
- )}
- {!site.enabled && 已停用}
-
-
-
- {/* 标签 */}
-
-
- {SITE_TYPE_LABELS[site.type] || site.type}
-
-
- {AUTH_TYPE_LABELS[site.auth_type] || site.auth_type}
-
- {site.is_default && (
- 默认
- )}
- {site.use_proxy && (
- 代理
- )}
- {site.rate_limit && (
- 限流
- )}
- {site.browser_emulation && (
- 浏览器
- )}
-
-
- {/* 状态与统计(只读) */}
-
- 状态:
-
- {site.login_status || 'unknown'}
-
-
- {(site.upload_bytes || 0) > 0 && (
- ↑ {Math.round((site.upload_bytes ?? 0) / 1073741824 * 100) / 100} GB / ↓ {Math.round((site.download_bytes ?? 0) / 1073741824 * 100) / 100} GB
- )}
- {site.priority !== 50 && (
- 优先级:{site.priority}
- )}
-
-
- {/* 操作按钮 */}
-
- handleTest(site.id)}
- disabled={testingId === site.id}
- className="flex-1 rounded-lg border border-gray-200 px-2 py-1.5 text-xs text-ink-100 hover:bg-gray-50 disabled:opacity-50 flex items-center justify-center gap-1 transition"
- >
- {testingId === site.id ? (
- <>
-
- 测试中...
- >
- ) : (
- <>
-
- 测试连接
- >
- )}
-
- openEdit(site.id)}
- className="rounded-lg border border-gray-200 p-1.5 text-ink-50 hover:text-white hover:bg-gray-50 transition"
- title="编辑"
- >
-
-
- handleDelete(site)}
- className="rounded-lg border border-gray-200 p-1.5 text-ink-50 hover:text-red-400 hover:bg-red-400/10 transition"
- title="删除"
- >
-
-
-
-
+ handleTest(site.id)}
+ onEdit={() => openEdit(site.id)}
+ onDelete={() => handleDelete(site)}
+ />
))}
- {/* 空状态 */}
{!loading && sites.length === 0 && (
暂无站点
- 点击「添加站点」添加 PT/BT 站点
+
+ 点击「添加站点」添加 PT/BT 站点
+
)}
- {/* 加载中 */}
{loading && (
@@ -288,320 +218,19 @@ export function SitesPage() {
)}
- {/* ── 创建/编辑弹窗 ── */}
{showModal && (
-
- e.stopPropagation()}
- >
- {/* 标题栏 */}
-
-
- {editingId ? '编辑站点' : '添加站点'}
-
-
-
-
-
-
-
-
-
+
)}
- )
+ );
}
diff --git a/web/src/pages/sitesPageModel.ts b/web/src/pages/sitesPageModel.ts
index 7c173b2..fe4d9a9 100644
--- a/web/src/pages/sitesPageModel.ts
+++ b/web/src/pages/sitesPageModel.ts
@@ -1,89 +1,91 @@
-import type { Site } from '../types'
+import type { Site } from "../types";
// ── 站点类型映射 ──
export const SITE_TYPE_LABELS: Record = {
- nexusphp: 'NexusPHP',
- gazelle: 'Gazelle',
- unit3d: 'UNIT3D',
- mteam: 'M-Team',
- discuz: 'Discuz',
- custom_rss: '自定义 RSS',
-}
+ nexusphp: "NexusPHP",
+ gazelle: "Gazelle",
+ unit3d: "UNIT3D",
+ mteam: "M-Team",
+ discuz: "Discuz",
+ custom_rss: "自定义 RSS",
+};
export const SITE_TYPE_ABBR: Record = {
- nexusphp: 'NP',
- gazelle: 'GZ',
- unit3d: 'U3',
- mteam: 'MT',
- discuz: 'DZ',
- custom_rss: 'RS',
-}
+ nexusphp: "NP",
+ gazelle: "GZ",
+ unit3d: "U3",
+ mteam: "MT",
+ discuz: "DZ",
+ custom_rss: "RS",
+};
export const SITE_TYPE_COLORS: Record = {
- nexusphp: 'bg-blue-500/15 text-blue-400',
- gazelle: 'bg-purple-500/15 text-purple-400',
- unit3d: 'bg-orange-500/15 text-orange-400',
- mteam: 'bg-green-500/15 text-green-400',
- discuz: 'bg-yellow-500/15 text-yellow-400',
- custom_rss: 'bg-sand-500/15 text-ink-50',
-}
+ nexusphp: "bg-blue-500/15 text-blue-400",
+ gazelle: "bg-purple-500/15 text-purple-400",
+ unit3d: "bg-orange-500/15 text-orange-400",
+ mteam: "bg-green-500/15 text-green-400",
+ discuz: "bg-yellow-500/15 text-yellow-400",
+ custom_rss: "bg-sand-500/15 text-ink-50",
+};
export const AUTH_TYPE_LABELS: Record = {
- cookie: 'Cookie',
- api_key: 'API Key',
- auth_header: 'Auth Header',
-}
+ cookie: "Cookie",
+ api_key: "API Key",
+ auth_header: "Auth Header",
+};
// ── 默认表单 ──
export const defaultSiteForm = () => ({
- name: '',
- url: '',
- type: 'nexusphp',
- auth_type: 'cookie',
- cookie: '',
- api_key: '',
- auth_header: '',
+ name: "",
+ url: "",
+ type: "nexusphp",
+ auth_type: "cookie",
+ cookie: "",
+ api_key: "",
+ auth_header: "",
enabled: true,
is_default: false,
- extra: '',
+ extra: "",
// 高级设置
- user_agent: '',
- rss_url: '',
+ user_agent: "",
+ rss_url: "",
timeout: 15,
priority: 50,
use_proxy: false,
rate_limit: false,
browser_emulation: false,
- downloader: '',
-})
+ downloader: "",
+});
-
-export type SiteForm = ReturnType
+export type SiteForm = ReturnType;
export function siteToForm(site: Site): SiteForm {
return {
- name: site.name || '',
- url: site.url || '',
- type: site.type || 'nexusphp',
- auth_type: site.auth_type || 'cookie',
- cookie: site.cookie || '',
- api_key: site.api_key || '',
- auth_header: site.auth_header || '',
+ name: site.name || "",
+ url: site.url || "",
+ type: site.type || "nexusphp",
+ auth_type: site.auth_type || "cookie",
+ cookie: site.cookie || "",
+ api_key: site.api_key || "",
+ auth_header: site.auth_header || "",
enabled: site.enabled !== false,
is_default: site.is_default || false,
- extra: site.extra || '',
- user_agent: site.user_agent || '',
- rss_url: site.rss_url || '',
+ extra: site.extra || "",
+ user_agent: site.user_agent || "",
+ rss_url: site.rss_url || "",
timeout: site.timeout ?? 15,
priority: site.priority ?? 50,
use_proxy: site.use_proxy || false,
rate_limit: site.rate_limit || false,
browser_emulation: site.browser_emulation || false,
- downloader: site.downloader || '',
- }
+ downloader: site.downloader || "",
+ };
}
-export function siteFormToPayload(form: SiteForm, includeEmptySecrets: boolean): Record {
+export function siteFormToPayload(
+ form: SiteForm,
+ includeEmptySecrets: boolean,
+): Record {
const payload: Record = {
name: form.name.trim(),
url: form.url.trim(),
@@ -91,18 +93,21 @@ export function siteFormToPayload(form: SiteForm, includeEmptySecrets: boolean):
auth_type: form.auth_type,
enabled: form.enabled,
is_default: form.is_default,
- extra: form.extra || '',
- user_agent: form.user_agent || '',
- rss_url: form.rss_url || '',
+ extra: form.extra || "",
+ user_agent: form.user_agent || "",
+ rss_url: form.rss_url || "",
timeout: Number(form.timeout) || 15,
priority: Number(form.priority) || 50,
use_proxy: !!form.use_proxy,
rate_limit: !!form.rate_limit,
browser_emulation: !!form.browser_emulation,
- downloader: form.downloader || '',
- }
- if (includeEmptySecrets || form.cookie.trim()) payload.cookie = form.cookie.trim()
- if (includeEmptySecrets || form.api_key.trim()) payload.api_key = form.api_key.trim()
- if (includeEmptySecrets || form.auth_header.trim()) payload.auth_header = form.auth_header.trim()
- return payload
+ downloader: form.downloader || "",
+ };
+ if (includeEmptySecrets || form.cookie.trim())
+ payload.cookie = form.cookie.trim();
+ if (includeEmptySecrets || form.api_key.trim())
+ payload.api_key = form.api_key.trim();
+ if (includeEmptySecrets || form.auth_header.trim())
+ payload.auth_header = form.auth_header.trim();
+ return payload;
}
|