mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
refactor: split site adapters and site page modules
This commit is contained in:
@@ -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(`<tr[^>]*>\s*<td[^>]*class="rowfollow"[^>]*>.*?</tr>`)
|
||||
matches := rowRegex.FindAllString(html, -1)
|
||||
|
||||
for _, row := range matches {
|
||||
item := parseNexusPHPRow(row, baseURL)
|
||||
if item.ID != "" {
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
}
|
||||
|
||||
result.Total = len(result.Items)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseNexusPHPRow 解析单行种子条目。
|
||||
func parseNexusPHPRow(row, baseURL string) TorrentItem {
|
||||
item := TorrentItem{}
|
||||
|
||||
// Extract torrent ID and title
|
||||
idRegex := regexp.MustCompile(`details\.php\?id=(\d+)[^"]*"[^>]*>([^<]+)`)
|
||||
idMatches := idRegex.FindStringSubmatch(row)
|
||||
if len(idMatches) >= 3 {
|
||||
item.ID = idMatches[1]
|
||||
item.Title = strings.TrimSpace(idMatches[2])
|
||||
item.DetailURL = baseURL + "/details.php?id=" + item.ID
|
||||
}
|
||||
|
||||
// Extract download link
|
||||
dlRegex := regexp.MustCompile(`download\.php\?id=(\d+)`)
|
||||
if dlMatches := dlRegex.FindStringSubmatch(row); len(dlMatches) >= 2 {
|
||||
item.DownloadURL = baseURL + "/download.php?id=" + dlMatches[1]
|
||||
}
|
||||
|
||||
// Extract size
|
||||
sizeRegex := regexp.MustCompile(`(?i)(\d+\.?\d*)\s*(GB|MB|TB|KB)`)
|
||||
if sizeMatches := sizeRegex.FindStringSubmatch(row); len(sizeMatches) >= 3 {
|
||||
item.Size = parseSizeString(sizeMatches[1], sizeMatches[2])
|
||||
}
|
||||
|
||||
// Extract seeders and leechers
|
||||
seedersRegex := regexp.MustCompile(`seeders[^"]*"[^>]*>(\d+)<`)
|
||||
if m := seedersRegex.FindStringSubmatch(row); len(m) >= 2 {
|
||||
item.Seeders, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
leechersRegex := regexp.MustCompile(`leechers[^"]*"[^>]*>(\d+)<`)
|
||||
if m := leechersRegex.FindStringSubmatch(row); len(m) >= 2 {
|
||||
item.Leechers, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
|
||||
// Extract snatched
|
||||
snatchedRegex := regexp.MustCompile(`snatched[^"]*"[^>]*>(\d+)`)
|
||||
if m := snatchedRegex.FindStringSubmatch(row); len(m) >= 2 {
|
||||
item.Snatched, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
|
||||
// Check for free flag
|
||||
freeRegex := regexp.MustCompile(`(?i)(class="free|free2|twoupfree|free_download|促销|免费)`)
|
||||
item.Free = freeRegex.MatchString(row)
|
||||
|
||||
// Extract upload time
|
||||
timeRegex := regexp.MustCompile(`(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})`)
|
||||
if m := timeRegex.FindStringSubmatch(row); len(m) >= 2 {
|
||||
if t, err := time.Parse("2006-01-02 15:04", m[1]); err == nil {
|
||||
item.UploadTime = t
|
||||
}
|
||||
}
|
||||
|
||||
// Extract category
|
||||
catRegex := regexp.MustCompile(`cat=(\d+)[^"]*"[^>]*title="([^"]+)"`)
|
||||
if m := catRegex.FindStringSubmatch(row); len(m) >= 3 {
|
||||
item.Category = strings.TrimSpace(m[2])
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
// parseNexusPHPDetailHTML 解析种子详情页。
|
||||
func parseNexusPHPDetailHTML(html, id, baseURL string) (*TorrentDetail, error) {
|
||||
detail := &TorrentDetail{
|
||||
ID: id,
|
||||
DetailURL: baseURL + "/details.php?id=" + id,
|
||||
}
|
||||
|
||||
// Title
|
||||
titleRegex := regexp.MustCompile(`<h1[^>]*>([^<]+)</h1>`)
|
||||
if m := titleRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.Title = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// Subtitle
|
||||
subRegex := regexp.MustCompile(`<span[^>]*class="[^"]*sub[^"]*"[^>]*>([^<]+)</span>`)
|
||||
if m := subRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.Subtitle = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// Info hash
|
||||
hashRegex := regexp.MustCompile(`(?i)info_hash[^<]*</td>\s*<td[^>]*>([^<]+)</td>`)
|
||||
if m := hashRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.InfoHash = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// IMDB ID
|
||||
imdbRegex := regexp.MustCompile(`(?i)imdb[^<]*</td>\s*<td[^>]*>[^<]*(tt\d+)`)
|
||||
if m := imdbRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.ImdbID = m[1]
|
||||
}
|
||||
|
||||
// Size
|
||||
sizeRegex := regexp.MustCompile(`(?i)size[^<]*</td>\s*<td[^>]*>(\d+\.?\d*)\s*(GB|MB|TB|KB)`)
|
||||
if m := sizeRegex.FindStringSubmatch(html); len(m) >= 3 {
|
||||
detail.Size = parseSizeString(m[1], m[2])
|
||||
}
|
||||
|
||||
// Seeders / Leechers / Snatched
|
||||
slRegex := regexp.MustCompile(`seeders[^<]*</td>\s*<td[^>]*>(\d+)</td>\s*<td[^>]*>\s*</td>\s*<td[^>]*>\s*</td>\s*<td[^>]*>leechers[^<]*</td>\s*<td[^>]*>(\d+)`)
|
||||
if m := slRegex.FindStringSubmatch(html); len(m) >= 3 {
|
||||
detail.Seeders, _ = strconv.Atoi(m[1])
|
||||
detail.Leechers, _ = strconv.Atoi(m[2])
|
||||
}
|
||||
|
||||
snRegex := regexp.MustCompile(`(?i)times completed[^<]*</td>\s*<td[^>]*>(\d+)`)
|
||||
if m := snRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.Snatched, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
|
||||
// Description
|
||||
descRegex := regexp.MustCompile(`(?i)<div[^>]*id="kdescr"[^>]*>(.*?)</div>`)
|
||||
if m := descRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.Description = stripHTML(m[1])
|
||||
}
|
||||
|
||||
detail.DownloadURL = baseURL + "/download.php?id=" + id
|
||||
detail.Free = strings.Contains(html, "free") || strings.Contains(html, "免费")
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// ─── Gazelle 适配器 ──────────────────────────────────────────────────────────
|
||||
|
||||
// GazelleAdapter Gazelle 框架适配器(What.cd 开源)。
|
||||
type GazelleAdapter struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewGazelleAdapter 创建 Gazelle 适配器。
|
||||
func NewGazelleAdapter() *GazelleAdapter {
|
||||
return &GazelleAdapter{
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *GazelleAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
||||
u := cfg.URL + "/ajax.php?action=index"
|
||||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("authenticate: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("authenticate failed: status %d", status)
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
if statusMsg, ok := result["status"].(string); ok && statusMsg == "failure" {
|
||||
return fmt.Errorf("authentication failed: %v", result["error"])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *GazelleAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||||
params := url.Values{}
|
||||
params.Set("action", "browse")
|
||||
params.Set("searchstr", keyword)
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
u := cfg.URL + "/ajax.php?" + params.Encode()
|
||||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("search failed: status %d", status)
|
||||
}
|
||||
|
||||
return parseGazelleJSON(data, cfg.Name, cfg.URL)
|
||||
}
|
||||
|
||||
func (a *GazelleAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||||
params := url.Values{}
|
||||
params.Set("action", "browse")
|
||||
if category != "" {
|
||||
params.Set("filter_cat["+category+"]", "1")
|
||||
}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
u := cfg.URL + "/ajax.php?" + params.Encode()
|
||||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("browse: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("browse failed: status %d", status)
|
||||
}
|
||||
|
||||
return parseGazelleJSON(data, cfg.Name, cfg.URL)
|
||||
}
|
||||
|
||||
func (a *GazelleAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
|
||||
params := url.Values{}
|
||||
params.Set("action", "torrent")
|
||||
params.Set("id", id)
|
||||
|
||||
u := cfg.URL + "/ajax.php?" + params.Encode()
|
||||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("detail: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("detail failed: status %d", status)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
torrent, ok := resp["torrent"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("torrent not found")
|
||||
}
|
||||
|
||||
detail := &TorrentDetail{
|
||||
ID: id,
|
||||
DetailURL: cfg.URL + "/torrents.php?torrentid=" + id,
|
||||
DownloadURL: cfg.URL + "/torrents.php?action=download&id=" + id,
|
||||
}
|
||||
|
||||
if v, ok := torrent["groupName"].(string); ok {
|
||||
detail.Title = v
|
||||
}
|
||||
if v, ok := torrent["subName"].(string); ok {
|
||||
detail.Subtitle = v
|
||||
}
|
||||
if v, ok := torrent["size"].(float64); ok {
|
||||
detail.Size = int64(v)
|
||||
}
|
||||
if v, ok := torrent["seeders"].(float64); ok {
|
||||
detail.Seeders = int(v)
|
||||
}
|
||||
if v, ok := torrent["leechers"].(float64); ok {
|
||||
detail.Leechers = int(v)
|
||||
}
|
||||
if v, ok := torrent["snatched"].(float64); ok {
|
||||
detail.Snatched = int(v)
|
||||
}
|
||||
if v, ok := torrent["freeTorrent"].(string); ok && v == "1" {
|
||||
detail.Free = true
|
||||
}
|
||||
if v, ok := torrent["freeTorrent"].(bool); ok {
|
||||
detail.Free = v
|
||||
}
|
||||
if v, ok := torrent["infoHash"].(string); ok {
|
||||
detail.InfoHash = v
|
||||
}
|
||||
if v, ok := torrent["groupDesc"].(string); ok {
|
||||
detail.Description = stripHTML(v)
|
||||
}
|
||||
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (a *GazelleAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
|
||||
return cfg.URL + "/torrents.php?action=download&id=" + id, nil
|
||||
}
|
||||
|
||||
// parseGazelleJSON 解析 Gazelle JSON 响应。
|
||||
func parseGazelleJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("parse JSON: %w", err)
|
||||
}
|
||||
|
||||
result := &SiteSearchResult{
|
||||
SiteName: siteName,
|
||||
Items: []TorrentItem{},
|
||||
}
|
||||
|
||||
if status, ok := resp["status"].(string); ok && status == "failure" {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
results, ok := resp["results"].([]interface{})
|
||||
if !ok {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
for _, r := range results {
|
||||
torrent, ok := r.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := TorrentItem{}
|
||||
if v, ok := torrent["torrentId"].(float64); ok {
|
||||
item.ID = strconv.Itoa(int(v))
|
||||
}
|
||||
if v, ok := torrent["groupName"].(string); ok {
|
||||
item.Title = v
|
||||
}
|
||||
if v, ok := torrent["artist"].(string); ok {
|
||||
item.Subtitle = v
|
||||
}
|
||||
if v, ok := torrent["category"].(string); ok {
|
||||
item.Category = v
|
||||
}
|
||||
if v, ok := torrent["size"].(float64); ok {
|
||||
item.Size = int64(v)
|
||||
}
|
||||
if v, ok := torrent["seeders"].(float64); ok {
|
||||
item.Seeders = int(v)
|
||||
}
|
||||
if v, ok := torrent["leechers"].(float64); ok {
|
||||
item.Leechers = int(v)
|
||||
}
|
||||
if v, ok := torrent["snatched"].(float64); ok {
|
||||
item.Snatched = int(v)
|
||||
}
|
||||
if v, ok := torrent["freeTorrent"].(string); ok && v == "1" {
|
||||
item.Free = true
|
||||
}
|
||||
if v, ok := torrent["freeTorrent"].(bool); ok {
|
||||
item.Free = v
|
||||
}
|
||||
if v, ok := torrent["time"].(string); ok {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
item.UploadTime = t
|
||||
}
|
||||
}
|
||||
|
||||
item.DetailURL = baseURL + "/torrents.php?torrentid=" + item.ID
|
||||
item.DownloadURL = baseURL + "/torrents.php?action=download&id=" + item.ID
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
|
||||
if total, ok := resp["totalResults"].(float64); ok {
|
||||
result.Total = int(total)
|
||||
} else {
|
||||
result.Total = len(result.Items)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ─── UNIT3D 适配器 ───────────────────────────────────────────────────────────
|
||||
|
||||
// UNIT3DAdapter UNIT3D 框架适配器。
|
||||
type UNIT3DAdapter struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewUNIT3DAdapter 创建 UNIT3D 适配器。
|
||||
func NewUNIT3DAdapter() *UNIT3DAdapter {
|
||||
return &UNIT3DAdapter{
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *UNIT3DAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
||||
u := cfg.URL + "/api/torrents?limit=1"
|
||||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("authenticate: %w", err)
|
||||
}
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
return fmt.Errorf("authentication failed: status %d", status)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("authenticate failed: status %d", status)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(data, &resp); err == nil {
|
||||
if errMsg, ok := resp["message"].(string); ok {
|
||||
return fmt.Errorf("authentication failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *UNIT3DAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||||
params := url.Values{}
|
||||
params.Set("search", keyword)
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
u := cfg.URL + "/api/torrents?" + params.Encode()
|
||||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("search failed: status %d", status)
|
||||
}
|
||||
|
||||
return parseUNIT3DJSON(data, cfg.Name, cfg.URL)
|
||||
}
|
||||
|
||||
func (a *UNIT3DAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||||
params := url.Values{}
|
||||
if category != "" {
|
||||
params.Set("category", category)
|
||||
}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
u := cfg.URL + "/api/torrents?" + params.Encode()
|
||||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("browse: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("browse failed: status %d", status)
|
||||
}
|
||||
|
||||
return parseUNIT3DJSON(data, cfg.Name, cfg.URL)
|
||||
}
|
||||
|
||||
func (a *UNIT3DAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
|
||||
u := cfg.URL + "/api/torrents/" + id
|
||||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("detail: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("detail failed: status %d", status)
|
||||
}
|
||||
|
||||
var torrent map[string]interface{}
|
||||
if err := json.Unmarshal(data, &torrent); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
detail := &TorrentDetail{
|
||||
ID: id,
|
||||
DetailURL: cfg.URL + "/torrents/" + id,
|
||||
}
|
||||
|
||||
if v, ok := torrent["name"].(string); ok {
|
||||
detail.Title = v
|
||||
}
|
||||
if v, ok := torrent["description"].(string); ok {
|
||||
detail.Description = stripHTML(v)
|
||||
}
|
||||
if v, ok := torrent["size"].(float64); ok {
|
||||
detail.Size = int64(v)
|
||||
}
|
||||
if v, ok := torrent["seeders"].(float64); ok {
|
||||
detail.Seeders = int(v)
|
||||
}
|
||||
if v, ok := torrent["leechers"].(float64); ok {
|
||||
detail.Leechers = int(v)
|
||||
}
|
||||
if v, ok := torrent["times_completed"].(float64); ok {
|
||||
detail.Snatched = int(v)
|
||||
}
|
||||
if v, ok := torrent["free"].(bool); ok {
|
||||
detail.Free = v
|
||||
}
|
||||
if v, ok := torrent["info_hash"].(string); ok {
|
||||
detail.InfoHash = v
|
||||
}
|
||||
|
||||
detail.DownloadURL = cfg.URL + "/api/torrents/" + id + "/download"
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (a *UNIT3DAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
|
||||
return cfg.URL + "/api/torrents/" + id + "/download", nil
|
||||
}
|
||||
|
||||
// parseUNIT3DJSON 解析 UNIT3D JSON 响应。
|
||||
func parseUNIT3DJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
|
||||
var resp struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
Meta struct {
|
||||
Total int `json:"total"`
|
||||
CurrentPage int `json:"current_page"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("parse JSON: %w", err)
|
||||
}
|
||||
|
||||
result := &SiteSearchResult{
|
||||
SiteName: siteName,
|
||||
Items: []TorrentItem{},
|
||||
Page: resp.Meta.CurrentPage,
|
||||
Total: resp.Meta.Total,
|
||||
}
|
||||
|
||||
for _, t := range resp.Data {
|
||||
item := TorrentItem{}
|
||||
if v, ok := t["id"].(float64); ok {
|
||||
item.ID = strconv.Itoa(int(v))
|
||||
}
|
||||
if v, ok := t["name"].(string); ok {
|
||||
item.Title = v
|
||||
}
|
||||
if v, ok := t["category"].(map[string]interface{}); ok {
|
||||
if name, ok := v["name"].(string); ok {
|
||||
item.Category = name
|
||||
}
|
||||
}
|
||||
if v, ok := t["size"].(float64); ok {
|
||||
item.Size = int64(v)
|
||||
}
|
||||
if v, ok := t["seeders"].(float64); ok {
|
||||
item.Seeders = int(v)
|
||||
}
|
||||
if v, ok := t["leechers"].(float64); ok {
|
||||
item.Leechers = int(v)
|
||||
}
|
||||
if v, ok := t["times_completed"].(float64); ok {
|
||||
item.Snatched = int(v)
|
||||
}
|
||||
if v, ok := t["free"].(bool); ok {
|
||||
item.Free = v
|
||||
}
|
||||
if v, ok := t["created_at"].(string); ok {
|
||||
if t2, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
item.UploadTime = t2
|
||||
}
|
||||
}
|
||||
|
||||
item.DetailURL = baseURL + "/torrents/" + item.ID
|
||||
item.DownloadURL = baseURL + "/api/torrents/" + item.ID + "/download"
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ─── Discuz 适配器 ───────────────────────────────────────────────────────────
|
||||
|
||||
// DiscuzAdapter 基于 Discuz! X 的站点适配器。
|
||||
type DiscuzAdapter struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewDiscuzAdapter 创建 Discuz 适配器。
|
||||
func NewDiscuzAdapter() *DiscuzAdapter {
|
||||
return &DiscuzAdapter{
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *DiscuzAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
||||
u := cfg.URL + "/home.php?mod=space"
|
||||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("authenticate: %w", err)
|
||||
}
|
||||
if status == http.StatusFound {
|
||||
return fmt.Errorf("authentication failed: redirected to login")
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("authenticate failed: status %d", status)
|
||||
}
|
||||
body := string(data)
|
||||
if strings.Contains(body, "login") && !strings.Contains(body, "我的空间") {
|
||||
return fmt.Errorf("authentication failed: not logged in")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *DiscuzAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||||
params := url.Values{}
|
||||
params.Set("mod", "forum")
|
||||
params.Set("srchtxt", keyword)
|
||||
params.Set("searchsubmit", "true")
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
u := cfg.URL + "/search.php?" + params.Encode()
|
||||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("search failed: status %d", status)
|
||||
}
|
||||
|
||||
return parseDiscuzHTML(string(data), cfg.Name, cfg.URL)
|
||||
}
|
||||
|
||||
func (a *DiscuzAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||||
params := url.Values{}
|
||||
if category != "" {
|
||||
params.Set("fid", category)
|
||||
}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
u := cfg.URL + "/forum.php?" + params.Encode()
|
||||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("browse: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("browse failed: status %d", status)
|
||||
}
|
||||
|
||||
return parseDiscuzHTML(string(data), cfg.Name, cfg.URL)
|
||||
}
|
||||
|
||||
func (a *DiscuzAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
|
||||
u := cfg.URL + "/forum.php?mod=viewthread&tid=" + id
|
||||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("detail: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("detail failed: status %d", status)
|
||||
}
|
||||
|
||||
html := string(data)
|
||||
detail := &TorrentDetail{
|
||||
ID: id,
|
||||
DetailURL: cfg.URL + "/forum.php?mod=viewthread&tid=" + id,
|
||||
}
|
||||
|
||||
// Title
|
||||
titleRegex := regexp.MustCompile(`<span[^>]*id="thread_subject"[^>]*>([^<]+)</span>`)
|
||||
if m := titleRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.Title = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// Extract magnet/torrent links
|
||||
magnetRegex := regexp.MustCompile(`magnet:\?[^\s"'<>]+`)
|
||||
if m := magnetRegex.FindString(html); m != "" {
|
||||
detail.DownloadURL = m
|
||||
}
|
||||
torrentRegex := regexp.MustCompile(`(attachment\.php\?aid=\d+)`)
|
||||
if m := torrentRegex.FindString(html); m != "" && detail.DownloadURL == "" {
|
||||
detail.DownloadURL = cfg.URL + "/" + m
|
||||
}
|
||||
|
||||
// Description
|
||||
descRegex := regexp.MustCompile(`<div[^>]*class="t_fsz"[^>]*>(.*?)</div>`)
|
||||
if m := descRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.Description = stripHTML(m[1])
|
||||
}
|
||||
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (a *DiscuzAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
|
||||
return cfg.URL + "/forum.php?mod=viewthread&tid=" + id, nil
|
||||
}
|
||||
|
||||
// parseDiscuzHTML 解析 Discuz HTML 响应。
|
||||
func parseDiscuzHTML(html, siteName, baseURL string) (*SiteSearchResult, error) {
|
||||
result := &SiteSearchResult{
|
||||
SiteName: siteName,
|
||||
Items: []TorrentItem{},
|
||||
Page: 1,
|
||||
}
|
||||
|
||||
// Extract thread links
|
||||
threadRegex := regexp.MustCompile(`<a[^>]*href="(?:forum\.php\?mod=viewthread&tid=|thread-(\d+)-1-1)\.html"[^>]*>([^<]+)</a>`)
|
||||
matches := threadRegex.FindAllStringSubmatch(html, -1)
|
||||
|
||||
for _, m := range matches {
|
||||
item := TorrentItem{}
|
||||
if m[1] != "" {
|
||||
item.ID = m[1]
|
||||
} else {
|
||||
// Extract tid from URL
|
||||
tidRegex := regexp.MustCompile(`tid=(\d+)`)
|
||||
if tidM := tidRegex.FindStringSubmatch(m[0]); len(tidM) >= 2 {
|
||||
item.ID = tidM[1]
|
||||
}
|
||||
}
|
||||
if item.ID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
item.Title = strings.TrimSpace(m[2])
|
||||
item.DetailURL = baseURL + "/forum.php?mod=viewthread&tid=" + item.ID
|
||||
item.UploadTime = time.Now()
|
||||
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
|
||||
result.Total = len(result.Items)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ─── Custom RSS 适配器 ───────────────────────────────────────────────────────
|
||||
|
||||
// CustomRSSAdapter 自定义 RSS 源适配器。
|
||||
type CustomRSSAdapter struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewCustomRSSAdapter 创建 Custom RSS 适配器。
|
||||
func NewCustomRSSAdapter() *CustomRSSAdapter {
|
||||
return &CustomRSSAdapter{
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *CustomRSSAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
||||
// RSS 源通常不需要认证,或者认证通过 URL 参数
|
||||
if cfg.URL == "" {
|
||||
return fmt.Errorf("RSS URL is required")
|
||||
}
|
||||
_, status, err := doRequest(ctx, a.client, "GET", cfg.URL, cfg, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("authenticate: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("authenticate failed: status %d", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *CustomRSSAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||||
searchURL := cfg.URL
|
||||
// If extra has search URL template, use it
|
||||
if searchTpl, ok := cfg.Extra["search_url"]; ok && searchTpl != "" {
|
||||
searchURL = strings.ReplaceAll(searchTpl, "{keyword}", url.QueryEscape(keyword))
|
||||
searchURL = strings.ReplaceAll(searchURL, "{page}", strconv.Itoa(page))
|
||||
}
|
||||
|
||||
data, status, err := doRequest(ctx, a.client, "GET", searchURL, cfg, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("search failed: status %d", status)
|
||||
}
|
||||
|
||||
result, err := parseRSSXML(data, cfg.Name, keyword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse RSS: %w", err)
|
||||
}
|
||||
|
||||
if page > 1 {
|
||||
// Simple pagination for RSS: skip items already seen
|
||||
start := (page - 1) * 50
|
||||
if start < len(result.Items) {
|
||||
result.Items = result.Items[start:]
|
||||
} else {
|
||||
result.Items = []TorrentItem{}
|
||||
}
|
||||
}
|
||||
result.Page = page
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *CustomRSSAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||||
// RSS browse is essentially the same as search with empty keyword
|
||||
return a.Search(ctx, cfg, "", page)
|
||||
}
|
||||
|
||||
func (a *CustomRSSAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
|
||||
// RSS typically doesn't support detail page; return basic info
|
||||
return &TorrentDetail{
|
||||
ID: id,
|
||||
Title: id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *CustomRSSAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
|
||||
return id, nil // In RSS, the ID is often the download URL
|
||||
}
|
||||
|
||||
// parseRSSXML 解析 RSS XML 内容。
|
||||
func parseRSSXML(data []byte, siteName, keyword string) (*SiteSearchResult, error) {
|
||||
result := &SiteSearchResult{
|
||||
SiteName: siteName,
|
||||
Items: []TorrentItem{},
|
||||
}
|
||||
|
||||
html := string(data)
|
||||
// Simple regex-based XML parsing for RSS items
|
||||
itemRegex := regexp.MustCompile(`<item>(.*?)</item>`)
|
||||
items := itemRegex.FindAllStringSubmatch(html, -1)
|
||||
|
||||
for i, item := range items {
|
||||
ri := TorrentItem{}
|
||||
|
||||
// Title
|
||||
titleRegex := regexp.MustCompile(`<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</title>`)
|
||||
if m := titleRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
|
||||
ri.Title = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// Filter by keyword
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(ri.Title), strings.ToLower(keyword)) {
|
||||
continue
|
||||
}
|
||||
|
||||
ri.ID = strconv.Itoa(i)
|
||||
|
||||
// Link
|
||||
linkRegex := regexp.MustCompile(`<link>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</link>`)
|
||||
if m := linkRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
|
||||
ri.DetailURL = strings.TrimSpace(m[1])
|
||||
ri.DownloadURL = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// Description
|
||||
descRegex := regexp.MustCompile(`<description>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</description>`)
|
||||
if m := descRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
|
||||
desc := stripHTML(m[1])
|
||||
ri.Subtitle = desc
|
||||
}
|
||||
|
||||
// Size from description
|
||||
sizeRegex := regexp.MustCompile(`(\d+\.?\d*)\s*(GB|MB|TB|KB)`)
|
||||
if m := sizeRegex.FindStringSubmatch(item[1]); len(m) >= 3 {
|
||||
ri.Size = parseSizeString(m[1], m[2])
|
||||
}
|
||||
|
||||
// Category
|
||||
catRegex := regexp.MustCompile(`<category>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</category>`)
|
||||
if m := catRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
|
||||
ri.Category = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// Date
|
||||
dateRegex := regexp.MustCompile(`<pubDate>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</pubDate>`)
|
||||
if m := dateRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
|
||||
for _, layout := range []string{
|
||||
time.RFC1123, time.RFC1123Z, time.RFC3339,
|
||||
"2006-01-02 15:04:05", "2006-01-02T15:04:05-07:00",
|
||||
} {
|
||||
if t, err := time.Parse(layout, strings.TrimSpace(m[1])); err == nil {
|
||||
ri.UploadTime = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Items = append(result.Items, ri)
|
||||
}
|
||||
|
||||
result.Total = len(result.Items)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// TorrentDetail has a Description field used by RSS adapter.
|
||||
// (Already defined above)
|
||||
|
||||
// ─── 辅助函数 ────────────────────────────────────────────────────────────────
|
||||
|
||||
// doRequestJSON 执行 JSON 请求。
|
||||
|
||||
@@ -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(`<item>(.*?)</item>`)
|
||||
items := itemRegex.FindAllStringSubmatch(html, -1)
|
||||
|
||||
for i, item := range items {
|
||||
ri := TorrentItem{}
|
||||
|
||||
// Title
|
||||
titleRegex := regexp.MustCompile(`<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</title>`)
|
||||
if m := titleRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
|
||||
ri.Title = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// Filter by keyword
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(ri.Title), strings.ToLower(keyword)) {
|
||||
continue
|
||||
}
|
||||
|
||||
ri.ID = strconv.Itoa(i)
|
||||
|
||||
// Link
|
||||
linkRegex := regexp.MustCompile(`<link>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</link>`)
|
||||
if m := linkRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
|
||||
ri.DetailURL = strings.TrimSpace(m[1])
|
||||
ri.DownloadURL = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// Description
|
||||
descRegex := regexp.MustCompile(`<description>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</description>`)
|
||||
if m := descRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
|
||||
desc := stripHTML(m[1])
|
||||
ri.Subtitle = desc
|
||||
}
|
||||
|
||||
// Size from description
|
||||
sizeRegex := regexp.MustCompile(`(\d+\.?\d*)\s*(GB|MB|TB|KB)`)
|
||||
if m := sizeRegex.FindStringSubmatch(item[1]); len(m) >= 3 {
|
||||
ri.Size = parseSizeString(m[1], m[2])
|
||||
}
|
||||
|
||||
// Category
|
||||
catRegex := regexp.MustCompile(`<category>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</category>`)
|
||||
if m := catRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
|
||||
ri.Category = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// Date
|
||||
dateRegex := regexp.MustCompile(`<pubDate>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</pubDate>`)
|
||||
if m := dateRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
|
||||
for _, layout := range []string{
|
||||
time.RFC1123, time.RFC1123Z, time.RFC3339,
|
||||
"2006-01-02 15:04:05", "2006-01-02T15:04:05-07:00",
|
||||
} {
|
||||
if t, err := time.Parse(layout, strings.TrimSpace(m[1])); err == nil {
|
||||
ri.UploadTime = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Items = append(result.Items, ri)
|
||||
}
|
||||
|
||||
result.Total = len(result.Items)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// TorrentDetail has a Description field used by RSS adapter.
|
||||
// (Already defined above)
|
||||
@@ -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(`<span[^>]*id="thread_subject"[^>]*>([^<]+)</span>`)
|
||||
if m := titleRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.Title = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// Extract magnet/torrent links
|
||||
magnetRegex := regexp.MustCompile(`magnet:\?[^\s"'<>]+`)
|
||||
if m := magnetRegex.FindString(html); m != "" {
|
||||
detail.DownloadURL = m
|
||||
}
|
||||
torrentRegex := regexp.MustCompile(`(attachment\.php\?aid=\d+)`)
|
||||
if m := torrentRegex.FindString(html); m != "" && detail.DownloadURL == "" {
|
||||
detail.DownloadURL = cfg.URL + "/" + m
|
||||
}
|
||||
|
||||
// Description
|
||||
descRegex := regexp.MustCompile(`<div[^>]*class="t_fsz"[^>]*>(.*?)</div>`)
|
||||
if m := descRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.Description = stripHTML(m[1])
|
||||
}
|
||||
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (a *DiscuzAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
|
||||
return cfg.URL + "/forum.php?mod=viewthread&tid=" + id, nil
|
||||
}
|
||||
|
||||
// parseDiscuzHTML 解析 Discuz HTML 响应。
|
||||
func parseDiscuzHTML(html, siteName, baseURL string) (*SiteSearchResult, error) {
|
||||
result := &SiteSearchResult{
|
||||
SiteName: siteName,
|
||||
Items: []TorrentItem{},
|
||||
Page: 1,
|
||||
}
|
||||
|
||||
// Extract thread links
|
||||
threadRegex := regexp.MustCompile(`<a[^>]*href="(?:forum\.php\?mod=viewthread&tid=|thread-(\d+)-1-1)\.html"[^>]*>([^<]+)</a>`)
|
||||
matches := threadRegex.FindAllStringSubmatch(html, -1)
|
||||
|
||||
for _, m := range matches {
|
||||
item := TorrentItem{}
|
||||
if m[1] != "" {
|
||||
item.ID = m[1]
|
||||
} else {
|
||||
// Extract tid from URL
|
||||
tidRegex := regexp.MustCompile(`tid=(\d+)`)
|
||||
if tidM := tidRegex.FindStringSubmatch(m[0]); len(tidM) >= 2 {
|
||||
item.ID = tidM[1]
|
||||
}
|
||||
}
|
||||
if item.ID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
item.Title = strings.TrimSpace(m[2])
|
||||
item.DetailURL = baseURL + "/forum.php?mod=viewthread&tid=" + item.ID
|
||||
item.UploadTime = time.Now()
|
||||
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
|
||||
result.Total = len(result.Items)
|
||||
return result, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(`<tr[^>]*>\s*<td[^>]*class="rowfollow"[^>]*>.*?</tr>`)
|
||||
matches := rowRegex.FindAllString(html, -1)
|
||||
|
||||
for _, row := range matches {
|
||||
item := parseNexusPHPRow(row, baseURL)
|
||||
if item.ID != "" {
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
}
|
||||
|
||||
result.Total = len(result.Items)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseNexusPHPRow 解析单行种子条目。
|
||||
func parseNexusPHPRow(row, baseURL string) TorrentItem {
|
||||
item := TorrentItem{}
|
||||
|
||||
// Extract torrent ID and title
|
||||
idRegex := regexp.MustCompile(`details\.php\?id=(\d+)[^"]*"[^>]*>([^<]+)`)
|
||||
idMatches := idRegex.FindStringSubmatch(row)
|
||||
if len(idMatches) >= 3 {
|
||||
item.ID = idMatches[1]
|
||||
item.Title = strings.TrimSpace(idMatches[2])
|
||||
item.DetailURL = baseURL + "/details.php?id=" + item.ID
|
||||
}
|
||||
|
||||
// Extract download link
|
||||
dlRegex := regexp.MustCompile(`download\.php\?id=(\d+)`)
|
||||
if dlMatches := dlRegex.FindStringSubmatch(row); len(dlMatches) >= 2 {
|
||||
item.DownloadURL = baseURL + "/download.php?id=" + dlMatches[1]
|
||||
}
|
||||
|
||||
// Extract size
|
||||
sizeRegex := regexp.MustCompile(`(?i)(\d+\.?\d*)\s*(GB|MB|TB|KB)`)
|
||||
if sizeMatches := sizeRegex.FindStringSubmatch(row); len(sizeMatches) >= 3 {
|
||||
item.Size = parseSizeString(sizeMatches[1], sizeMatches[2])
|
||||
}
|
||||
|
||||
// Extract seeders and leechers
|
||||
seedersRegex := regexp.MustCompile(`seeders[^"]*"[^>]*>(\d+)<`)
|
||||
if m := seedersRegex.FindStringSubmatch(row); len(m) >= 2 {
|
||||
item.Seeders, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
leechersRegex := regexp.MustCompile(`leechers[^"]*"[^>]*>(\d+)<`)
|
||||
if m := leechersRegex.FindStringSubmatch(row); len(m) >= 2 {
|
||||
item.Leechers, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
|
||||
// Extract snatched
|
||||
snatchedRegex := regexp.MustCompile(`snatched[^"]*"[^>]*>(\d+)`)
|
||||
if m := snatchedRegex.FindStringSubmatch(row); len(m) >= 2 {
|
||||
item.Snatched, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
|
||||
// Check for free flag
|
||||
freeRegex := regexp.MustCompile(`(?i)(class="free|free2|twoupfree|free_download|促销|免费)`)
|
||||
item.Free = freeRegex.MatchString(row)
|
||||
|
||||
// Extract upload time
|
||||
timeRegex := regexp.MustCompile(`(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})`)
|
||||
if m := timeRegex.FindStringSubmatch(row); len(m) >= 2 {
|
||||
if t, err := time.Parse("2006-01-02 15:04", m[1]); err == nil {
|
||||
item.UploadTime = t
|
||||
}
|
||||
}
|
||||
|
||||
// Extract category
|
||||
catRegex := regexp.MustCompile(`cat=(\d+)[^"]*"[^>]*title="([^"]+)"`)
|
||||
if m := catRegex.FindStringSubmatch(row); len(m) >= 3 {
|
||||
item.Category = strings.TrimSpace(m[2])
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
// parseNexusPHPDetailHTML 解析种子详情页。
|
||||
func parseNexusPHPDetailHTML(html, id, baseURL string) (*TorrentDetail, error) {
|
||||
detail := &TorrentDetail{
|
||||
ID: id,
|
||||
DetailURL: baseURL + "/details.php?id=" + id,
|
||||
}
|
||||
|
||||
// Title
|
||||
titleRegex := regexp.MustCompile(`<h1[^>]*>([^<]+)</h1>`)
|
||||
if m := titleRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.Title = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// Subtitle
|
||||
subRegex := regexp.MustCompile(`<span[^>]*class="[^"]*sub[^"]*"[^>]*>([^<]+)</span>`)
|
||||
if m := subRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.Subtitle = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// Info hash
|
||||
hashRegex := regexp.MustCompile(`(?i)info_hash[^<]*</td>\s*<td[^>]*>([^<]+)</td>`)
|
||||
if m := hashRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.InfoHash = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// IMDB ID
|
||||
imdbRegex := regexp.MustCompile(`(?i)imdb[^<]*</td>\s*<td[^>]*>[^<]*(tt\d+)`)
|
||||
if m := imdbRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.ImdbID = m[1]
|
||||
}
|
||||
|
||||
// Size
|
||||
sizeRegex := regexp.MustCompile(`(?i)size[^<]*</td>\s*<td[^>]*>(\d+\.?\d*)\s*(GB|MB|TB|KB)`)
|
||||
if m := sizeRegex.FindStringSubmatch(html); len(m) >= 3 {
|
||||
detail.Size = parseSizeString(m[1], m[2])
|
||||
}
|
||||
|
||||
// Seeders / Leechers / Snatched
|
||||
slRegex := regexp.MustCompile(`seeders[^<]*</td>\s*<td[^>]*>(\d+)</td>\s*<td[^>]*>\s*</td>\s*<td[^>]*>\s*</td>\s*<td[^>]*>leechers[^<]*</td>\s*<td[^>]*>(\d+)`)
|
||||
if m := slRegex.FindStringSubmatch(html); len(m) >= 3 {
|
||||
detail.Seeders, _ = strconv.Atoi(m[1])
|
||||
detail.Leechers, _ = strconv.Atoi(m[2])
|
||||
}
|
||||
|
||||
snRegex := regexp.MustCompile(`(?i)times completed[^<]*</td>\s*<td[^>]*>(\d+)`)
|
||||
if m := snRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.Snatched, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
|
||||
// Description
|
||||
descRegex := regexp.MustCompile(`(?i)<div[^>]*id="kdescr"[^>]*>(.*?)</div>`)
|
||||
if m := descRegex.FindStringSubmatch(html); len(m) >= 2 {
|
||||
detail.Description = stripHTML(m[1])
|
||||
}
|
||||
|
||||
detail.DownloadURL = baseURL + "/download.php?id=" + id
|
||||
detail.Free = strings.Contains(html, "free") || strings.Contains(html, "免费")
|
||||
return detail, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="glass-panel p-4 space-y-3 transition-all hover:border-primary-400/30">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-xl flex items-center justify-center text-xs font-bold shrink-0 ${SITE_TYPE_COLORS[site.type] || "bg-sand-500/15 text-ink-50"}`}
|
||||
>
|
||||
{SITE_TYPE_ABBR[site.type] || "?"}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-ink-600 truncate">{site.name}</div>
|
||||
<div className="text-xs text-ink-50 truncate max-w-[160px]">
|
||||
{site.url}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 状态指示 */}
|
||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||
{site.last_check_at ? (
|
||||
site.last_error ? (
|
||||
<XCircle size={14} className="text-red-400" />
|
||||
) : (
|
||||
<CheckCircle size={14} className="text-green-400" />
|
||||
)
|
||||
) : (
|
||||
<HelpCircle size={14} className="text-sand-500" />
|
||||
)}
|
||||
{!site.enabled && (
|
||||
<span className="text-xs text-sand-500 ml-1">已停用</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-gray-50 text-ink-50">
|
||||
{SITE_TYPE_LABELS[site.type] || site.type}
|
||||
</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-gray-50 text-ink-50">
|
||||
{AUTH_TYPE_LABELS[site.auth_type] || site.auth_type}
|
||||
</span>
|
||||
{site.is_default && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-primary-400/15 text-brand-500">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{site.use_proxy && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-blue-500/15 text-blue-400">
|
||||
代理
|
||||
</span>
|
||||
)}
|
||||
{site.rate_limit && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-yellow-500/15 text-yellow-400">
|
||||
限流
|
||||
</span>
|
||||
)}
|
||||
{site.browser_emulation && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-purple-500/15 text-purple-400">
|
||||
浏览器
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 状态与统计(只读) */}
|
||||
<div className="text-xs text-sand-500 space-y-0.5">
|
||||
<div>
|
||||
状态:
|
||||
<span
|
||||
className={
|
||||
site.login_status === "ok"
|
||||
? "text-green-400"
|
||||
: site.login_status === "failed"
|
||||
? "text-red-400"
|
||||
: "text-ink-50"
|
||||
}
|
||||
>
|
||||
{site.login_status || "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
{(site.upload_bytes || 0) > 0 && (
|
||||
<div>
|
||||
↑ {Math.round(((site.upload_bytes ?? 0) / 1073741824) * 100) / 100}{" "}
|
||||
GB / ↓{" "}
|
||||
{Math.round(((site.download_bytes ?? 0) / 1073741824) * 100) / 100}{" "}
|
||||
GB
|
||||
</div>
|
||||
)}
|
||||
{site.priority !== 50 && <div>优先级:{site.priority}</div>}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => onTest()}
|
||||
disabled={testing}
|
||||
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"
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<RefreshCw size={12} className="animate-spin" />
|
||||
测试中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wifi size={12} />
|
||||
测试连接
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEdit()}
|
||||
className="rounded-lg border border-gray-200 p-1.5 text-ink-50 hover:text-white hover:bg-gray-50 transition"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete()}
|
||||
className="rounded-lg border border-gray-200 p-1.5 text-ink-50 hover:text-red-400 hover:bg-red-400/10 transition"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<SetStateAction<SiteForm>>;
|
||||
saving: boolean;
|
||||
advancedOpen: boolean;
|
||||
setAdvancedOpen: Dispatch<SetStateAction<boolean>>;
|
||||
onClose: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
onTypeChange: (type: string) => void;
|
||||
};
|
||||
|
||||
export function SiteFormModal({
|
||||
editingId,
|
||||
form,
|
||||
setForm,
|
||||
saving,
|
||||
advancedOpen,
|
||||
setAdvancedOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onTypeChange,
|
||||
}: SiteFormModalProps) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="glass-panel w-full max-w-xl max-h-[75vh] overflow-y-auto mx-4 space-y-5"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-ink-600">
|
||||
{editingId ? "编辑站点" : "添加站点"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-ink-50 hover:text-white transition"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">
|
||||
站点名称 *
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="例如: 馒头、观众、家园"
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, name: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">
|
||||
站点地址 *
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="https://www.example.com/"
|
||||
value={form.url}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, url: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
格式: https://www.example.com/
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">
|
||||
站点类型
|
||||
</label>
|
||||
<select
|
||||
className="input-base w-full"
|
||||
value={form.type}
|
||||
onChange={(event) => onTypeChange(event.target.value)}
|
||||
>
|
||||
<option value="nexusphp">NexusPHP(国内主流PT)</option>
|
||||
<option value="gazelle">Gazelle(HDBits等)</option>
|
||||
<option value="unit3d">UNIT3D(BeyondHD等)</option>
|
||||
<option value="mteam">馒头 M-Team(专用API)</option>
|
||||
<option value="discuz">Discuz 论坛型</option>
|
||||
<option value="custom_rss">自定义 RSS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">状态</label>
|
||||
<div className="flex items-center gap-3 h-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
enabled: !current.enabled,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.enabled ? "bg-primary-500" : "bg-gray-200"}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.enabled ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
className={`text-sm ${form.enabled ? "text-white" : "text-sand-500"}`}
|
||||
>
|
||||
{form.enabled ? "启用" : "停用"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.type === "mteam" && (
|
||||
<div className="p-3 rounded-xl border border-green-500/30 bg-green-500/5">
|
||||
<div className="text-sm font-medium text-green-400 mb-1">
|
||||
馒头站点配置指南
|
||||
</div>
|
||||
<div className="text-xs text-ink-50 space-y-1">
|
||||
<div>
|
||||
<b>正式站地址:</b>
|
||||
<code className="text-green-300">https://api.m-team.cc</code>
|
||||
</div>
|
||||
<div>
|
||||
<b>测试站地址:</b>
|
||||
<code className="text-green-300">
|
||||
https://test2.m-team.cc
|
||||
</code>
|
||||
(开发测试环境)
|
||||
</div>
|
||||
<div>
|
||||
<b>认证方式:</b>必须使用「API Access Token」,不要使用 Cookie
|
||||
</div>
|
||||
<div className="pl-3 text-sand-500">
|
||||
1. 登录馒头站 → 控制台 → 实验室 → 存取令牌
|
||||
<br />
|
||||
2. 点击「创建令牌」,复制生成的 Token
|
||||
<br />
|
||||
3. 将 Token 填入下方「令牌」输入框
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-2">认证方式</label>
|
||||
<div className="flex gap-2 mb-3">
|
||||
{[
|
||||
{ value: "cookie", label: "Cookie" },
|
||||
{ value: "api_key", label: "API Key" },
|
||||
{ value: "auth_header", label: "Auth Header" },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
auth_type: option.value,
|
||||
}))
|
||||
}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-medium border transition ${
|
||||
form.auth_type === option.value
|
||||
? "bg-primary-500 text-ink-600 border-primary-500"
|
||||
: "border-gray-200 text-ink-50 hover:border-primary-500/50"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{form.auth_type === "cookie" && (
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">Cookie</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base w-full resize-none text-xs font-mono"
|
||||
placeholder="uid=xxx; pass=xxx; ..."
|
||||
value={form.cookie}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
cookie: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
从浏览器开发者工具的请求头中获取 Cookie 值
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.auth_type === "api_key" && (
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
令牌(API Key / Passkey)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-base w-full font-mono text-sm"
|
||||
placeholder={
|
||||
editingId
|
||||
? "留空则保留原令牌,输入新值则替换"
|
||||
: "输入 API Access Token"
|
||||
}
|
||||
value={form.api_key}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
api_key: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
{form.type === "mteam"
|
||||
? "馒头:控制台 → 实验室 → 存取令牌;第三方工具通过 x-api-key 请求头访问"
|
||||
: "站点的访问 API Key"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.auth_type === "auth_header" && (
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
请求头(Authorization)
|
||||
</label>
|
||||
<input
|
||||
className="input-base w-full font-mono text-xs"
|
||||
placeholder="Bearer eyJhbGciOiJIUzI1NiIs..."
|
||||
value={form.auth_header}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
auth_header: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="block text-xs text-ink-50 mb-1">RSS 地址</label>
|
||||
<input
|
||||
className="input-base w-full text-xs font-mono"
|
||||
placeholder="https://.../torrents/rss?..."
|
||||
value={form.rss_url}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
rss_url: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
站点 RSS 订阅地址,用于获取最新资源
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||
className="flex items-center gap-1 text-xs text-ink-50 hover:text-white transition"
|
||||
>
|
||||
{advancedOpen ? "▾" : "▸"} 高级选项
|
||||
</button>
|
||||
{advancedOpen && (
|
||||
<div className="mt-3 pl-4 space-y-3 border-l border-gray-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
User-Agent
|
||||
</label>
|
||||
<input
|
||||
className="input-base w-full text-xs"
|
||||
placeholder="自定义 UA,留空使用默认"
|
||||
value={form.user_agent}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
user_agent: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
请求超时 (秒)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-base w-full"
|
||||
min={1}
|
||||
max={300}
|
||||
value={form.timeout}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
timeout: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
优先级 (数字越大越优先)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-base w-full"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.priority}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
priority: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
关联下载器
|
||||
</label>
|
||||
<input
|
||||
className="input-base w-full text-xs"
|
||||
placeholder="下载器 ID 或名称"
|
||||
value={form.downloader}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
downloader: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.use_proxy}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
use_proxy: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-ink-100">使用代理</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.rate_limit}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
rate_limit: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-ink-100">启用限流</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.browser_emulation}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
browser_emulation: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-ink-100">浏览器模拟</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
Extra 扩展配置 (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base w-full resize-none text-xs font-mono"
|
||||
placeholder='{"key":"value"}'
|
||||
value={form.extra}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
extra: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
is_default: !current.is_default,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.is_default ? "bg-primary-500" : "bg-gray-200"}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.is_default ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-ink-50">设为默认站点</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50 transition"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !form.name.trim() || !form.url.trim()}
|
||||
className="neon-button text-sm disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+151
-522
@@ -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<Site[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState(defaultSiteForm())
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testingId, setTestingId] = useState<string | null>(null)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState(defaultSiteForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testingId, setTestingId] = useState<string | null>(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<boolean> => {
|
||||
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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
@@ -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: "查看下载任务状态和历史记录",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 页头 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">站点管理</h1>
|
||||
<button onClick={openCreate} className="neon-button flex items-center gap-2">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">
|
||||
站点管理
|
||||
</h1>
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="neon-button flex items-center gap-2"
|
||||
>
|
||||
<Plus size={16} />
|
||||
添加站点
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 站点卡片网格 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sites.map((site) => (
|
||||
<div key={site.id} className="glass-panel p-4 space-y-3 transition-all hover:border-primary-400/30">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={`w-8 h-8 rounded-xl flex items-center justify-center text-xs font-bold shrink-0 ${SITE_TYPE_COLORS[site.type] || 'bg-sand-500/15 text-ink-50'}`}>
|
||||
{SITE_TYPE_ABBR[site.type] || '?'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-ink-600 truncate">{site.name}</div>
|
||||
<div className="text-xs text-ink-50 truncate max-w-[160px]">{site.url}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 状态指示 */}
|
||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||
{site.last_check_at ? (
|
||||
site.last_error ? (
|
||||
<XCircle size={14} className="text-red-400" />
|
||||
) : (
|
||||
<CheckCircle size={14} className="text-green-400" />
|
||||
)
|
||||
) : (
|
||||
<HelpCircle size={14} className="text-sand-500" />
|
||||
)}
|
||||
{!site.enabled && <span className="text-xs text-sand-500 ml-1">已停用</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-gray-50 text-ink-50">
|
||||
{SITE_TYPE_LABELS[site.type] || site.type}
|
||||
</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-gray-50 text-ink-50">
|
||||
{AUTH_TYPE_LABELS[site.auth_type] || site.auth_type}
|
||||
</span>
|
||||
{site.is_default && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-primary-400/15 text-brand-500">默认</span>
|
||||
)}
|
||||
{site.use_proxy && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-blue-500/15 text-blue-400">代理</span>
|
||||
)}
|
||||
{site.rate_limit && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-yellow-500/15 text-yellow-400">限流</span>
|
||||
)}
|
||||
{site.browser_emulation && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-purple-500/15 text-purple-400">浏览器</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 状态与统计(只读) */}
|
||||
<div className="text-xs text-sand-500 space-y-0.5">
|
||||
<div>状态:
|
||||
<span className={
|
||||
site.login_status === 'ok' ? 'text-green-400' :
|
||||
site.login_status === 'failed' ? 'text-red-400' : 'text-ink-50'
|
||||
}>
|
||||
{site.login_status || 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{(site.upload_bytes || 0) > 0 && (
|
||||
<div>↑ {Math.round((site.upload_bytes ?? 0) / 1073741824 * 100) / 100} GB / ↓ {Math.round((site.download_bytes ?? 0) / 1073741824 * 100) / 100} GB</div>
|
||||
)}
|
||||
{site.priority !== 50 && (
|
||||
<div>优先级:{site.priority}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => 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 ? (
|
||||
<>
|
||||
<RefreshCw size={12} className="animate-spin" />
|
||||
测试中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wifi size={12} />
|
||||
测试连接
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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="编辑"
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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="删除"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SiteCard
|
||||
key={site.id}
|
||||
site={site}
|
||||
testing={testingId === site.id}
|
||||
onTest={() => handleTest(site.id)}
|
||||
onEdit={() => openEdit(site.id)}
|
||||
onDelete={() => handleDelete(site)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!loading && sites.length === 0 && (
|
||||
<div className="col-span-full py-12 text-center text-ink-50">
|
||||
<Globe size={40} className="mx-auto mb-3 text-gray-500" />
|
||||
<p>暂无站点</p>
|
||||
<p className="text-sm mt-1 text-sand-500">点击「添加站点」添加 PT/BT 站点</p>
|
||||
<p className="text-sm mt-1 text-sand-500">
|
||||
点击「添加站点」添加 PT/BT 站点
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载中 */}
|
||||
{loading && (
|
||||
<div className="col-span-full py-12 text-center text-ink-50">
|
||||
<RefreshCw size={24} className="mx-auto mb-3 animate-spin" />
|
||||
@@ -288,320 +218,19 @@ export function SitesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 创建/编辑弹窗 ── */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] bg-black/60 backdrop-blur-sm" onClick={closeModal}>
|
||||
<div
|
||||
className="glass-panel w-full max-w-xl max-h-[75vh] overflow-y-auto mx-4 space-y-5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-ink-600">
|
||||
{editingId ? '编辑站点' : '添加站点'}
|
||||
</h2>
|
||||
<button onClick={closeModal} className="text-ink-50 hover:text-white transition">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">站点名称 *</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="例如: 馒头、观众、家园"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 地址 */}
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">站点地址 *</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="https://www.example.com/"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">格式: https://www.example.com/</p>
|
||||
</div>
|
||||
|
||||
{/* 站点类型 + 状态 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">站点类型</label>
|
||||
<select
|
||||
className="input-base w-full"
|
||||
value={form.type}
|
||||
onChange={(e) => handleTypeChange(e.target.value)}
|
||||
>
|
||||
<option value="nexusphp">NexusPHP(国内主流PT)</option>
|
||||
<option value="gazelle">Gazelle(HDBits等)</option>
|
||||
<option value="unit3d">UNIT3D(BeyondHD等)</option>
|
||||
<option value="mteam">馒头 M-Team(专用API)</option>
|
||||
<option value="discuz">Discuz 论坛型</option>
|
||||
<option value="custom_rss">自定义 RSS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">状态</label>
|
||||
<div className="flex items-center gap-3 h-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({ ...f, enabled: !f.enabled }))}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.enabled ? 'bg-primary-500' : 'bg-gray-200'}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.enabled ? 'translate-x-4' : 'translate-x-0.5'}`}
|
||||
/>
|
||||
</button>
|
||||
<span className={`text-sm ${form.enabled ? 'text-white' : 'text-sand-500'}`}>
|
||||
{form.enabled ? '启用' : '停用'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 馒头提示 */}
|
||||
{form.type === 'mteam' && (
|
||||
<div className="p-3 rounded-xl border border-green-500/30 bg-green-500/5">
|
||||
<div className="text-sm font-medium text-green-400 mb-1">馒头站点配置指南</div>
|
||||
<div className="text-xs text-ink-50 space-y-1">
|
||||
<div><b>正式站地址:</b><code className="text-green-300">https://api.m-team.cc</code></div>
|
||||
<div><b>测试站地址:</b><code className="text-green-300">https://test2.m-team.cc</code>(开发测试环境)</div>
|
||||
<div><b>认证方式:</b>必须使用「API Access Token」,不要使用 Cookie</div>
|
||||
<div className="pl-3 text-sand-500">
|
||||
1. 登录馒头站 → 控制台 → 实验室 → 存取令牌<br />
|
||||
2. 点击「创建令牌」,复制生成的 Token<br />
|
||||
3. 将 Token 填入下方「令牌」输入框
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 认证方式 */}
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-2">认证方式</label>
|
||||
<div className="flex gap-2 mb-3">
|
||||
{[
|
||||
{ value: 'cookie', label: 'Cookie' },
|
||||
{ value: 'api_key', label: 'API Key' },
|
||||
{ value: 'auth_header', label: 'Auth Header' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({ ...f, auth_type: opt.value }))}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-medium border transition ${
|
||||
form.auth_type === opt.value
|
||||
? 'bg-primary-500 text-ink-600 border-primary-500'
|
||||
: 'border-gray-200 text-ink-50 hover:border-primary-500/50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{form.auth_type === 'cookie' && (
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">Cookie</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base w-full resize-none text-xs font-mono"
|
||||
placeholder="uid=xxx; pass=xxx; ..."
|
||||
value={form.cookie}
|
||||
onChange={(e) => setForm((f) => ({ ...f, cookie: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">从浏览器开发者工具的请求头中获取 Cookie 值</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.auth_type === 'api_key' && (
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">令牌(API Key / Passkey)</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-base w-full font-mono text-sm"
|
||||
placeholder={editingId ? '留空则保留原令牌,输入新值则替换' : '输入 API Access Token'}
|
||||
value={form.api_key}
|
||||
onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
{form.type === 'mteam'
|
||||
? '馒头:控制台 → 实验室 → 存取令牌;第三方工具通过 x-api-key 请求头访问'
|
||||
: '站点的访问 API Key'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.auth_type === 'auth_header' && (
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">请求头(Authorization)</label>
|
||||
<input
|
||||
className="input-base w-full font-mono text-xs"
|
||||
placeholder="Bearer eyJhbGciOiJIUzI1NiIs..."
|
||||
value={form.auth_header}
|
||||
onChange={(e) => setForm((f) => ({ ...f, auth_header: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RSS 地址(主表单) */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-xs text-ink-50 mb-1">RSS 地址</label>
|
||||
<input
|
||||
className="input-base w-full text-xs font-mono"
|
||||
placeholder="https://.../torrents/rss?..."
|
||||
value={form.rss_url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, rss_url: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">站点 RSS 订阅地址,用于获取最新资源</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 高级选项 */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||
className="flex items-center gap-1 text-xs text-ink-50 hover:text-white transition"
|
||||
>
|
||||
{advancedOpen ? '▾' : '▸'} 高级选项
|
||||
</button>
|
||||
{advancedOpen && (
|
||||
<div className="mt-3 pl-4 space-y-3 border-l border-gray-200">
|
||||
{/* 新增高级字段 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">User-Agent</label>
|
||||
<input
|
||||
className="input-base w-full text-xs"
|
||||
placeholder="自定义 UA,留空使用默认"
|
||||
value={form.user_agent}
|
||||
onChange={(e) => setForm((f) => ({ ...f, user_agent: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">请求超时 (秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-base w-full"
|
||||
min={1}
|
||||
max={300}
|
||||
value={form.timeout}
|
||||
onChange={(e) => setForm((f) => ({ ...f, timeout: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">优先级 (数字越大越优先)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-base w-full"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.priority}
|
||||
onChange={(e) => setForm((f) => ({ ...f, priority: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">关联下载器</label>
|
||||
<input
|
||||
className="input-base w-full text-xs"
|
||||
placeholder="下载器 ID 或名称"
|
||||
value={form.downloader}
|
||||
onChange={(e) => setForm((f) => ({ ...f, downloader: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 开关类字段 */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.use_proxy}
|
||||
onChange={(e) => setForm((f) => ({ ...f, use_proxy: e.target.checked }))}
|
||||
/>
|
||||
<span className="text-xs text-ink-100">使用代理</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.rate_limit}
|
||||
onChange={(e) => setForm((f) => ({ ...f, rate_limit: e.target.checked }))}
|
||||
/>
|
||||
<span className="text-xs text-ink-100">启用限流</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.browser_emulation}
|
||||
onChange={(e) => setForm((f) => ({ ...f, browser_emulation: e.target.checked }))}
|
||||
/>
|
||||
<span className="text-xs text-ink-100">浏览器模拟</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Extra JSON */}
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">Extra 扩展配置 (JSON)</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base w-full resize-none text-xs font-mono"
|
||||
placeholder='{"key":"value"}'
|
||||
value={form.extra}
|
||||
onChange={(e) => setForm((f) => ({ ...f, extra: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({ ...f, is_default: !f.is_default }))}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.is_default ? 'bg-primary-500' : 'bg-gray-200'}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.is_default ? 'translate-x-4' : 'translate-x-0.5'}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-ink-50">设为默认站点</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={closeModal} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50 transition">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !form.name.trim() || !form.url.trim()}
|
||||
className="neon-button text-sm disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
'保存'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFormModal
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
saving={saving}
|
||||
advancedOpen={advancedOpen}
|
||||
setAdvancedOpen={setAdvancedOpen}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleSave}
|
||||
onTypeChange={handleTypeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,89 +1,91 @@
|
||||
import type { Site } from '../types'
|
||||
import type { Site } from "../types";
|
||||
|
||||
// ── 站点类型映射 ──
|
||||
export const SITE_TYPE_LABELS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<typeof defaultSiteForm>
|
||||
export type SiteForm = ReturnType<typeof defaultSiteForm>;
|
||||
|
||||
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<string, unknown> {
|
||||
export function siteFormToPayload(
|
||||
form: SiteForm,
|
||||
includeEmptySecrets: boolean,
|
||||
): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user