From a5d061afa6bb288fc62c5a39694e417cb0c0d06e Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Sun, 7 Jun 2026 18:55:51 +0800 Subject: [PATCH] refactor: split site adapters and site page modules --- internal/service/site_adapter.go | 960 -------------------- internal/service/site_adapter_custom_rss.go | 172 ++++ internal/service/site_adapter_discuz.go | 167 ++++ internal/service/site_adapter_gazelle.go | 227 +++++ internal/service/site_adapter_nexusphp.go | 257 ++++++ internal/service/site_adapter_unit3d.go | 198 ++++ web/src/pages/SiteCard.tsx | 161 ++++ web/src/pages/SiteFormModal.tsx | 466 ++++++++++ web/src/pages/SitesPage.tsx | 673 +++----------- web/src/pages/sitesPageModel.ts | 129 +-- 10 files changed, 1866 insertions(+), 1544 deletions(-) create mode 100644 internal/service/site_adapter_custom_rss.go create mode 100644 internal/service/site_adapter_discuz.go create mode 100644 internal/service/site_adapter_gazelle.go create mode 100644 internal/service/site_adapter_nexusphp.go create mode 100644 internal/service/site_adapter_unit3d.go create mode 100644 web/src/pages/SiteCard.tsx create mode 100644 web/src/pages/SiteFormModal.tsx 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(`(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?`) - 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(`(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?`) + 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 ? "编辑站点" : "添加站点"} +

+ +
+ +
+
+ + + setForm((current) => ({ ...current, name: event.target.value })) + } + /> +
+ +
+ + + setForm((current) => ({ ...current, url: event.target.value })) + } + /> +

+ 格式: https://www.example.com/ +

+
+ +
+
+ + +
+
+ +
+ + + {form.enabled ? "启用" : "停用"} + +
+
+
+ + {form.type === "mteam" && ( +
+
+ 馒头站点配置指南 +
+
+
+ 正式站地址: + https://api.m-team.cc +
+
+ 测试站地址: + + https://test2.m-team.cc + + (开发测试环境) +
+
+ 认证方式:必须使用「API Access Token」,不要使用 Cookie +
+
+ 1. 登录馒头站 → 控制台 → 实验室 → 存取令牌 +
+ 2. 点击「创建令牌」,复制生成的 Token +
+ 3. 将 Token 填入下方「令牌」输入框 +
+
+
+ )} + +
+ +
+ {[ + { value: "cookie", label: "Cookie" }, + { value: "api_key", label: "API Key" }, + { value: "auth_header", label: "Auth Header" }, + ].map((option) => ( + + ))} +
+ + {form.auth_type === "cookie" && ( +
+ +