feat(adapters): port MoviePilot open site framework (7 spiders, 20 user parsers) + MovieClaw v0.27.0 site definitions + mp-sync tools (W1-3c)
- sites/spiders: Haidan/HDDolby/RousiPro/SunnyPT/TNode/TorrentLeech/Yema (+mTorrent wrapper), specialized SiteAdapter, indirect download resolved at download time - sites/user-parsers: SiteParserBase + all 20 MP SiteSchema parsers (lxml XPath via xpath+xmldom), refreshSiteUserData with schema fallback - sites/definitions: zod SiteDefinition schemas, 23 MovieClaw v0.27.0 (MIT) definitions via scripts/mp-sync/movieclaw-import.mjs, registry getSiteDefinition/listSiteDefinitions, NOTICE - scripts/mp-sync: ledger.json (upstream sha256), check.mjs, build-ledger.mjs; docs/porting/mp-sync-ledger.md - license-and-provenance: add copied-mit kind, re-anchor MovieClaw rows to v0.27.0 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
cf85481d95
commit
d9f2af2f3c
166 files changed
+16577
-186
No files matched your search
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||
* @mp-source app/modules/indexer/__init__.py @ e6820a8
|
||||
*
|
||||
* 专用 spider 站点适配器:对应 MP `IndexerModule.search_torrents/refresh_torrents` 按 `indexer.parser`
|
||||
* 分派到 `SPIDER_PARSER_CLASSES` 并以 `_SPECIALIZED_SEARCH_ARGUMENTS` 传参;
|
||||
* 下载对应 `submission.download_torrent`(两段式换票由 spider.resolveDownload 现场完成)。
|
||||
*
|
||||
* 与 MP 的差异:MP `search` 失败返回 `(True, [])`;本项目抛 SiteError(凭据/限流/解析分类),
|
||||
* 健康检查即一次空关键词列表。list 对外页码 1 起;`pageSize=null` 的站(Haidan、TorrentLeech 关键词)只有第 1 页。
|
||||
*/
|
||||
import { buildHttpContext, type SiteAdapterDeps } from "../deps.ts";
|
||||
import { healthFromError } from "../spider/adapter.ts";
|
||||
import {
|
||||
type DownloadTarget,
|
||||
type SiteAdapter,
|
||||
type SiteCredentialKind,
|
||||
SiteError,
|
||||
type SiteHealth,
|
||||
type SiteListFilters,
|
||||
type SitePage,
|
||||
} from "../types.ts";
|
||||
import { specializedTorrentToRelease } from "./map.ts";
|
||||
import type { SpecializedSiteDefinition, SpecializedSpider, SpiderContext } from "./types.ts";
|
||||
|
||||
export function createSpecializedSiteAdapter(
|
||||
input: SpecializedSiteDefinition,
|
||||
spider: SpecializedSpider,
|
||||
deps: SiteAdapterDeps,
|
||||
): SiteAdapter {
|
||||
const def: SpecializedSiteDefinition = deps.baseUrl
|
||||
? {
|
||||
...input,
|
||||
domain: deps.baseUrl.endsWith("/") ? deps.baseUrl : `${deps.baseUrl}/`,
|
||||
umt: { ...input.umt, apiBaseUrl: deps.baseUrl.replace(/\/+$/, "") },
|
||||
}
|
||||
: input;
|
||||
const http = buildHttpContext(
|
||||
def.id,
|
||||
{ ...(def.timeout ? { timeoutMs: def.timeout * 1000 } : {}), ...deps },
|
||||
def.umt.rateLimit,
|
||||
);
|
||||
const cache = new Map<string, { value: unknown; expiresAt: number }>();
|
||||
/** 最近搜索结果的直链(仅内存,供 downloadTarget 复用;含 passkey 类参数,不外泄) */
|
||||
const enclosures = new Map<string, string>();
|
||||
|
||||
function ctx(signal?: AbortSignal): SpiderContext {
|
||||
const c: SpiderContext = {
|
||||
def,
|
||||
http,
|
||||
cache,
|
||||
async secret(kind: SiteCredentialKind, required = false) {
|
||||
const v = await deps.secrets.resolve({ siteId: def.id, kind });
|
||||
if (!v && required) {
|
||||
throw new SiteError(
|
||||
def.id,
|
||||
"auth_missing",
|
||||
`${def.name} 未配置${kind === "cookie" ? " Cookie" : " API Key"}`,
|
||||
);
|
||||
}
|
||||
return v;
|
||||
},
|
||||
};
|
||||
if (deps.userAgent) c.userAgent = deps.userAgent;
|
||||
if (signal) c.signal = signal;
|
||||
return c;
|
||||
}
|
||||
|
||||
async function doSearch(page: number, filters: SiteListFilters, keyword: string | null, signal?: AbortSignal) {
|
||||
const p = Math.max(1, page);
|
||||
const fetchedAt = http.now().toISOString();
|
||||
const size = spider.pageSize(keyword);
|
||||
if (size === null && p > 1) {
|
||||
return { items: [], page: p, totalPages: 1, totalPagesEstimated: true, fetchedAt } satisfies SitePage;
|
||||
}
|
||||
const q: Parameters<SpecializedSpider["search"]>[1] = { keyword, page: p - 1 };
|
||||
if (filters.mtype) q.mtype = filters.mtype;
|
||||
if (filters.categoryIds?.length) q.cat = filters.categoryIds.join(",");
|
||||
const rows = await spider.search(ctx(signal), q);
|
||||
let items = rows.flatMap((t) => {
|
||||
const r = specializedTorrentToRelease(t, def, fetchedAt);
|
||||
if (r && t.enclosure) enclosures.set(r.siteReleaseId, t.enclosure);
|
||||
return r ? [r] : [];
|
||||
});
|
||||
if (filters.categoryIds?.length) {
|
||||
const allow = new Set(filters.categoryIds.map(String));
|
||||
items = items.filter((r) => r.category.id === null || allow.has(r.category.id));
|
||||
}
|
||||
if (filters.freeOnly) items = items.filter((r) => r.promotion.downloadFactor === 0);
|
||||
return { items, page: p, totalPages: null, totalPagesEstimated: false, fetchedAt } satisfies SitePage;
|
||||
}
|
||||
|
||||
return {
|
||||
id: def.id,
|
||||
kind: "api",
|
||||
displayName: def.umt.displayName,
|
||||
capabilities: def.umt.capabilities,
|
||||
definitionVersion: def.umt.version,
|
||||
list(page, filters = {}, opts = {}) {
|
||||
return doSearch(page, filters, null, opts.signal);
|
||||
},
|
||||
search(query, filters = {}, opts = {}) {
|
||||
return doSearch(filters.page ?? 1, filters, query.trim() || null, opts.signal);
|
||||
},
|
||||
async detail() {
|
||||
throw new SiteError(def.id, "unsupported", `${def.name}:专用 API spider 不提供详情接口`);
|
||||
},
|
||||
async downloadTarget(siteReleaseId, opts = {}): Promise<DownloadTarget> {
|
||||
return spider.resolveDownload(ctx(opts.signal), siteReleaseId, enclosures.get(String(siteReleaseId)));
|
||||
},
|
||||
async healthCheck(opts = {}): Promise<SiteHealth> {
|
||||
const started = Date.now();
|
||||
const checkedAt = http.now().toISOString();
|
||||
try {
|
||||
await spider.search(ctx(opts.signal), { keyword: null, page: 0 });
|
||||
return { siteId: def.id, status: "ok", ok: true, checkedAt, latencyMs: Date.now() - started };
|
||||
} catch (e) {
|
||||
return healthFromError(def.id, e, checkedAt);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||
* @mp-source app/chain/download/submission.py @ e6820a8
|
||||
* @mp-source app/foundation/temporal.py @ e6820a8
|
||||
*
|
||||
* 专用 spider 共用 IO 与工具:
|
||||
* - `submission._resolve_indirect_download_url` → `resolveIndirectDownloadUrl`(配置对象在内存中构造,不再 base64 编进 enclosure)
|
||||
* - `submission._normalize_indirect_download_url` → `normalizeIndirectDownloadUrl`
|
||||
* - `submission.download_torrent`(带/不带 cookie 下载并校验)→ `downloadTorrentFile`
|
||||
* - `temporal.format_timestamp` → `formatTimestampTz`;`urllib.parse.quote` → `pyQuote`
|
||||
* - 各 spider `__process_response` 的错误分支 → `apiRequest`(HTTP 401/403 → auth_invalid)+ `businessError`
|
||||
*/
|
||||
import { redactText, redactUrl } from "../../rss/redact.ts";
|
||||
import { readBody, readText, siteFetch } from "../http.ts";
|
||||
import { type DownloadTarget, SiteError, type SiteErrorKind } from "../types.ts";
|
||||
import { inspectTorrent } from "../util.ts";
|
||||
import type { SpiderContext } from "./types.ts";
|
||||
|
||||
/** 下载链接中需要脱敏的额外参数名(HDDolby downhash 等效 passkey) */
|
||||
export const EXTRA_SECRET_KEYS = ["downhash", "token", "credential", "sign", "key"];
|
||||
|
||||
const RATE_KEYWORDS = ["頻繁", "频繁", "too many", "rate limit", "稍后"];
|
||||
const NOT_FOUND_KEYWORDS = ["不存在", "not found", "not exist"];
|
||||
const AUTH_KEYWORDS = [
|
||||
"停用",
|
||||
"封禁",
|
||||
"禁用",
|
||||
"banned",
|
||||
"disabled",
|
||||
"無效",
|
||||
"无效",
|
||||
"invalid",
|
||||
"unauthorized",
|
||||
"未授權",
|
||||
"未授权",
|
||||
"未登录",
|
||||
"登录",
|
||||
"api key",
|
||||
"apikey",
|
||||
"token",
|
||||
"permission",
|
||||
"权限",
|
||||
"forbidden",
|
||||
];
|
||||
|
||||
/** 业务错误文案 → SiteError 类别(默认 parse_error) */
|
||||
export function classifyMessage(message: string | null | undefined): SiteErrorKind {
|
||||
const lower = String(message ?? "").toLowerCase();
|
||||
if (RATE_KEYWORDS.some((k) => lower.includes(k))) return "rate_limited";
|
||||
if (NOT_FOUND_KEYWORDS.some((k) => lower.includes(k))) return "not_found";
|
||||
if (AUTH_KEYWORDS.some((k) => lower.includes(k.toLowerCase()))) return "auth_invalid";
|
||||
return "parse_error";
|
||||
}
|
||||
|
||||
export function businessError(ctx: SpiderContext, what: string, message: unknown): SiteError {
|
||||
const msg = message == null || message === "" ? "未知错误" : String(message).slice(0, 200);
|
||||
return new SiteError(ctx.def.id, classifyMessage(msg), redactText(`${ctx.def.name} ${what}失败:${msg}`));
|
||||
}
|
||||
|
||||
export interface ApiRequestInit {
|
||||
method?: "GET" | "POST";
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string | number | null | undefined>;
|
||||
json?: unknown;
|
||||
/** 原样 query 串(已编码,不含 ?) */
|
||||
rawQuery?: string;
|
||||
}
|
||||
|
||||
export function withQuery(url: string, init: Pick<ApiRequestInit, "query" | "rawQuery">): string {
|
||||
const parts: string[] = [];
|
||||
if (init.rawQuery) parts.push(init.rawQuery);
|
||||
if (init.query) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(init.query)) if (v !== undefined && v !== null) qs.append(k, String(v));
|
||||
const s = qs.toString();
|
||||
if (s) parts.push(s);
|
||||
}
|
||||
if (!parts.length) return url;
|
||||
return `${url}${url.includes("?") ? "&" : "?"}${parts.join("&")}`;
|
||||
}
|
||||
|
||||
/** 不跟随跳转(请求头可能带 cookie/key);跳 login 视为登录失效 */
|
||||
function checkRedirect(ctx: SpiderContext, res: Response, safe: string): void {
|
||||
if (res.status < 300 || res.status >= 400) return;
|
||||
void res.body?.cancel().catch(() => {});
|
||||
const loc = res.headers.get("location") ?? "";
|
||||
if (/login/i.test(loc))
|
||||
throw new SiteError(ctx.def.id, "auth_expired", `${ctx.def.name} 请求 ${safe}:跳转登录页,登录已失效`);
|
||||
throw new SiteError(ctx.def.id, "network", `${ctx.def.name} 请求 ${safe}:意外跳转 HTTP ${res.status}`, {
|
||||
httpStatus: res.status,
|
||||
});
|
||||
}
|
||||
|
||||
/** 发请求并解析 JSON;HTTP 401/403 → auth_invalid,404 → not_found,其它非 2xx → network,非 JSON → parse_error/site_maintenance */
|
||||
export async function apiRequest(ctx: SpiderContext, url: string, init: ApiRequestInit = {}): Promise<unknown> {
|
||||
const headers: Record<string, string> = { ...(init.headers ?? {}) };
|
||||
if (ctx.userAgent && !Object.keys(headers).some((k) => k.toLowerCase() === "user-agent"))
|
||||
headers["User-Agent"] = ctx.userAgent;
|
||||
let body: string | undefined;
|
||||
if (init.json !== undefined) {
|
||||
body = JSON.stringify(init.json);
|
||||
if (!Object.keys(headers).some((k) => k.toLowerCase() === "content-type"))
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
const full = withQuery(url, init);
|
||||
const safe = redactUrl(full, EXTRA_SECRET_KEYS);
|
||||
const res = await siteFetch(ctx.http, full, {
|
||||
method: init.method ?? (body !== undefined ? "POST" : "GET"),
|
||||
headers,
|
||||
...(body !== undefined ? { body } : {}),
|
||||
redirect: "manual",
|
||||
...(ctx.signal ? { signal: ctx.signal } : {}),
|
||||
});
|
||||
checkRedirect(ctx, res, safe);
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new SiteError(
|
||||
ctx.def.id,
|
||||
"auth_invalid",
|
||||
`${ctx.def.name} 请求 ${safe}:HTTP ${res.status},凭据无效或无权限`,
|
||||
{
|
||||
httpStatus: res.status,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new SiteError(
|
||||
ctx.def.id,
|
||||
res.status === 404 ? "not_found" : "network",
|
||||
`${ctx.def.name} 请求 ${safe}:HTTP ${res.status}`,
|
||||
{
|
||||
httpStatus: res.status,
|
||||
},
|
||||
);
|
||||
}
|
||||
const text = await readText(ctx.http, res);
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
const maintenance = /維護|维护|maintenance/i.test(text);
|
||||
throw new SiteError(
|
||||
ctx.def.id,
|
||||
maintenance ? "site_maintenance" : "parse_error",
|
||||
`${ctx.def.name} 请求 ${safe}:响应不是有效 JSON${maintenance ? "(站点维护)" : ""}`,
|
||||
{ httpStatus: res.status },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 发请求取文本(TNode 首页取 CSRF token) */
|
||||
export async function textRequest(ctx: SpiderContext, url: string, headers: Record<string, string>): Promise<string> {
|
||||
const h = { ...headers };
|
||||
if (ctx.userAgent) h["User-Agent"] = ctx.userAgent;
|
||||
const res = await siteFetch(ctx.http, url, {
|
||||
method: "GET",
|
||||
headers: h,
|
||||
redirect: "manual",
|
||||
...(ctx.signal ? { signal: ctx.signal } : {}),
|
||||
});
|
||||
checkRedirect(ctx, res, redactUrl(url));
|
||||
if (res.status !== 200) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new SiteError(
|
||||
ctx.def.id,
|
||||
res.status === 401 || res.status === 403 ? "auth_invalid" : "network",
|
||||
`${ctx.def.name} 请求 ${redactUrl(url)}:HTTP ${res.status}`,
|
||||
{ httpStatus: res.status },
|
||||
);
|
||||
}
|
||||
return readText(ctx.http, res);
|
||||
}
|
||||
|
||||
export function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return v != null && typeof v === "object" && !Array.isArray(v);
|
||||
}
|
||||
|
||||
// ---- 时间 ----
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
function offsetMinutes(tz: string): number {
|
||||
const m = /^([+-])(\d{2}):?(\d{2})$/.exec(tz.trim());
|
||||
if (!m) return 0;
|
||||
return (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]));
|
||||
}
|
||||
|
||||
/**
|
||||
* MP `format_timestamp`:非纯数字字符串原样返回;Unix 秒 → 本地墙钟 "YYYY-MM-DD HH:MM:SS"。
|
||||
* MP 用进程本地时区,这里用站点时区(`umt.timezone`),随后 map 再按同一时区转 UTC,结果等价。
|
||||
*/
|
||||
export function formatTimestampTz(timestamp: unknown, tz: string): string | undefined {
|
||||
if (timestamp == null || timestamp === "") return undefined;
|
||||
if (typeof timestamp === "string" && !/^\d+$/.test(timestamp)) return timestamp;
|
||||
const n = Number(timestamp);
|
||||
if (!Number.isFinite(n)) return String(timestamp);
|
||||
const d = new Date(Math.trunc(n) * 1000 + offsetMinutes(tz) * 60_000);
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
/** 带显式时区(Z / ±HH:MM)的 ISO 时间 → UTC ISO;无时区返回 undefined */
|
||||
export function isoWithZoneToUtc(v: unknown): string | undefined {
|
||||
if (typeof v !== "string") return undefined;
|
||||
if (!/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?\s*(Z|[+-]\d{2}:?\d{2})$/i.test(v.trim())) return undefined;
|
||||
const t = Date.parse(v.trim().replace(" ", "T"));
|
||||
return Number.isNaN(t) ? undefined : new Date(t).toISOString();
|
||||
}
|
||||
|
||||
/** Python `urllib.parse.quote`(safe='/') */
|
||||
export function pyQuote(s: string): string {
|
||||
return encodeURIComponent(s)
|
||||
.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
|
||||
.replace(/%2F/g, "/");
|
||||
}
|
||||
|
||||
/** Python `int(x or 0)`;非法值 0(MP 会抛异常丢整页,这里保守取 0) */
|
||||
export function toInt(v: unknown): number {
|
||||
if (typeof v === "number") return Number.isFinite(v) ? Math.trunc(v) : 0;
|
||||
const n = Number.parseInt(String(v ?? "0") || "0", 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** Python `float(x)`;非法值回退 */
|
||||
export function toFloat(v: unknown, fallback = 1): number {
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
// ---- 缓存 ----
|
||||
|
||||
export function cacheGet<T>(ctx: SpiderContext, key: string): T | undefined {
|
||||
const hit = ctx.cache.get(key);
|
||||
if (!hit) return undefined;
|
||||
if (hit.expiresAt <= ctx.http.now().getTime()) {
|
||||
ctx.cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return hit.value as T;
|
||||
}
|
||||
|
||||
export function cacheSet(ctx: SpiderContext, key: string, value: unknown, ttlMs: number): void {
|
||||
ctx.cache.set(key, { value, expiresAt: ctx.http.now().getTime() + ttlMs });
|
||||
}
|
||||
|
||||
// ---- 下载 ----
|
||||
|
||||
/** MP 两段式下载的请求配置(原本 base64 编进 enclosure;这里只在内存中构造) */
|
||||
export interface IndirectRequestConfig {
|
||||
method: "get" | "post";
|
||||
header: Record<string, string>;
|
||||
/** requests params → query string */
|
||||
params?: Record<string, string | number>;
|
||||
/** 成功标志键(Yema: "success") */
|
||||
success?: string;
|
||||
/** 结果字段路径,点分(如 "data.download_url") */
|
||||
result?: string;
|
||||
result_base_url?: string;
|
||||
result_path?: string;
|
||||
result_query_param?: string;
|
||||
}
|
||||
|
||||
/** MP `_normalize_indirect_download_url`:把换票结果约束到可信 API 来源 */
|
||||
export function normalizeIndirectDownloadUrl(url: string, baseUrl?: string): string {
|
||||
if (!url || !baseUrl) return url;
|
||||
let base: URL;
|
||||
try {
|
||||
base = new URL(baseUrl);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(url);
|
||||
} catch {
|
||||
return new URL(url, `${baseUrl.replace(/\/+$/, "")}/`).toString();
|
||||
}
|
||||
u.protocol = base.protocol;
|
||||
u.host = base.host;
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
/** MP `_resolve_indirect_download_url`:换票 → 临时直链(失败抛 SiteError,MP 返回 None) */
|
||||
export async function resolveIndirectDownloadUrl(
|
||||
ctx: SpiderContext,
|
||||
requestUrl: string,
|
||||
cfg: IndirectRequestConfig,
|
||||
): Promise<string> {
|
||||
const data = await apiRequest(ctx, requestUrl, {
|
||||
method: cfg.method === "get" ? "GET" : "POST",
|
||||
headers: cfg.header,
|
||||
...(cfg.params ? { query: cfg.params } : {}),
|
||||
});
|
||||
const fail = (why: string): never => {
|
||||
const msg = isPlainObject(data) ? (data.message ?? data.msg ?? data.errorMessage) : undefined;
|
||||
throw new SiteError(
|
||||
ctx.def.id,
|
||||
msg ? classifyMessage(String(msg)) : "parse_error",
|
||||
redactText(`${ctx.def.name} 换取下载地址失败:${msg ? String(msg).slice(0, 200) : why}`),
|
||||
);
|
||||
};
|
||||
if (!cfg.result) return typeof data === "string" ? data : fail("响应不是字符串");
|
||||
if (cfg.success && !(isPlainObject(data) && data[cfg.success])) fail("success 标志为假");
|
||||
let cur: unknown = data;
|
||||
for (const key of cfg.result.split(".")) {
|
||||
if (!isPlainObject(cur)) fail(`缺少字段 ${cfg.result}`);
|
||||
cur = (cur as Record<string, unknown>)[key];
|
||||
if (!cur) fail(`字段 ${cfg.result} 为空`);
|
||||
}
|
||||
if (cfg.result_path && cfg.result_query_param) {
|
||||
const u = new URL(cfg.result_path.replace(/^\/+/, ""), `${String(cfg.result_base_url).replace(/\/+$/, "")}/`);
|
||||
return `${u.toString()}?${new URLSearchParams({ [cfg.result_query_param]: String(cur) }).toString()}`;
|
||||
}
|
||||
if (typeof cur !== "string") return fail("下载地址不是字符串");
|
||||
return normalizeIndirectDownloadUrl(cur, cfg.result_base_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* MP `download_torrent`:GET 种子并校验。cookie 为空表示"涉及解析地址的不使用 Cookie 下载"。
|
||||
* 带 cookie 时不跟随跳转(避免 cookie 外泄;跳 login 视为失效;跳磁力原样返回)。
|
||||
*/
|
||||
export async function downloadTorrentFile(
|
||||
ctx: SpiderContext,
|
||||
url: string,
|
||||
opts: { cookie?: string | null; headers?: Record<string, string> } = {},
|
||||
): Promise<DownloadTarget> {
|
||||
const headers: Record<string, string> = { ...(opts.headers ?? {}) };
|
||||
if (ctx.userAgent) headers["User-Agent"] = ctx.userAgent;
|
||||
if (opts.cookie) headers.cookie = opts.cookie;
|
||||
const safe = redactUrl(url, EXTRA_SECRET_KEYS);
|
||||
const res = await siteFetch(ctx.http, url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
...(opts.cookie ? { redirect: "manual" as const } : {}),
|
||||
...(ctx.signal ? { signal: ctx.signal } : {}),
|
||||
});
|
||||
const verifiedAt = ctx.http.now().toISOString();
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
const loc = res.headers.get("location") ?? "";
|
||||
if (loc.startsWith("magnet:")) return { kind: "magnet", uri: loc, verifiedAt };
|
||||
if (/login/i.test(loc)) throw new SiteError(ctx.def.id, "auth_expired", `下载 ${safe}:跳转登录页,Cookie 已失效`);
|
||||
throw new SiteError(ctx.def.id, "network", `下载 ${safe}:意外跳转 HTTP ${res.status}`, { httpStatus: res.status });
|
||||
}
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new SiteError(ctx.def.id, "auth_invalid", `下载 ${safe}:HTTP ${res.status}`, { httpStatus: res.status });
|
||||
}
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new SiteError(ctx.def.id, res.status === 404 ? "not_found" : "network", `下载 ${safe}:HTTP ${res.status}`, {
|
||||
httpStatus: res.status,
|
||||
});
|
||||
}
|
||||
const bytes = await readBody(ctx.http, res);
|
||||
const info = inspectTorrent(bytes);
|
||||
if (!info) throw new SiteError(ctx.def.id, "parse_error", `下载 ${safe}:返回内容不是 .torrent`);
|
||||
return { kind: "torrent", bytes, infoHash: info.infoHash, name: info.name, fileName: null, verifiedAt };
|
||||
}
|
||||
|
||||
/** 站点前端地址(以 / 结尾) */
|
||||
export function siteBase(def: { domain: string }): string {
|
||||
return def.domain.endsWith("/") ? def.domain : `${def.domain}/`;
|
||||
}
|
||||
|
||||
/** 提取资源 ID:纯数字或 URL 末段数字 */
|
||||
export function checkReleaseId(siteId: string, id: string): string {
|
||||
const m = /(\d+)\/?$/.exec(String(id).trim());
|
||||
if (!m) throw new SiteError(siteId, "not_found", "资源 ID 非法");
|
||||
return m[1]!;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createMockFetch, HAIDAN_SEARCH_ERROR, HAIDAN_SEARCH_OK } from "@umt/testkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { haidanSearchQuery, haidanSpider } from "./haidan.ts";
|
||||
import { adapterWith, expectSiteError, testDef, withTorrent } from "./testing.ts";
|
||||
|
||||
const def = testDef("Haidan", { domain: "https://www.haidan.video/" });
|
||||
|
||||
describe("HaiDanSpider(MP haidan.py)", () => {
|
||||
it("__get_params:isapi/search_area/空关键词/分类逗号拼接", () => {
|
||||
expect(haidanSearchQuery(null)).toBe("isapi=1&search_area=0&search=&search_mode=0&cat=");
|
||||
expect(haidanSearchQuery("tt1160419", "tv")).toBe(
|
||||
"isapi=1&search_area=4&search=tt1160419&search_mode=0&cat=402%2C403%2C404%2C405",
|
||||
);
|
||||
});
|
||||
|
||||
it("列表解析:字段映射、分类投影、促销系数表、passkey 脱敏", async () => {
|
||||
const m = createMockFetch({ "/torrents.php": { body: HAIDAN_SEARCH_OK } });
|
||||
const page = await adapterWith(def, haidanSpider, m.fetch).search("Dune", { mtype: "movie" });
|
||||
expect(m.requests[0]!.headers.cookie).toBe("c=COOKIESECRET");
|
||||
expect(m.requests[0]!.url.searchParams.get("cat")).toBe("401,404,405");
|
||||
const by = Object.fromEntries(page.items.map((i) => [i.siteReleaseId, i]));
|
||||
expect(by["30001"]).toMatchObject({
|
||||
title: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.HEVC.Atmos-HDH",
|
||||
subtitle: "沙丘2 | 4K 原盘 REMUX",
|
||||
category: { id: "401" },
|
||||
sizeBytes: 76543210987,
|
||||
seeders: 88,
|
||||
leechers: 3,
|
||||
snatched: 412,
|
||||
promotion: { type: "free", downloadFactor: 0, uploadFactor: 1 },
|
||||
hr: "unknown",
|
||||
uploadedAt: "2024-09-20T00:00:00.000Z",
|
||||
detailUrl: "https://www.haidan.video/details.php?group_id=9001&torrent_id=30001",
|
||||
raw: expect.objectContaining({ category: "电影" }),
|
||||
});
|
||||
expect(by["30002"]!.promotion).toMatchObject({ type: "2x50%", downloadFactor: 0.5, uploadFactor: 2 });
|
||||
expect(by["30002"]!.raw.category).toBe("电视剧");
|
||||
expect(by["30003"]!.raw.category).toBe("音乐");
|
||||
expect(JSON.stringify(page)).not.toContain("HDPASSKEY123");
|
||||
expect(page.totalPages).toBeNull();
|
||||
// 不翻页:第 2 页直接为空
|
||||
expect((await adapterWith(def, haidanSpider, m.fetch).list(2)).items).toEqual([]);
|
||||
});
|
||||
|
||||
it("业务错误 / 缺 Cookie / 下载沿用缓存直链带 Cookie", async () => {
|
||||
const bad = createMockFetch({ "/torrents.php": { body: HAIDAN_SEARCH_ERROR } });
|
||||
await expectSiteError(adapterWith(def, haidanSpider, bad.fetch).list(1), "auth_invalid");
|
||||
await expectSiteError(adapterWith(def, haidanSpider, bad.fetch, {}).list(1), "auth_missing");
|
||||
const m = createMockFetch({ "/torrents.php": { body: HAIDAN_SEARCH_OK } });
|
||||
const t = withTorrent(m.fetch, (u) => u.pathname === "/download.php");
|
||||
const a = adapterWith(def, haidanSpider, t.fetch);
|
||||
await a.list(1);
|
||||
const target = await a.downloadTarget("30001");
|
||||
expect(target).toMatchObject({ kind: "torrent", infoHash: t.infoHash });
|
||||
expect(t.hits[0]!.url.searchParams.get("passkey")).toBe("HDPASSKEY123");
|
||||
expect(t.hits[0]!.headers.get("cookie")).toBe("c=COOKIESECRET");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||
* @mp-source app/modules/indexer/spider/haidan.py @ e6820a8
|
||||
*
|
||||
* 海胆 haidan.video(parser "Haidan",Cookie + `torrents.php?isapi=1` JSON)。函数对照 `HaiDanSpider`:
|
||||
* - `_movie_category/_tv_category/_music_category/_dl_state/_up_state` → 同名常量
|
||||
* - `get_search_page_size` → `pageSize`(恒 null:不翻页)
|
||||
* - `__get_params`(含 `__dict_to_query`)→ `haidanSearchQuery`
|
||||
* - `__parse_result` → `parseHaidanResult`;`__process_response` → `processHaidanResponse`
|
||||
* - `search/async_search` → `haidanSpider.search`
|
||||
* - `__get_downloadvolumefactor/__get_uploadvolumefactor` → `haidanDownloadVolumeFactor/haidanUploadVolumeFactor`
|
||||
* 下载:MP 用结果里的 `url` 字段(可能含 passkey)带 Cookie 下载;缓存缺失时回退 NexusPHP `download.php?id=`(TODO(verify))。
|
||||
*/
|
||||
import type { MpMediaType } from "../spider/spider.ts";
|
||||
import { SiteError } from "../types.ts";
|
||||
import {
|
||||
apiRequest,
|
||||
businessError,
|
||||
downloadTorrentFile,
|
||||
formatTimestampTz,
|
||||
isPlainObject,
|
||||
siteBase,
|
||||
toInt,
|
||||
} from "./common.ts";
|
||||
import type { SpecializedSiteDefinition, SpecializedSpider, SpecializedTorrent, SpiderSearchQuery } from "./types.ts";
|
||||
|
||||
export const HAIDAN_MOVIE_CATEGORY = ["401", "404", "405"];
|
||||
export const HAIDAN_TV_CATEGORY = ["402", "403", "404", "405"];
|
||||
/** 音乐搜索同时覆盖高品质音频和音乐视频/演唱会分区 */
|
||||
export const HAIDAN_MUSIC_CATEGORY = ["406", "408"];
|
||||
|
||||
/** 促销状态 1-普通,2-免费,3-2X,4-2X免费,5-50%,6-2X50%,7-30% */
|
||||
export const HAIDAN_DL_STATE: Record<string, number> = { "1": 1, "2": 0, "3": 1, "4": 0, "5": 0.5, "6": 0.5, "7": 0.3 };
|
||||
export const HAIDAN_UP_STATE: Record<string, number> = { "1": 1, "2": 1, "3": 2, "4": 2, "5": 1, "6": 2, "7": 1 };
|
||||
|
||||
export function haidanDownloadVolumeFactor(discount: unknown): number {
|
||||
return discount ? (HAIDAN_DL_STATE[String(discount)] ?? 1) : 1;
|
||||
}
|
||||
|
||||
export function haidanUploadVolumeFactor(discount: unknown): number {
|
||||
return discount ? (HAIDAN_UP_STATE[String(discount)] ?? 1) : 1;
|
||||
}
|
||||
|
||||
/** MP `__get_params`:返回已编码的 query 串(列表转逗号分隔;空关键词显式传空串) */
|
||||
export function haidanSearchQuery(keyword: string | null | undefined, mtype?: SpiderSearchQuery["mtype"]): string {
|
||||
let categories: string[];
|
||||
if (!mtype) categories = [];
|
||||
else if (mtype === "tv") categories = HAIDAN_TV_CATEGORY;
|
||||
else if (mtype === "music") categories = HAIDAN_MUSIC_CATEGORY;
|
||||
else categories = HAIDAN_MOVIE_CATEGORY;
|
||||
// 0-标题 1-简介(较慢)3-发种用户名 4-IMDb
|
||||
const searchArea = keyword?.startsWith("tt") ? "4" : "0";
|
||||
return new URLSearchParams({
|
||||
isapi: "1",
|
||||
search_area: searchArea,
|
||||
search: keyword || "",
|
||||
search_mode: "0", // 0-与 1-或 2-精准
|
||||
cat: categories.join(","),
|
||||
}).toString();
|
||||
}
|
||||
|
||||
export interface HaidanItem {
|
||||
group_id?: string | number;
|
||||
category?: string | number | null;
|
||||
name?: string;
|
||||
small_descr?: string;
|
||||
url?: string;
|
||||
added?: string | number;
|
||||
size?: string | number;
|
||||
seeders?: string | number;
|
||||
leechers?: string | number;
|
||||
times_completed?: string | number;
|
||||
sp_state?: string | number;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** MP `__parse_result`(data 为 {tid: item} 字典) */
|
||||
export function parseHaidanResult(
|
||||
result: { data?: Record<string, HaidanItem> | null },
|
||||
def: SpecializedSiteDefinition,
|
||||
) {
|
||||
const torrents: SpecializedTorrent[] = [];
|
||||
const base = siteBase(def);
|
||||
for (const [tid, item] of Object.entries(result.data ?? {})) {
|
||||
// 分类字段在每条种子内,接口顶层没有 category 字段
|
||||
const categoryValue = String(item.category || "");
|
||||
let category: MpMediaType;
|
||||
if (HAIDAN_MUSIC_CATEGORY.includes(categoryValue)) category = "音乐";
|
||||
else if (HAIDAN_TV_CATEGORY.includes(categoryValue) && !HAIDAN_MOVIE_CATEGORY.includes(categoryValue))
|
||||
category = "电视剧";
|
||||
else if (HAIDAN_MOVIE_CATEGORY.includes(categoryValue)) category = "电影";
|
||||
else category = "未知";
|
||||
const t: SpecializedTorrent = {
|
||||
title: item.name,
|
||||
description: item.small_descr,
|
||||
size: toInt(item.size),
|
||||
seeders: toInt(item.seeders),
|
||||
peers: toInt(item.leechers),
|
||||
grabs: toInt(item.times_completed),
|
||||
downloadvolumefactor: haidanDownloadVolumeFactor(item.sp_state),
|
||||
uploadvolumefactor: haidanUploadVolumeFactor(item.sp_state),
|
||||
page_url: `${base}details.php?group_id=${item.group_id}&torrent_id=${tid}`,
|
||||
labels: [],
|
||||
category,
|
||||
site_release_id: tid,
|
||||
};
|
||||
if (item.url) t.enclosure = String(item.url);
|
||||
const pubdate = formatTimestampTz(item.added, def.umt.timezone);
|
||||
if (pubdate) t.pubdate = pubdate;
|
||||
if (categoryValue) t.category_id = categoryValue;
|
||||
torrents.push(t);
|
||||
}
|
||||
return torrents;
|
||||
}
|
||||
|
||||
export const haidanSpider: SpecializedSpider = {
|
||||
parser: "Haidan",
|
||||
credential: "cookie",
|
||||
pageSize: () => null,
|
||||
async search(ctx, q) {
|
||||
const cookie = await ctx.secret("cookie", true);
|
||||
const data = await apiRequest(ctx, `${siteBase(ctx.def)}torrents.php`, {
|
||||
headers: { cookie: cookie! },
|
||||
rawQuery: haidanSearchQuery(q.keyword, q.mtype),
|
||||
});
|
||||
// MP __process_response:code != 0 → 失败
|
||||
if (!isPlainObject(data) || data.code !== 0) {
|
||||
throw businessError(ctx, "搜索", isPlainObject(data) ? data.msg : "响应结构无效");
|
||||
}
|
||||
return parseHaidanResult(data as { data?: Record<string, HaidanItem> }, ctx.def);
|
||||
},
|
||||
async resolveDownload(ctx, siteReleaseId, enclosure) {
|
||||
const cookie = await ctx.secret("cookie", true);
|
||||
const id = /(\d+)\/?$/.exec(String(siteReleaseId))?.[1];
|
||||
if (!id) throw new SiteError(ctx.def.id, "not_found", "资源 ID 非法");
|
||||
// TODO(verify):无缓存直链时回退 download.php?id=
|
||||
const url = enclosure ?? `${siteBase(ctx.def)}download.php?id=${id}`;
|
||||
return downloadTorrentFile(ctx, url, { cookie });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createMockFetch, HDDOLBY_SEARCH_ERROR, HDDOLBY_SEARCH_OK } from "@umt/testkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hddolbyApiBase, hddolbySearchParams, hddolbySpider } from "./hddolby.ts";
|
||||
import { adapterWith, expectSiteError, testDef, withTorrent } from "./testing.ts";
|
||||
|
||||
const def = testDef("HDDolby", { domain: "https://www.hddolby.com/" });
|
||||
|
||||
describe("HddolbySpider(MP hddolby.py)", () => {
|
||||
it("__get_params 与 API 地址:page_number 为 0 起、无类型时 movie+tv 去重升序", () => {
|
||||
expect(hddolbyApiBase(def)).toBe("https://api.hddolby.com");
|
||||
expect(hddolbySearchParams("Lost")).toEqual({
|
||||
keyword: "Lost",
|
||||
page_number: 0,
|
||||
page_size: 100,
|
||||
categories: [401, 402, 403, 404, 405],
|
||||
visible: 1,
|
||||
});
|
||||
expect(hddolbySearchParams(null, "music", 2).categories).toEqual([406, 408]);
|
||||
});
|
||||
|
||||
it("列表解析:促销类型表、标签表、分类优先级(405 归电视剧)、downhash 脱敏", async () => {
|
||||
const m = createMockFetch({ "POST https://api.hddolby.com/api/v1/torrent/search": { body: HDDOLBY_SEARCH_OK } });
|
||||
const page = await adapterWith(def, hddolbySpider, m.fetch).list(1);
|
||||
const req = m.requests[0]!;
|
||||
expect(req.headers["x-api-key"]).toBe("APIKEYSECRET");
|
||||
expect(req.headers.cookie).toBe("c=COOKIESECRET");
|
||||
expect(JSON.parse(req.body!)).toMatchObject({ keyword: null, page_number: 0 });
|
||||
const [a, b, c] = page.items;
|
||||
expect(a).toMatchObject({
|
||||
siteReleaseId: "120202",
|
||||
title: "[DBY] Lost S06 2010 Complete 1080p Netflix WEB-DL AVC DDP5.1-DBTV",
|
||||
category: { id: "402" },
|
||||
promotion: { type: "free", downloadFactor: 0, uploadFactor: 1, expiresAt: "2026-09-30T04:00:00.000Z" },
|
||||
tags: ["官方", "中文字幕"],
|
||||
uploadedAt: "2025-02-18T11:47:56.000Z",
|
||||
detailUrl: "https://www.hddolby.com/details.php?id=120202&hit=1",
|
||||
hr: "unknown",
|
||||
});
|
||||
expect(a!.raw.category).toBe("电视剧");
|
||||
// promotion_time_type 6 → 2x(下载 1 上传 2),0000 截止无效
|
||||
expect(b!.promotion).toEqual({ type: "2x", downloadFactor: 1, uploadFactor: 2, expiresAt: null });
|
||||
expect(b!.raw.category).toBe("电影");
|
||||
expect(c!.raw.category).toBe("音乐");
|
||||
expect(c!.tags).toEqual([]);
|
||||
expect(JSON.stringify(page)).not.toContain("DOWNHASHSECRET");
|
||||
const t = withTorrent(m.fetch, (u) => u.pathname === "/download.php");
|
||||
const ad = adapterWith(def, hddolbySpider, t.fetch);
|
||||
await ad.list(1);
|
||||
await ad.downloadTarget("120202");
|
||||
expect(t.hits[0]!.url.searchParams.get("downhash")).toBe("DOWNHASHSECRET");
|
||||
});
|
||||
|
||||
it("error 字段 / HTTP 401 / 缺 API Key", async () => {
|
||||
const m = createMockFetch({ "/api/v1/torrent/search": { body: HDDOLBY_SEARCH_ERROR } });
|
||||
const e = await expectSiteError(adapterWith(def, hddolbySpider, m.fetch).list(1), "auth_invalid");
|
||||
expect(e.message).toContain("invalid api key");
|
||||
const h = createMockFetch({ "/api/v1/torrent/search": { status: 401, body: "no" } });
|
||||
await expectSiteError(adapterWith(def, hddolbySpider, h.fetch).list(1), "auth_invalid");
|
||||
await expectSiteError(adapterWith(def, hddolbySpider, m.fetch, { cookie: "c=1" }).list(1), "auth_missing");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||
* @mp-source app/modules/indexer/spider/hddolby.py @ e6820a8
|
||||
*
|
||||
* HDDolby(parser "HDDolby",`https://api.<域>/api/v1/torrent/search`,x-api-key + Cookie)。函数对照 `HddolbySpider`:
|
||||
* - `_movie_category/_tv_category/_music_category/_labels` → 同名常量
|
||||
* - `get_search_page_size` → `pageSize`(100)
|
||||
* - `__get_params` → `hddolbySearchParams`(注意 MP 的 page_number 直接传 0 起页码,原样保留)
|
||||
* - `__parse_result` → `parseHddolbyResult`;`__process_response` → `hddolbySpider.search` 内的错误分支
|
||||
* - `__get_downloadvolumefactor/__get_uploadvolumefactor` → `hddolbyDownloadVolumeFactor/hddolbyUploadVolumeFactor`
|
||||
* - `__get_download_url` → `hddolbyDownloadUrl`(含 downhash,raw/downloadUrl 中脱敏)
|
||||
* 差异:API 返回的 `hr` 字段 MP 未映射,这里同样不映射(H&R = unknown,待核验语义)。
|
||||
*/
|
||||
import type { MpMediaType } from "../spider/spider.ts";
|
||||
import { extractDomain } from "../user-parsers/common.ts";
|
||||
import { apiRequest, businessError, checkReleaseId, downloadTorrentFile, isPlainObject, siteBase } from "./common.ts";
|
||||
import type { SpecializedSiteDefinition, SpecializedSpider, SpecializedTorrent, SpiderSearchQuery } from "./types.ts";
|
||||
|
||||
export const HDDOLBY_MOVIE_CATEGORY = [401, 405];
|
||||
export const HDDOLBY_TV_CATEGORY = [402, 403, 404, 405];
|
||||
/** 音乐搜索同时覆盖高品质音频和音乐视频/演唱会分区 */
|
||||
export const HDDOLBY_MUSIC_CATEGORY = [406, 408];
|
||||
|
||||
export const HDDOLBY_LABELS: Record<string, string> = {
|
||||
gf: "官方",
|
||||
gy: "国语",
|
||||
yy: "粤语",
|
||||
ja: "日语",
|
||||
ko: "韩语",
|
||||
zz: "中文字幕",
|
||||
jz: "禁转",
|
||||
xz: "限转",
|
||||
diy: "DIY",
|
||||
sf: "首发",
|
||||
yq: "应求",
|
||||
m0: "零魔",
|
||||
yc: "原创",
|
||||
gz: "官字",
|
||||
db: "Dolby Vision",
|
||||
hdr10: "HDR10",
|
||||
hdrm: "HDR10+",
|
||||
tx: "特效",
|
||||
lz: "连载",
|
||||
wj: "完结",
|
||||
hdrv: "HDR Vivid",
|
||||
hlg: "HLG",
|
||||
hq: "高码率",
|
||||
hfr: "高帧率",
|
||||
};
|
||||
|
||||
export function hddolbyDownloadVolumeFactor(discount: unknown): number {
|
||||
const map: Record<number, number> = { 2: 0, 5: 0.5, 6: 1, 7: 0.3 };
|
||||
return discount ? (map[Number(discount)] ?? 1) : 1;
|
||||
}
|
||||
|
||||
export function hddolbyUploadVolumeFactor(discount: unknown): number {
|
||||
const map: Record<number, number> = { 3: 2, 4: 2, 6: 2 };
|
||||
return discount ? (map[Number(discount)] ?? 1) : 1;
|
||||
}
|
||||
|
||||
export function hddolbyApiBase(def: SpecializedSiteDefinition): string {
|
||||
return (def.umt.apiBaseUrl ?? `https://api.${extractDomain(def.domain)}`).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** MP `__get_params` */
|
||||
export function hddolbySearchParams(keyword: string | null | undefined, mtype?: SpiderSearchQuery["mtype"], page = 0) {
|
||||
let categories: number[];
|
||||
if (mtype === "tv") categories = HDDOLBY_TV_CATEGORY;
|
||||
else if (mtype === "movie") categories = HDDOLBY_MOVIE_CATEGORY;
|
||||
else if (mtype === "music") categories = HDDOLBY_MUSIC_CATEGORY;
|
||||
// list(set(movie + tv)):CPython 小整数集合的迭代顺序即升序
|
||||
else categories = [...new Set([...HDDOLBY_MOVIE_CATEGORY, ...HDDOLBY_TV_CATEGORY])].sort((a, b) => a - b);
|
||||
return { keyword: keyword ?? null, page_number: page, page_size: 100, categories, visible: 1 };
|
||||
}
|
||||
|
||||
/** MP `__get_download_url` */
|
||||
export function hddolbyDownloadUrl(def: SpecializedSiteDefinition, id: unknown, downhash: unknown): string {
|
||||
return `${siteBase(def)}download.php?id=${id}&downhash=${downhash}`;
|
||||
}
|
||||
|
||||
export interface HddolbyItem {
|
||||
id: number | string;
|
||||
promotion_time_type?: number;
|
||||
promotion_until?: string;
|
||||
category?: number;
|
||||
name?: string;
|
||||
small_descr?: string;
|
||||
times_completed?: number;
|
||||
size?: number;
|
||||
added?: string;
|
||||
seeders?: number;
|
||||
leechers?: number;
|
||||
downhash?: string;
|
||||
tags?: string | null;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** MP `__parse_result` */
|
||||
export function parseHddolbyResult(results: HddolbyItem[] | null | undefined, def: SpecializedSiteDefinition) {
|
||||
const torrents: SpecializedTorrent[] = [];
|
||||
if (!results?.length) return torrents;
|
||||
for (const result of results) {
|
||||
const cv = result.category;
|
||||
let category: MpMediaType;
|
||||
if (cv != null && HDDOLBY_MUSIC_CATEGORY.includes(cv)) category = "音乐";
|
||||
else if (cv != null && HDDOLBY_TV_CATEGORY.includes(cv)) category = "电视剧";
|
||||
else if (cv != null && HDDOLBY_MOVIE_CATEGORY.includes(cv)) category = "电影";
|
||||
else category = "未知";
|
||||
const labels: string[] = [];
|
||||
for (const labelId of String(result.tags ?? "").split(";")) {
|
||||
const l = HDDOLBY_LABELS[labelId];
|
||||
if (l !== undefined) labels.push(l);
|
||||
}
|
||||
const t: SpecializedTorrent = {
|
||||
title: result.name,
|
||||
description: result.small_descr,
|
||||
enclosure: hddolbyDownloadUrl(def, result.id, result.downhash),
|
||||
size: result.size,
|
||||
seeders: result.seeders,
|
||||
peers: result.leechers,
|
||||
grabs: result.times_completed,
|
||||
downloadvolumefactor: hddolbyDownloadVolumeFactor(result.promotion_time_type),
|
||||
uploadvolumefactor: hddolbyUploadVolumeFactor(result.promotion_time_type),
|
||||
page_url: `${siteBase(def)}details.php?id=${result.id}&hit=1`,
|
||||
labels,
|
||||
category,
|
||||
site_release_id: String(result.id),
|
||||
};
|
||||
if (result.added) t.pubdate = result.added;
|
||||
if (result.promotion_until) t.freedate = result.promotion_until;
|
||||
if (cv != null) t.category_id = String(cv);
|
||||
torrents.push(t);
|
||||
}
|
||||
return torrents;
|
||||
}
|
||||
|
||||
export const hddolbySpider: SpecializedSpider = {
|
||||
parser: "HDDolby",
|
||||
credential: "apiKey",
|
||||
pageSize: () => 100,
|
||||
async search(ctx, q) {
|
||||
const apikey = await ctx.secret("apiKey", true);
|
||||
const cookie = await ctx.secret("cookie");
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/plain, */*",
|
||||
"x-api-key": apikey!,
|
||||
Referer: siteBase(ctx.def),
|
||||
};
|
||||
if (cookie) headers.cookie = cookie;
|
||||
const data = await apiRequest(ctx, `${hddolbyApiBase(ctx.def)}/api/v1/torrent/search`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
json: hddolbySearchParams(q.keyword, q.mtype, q.page ?? 0),
|
||||
});
|
||||
if (!isPlainObject(data)) throw businessError(ctx, "搜索", "响应结构无效");
|
||||
if (data.error) {
|
||||
const err = data.error;
|
||||
throw businessError(ctx, "搜索", isPlainObject(err) ? err.message : err);
|
||||
}
|
||||
return parseHddolbyResult(data.data as HddolbyItem[], ctx.def);
|
||||
},
|
||||
async resolveDownload(ctx, siteReleaseId, enclosure) {
|
||||
const cookie = await ctx.secret("cookie");
|
||||
const id = checkReleaseId(ctx.def.id, siteReleaseId);
|
||||
// TODO(verify):无 downhash 时是否仅凭 Cookie 可下载
|
||||
const url = enclosure ?? `${siteBase(ctx.def)}download.php?id=${id}`;
|
||||
return downloadTorrentFile(ctx, url, { cookie });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||
* @mp-source app/modules/indexer/__init__.py @ e6820a8
|
||||
*
|
||||
* 专用 spider 汇总:`SPECIALIZED_SPIDERS` = MP `SPIDER_PARSER_CLASSES`。
|
||||
*/
|
||||
import type { SiteAdapterDeps } from "../deps.ts";
|
||||
import type { SiteAdapter } from "../types.ts";
|
||||
import { createSpecializedSiteAdapter } from "./adapter.ts";
|
||||
import { haidanSpider } from "./haidan.ts";
|
||||
import { hddolbySpider } from "./hddolby.ts";
|
||||
import { mtorrentSpider } from "./mtorrent.ts";
|
||||
import { rousiSpider } from "./rousi.ts";
|
||||
import { sunnyptSpider } from "./sunnypt.ts";
|
||||
import { tnodeSpider } from "./tnode.ts";
|
||||
import { torrentleechSpider } from "./torrentleech.ts";
|
||||
import type { SpecializedParser, SpecializedSiteDefinition, SpecializedSpider } from "./types.ts";
|
||||
import { yemaSpider } from "./yema.ts";
|
||||
|
||||
export const SPECIALIZED_SPIDERS: Record<SpecializedParser, SpecializedSpider> = {
|
||||
TNodeSpider: tnodeSpider,
|
||||
TorrentLeech: torrentleechSpider,
|
||||
mTorrent: mtorrentSpider,
|
||||
Yema: yemaSpider,
|
||||
Haidan: haidanSpider,
|
||||
HDDolby: hddolbySpider,
|
||||
RousiPro: rousiSpider,
|
||||
SunnyPT: sunnyptSpider,
|
||||
};
|
||||
|
||||
export function isSpecializedParser(v: unknown): v is SpecializedParser {
|
||||
return typeof v === "string" && Object.hasOwn(SPECIALIZED_SPIDERS, v);
|
||||
}
|
||||
|
||||
/** 按定义的 parser 键选择专用 spider 创建适配器 */
|
||||
export function createSpecializedAdapterFor(def: SpecializedSiteDefinition, deps: SiteAdapterDeps): SiteAdapter {
|
||||
const spider = SPECIALIZED_SPIDERS[def.parser];
|
||||
if (!spider) throw new Error(`未知专用 spider:${def.parser}`);
|
||||
return createSpecializedSiteAdapter(def, spider, deps);
|
||||
}
|
||||
|
||||
export { createSpecializedSiteAdapter } from "./adapter.ts";
|
||||
export {
|
||||
apiRequest,
|
||||
classifyMessage,
|
||||
downloadTorrentFile,
|
||||
formatTimestampTz,
|
||||
type IndirectRequestConfig,
|
||||
normalizeIndirectDownloadUrl,
|
||||
pyQuote,
|
||||
resolveIndirectDownloadUrl,
|
||||
} from "./common.ts";
|
||||
export * from "./haidan.ts";
|
||||
export * from "./hddolby.ts";
|
||||
export { releaseIdFromPageUrl, specializedTorrentToRelease } from "./map.ts";
|
||||
export * from "./mtorrent.ts";
|
||||
export * from "./rousi.ts";
|
||||
export * from "./sunnypt.ts";
|
||||
export * from "./tnode.ts";
|
||||
export * from "./torrentleech.ts";
|
||||
export type {
|
||||
SpecializedParser,
|
||||
SpecializedSiteDefinition,
|
||||
SpecializedSpider,
|
||||
SpecializedTorrent,
|
||||
SpiderContext,
|
||||
SpiderSearchQuery,
|
||||
} from "./types.ts";
|
||||
export * from "./yema.ts";
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||
* @mp-source app/modules/indexer/__init__.py @ e6820a8
|
||||
*
|
||||
* 专用 spider 输出(MP TorrentInfo 字段)→ 统一 `SiteRelease`。
|
||||
* 促销类型由 MP 系数组合推出(复用 spider/map.ts `promotionFromFactors`);H&R 未提供时为 unknown;
|
||||
* raw 为完整 MP 字段,enclosure 脱敏(HDDolby downhash、Haidan passkey 等)。
|
||||
*/
|
||||
import { redactUrl } from "../../rss/redact.ts";
|
||||
import { promotionFromFactors } from "../spider/map.ts";
|
||||
import type { SiteRelease } from "../types.ts";
|
||||
import { parseSiteTime } from "../util.ts";
|
||||
import { EXTRA_SECRET_KEYS } from "./common.ts";
|
||||
import type { SpecializedSiteDefinition, SpecializedTorrent } from "./types.ts";
|
||||
|
||||
function num(v: unknown): number | null {
|
||||
if (v == null || v === "") return null;
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function factor(v: unknown): number | undefined {
|
||||
const n = num(v);
|
||||
return n === null ? undefined : n;
|
||||
}
|
||||
|
||||
/** 从详情链接取资源 ID:torrent_id= → id= → 末段数字(含 #/torrent/detail/1/ 形式) */
|
||||
export function releaseIdFromPageUrl(url: string | undefined): string | null {
|
||||
if (!url) return null;
|
||||
return (
|
||||
/[?&]torrent_id=(\d+)/.exec(url)?.[1] ??
|
||||
/[?&]id=(\d+)/.exec(url)?.[1] ??
|
||||
/\/(\d+)\/?(?:[?#].*)?$/.exec(url)?.[1] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function specializedCategoryName(def: SpecializedSiteDefinition, id: string | undefined): string | null {
|
||||
if (!id) return null;
|
||||
const named = def.umt.categoryNames?.[id];
|
||||
if (named) return named;
|
||||
for (const k of ["movie", "tv", "music"] as const) {
|
||||
const hit = (def.category?.[k] ?? []).find((c) => String(c.id) === id);
|
||||
if (hit?.desc) return hit.desc;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function specializedTorrentToRelease(
|
||||
t: SpecializedTorrent,
|
||||
def: SpecializedSiteDefinition,
|
||||
observedAt: string,
|
||||
): SiteRelease | null {
|
||||
const id = t.site_release_id ?? releaseIdFromPageUrl(t.page_url);
|
||||
if (!id || !t.title) return null;
|
||||
const tz = def.umt.timezone;
|
||||
const freedate = t.freedate_utc ?? (t.freedate ? parseSiteTime(t.freedate, tz) : null);
|
||||
const raw: Record<string, unknown> = { ...t };
|
||||
if (t.enclosure) raw.enclosure = redactUrl(t.enclosure, EXTRA_SECRET_KEYS);
|
||||
const release: SiteRelease = {
|
||||
siteId: def.id,
|
||||
siteReleaseId: id,
|
||||
title: String(t.title).trim(),
|
||||
category: { id: t.category_id ?? null, name: specializedCategoryName(def, t.category_id) },
|
||||
sizeBytes: num(t.size),
|
||||
seeders: num(t.seeders),
|
||||
leechers: num(t.peers),
|
||||
snatched: num(t.grabs),
|
||||
promotion: promotionFromFactors(factor(t.downloadvolumefactor), factor(t.uploadvolumefactor), freedate),
|
||||
hr: t.hit_and_run === undefined ? "unknown" : t.hit_and_run,
|
||||
uploadedAt: t.pubdate_utc ?? parseSiteTime(t.pubdate, tz),
|
||||
detailUrl: t.page_url ?? `${def.domain}`,
|
||||
tags: t.labels ?? [],
|
||||
raw,
|
||||
observedAt,
|
||||
};
|
||||
if (t.enclosure) release.downloadUrl = redactUrl(t.enclosure, EXTRA_SECRET_KEYS);
|
||||
if (t.description) release.subtitle = String(t.description).trim();
|
||||
if (t.imdbid) release.imdbId = t.imdbid;
|
||||
return release;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||
* @mp-source app/modules/indexer/spider/mtorrent.py @ e6820a8
|
||||
*
|
||||
* `mTorrent` 键的专用 spider 形态,复用 W1-3b 已移植的 mteam/spider.ts(`__get_params`/`__parse_result`)
|
||||
* 与 mteam/client.ts(`__process_response` + 业务码检查),供 `SPECIALIZED_SPIDERS` 统一分派。
|
||||
* 完整适配(详情、商城免费、healthCheck=profile)仍以 `createMteamAdapter` 为准。
|
||||
*/
|
||||
import { mteamPost } from "../mteam/client.ts";
|
||||
import { rootDomain } from "../mteam/definition.ts";
|
||||
import { type MteamTorrentJson, mteamSearchParams, parseMteamResult } from "../mteam/spider.ts";
|
||||
import { SiteError } from "../types.ts";
|
||||
import { checkReleaseId, downloadTorrentFile, siteBase } from "./common.ts";
|
||||
import type { SpecializedSiteDefinition, SpecializedSpider, SpecializedTorrent } from "./types.ts";
|
||||
|
||||
export function mtorrentApiBase(def: SpecializedSiteDefinition): string {
|
||||
return (def.umt.apiBaseUrl ?? `https://api.${rootDomain(def.domain)}`).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export const mtorrentSpider: SpecializedSpider = {
|
||||
parser: "mTorrent",
|
||||
credential: "apiKey",
|
||||
pageSize: () => 100,
|
||||
async search(ctx, q) {
|
||||
const apikey = (await ctx.secret("apiKey", true))!;
|
||||
const params = mteamSearchParams(q.keyword ?? null, q.mtype, q.page ?? 0);
|
||||
const data = await mteamPost<{ data?: MteamTorrentJson[] | null }>(
|
||||
ctx.http,
|
||||
mtorrentApiBase(ctx.def),
|
||||
apikey,
|
||||
"/api/torrent/search",
|
||||
{ json: params },
|
||||
ctx.signal,
|
||||
);
|
||||
const src = data?.data ?? [];
|
||||
return parseMteamResult(src, siteBase(ctx.def)).map(
|
||||
(t, i): SpecializedTorrent => ({ ...t, site_release_id: String(src[i]!.id) }),
|
||||
);
|
||||
},
|
||||
async resolveDownload(ctx, siteReleaseId) {
|
||||
const apikey = (await ctx.secret("apiKey", true))!;
|
||||
const id = checkReleaseId(ctx.def.id, siteReleaseId);
|
||||
const url = await mteamPost<string | null>(
|
||||
ctx.http,
|
||||
mtorrentApiBase(ctx.def),
|
||||
apikey,
|
||||
"/api/torrent/genDlToken",
|
||||
{ query: { id } },
|
||||
ctx.signal,
|
||||
);
|
||||
if (!url || typeof url !== "string")
|
||||
throw new SiteError(ctx.def.id, "rate_limited", `genDlToken ${id}:未返回下载链接`);
|
||||
// 涉及解析地址的不使用 Cookie 下载
|
||||
return downloadTorrentFile(ctx, url);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createMockFetch, ROUSI_DETAIL_OK, ROUSI_SEARCH_ERROR, ROUSI_SEARCH_OK } from "@umt/testkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rousiCategoryNamesByIds, rousiSearchParams, rousiSpider } from "./rousi.ts";
|
||||
import { adapterWith, expectSiteError, testDef, withTorrent } from "./testing.ts";
|
||||
|
||||
const def = testDef("RousiPro", { domain: "https://rousi.pro/" });
|
||||
|
||||
describe("RousiSpider(MP rousi.py)", () => {
|
||||
it("__get_params:页码 +1、cat 映射只取第一个、否则按 mtype", () => {
|
||||
expect(rousiCategoryNamesByIds("3, 6,99")).toEqual(["documentary", "variety"]);
|
||||
expect(rousiSearchParams({ keyword: "x", cat: "6,1", page: 0 })).toEqual({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
keyword: "x",
|
||||
category: "variety",
|
||||
});
|
||||
expect(rousiSearchParams({ mtype: "tv", page: 2 })).toEqual({ page: 3, page_size: 100, category: "tv" });
|
||||
});
|
||||
|
||||
it("列表解析:Bearer、促销 is_active、带时区时间转 UTC、分类 slug", async () => {
|
||||
const m = createMockFetch({ "GET https://rousi.pro/api/v1/torrents": { body: ROUSI_SEARCH_OK } });
|
||||
const page = await adapterWith(def, rousiSpider, m.fetch).search("bear", { mtype: "tv" });
|
||||
expect(m.requests[0]!.headers.authorization).toBe("Bearer APIKEYSECRET");
|
||||
expect(m.requests[0]!.url.searchParams.get("category")).toBe("tv");
|
||||
const [a, b, c] = page.items;
|
||||
expect(a).toMatchObject({
|
||||
siteReleaseId: "501",
|
||||
category: { id: "tv" },
|
||||
sizeBytes: 50000000000,
|
||||
snatched: 77,
|
||||
promotion: { type: "2xfree", downloadFactor: 0, uploadFactor: 2, expiresAt: "2026-09-27T16:00:00.000Z" },
|
||||
uploadedAt: "2026-09-20T02:00:00.000Z",
|
||||
detailUrl: "https://rousi.pro/torrents/501",
|
||||
});
|
||||
expect(a!.raw.category).toBe("电视剧");
|
||||
// 未激活促销 → 1/1
|
||||
expect(b!.promotion).toMatchObject({ type: "none", downloadFactor: 1, uploadFactor: 1 });
|
||||
expect(b!.uploadedAt).toBe("2026-09-19T02:00:00.000Z");
|
||||
expect(c!.raw.category).toBe("未知");
|
||||
});
|
||||
|
||||
it("业务错误 / 缺 Key / 下载经详情接口换取 download_url,直链不带凭据", async () => {
|
||||
const bad = createMockFetch({ "/api/v1/torrents": { body: ROUSI_SEARCH_ERROR } });
|
||||
await expectSiteError(adapterWith(def, rousiSpider, bad.fetch).list(1), "auth_invalid");
|
||||
await expectSiteError(adapterWith(def, rousiSpider, bad.fetch, {}).list(1), "auth_missing");
|
||||
const m = createMockFetch({ "GET https://rousi.pro/api/v1/torrents/501": { body: ROUSI_DETAIL_OK } });
|
||||
const t = withTorrent(m.fetch, (u) => u.host === "dl.rousi.invalid");
|
||||
const target = await adapterWith(def, rousiSpider, t.fetch).downloadTarget("501");
|
||||
expect(target).toMatchObject({ kind: "torrent", infoHash: t.infoHash });
|
||||
expect(m.requests[0]!.headers.authorization).toBe("Bearer APIKEYSECRET");
|
||||
expect(t.hits[0]!.headers.get("authorization")).toBeNull();
|
||||
expect(t.hits[0]!.headers.get("cookie")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||
* @mp-source app/modules/indexer/spider/rousi.py @ e6820a8
|
||||
*
|
||||
* Rousi.pro PeerGo 兼容 API(parser "RousiPro",Bearer 个人 API Key,需 torrent:read + torrent:download)。
|
||||
* 函数对照 `RousiSpider`:
|
||||
* - `_movie_category/_tv_category/_music_category` → `ROUSI_*_CATEGORY`
|
||||
* - `get_search_page_size` → `pageSize`(100)
|
||||
* - `__get_params` → `rousiSearchParams`;`__get_category_names_by_ids` → `rousiCategoryNamesByIds`
|
||||
* - `__process_response` → `rousiSpider.search` 错误分支;`__parse_result` → `parseRousiResult`
|
||||
* - `__get_download_url`(GET 详情接口,result=data.download_url)→ `rousiSpider.resolveDownload`(现场换票,不生成含 key 的 enclosure)
|
||||
*/
|
||||
import type { MpMediaType } from "../spider/spider.ts";
|
||||
import { extractDomain, normalizeDatetime } from "../user-parsers/common.ts";
|
||||
import {
|
||||
apiRequest,
|
||||
businessError,
|
||||
checkReleaseId,
|
||||
downloadTorrentFile,
|
||||
isoWithZoneToUtc,
|
||||
isPlainObject,
|
||||
resolveIndirectDownloadUrl,
|
||||
toFloat,
|
||||
toInt,
|
||||
} from "./common.ts";
|
||||
import type { SpecializedSiteDefinition, SpecializedSpider, SpecializedTorrent, SpiderSearchQuery } from "./types.ts";
|
||||
|
||||
/** API 不支持多分类搜索,每次只使用一个分类 */
|
||||
export const ROUSI_MOVIE_CATEGORY = "movie";
|
||||
export const ROUSI_TV_CATEGORY = "tv";
|
||||
export const ROUSI_MUSIC_CATEGORY = "music";
|
||||
|
||||
const ROUSI_ID_TO_NAME: Record<string, string> = {
|
||||
"1": "movie",
|
||||
"2": "tv",
|
||||
"3": "documentary",
|
||||
"4": "animation",
|
||||
"5": "music",
|
||||
"6": "variety",
|
||||
};
|
||||
|
||||
/** 站点 origin(MP `https://%s` % extract_domain(domain)) */
|
||||
export function rousiBase(def: SpecializedSiteDefinition): string {
|
||||
return (def.umt.apiBaseUrl ?? `https://${extractDomain(def.domain)}`).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** MP `__get_category_names_by_ids` */
|
||||
export function rousiCategoryNamesByIds(cat: string | null | undefined): string[] | null {
|
||||
if (!cat) return null;
|
||||
const ids = cat
|
||||
.split(",")
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
const names = ids.filter((id) => id in ROUSI_ID_TO_NAME).map((id) => ROUSI_ID_TO_NAME[id]!);
|
||||
return names.length ? names : null;
|
||||
}
|
||||
|
||||
/** MP `__get_params` */
|
||||
export function rousiSearchParams(q: SpiderSearchQuery): Record<string, string | number> {
|
||||
const params: Record<string, string | number> = { page: Number(q.page ?? 0) + 1, page_size: 100 };
|
||||
if (q.keyword) params.keyword = q.keyword;
|
||||
if (q.cat) {
|
||||
const names = rousiCategoryNamesByIds(q.cat);
|
||||
if (names) params.category = names[0]!;
|
||||
} else if (q.mtype) {
|
||||
if (q.mtype === "movie") params.category = ROUSI_MOVIE_CATEGORY;
|
||||
else if (q.mtype === "tv") params.category = ROUSI_TV_CATEGORY;
|
||||
else if (q.mtype === "music") params.category = ROUSI_MUSIC_CATEGORY;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export interface RousiItem {
|
||||
id: number | string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
category?: string | { slug?: string; name?: string } | null;
|
||||
size?: number | string;
|
||||
seeders?: number | string;
|
||||
leechers?: number | string;
|
||||
downloads?: number | string;
|
||||
created_at?: string;
|
||||
promotion?: { is_active?: boolean; down_multiplier?: number; up_multiplier?: number; until?: string } | null;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** MP `__parse_result` */
|
||||
export function parseRousiResult(results: RousiItem[] | null | undefined, def: SpecializedSiteDefinition) {
|
||||
const torrents: SpecializedTorrent[] = [];
|
||||
if (!results?.length) return torrents;
|
||||
const base = rousiBase(def);
|
||||
for (const result of results) {
|
||||
const rawCat = result.category;
|
||||
let catVal: string | null | undefined = null;
|
||||
let category: MpMediaType = "未知";
|
||||
if (rawCat && typeof rawCat === "object") catVal = rawCat.slug || rawCat.name;
|
||||
else if (typeof rawCat === "string") catVal = rawCat;
|
||||
if (catVal) {
|
||||
catVal = String(catVal).toLowerCase();
|
||||
if (catVal === ROUSI_MOVIE_CATEGORY) category = "电影";
|
||||
else if (catVal === ROUSI_TV_CATEGORY) category = "电视剧";
|
||||
else if (catVal === ROUSI_MUSIC_CATEGORY) category = "音乐";
|
||||
}
|
||||
// API 后端已处理全站促销优先级,直接使用返回的 promotion 数据
|
||||
let dl = 1.0;
|
||||
let ul = 1.0;
|
||||
let freedate: string | undefined;
|
||||
let freedateUtc: string | undefined;
|
||||
const promotion = result.promotion;
|
||||
if (promotion?.is_active) {
|
||||
dl = toFloat(promotion.down_multiplier ?? 1.0);
|
||||
ul = toFloat(promotion.up_multiplier ?? 1.0);
|
||||
if (promotion.until) {
|
||||
freedate = normalizeDatetime(promotion.until) ?? undefined;
|
||||
freedateUtc = isoWithZoneToUtc(promotion.until);
|
||||
}
|
||||
}
|
||||
const id = result.id;
|
||||
const t: SpecializedTorrent = {
|
||||
title: result.title,
|
||||
description: result.subtitle,
|
||||
size: toInt(result.size),
|
||||
seeders: toInt(result.seeders),
|
||||
peers: toInt(result.leechers),
|
||||
grabs: toInt(result.downloads),
|
||||
downloadvolumefactor: dl,
|
||||
uploadvolumefactor: ul,
|
||||
page_url: `${base}/torrents/${id}`,
|
||||
labels: [],
|
||||
category,
|
||||
site_release_id: String(id),
|
||||
};
|
||||
const pubdate = normalizeDatetime(result.created_at);
|
||||
if (pubdate) t.pubdate = pubdate;
|
||||
const pubUtc = isoWithZoneToUtc(result.created_at);
|
||||
if (pubUtc) t.pubdate_utc = pubUtc;
|
||||
if (freedate) t.freedate = freedate;
|
||||
if (freedateUtc) t.freedate_utc = freedateUtc;
|
||||
if (catVal) t.category_id = catVal;
|
||||
torrents.push(t);
|
||||
}
|
||||
return torrents;
|
||||
}
|
||||
|
||||
export const rousiSpider: SpecializedSpider = {
|
||||
parser: "RousiPro",
|
||||
credential: "apiKey",
|
||||
pageSize: () => 100,
|
||||
async search(ctx, q) {
|
||||
const apikey = await ctx.secret("apiKey", true);
|
||||
const data = await apiRequest(ctx, `${rousiBase(ctx.def)}/api/v1/torrents`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${apikey}`, Accept: "application/json" },
|
||||
query: rousiSearchParams(q),
|
||||
});
|
||||
if (!isPlainObject(data)) throw businessError(ctx, "搜索", "搜索响应结构无效");
|
||||
if (data.code === 0) {
|
||||
const d = isPlainObject(data.data) ? data.data : {};
|
||||
return parseRousiResult((d.torrents as RousiItem[]) ?? [], ctx.def);
|
||||
}
|
||||
throw businessError(ctx, "搜索", data.message);
|
||||
},
|
||||
async resolveDownload(ctx, siteReleaseId) {
|
||||
const apikey = await ctx.secret("apiKey", true);
|
||||
const id = checkReleaseId(ctx.def.id, siteReleaseId);
|
||||
// MP __get_download_url:method=get, cookie=False, Bearer, result=data.download_url
|
||||
const url = await resolveIndirectDownloadUrl(ctx, `${rousiBase(ctx.def)}/api/v1/torrents/${id}`, {
|
||||
method: "get",
|
||||
header: { Authorization: `Bearer ${apikey}`, Accept: "application/json" },
|
||||
result: "data.download_url",
|
||||
});
|
||||
return downloadTorrentFile(ctx, url);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
createMockFetch,
|
||||
SUNNYPT_CATEGORIES_OK,
|
||||
SUNNYPT_DOWNLOAD_TOKEN_OK,
|
||||
SUNNYPT_SEARCH_ERROR,
|
||||
SUNNYPT_SEARCH_OK,
|
||||
} from "@umt/testkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sunnyptBuildParams, sunnyptParseCategoryItems, sunnyptSpider } from "./sunnypt.ts";
|
||||
import { adapterWith, expectSiteError, testDef, withTorrent } from "./testing.ts";
|
||||
|
||||
const def = testDef("SunnyPT", {
|
||||
domain: "https://sunnypt.top/",
|
||||
api_url: "https://api.sunnypt.top/api/v1/mp",
|
||||
category: { movie: [{ id: 401 }], tv: [{ id: 402 }] },
|
||||
});
|
||||
|
||||
describe("SunnyPTSpider(MP sunnypt.py)", () => {
|
||||
it("_parse_category_items / _build_params", () => {
|
||||
const map = sunnyptParseCategoryItems(SUNNYPT_CATEGORIES_OK.data);
|
||||
expect(map).toEqual({ movie: ["401", "405"], tv: ["402", "405"], music: ["406"] });
|
||||
expect(sunnyptBuildParams({ keyword: "x", mtype: "tv", page: 1 }, map)).toEqual({
|
||||
page: 2,
|
||||
page_size: 100,
|
||||
sort: "created_at",
|
||||
order: "desc",
|
||||
keyword: "x",
|
||||
media_type: "tv",
|
||||
categories: "402,405",
|
||||
});
|
||||
});
|
||||
|
||||
it("列表解析:分类接口缓存、促销、H&R 布尔、标签", async () => {
|
||||
const m = createMockFetch({
|
||||
"GET https://api.sunnypt.top/api/v1/mp/categories": { body: SUNNYPT_CATEGORIES_OK },
|
||||
"GET https://api.sunnypt.top/api/v1/mp/torrents": { body: SUNNYPT_SEARCH_OK },
|
||||
});
|
||||
const a = adapterWith(def, sunnyptSpider, m.fetch);
|
||||
const page = await a.search("arcane", { mtype: "tv" });
|
||||
await a.search("arcane", { mtype: "tv" });
|
||||
expect(m.requests.filter((r) => r.url.pathname.endsWith("/categories"))).toHaveLength(1);
|
||||
expect(m.requests[0]!.headers["x-api-key"]).toBe("APIKEYSECRET");
|
||||
expect(m.requests[1]!.url.searchParams.get("categories")).toBe("402,405");
|
||||
const [x, y] = page.items;
|
||||
expect(x).toMatchObject({
|
||||
siteReleaseId: "7001",
|
||||
category: { id: "402" },
|
||||
promotion: { type: "free", downloadFactor: 0, uploadFactor: 1, expiresAt: "2026-09-30T15:59:59.000Z" },
|
||||
hr: true,
|
||||
tags: ["官方", "中字"],
|
||||
imdbId: "tt11126994",
|
||||
detailUrl: "https://sunnypt.top/details.php?id=7001",
|
||||
});
|
||||
expect(y).toMatchObject({ hr: false, promotion: { type: "none" }, sizeBytes: 9000000000 });
|
||||
});
|
||||
|
||||
it("业务错误 / 分类接口失败回退配置 / 下载 token 相对地址按 API 基址补全", async () => {
|
||||
const bad = createMockFetch({ "/api/v1/mp/torrents": { body: SUNNYPT_SEARCH_ERROR } });
|
||||
await expectSiteError(adapterWith(def, sunnyptSpider, bad.fetch).list(1), "auth_invalid");
|
||||
const fb = createMockFetch({
|
||||
"/api/v1/mp/categories": { status: 500, body: "x" },
|
||||
"/api/v1/mp/torrents": { body: SUNNYPT_SEARCH_OK },
|
||||
});
|
||||
await adapterWith(def, sunnyptSpider, fb.fetch).search("x", { mtype: "movie" });
|
||||
expect(fb.requests.at(-1)!.url.searchParams.get("categories")).toBe("401");
|
||||
const d = createMockFetch({ "POST /api/v1/mp/torrents/7001/download-token": { body: SUNNYPT_DOWNLOAD_TOKEN_OK } });
|
||||
const t = withTorrent(d.fetch, (u) => u.pathname.endsWith("/download"));
|
||||
await adapterWith(def, sunnyptSpider, t.fetch).downloadTarget("7001");
|
||||
expect(t.hits[0]!.url.toString()).toBe("https://api.sunnypt.top/api/v1/mp/torrents/7001/download?token=TOK");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||
* @mp-source app/modules/indexer/spider/sunnypt.py @ e6820a8
|
||||
*
|
||||
* SunnyPT MoviePilot API(parser "SunnyPT",X-API-Key)。函数对照 `SunnyPTSpider`:
|
||||
* - `_size/_category_cache_ttl` → `SUNNYPT_PAGE_SIZE/SUNNYPT_CATEGORY_CACHE_TTL_MS`
|
||||
* - `_parse_configured_categories` → `sunnyptConfiguredCategories`;`_parse_category_items` → `sunnyptParseCategoryItems`
|
||||
* - `_get_cached_categories/_set_cached_categories`(类级缓存)→ 适配器实例缓存 `ctx.cache`
|
||||
* - `_request_headers` → `sunnyptHeaders`;`_response_data` → `sunnyptResponseData`
|
||||
* - `_load_categories/_async_load_categories` → `sunnyptLoadCategories`
|
||||
* - `_media_type_value` → `mtype` 直用;`_build_params` → `sunnyptBuildParams`;`_parse_result` → `parseSunnyptResult`
|
||||
* - `_process_search_response` + `search` → `sunnyptSpider.search`
|
||||
* - `_build_download_url`(POST download-token,result=data.download_url,result_base_url=api_url)→ `resolveDownload`
|
||||
* 差异:分类接口失败时与 MP 一样回退站点配置分类,但凭据错误(HTTP 401/403)直接抛出,不再静默。
|
||||
*/
|
||||
import type { MpMediaType } from "../spider/spider.ts";
|
||||
import { SiteError } from "../types.ts";
|
||||
import { normalizeDatetime } from "../user-parsers/common.ts";
|
||||
import {
|
||||
apiRequest,
|
||||
businessError,
|
||||
cacheGet,
|
||||
cacheSet,
|
||||
checkReleaseId,
|
||||
downloadTorrentFile,
|
||||
isoWithZoneToUtc,
|
||||
isPlainObject,
|
||||
resolveIndirectDownloadUrl,
|
||||
toFloat,
|
||||
toInt,
|
||||
} from "./common.ts";
|
||||
import type {
|
||||
SpecializedSiteDefinition,
|
||||
SpecializedSpider,
|
||||
SpecializedTorrent,
|
||||
SpiderContext,
|
||||
SpiderSearchQuery,
|
||||
} from "./types.ts";
|
||||
|
||||
export const SUNNYPT_PAGE_SIZE = 100;
|
||||
export const SUNNYPT_CATEGORY_CACHE_TTL_MS = 3600 * 1000;
|
||||
|
||||
export type SunnyptCategoryMap = { movie: string[]; tv: string[]; music: string[] };
|
||||
|
||||
export function sunnyptApiUrl(def: SpecializedSiteDefinition): string {
|
||||
return String(def.umt.apiBaseUrl ?? def.api_url ?? "https://api.sunnypt.top/api/v1/mp").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** MP `_parse_configured_categories` */
|
||||
export function sunnyptConfiguredCategories(config: SpecializedSiteDefinition["category"]): SunnyptCategoryMap {
|
||||
const map: SunnyptCategoryMap = { movie: [], tv: [], music: [] };
|
||||
for (const k of ["movie", "tv", "music"] as const) {
|
||||
for (const item of config?.[k] ?? []) {
|
||||
const id = typeof item === "object" ? item.id : item;
|
||||
if (id != null) map[k].push(String(id));
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** MP `_parse_category_items` */
|
||||
export function sunnyptParseCategoryItems(items: unknown[]): SunnyptCategoryMap {
|
||||
const map: SunnyptCategoryMap = { movie: [], tv: [], music: [] };
|
||||
for (const item of items ?? []) {
|
||||
if (!isPlainObject(item) || item.id == null) continue;
|
||||
const id = String(item.id);
|
||||
for (const mt of (item.media_types as unknown[]) ?? []) {
|
||||
if (mt === "movie" || mt === "tv" || mt === "music") {
|
||||
if (!map[mt].includes(id)) map[mt].push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function sunnyptHeaders(apikey: string): Record<string, string> {
|
||||
return { Accept: "application/json", "X-API-Key": apikey };
|
||||
}
|
||||
|
||||
/** MP `_response_data`:code 不为 "0" → 失败 */
|
||||
function sunnyptResponseData(ctx: SpiderContext, payload: unknown, operation: string): unknown {
|
||||
if (!isPlainObject(payload)) throw businessError(ctx, operation, "响应结构无效");
|
||||
if (String(payload.code) !== "0") throw businessError(ctx, operation, payload.msg || "未知错误");
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
/** MP `_load_categories`:读取并缓存分类映射,失败回退站点配置 */
|
||||
export async function sunnyptLoadCategories(ctx: SpiderContext, apikey: string): Promise<SunnyptCategoryMap> {
|
||||
const api = sunnyptApiUrl(ctx.def);
|
||||
const cached = cacheGet<SunnyptCategoryMap>(ctx, `sunnypt:categories:${api}`);
|
||||
if (cached) return cached;
|
||||
const configured = sunnyptConfiguredCategories(ctx.def.category);
|
||||
let items: unknown;
|
||||
try {
|
||||
items = sunnyptResponseData(
|
||||
ctx,
|
||||
await apiRequest(ctx, `${api}/categories`, { method: "GET", headers: sunnyptHeaders(apikey) }),
|
||||
"获取分类",
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof SiteError && e.isAuthError) throw e;
|
||||
return configured;
|
||||
}
|
||||
const map = Array.isArray(items) ? sunnyptParseCategoryItems(items) : null;
|
||||
if (!map || !Object.values(map).some((v) => v.length)) return configured;
|
||||
cacheSet(ctx, `sunnypt:categories:${api}`, map, SUNNYPT_CATEGORY_CACHE_TTL_MS);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** MP `_build_params` */
|
||||
export function sunnyptBuildParams(q: SpiderSearchQuery, categoryMap: SunnyptCategoryMap) {
|
||||
const params: Record<string, string | number> = {
|
||||
page: Number(q.page || 0) + 1,
|
||||
page_size: SUNNYPT_PAGE_SIZE,
|
||||
sort: "created_at",
|
||||
order: "desc",
|
||||
};
|
||||
if (q.keyword) params.keyword = q.keyword;
|
||||
const apiMediaType = q.mtype ?? null;
|
||||
if (apiMediaType) params.media_type = apiMediaType;
|
||||
let categories = q.cat;
|
||||
if (!categories && apiMediaType) categories = (categoryMap[apiMediaType] ?? []).join(",");
|
||||
if (categories) params.categories = categories;
|
||||
return params;
|
||||
}
|
||||
|
||||
export interface SunnyptItem {
|
||||
id: number | string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
media_type?: string;
|
||||
category_id?: number | string;
|
||||
promotion?: { is_active?: boolean; down_multiplier?: number; up_multiplier?: number; until?: string } | null;
|
||||
created_at?: string;
|
||||
size?: number | string;
|
||||
seeders?: number | string;
|
||||
leechers?: number | string;
|
||||
completed?: number | string;
|
||||
details_url?: string;
|
||||
imdb_id?: string;
|
||||
tags?: string[];
|
||||
hit_and_run?: boolean | number;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** MP `_parse_result` */
|
||||
export function parseSunnyptResult(results: unknown[] | null | undefined): SpecializedTorrent[] {
|
||||
const torrents: SpecializedTorrent[] = [];
|
||||
for (const r of results ?? []) {
|
||||
if (!isPlainObject(r)) continue;
|
||||
const result = r as SunnyptItem;
|
||||
let category: MpMediaType;
|
||||
if (result.media_type === "movie") category = "电影";
|
||||
else if (result.media_type === "tv") category = "电视剧";
|
||||
else if (result.media_type === "music") category = "音乐";
|
||||
else category = "未知";
|
||||
const promotion = result.promotion ?? {};
|
||||
const active = Boolean(promotion.is_active);
|
||||
const t: SpecializedTorrent = {
|
||||
title: result.title,
|
||||
description: result.subtitle,
|
||||
size: toInt(result.size),
|
||||
seeders: toInt(result.seeders),
|
||||
peers: toInt(result.leechers),
|
||||
grabs: toInt(result.completed),
|
||||
downloadvolumefactor: active ? toFloat(promotion.down_multiplier ?? 1.0) : 1.0,
|
||||
uploadvolumefactor: active ? toFloat(promotion.up_multiplier ?? 1.0) : 1.0,
|
||||
labels: result.tags ?? [],
|
||||
hit_and_run: Boolean(result.hit_and_run),
|
||||
category,
|
||||
site_release_id: String(result.id),
|
||||
};
|
||||
if (result.details_url) t.page_url = result.details_url;
|
||||
if (result.imdb_id) t.imdbid = result.imdb_id;
|
||||
if (active && promotion.until) {
|
||||
const fd = normalizeDatetime(promotion.until);
|
||||
if (fd) t.freedate = fd;
|
||||
const fu = isoWithZoneToUtc(promotion.until);
|
||||
if (fu) t.freedate_utc = fu;
|
||||
}
|
||||
const pubdate = normalizeDatetime(result.created_at);
|
||||
if (pubdate) t.pubdate = pubdate;
|
||||
const pu = isoWithZoneToUtc(result.created_at);
|
||||
if (pu) t.pubdate_utc = pu;
|
||||
if (result.category_id != null) t.category_id = String(result.category_id);
|
||||
torrents.push(t);
|
||||
}
|
||||
return torrents;
|
||||
}
|
||||
|
||||
export const sunnyptSpider: SpecializedSpider = {
|
||||
parser: "SunnyPT",
|
||||
credential: "apiKey",
|
||||
pageSize: () => SUNNYPT_PAGE_SIZE,
|
||||
async search(ctx, q) {
|
||||
const apikey = (await ctx.secret("apiKey", true))!;
|
||||
const categoryMap =
|
||||
q.mtype && !q.cat ? await sunnyptLoadCategories(ctx, apikey) : sunnyptConfiguredCategories(ctx.def.category);
|
||||
const data = sunnyptResponseData(
|
||||
ctx,
|
||||
await apiRequest(ctx, `${sunnyptApiUrl(ctx.def)}/torrents`, {
|
||||
method: "GET",
|
||||
headers: sunnyptHeaders(apikey),
|
||||
query: sunnyptBuildParams(q, categoryMap),
|
||||
}),
|
||||
"搜索",
|
||||
);
|
||||
if (!isPlainObject(data)) throw businessError(ctx, "搜索", "data 结构无效");
|
||||
return parseSunnyptResult((data.items as unknown[]) ?? []);
|
||||
},
|
||||
async resolveDownload(ctx, siteReleaseId) {
|
||||
const apikey = (await ctx.secret("apiKey", true))!;
|
||||
const id = checkReleaseId(ctx.def.id, siteReleaseId);
|
||||
const api = sunnyptApiUrl(ctx.def);
|
||||
// MP _build_download_url:POST download-token,result=data.download_url,result_base_url=api_url
|
||||
const url = await resolveIndirectDownloadUrl(ctx, `${api}/torrents/${id}/download-token`, {
|
||||
method: "post",
|
||||
header: { "X-API-Key": apikey, Accept: "application/json" },
|
||||
result: "data.download_url",
|
||||
result_base_url: api,
|
||||
});
|
||||
return downloadTorrentFile(ctx, url);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
/** 测试辅助(非公开 API):构造适配器、在 mock fetch 前挂二进制 .torrent 路由。 */
|
||||
import { buildFakeTorrent } from "@umt/testkit";
|
||||
import { createSiteRateLimiter } from "../rate-limit.ts";
|
||||
import { type SiteAdapter, type SiteCredentialKind, SiteError, staticSecretResolver } from "../types.ts";
|
||||
import { createSpecializedSiteAdapter } from "./adapter.ts";
|
||||
import type { SpecializedSiteDefinition, SpecializedSpider } from "./types.ts";
|
||||
|
||||
export function testDef(
|
||||
parser: SpecializedSiteDefinition["parser"],
|
||||
over: Partial<SpecializedSiteDefinition> = {},
|
||||
): SpecializedSiteDefinition {
|
||||
return {
|
||||
id: "demo",
|
||||
name: "Demo",
|
||||
domain: "https://pt.example.com/",
|
||||
parser,
|
||||
...over,
|
||||
umt: {
|
||||
version: "[email protected]",
|
||||
kind: "api",
|
||||
displayName: "Demo",
|
||||
timezone: "+08:00",
|
||||
rateLimit: { minIntervalMs: 0, burst: 10 },
|
||||
capabilities: { list: true, search: true, detail: false, download: true, rss: false },
|
||||
...(over.umt ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function adapterWith(
|
||||
def: SpecializedSiteDefinition,
|
||||
spider: SpecializedSpider,
|
||||
fetchImpl: typeof globalThis.fetch,
|
||||
secrets: Partial<Record<SiteCredentialKind, string>> = { cookie: "c=COOKIESECRET", apiKey: "APIKEYSECRET" },
|
||||
): SiteAdapter {
|
||||
return createSpecializedSiteAdapter(def, spider, {
|
||||
fetch: fetchImpl,
|
||||
secrets: staticSecretResolver({ [def.id]: secrets }),
|
||||
limiter: createSiteRateLimiter({ minIntervalMs: 0 }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 包一层 fetch:命中 predicate 的请求返回合法 .torrent 字节 */
|
||||
export function withTorrent(
|
||||
inner: typeof globalThis.fetch,
|
||||
predicate: (url: URL) => boolean,
|
||||
name = "demo",
|
||||
): { fetch: typeof globalThis.fetch; infoHash: string; hits: Array<{ url: URL; headers: Headers }> } {
|
||||
const t = buildFakeTorrent({ name });
|
||||
const hits: Array<{ url: URL; headers: Headers }> = [];
|
||||
const f = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
||||
if (predicate(url)) {
|
||||
hits.push({ url, headers: new Headers(init?.headers) });
|
||||
return new Response(t.bytes as Uint8Array<ArrayBuffer>, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/x-bittorrent" },
|
||||
});
|
||||
}
|
||||
return inner(input, init);
|
||||
}) as typeof globalThis.fetch;
|
||||
return { fetch: f, infoHash: t.infoHash, hits };
|
||||
}
|
||||
|
||||
export async function expectSiteError(p: Promise<unknown>, kind: string): Promise<SiteError> {
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
if (!(e instanceof SiteError)) throw e;
|
||||
if (e.kind !== kind) throw new Error(`expected SiteError ${kind}, got ${e.kind}: ${e.message}`);
|
||||
return e;
|
||||
}
|
||||
throw new Error(`expected SiteError ${kind}`);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createMockFetch, TNODE_HOME_HTML, TNODE_HOME_NO_TOKEN, TNODE_SEARCH_OK } from "@umt/testkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { adapterWith, expectSiteError, testDef, withTorrent } from "./testing.ts";
|
||||
import { parseTnodeToken, tnodeSearchParams, tnodeSpider } from "./tnode.ts";
|
||||
|
||||
const def = testDef("TNodeSpider", { domain: "https://tnode.example/" });
|
||||
|
||||
describe("TNodeSpider(MP tnode.py)", () => {
|
||||
it("__get_params / token 解析", () => {
|
||||
expect(tnodeSearchParams("tt0923811", 1)).toMatchObject({
|
||||
page: 2,
|
||||
size: 100,
|
||||
type: "imdbid",
|
||||
keyword: "tt0923811",
|
||||
});
|
||||
expect(tnodeSearchParams(null)).toMatchObject({ type: "title", keyword: "", category: [501, 502, 503, 504] });
|
||||
expect(parseTnodeToken(TNODE_HOME_HTML)).toBe("CSRF-TOKEN-XYZ");
|
||||
});
|
||||
|
||||
it("列表解析:CSRF token 缓存、系数直取、时间戳转 UTC", async () => {
|
||||
const m = createMockFetch({
|
||||
"GET /": { body: TNODE_HOME_HTML },
|
||||
"POST /api/torrent/advancedSearch": { body: TNODE_SEARCH_OK },
|
||||
});
|
||||
const a = adapterWith(def, tnodeSpider, m.fetch);
|
||||
const page = await a.search("eva");
|
||||
await a.list(1);
|
||||
expect(m.requests.filter((r) => r.url.pathname === "/")).toHaveLength(1);
|
||||
expect(m.requests[1]!.headers["x-csrf-token"]).toBe("CSRF-TOKEN-XYZ");
|
||||
const [x, y] = page.items;
|
||||
expect(x).toMatchObject({
|
||||
siteReleaseId: "3301",
|
||||
promotion: { type: "free", downloadFactor: 0, uploadFactor: 1 },
|
||||
uploadedAt: "2024-09-20T00:00:00.000Z",
|
||||
imdbId: "tt0923811",
|
||||
detailUrl: "https://tnode.example/torrent/info/3301",
|
||||
hr: "unknown",
|
||||
});
|
||||
expect(y!.promotion.type).toBe("2x50%");
|
||||
const t = withTorrent(m.fetch, (u) => u.pathname.startsWith("/api/torrent/download/"));
|
||||
await adapterWith(def, tnodeSpider, t.fetch).downloadTarget("3301");
|
||||
expect(t.hits[0]!.headers.get("cookie")).toBe("c=COOKIESECRET");
|
||||
});
|
||||
|
||||
it("取不到 token → auth_expired;缺 Cookie → auth_missing;搜索 HTTP 403 → auth_invalid", async () => {
|
||||
const m = createMockFetch({ "GET /": { body: TNODE_HOME_NO_TOKEN } });
|
||||
await expectSiteError(adapterWith(def, tnodeSpider, m.fetch).list(1), "auth_expired");
|
||||
await expectSiteError(adapterWith(def, tnodeSpider, m.fetch, {}).list(1), "auth_missing");
|
||||
const f = createMockFetch({ "GET /": { body: TNODE_HOME_HTML }, "/api/torrent/advancedSearch": { status: 403 } });
|
||||
await expectSiteError(adapterWith(def, tnodeSpider, f.fetch).list(1), "auth_invalid");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||
* @mp-source app/modules/indexer/spider/tnode.py @ e6820a8
|
||||
*
|
||||
* TNode(parser "TNodeSpider",Cookie + 首页 CSRF token + `api/torrent/advancedSearch`)。函数对照 `TNodeSpider`:
|
||||
* - `get_search_page_size` → `pageSize`(100)
|
||||
* - `__get_token/__async_get_token`(@cached 24h)→ `tnodeToken`(适配器实例缓存,TTL 24h)
|
||||
* - `__parse_token_response` → `parseTnodeToken`
|
||||
* - `__get_params` → `tnodeSearchParams`;`__parse_result` → `parseTnodeResult`;`__process_response` → `tnodeSpider.search`
|
||||
* - `_downloadurl/_pageurl` → `resolveDownload`(带 Cookie 下载)/ page_url
|
||||
* 差异:取不到 token 时 MP 返回 `(True, [])`;这里抛 auth_expired(首页取不到 token 基本意味着 Cookie 失效)。
|
||||
*/
|
||||
import { SiteError } from "../types.ts";
|
||||
import {
|
||||
apiRequest,
|
||||
cacheGet,
|
||||
cacheSet,
|
||||
checkReleaseId,
|
||||
downloadTorrentFile,
|
||||
formatTimestampTz,
|
||||
isPlainObject,
|
||||
siteBase,
|
||||
textRequest,
|
||||
} from "./common.ts";
|
||||
import type { SpecializedSiteDefinition, SpecializedSpider, SpecializedTorrent, SpiderContext } from "./types.ts";
|
||||
|
||||
export const TNODE_TOKEN_TTL_MS = 24 * 3600 * 1000;
|
||||
|
||||
/** MP `__parse_token_response` */
|
||||
export function parseTnodeToken(html: string): string | null {
|
||||
return /<meta name="x-csrf-token" content="(.+?)">/.exec(html)?.[1] ?? null;
|
||||
}
|
||||
|
||||
async function tnodeToken(ctx: SpiderContext, cookie: string): Promise<string> {
|
||||
const key = `tnode:token:${ctx.def.domain}`;
|
||||
const cached = cacheGet<string>(ctx, key);
|
||||
if (cached) return cached;
|
||||
const token = parseTnodeToken(await textRequest(ctx, siteBase(ctx.def), { cookie }));
|
||||
if (!token)
|
||||
throw new SiteError(ctx.def.id, "auth_expired", `${ctx.def.name} 未获取到 token,无法搜索(Cookie 可能失效)`);
|
||||
cacheSet(ctx, key, token, TNODE_TOKEN_TTL_MS);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** MP `__get_params` */
|
||||
export function tnodeSearchParams(keyword: string | null | undefined, page = 0) {
|
||||
return {
|
||||
page: Number(page) + 1,
|
||||
size: 100,
|
||||
type: keyword?.startsWith("tt") ? "imdbid" : "title",
|
||||
keyword: keyword || "",
|
||||
sorter: "id",
|
||||
order: "desc",
|
||||
tags: [],
|
||||
category: [501, 502, 503, 504],
|
||||
medium: [],
|
||||
videoCoding: [],
|
||||
audioCoding: [],
|
||||
resolution: [],
|
||||
group: [],
|
||||
};
|
||||
}
|
||||
|
||||
export interface TnodeItem {
|
||||
id: number | string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
upload_time?: number | string;
|
||||
size?: number;
|
||||
seeding?: number;
|
||||
leeching?: number;
|
||||
complete?: number;
|
||||
downloadRate?: number;
|
||||
uploadRate?: number;
|
||||
imdb?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** MP `__parse_result` */
|
||||
export function parseTnodeResult(results: TnodeItem[] | null | undefined, def: SpecializedSiteDefinition) {
|
||||
const torrents: SpecializedTorrent[] = [];
|
||||
if (!results?.length) return torrents;
|
||||
const base = siteBase(def);
|
||||
for (const result of results) {
|
||||
const t: SpecializedTorrent = {
|
||||
title: result.title,
|
||||
description: result.subtitle,
|
||||
enclosure: `${base}api/torrent/download/${result.id}`,
|
||||
size: result.size,
|
||||
seeders: result.seeding,
|
||||
peers: result.leeching,
|
||||
grabs: result.complete,
|
||||
page_url: `${base}torrent/info/${result.id}`,
|
||||
site_release_id: String(result.id),
|
||||
};
|
||||
if (result.downloadRate != null) t.downloadvolumefactor = result.downloadRate;
|
||||
if (result.uploadRate != null) t.uploadvolumefactor = result.uploadRate;
|
||||
if (result.imdb) t.imdbid = result.imdb;
|
||||
const pubdate = formatTimestampTz(result.upload_time, def.umt.timezone);
|
||||
if (pubdate) t.pubdate = pubdate;
|
||||
torrents.push(t);
|
||||
}
|
||||
return torrents;
|
||||
}
|
||||
|
||||
export const tnodeSpider: SpecializedSpider = {
|
||||
parser: "TNodeSpider",
|
||||
credential: "cookie",
|
||||
pageSize: () => 100,
|
||||
async search(ctx, q) {
|
||||
const cookie = (await ctx.secret("cookie", true))!;
|
||||
const token = await tnodeToken(ctx, cookie);
|
||||
const data = await apiRequest(ctx, `${siteBase(ctx.def)}api/torrent/advancedSearch`, {
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-TOKEN": token, "Content-Type": "application/json; charset=utf-8", cookie },
|
||||
json: tnodeSearchParams(q.keyword, q.page ?? 0),
|
||||
});
|
||||
const d = isPlainObject(data) && isPlainObject(data.data) ? data.data : {};
|
||||
return parseTnodeResult((d.torrents as TnodeItem[]) ?? [], ctx.def);
|
||||
},
|
||||
async resolveDownload(ctx, siteReleaseId, enclosure) {
|
||||
const cookie = await ctx.secret("cookie", true);
|
||||
const id = checkReleaseId(ctx.def.id, siteReleaseId);
|
||||
return downloadTorrentFile(ctx, enclosure ?? `${siteBase(ctx.def)}api/torrent/download/${id}`, { cookie });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createMockFetch, TORRENTLEECH_SEARCH_OK } from "@umt/testkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { adapterWith, expectSiteError, testDef, withTorrent } from "./testing.ts";
|
||||
import { torrentleechSearchUrl, torrentleechSpider } from "./torrentleech.ts";
|
||||
|
||||
const def = testDef("TorrentLeech", {
|
||||
domain: "https://www.torrentleech.org/",
|
||||
category: { movie: [{ id: 47 }, { id: 8 }], tv: [{ id: 32 }] },
|
||||
umt: {
|
||||
version: "tl@test",
|
||||
kind: "api",
|
||||
displayName: "TL",
|
||||
timezone: "+00:00",
|
||||
rateLimit: { minIntervalMs: 0, burst: 10 },
|
||||
capabilities: { list: true, search: true, detail: false, download: true, rss: false },
|
||||
},
|
||||
});
|
||||
|
||||
describe("TorrentLeech(MP torrentleech.py)", () => {
|
||||
it("__get_search_url:分类 exact 查询与 quote 编码;浏览页码 +1", async () => {
|
||||
expect(torrentleechSearchUrl(def, "The Matrix (1999)", "movie")).toBe(
|
||||
"https://www.torrentleech.org/torrents/browse/list/categories/47,8/exact/1/query/The%20Matrix%20%281999%29",
|
||||
);
|
||||
expect(torrentleechSearchUrl(def, "a/b")).toBe("https://www.torrentleech.org/torrents/browse/list/query/a/b");
|
||||
const m = createMockFetch({ "/torrents/browse/list/page/3": { body: TORRENTLEECH_SEARCH_OK } });
|
||||
await adapterWith(def, torrentleechSpider, m.fetch).list(3);
|
||||
expect(m.requests[0]!.url.pathname).toBe("/torrents/browse/list/page/3");
|
||||
});
|
||||
|
||||
it("列表解析:download_multiplier、请求分类投影、UTC 时间", async () => {
|
||||
const m = createMockFetch({
|
||||
"/torrents/browse/list/categories/47,8/exact/1/query/Matrix": { body: TORRENTLEECH_SEARCH_OK },
|
||||
});
|
||||
const page = await adapterWith(def, torrentleechSpider, m.fetch).search("Matrix", { mtype: "movie" });
|
||||
const [a, b] = page.items;
|
||||
expect(a).toMatchObject({
|
||||
siteReleaseId: "240001",
|
||||
promotion: { type: "free", downloadFactor: 0, uploadFactor: 1 },
|
||||
uploadedAt: "2026-09-20T08:00:00.000Z",
|
||||
imdbId: "tt0133093",
|
||||
category: { id: "47" },
|
||||
detailUrl: "https://www.torrentleech.org/torrent/240001",
|
||||
downloadUrl: "https://www.torrentleech.org/download/240001/The.Matrix.1999.2160p.UHD.BluRay.x265-TL.torrent",
|
||||
});
|
||||
expect(a!.raw.category).toBe("电影");
|
||||
expect(b!.raw.category).toBe("未知");
|
||||
expect(b!.promotion.type).toBe("none");
|
||||
// 关键词搜索不翻页
|
||||
expect((await adapterWith(def, torrentleechSpider, m.fetch).search("Matrix", { page: 2 })).items).toEqual([]);
|
||||
});
|
||||
|
||||
it("中文关键词返回空且不发请求;缺 Cookie;跳登录页 → auth_expired;下载带 Cookie", async () => {
|
||||
const m = createMockFetch({
|
||||
"/torrents/browse/list/page/1": { status: 302, headers: { location: "/user/account/login" } },
|
||||
});
|
||||
expect((await adapterWith(def, torrentleechSpider, m.fetch).search("黑客帝国")).items).toEqual([]);
|
||||
expect(m.requests).toHaveLength(0);
|
||||
await expectSiteError(adapterWith(def, torrentleechSpider, m.fetch, {}).list(1), "auth_missing");
|
||||
await expectSiteError(adapterWith(def, torrentleechSpider, m.fetch).list(1), "auth_expired");
|
||||
const t = withTorrent(m.fetch, (u) => u.pathname.startsWith("/download/"));
|
||||
await adapterWith(def, torrentleechSpider, t.fetch).downloadTarget("240001");
|
||||
expect(t.hits[0]!.headers.get("cookie")).toBe("c=COOKIESECRET");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||
* @mp-source app/modules/indexer/spider/torrentleech.py @ e6820a8
|
||||
*
|
||||
* TorrentLeech JSON 搜索接口(parser "TorrentLeech",Cookie)。函数对照 `TorrentLeech`:
|
||||
* - `get_search_page_size` → `pageSize`(关键词搜索 null,浏览 100)
|
||||
* - `__category_ids` → `torrentleechCategoryIds`;`__get_search_url` → `torrentleechSearchUrl`
|
||||
* - `_browseurl` → `torrentleechBrowseUrl`;`__parse_result` → `parseTorrentleechResult`
|
||||
* - `__process_response` + `search` → `torrentleechSpider.search`(含中文关键词直接返回空,MP 行为)
|
||||
* 差异:MP 只在请求了媒体类型时写 category;这里另外总是输出原始 `category_id`。
|
||||
*/
|
||||
import type { MediaKind } from "../spider/spider.ts";
|
||||
import { containsChinese } from "../user-parsers/common.ts";
|
||||
import {
|
||||
apiRequest,
|
||||
checkReleaseId,
|
||||
downloadTorrentFile,
|
||||
formatTimestampTz,
|
||||
isPlainObject,
|
||||
pyQuote,
|
||||
siteBase,
|
||||
} from "./common.ts";
|
||||
import type { SpecializedSiteDefinition, SpecializedSpider, SpecializedTorrent } from "./types.ts";
|
||||
|
||||
const MEDIA_VALUE = { movie: "电影", tv: "电视剧", music: "音乐" } as const;
|
||||
|
||||
/** MP `__category_ids` */
|
||||
export function torrentleechCategoryIds(def: SpecializedSiteDefinition, mtype?: MediaKind): string[] {
|
||||
if (!mtype) return [];
|
||||
return (def.category?.[mtype] ?? []).filter((i) => i && i.id != null).map((i) => String(i.id));
|
||||
}
|
||||
|
||||
/** MP `__get_search_url` */
|
||||
export function torrentleechSearchUrl(def: SpecializedSiteDefinition, keyword: string, mtype?: MediaKind): string {
|
||||
const domain = siteBase(def);
|
||||
const encoded = pyQuote(keyword);
|
||||
const ids = torrentleechCategoryIds(def, mtype);
|
||||
if (ids.length) return `${domain}torrents/browse/list/categories/${ids.join(",")}/exact/1/query/${encoded}`;
|
||||
return `${domain}torrents/browse/list/query/${encoded}`;
|
||||
}
|
||||
|
||||
export function torrentleechBrowseUrl(def: SpecializedSiteDefinition, page = 0): string {
|
||||
return `${siteBase(def)}torrents/browse/list/page/${Number(page) + 1}`;
|
||||
}
|
||||
|
||||
export interface TorrentleechItem {
|
||||
fid: number | string;
|
||||
filename?: string;
|
||||
name?: string;
|
||||
addedTimestamp?: string | number;
|
||||
size?: number;
|
||||
seeders?: number;
|
||||
leechers?: number;
|
||||
completed?: number;
|
||||
download_multiplier?: number;
|
||||
imdbID?: string;
|
||||
categoryID?: number | string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** MP `__parse_result` */
|
||||
export function parseTorrentleechResult(
|
||||
results: TorrentleechItem[] | null | undefined,
|
||||
def: SpecializedSiteDefinition,
|
||||
mtype?: MediaKind,
|
||||
) {
|
||||
const torrents: SpecializedTorrent[] = [];
|
||||
if (!results?.length) return torrents;
|
||||
const domain = siteBase(def);
|
||||
const requested = new Set(torrentleechCategoryIds(def, mtype));
|
||||
for (const result of results) {
|
||||
const t: SpecializedTorrent = {
|
||||
title: result.name,
|
||||
enclosure: `${domain}download/${result.fid}/${result.filename}`,
|
||||
size: result.size,
|
||||
seeders: result.seeders,
|
||||
peers: result.leechers,
|
||||
grabs: result.completed,
|
||||
uploadvolumefactor: 1,
|
||||
page_url: `${domain}torrent/${result.fid}`,
|
||||
site_release_id: String(result.fid),
|
||||
};
|
||||
if (result.download_multiplier != null) t.downloadvolumefactor = result.download_multiplier;
|
||||
if (result.imdbID) t.imdbid = result.imdbID;
|
||||
const pubdate = formatTimestampTz(result.addedTimestamp, def.umt.timezone);
|
||||
if (pubdate) t.pubdate = pubdate;
|
||||
if (requested.size && mtype) {
|
||||
t.category = requested.has(String(result.categoryID)) ? MEDIA_VALUE[mtype] : "未知";
|
||||
}
|
||||
if (result.categoryID != null) t.category_id = String(result.categoryID);
|
||||
torrents.push(t);
|
||||
}
|
||||
return torrents;
|
||||
}
|
||||
|
||||
export const torrentleechSpider: SpecializedSpider = {
|
||||
parser: "TorrentLeech",
|
||||
credential: "cookie",
|
||||
pageSize: (keyword) => (keyword ? null : 100),
|
||||
async search(ctx, q) {
|
||||
// 不支持中文(MP 返回 (True, []))
|
||||
if (q.keyword && containsChinese(q.keyword)) return [];
|
||||
const cookie = (await ctx.secret("cookie", true))!;
|
||||
const url = q.keyword
|
||||
? torrentleechSearchUrl(ctx.def, q.keyword, q.mtype)
|
||||
: torrentleechBrowseUrl(ctx.def, q.page ?? 0);
|
||||
const data = await apiRequest(ctx, url, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8", cookie },
|
||||
});
|
||||
const list = isPlainObject(data) ? (data.torrentList as TorrentleechItem[]) : [];
|
||||
return parseTorrentleechResult(list ?? [], ctx.def, q.mtype);
|
||||
},
|
||||
async resolveDownload(ctx, siteReleaseId, enclosure) {
|
||||
const cookie = await ctx.secret("cookie", true);
|
||||
const id = checkReleaseId(ctx.def.id, siteReleaseId);
|
||||
// TODO(verify):无缓存直链时文件名段是否可任意
|
||||
return downloadTorrentFile(ctx, enclosure ?? `${siteBase(ctx.def)}download/${id}/${id}.torrent`, { cookie });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||
* @mp-source app/modules/indexer/__init__.py @ e6820a8
|
||||
*
|
||||
* 专用 spider(API 型站点)共用类型。
|
||||
* - `SpecializedParser` = MP `SPIDER_PARSER_CLASSES` 的键;`SPECIALIZED_SEARCH_ARGUMENTS` 的参数集合 → `SpiderSearchQuery`
|
||||
* - `SpecializedSiteDefinition` = 这些 spider 的 `__init__(indexer)` 读取的 indexer dict 键 + 本项目 `umt` 扩展
|
||||
*/
|
||||
import type { SiteHttpContext } from "../http.ts";
|
||||
import type { SpiderCategoryConfig } from "../spider/schema.ts";
|
||||
import type { MediaKind, SpiderTorrent } from "../spider/spider.ts";
|
||||
import type { DownloadTarget, SiteCapabilities, SiteCredentialKind } from "../types.ts";
|
||||
|
||||
export type SpecializedParser =
|
||||
| "Haidan"
|
||||
| "HDDolby"
|
||||
| "RousiPro"
|
||||
| "SunnyPT"
|
||||
| "TNodeSpider"
|
||||
| "TorrentLeech"
|
||||
| "Yema"
|
||||
| "mTorrent";
|
||||
|
||||
export interface SpecializedSiteDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled?: boolean;
|
||||
/** MP 用户信息解析器 schema(SiteSchema 值) */
|
||||
schema?: string;
|
||||
provenance?: import("../spider/schema.ts").DefinitionProvenance;
|
||||
/** 站点前端地址(MP indexer['domain'],以 / 结尾) */
|
||||
domain: string;
|
||||
parser: SpecializedParser;
|
||||
timeout?: number;
|
||||
proxy?: boolean;
|
||||
public?: boolean;
|
||||
/** 分类组(TorrentLeech 按媒体类型取分类 ID;SunnyPT 分类接口失败时兜底) */
|
||||
category?: SpiderCategoryConfig;
|
||||
/** 独立 API 地址(MP indexer['api_url'],SunnyPT) */
|
||||
api_url?: string;
|
||||
/** 本项目扩展(MP 无对应) */
|
||||
umt: {
|
||||
version: string;
|
||||
kind: "api";
|
||||
displayName: string;
|
||||
/** 站点时间时区偏移(pubdate/freedate 无时区时按此转 UTC) */
|
||||
timezone: string;
|
||||
rateLimit: { minIntervalMs: number; burst: number };
|
||||
capabilities: SiteCapabilities;
|
||||
categoryNames?: Record<string, string>;
|
||||
/** 覆盖 API 基址(测试指向 mock,或站点换域) */
|
||||
apiBaseUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 专用 spider 输出:MP TorrentInfo 字段 + 本项目扩展:
|
||||
* - `site_release_id`:站点资源 ID(MP 只在 page_url/enclosure 里隐含)
|
||||
* - `pubdate_utc` / `freedate_utc`:源数据自带时区时的精确 UTC(MP normalize_datetime 会丢时区)
|
||||
*/
|
||||
export interface SpecializedTorrent extends SpiderTorrent {
|
||||
site_release_id?: string;
|
||||
pubdate_utc?: string;
|
||||
freedate_utc?: string;
|
||||
}
|
||||
|
||||
/** MP `_SPECIALIZED_SEARCH_ARGUMENTS` 的并集;page 为 MP 内部 0 起页码 */
|
||||
export interface SpiderSearchQuery {
|
||||
keyword?: string | null;
|
||||
mtype?: MediaKind;
|
||||
/** 逗号分隔的站点分类 ID(RousiPro/SunnyPT) */
|
||||
cat?: string;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
export interface SpiderContext {
|
||||
def: SpecializedSiteDefinition;
|
||||
http: SiteHttpContext;
|
||||
/** 取凭据;required 时缺失抛 SiteError('auth_missing') */
|
||||
secret(kind: SiteCredentialKind, required?: boolean): Promise<string | null>;
|
||||
/** 实例内缓存(TNode CSRF token、SunnyPT 分类);值带过期时间 */
|
||||
cache: Map<string, { value: unknown; expiresAt: number }>;
|
||||
userAgent?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface SpecializedSpider {
|
||||
parser: SpecializedParser;
|
||||
/** 必需凭据(MP search 开头的检查) */
|
||||
credential: SiteCredentialKind;
|
||||
/** MP `get_search_page_size`:null 表示不支持翻页 */
|
||||
pageSize(keyword?: string | null): number | null;
|
||||
search(ctx: SpiderContext, query: SpiderSearchQuery): Promise<SpecializedTorrent[]>;
|
||||
/**
|
||||
* 下载前现场取种子(MP `__get_download_url` + submission `_resolve_indirect_download_url` + `download_torrent`)。
|
||||
* `enclosure` 为最近一次搜索结果里的直链(仅内存,不落库);缺失时按站点规则构造。
|
||||
*/
|
||||
resolveDownload(ctx: SpiderContext, siteReleaseId: string, enclosure?: string): Promise<DownloadTarget>;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
createMockFetch,
|
||||
YEMA_DOWNLOAD_KEY_OK,
|
||||
YEMA_SEARCH_ERROR,
|
||||
YEMA_SEARCH_MOVIE,
|
||||
YEMA_SEARCH_TV_A,
|
||||
} from "@umt/testkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { adapterWith, expectSiteError, testDef, withTorrent } from "./testing.ts";
|
||||
import { yemaSpider } from "./yema.ts";
|
||||
|
||||
const def = testDef("Yema", { domain: "https://www.yemapt.org/" });
|
||||
const URL_SEARCH = "POST /openApi/torrent/fetchOpenTorrentList.json";
|
||||
|
||||
describe("YemaSpider(MP yema.py)", () => {
|
||||
it("列表解析:促销枚举、标签表(未知 ID 丢弃)、H&R、非整数 ID 跳过", async () => {
|
||||
const m = createMockFetch({ [URL_SEARCH]: { body: YEMA_SEARCH_MOVIE } });
|
||||
const page = await adapterWith(def, yemaSpider, m.fetch).search("past", { mtype: "movie" });
|
||||
expect(m.requests[0]!.headers.authorization).toBe("APIKEYSECRET");
|
||||
expect(JSON.parse(m.requests[0]!.body!)).toEqual({
|
||||
pageParam: { current: 1, pageSize: 40 },
|
||||
sorter: {},
|
||||
keyword: "past",
|
||||
categoryId: 4,
|
||||
});
|
||||
expect(page.items).toHaveLength(1);
|
||||
expect(page.items[0]).toMatchObject({
|
||||
siteReleaseId: "88001",
|
||||
promotion: { type: "2xfree", downloadFactor: 0, uploadFactor: 2, expiresAt: "2026-09-28T16:00:00.000Z" },
|
||||
tags: ["官方", "中字"],
|
||||
hr: true,
|
||||
detailUrl: "https://www.yemapt.org/#/torrent/detail/88001/",
|
||||
category: { id: "4" },
|
||||
});
|
||||
});
|
||||
|
||||
it("电视剧按 6 个分类逐个查询,部分失败仍合并去重", async () => {
|
||||
let n = 0;
|
||||
const m = createMockFetch({
|
||||
[URL_SEARCH]: () => {
|
||||
n++;
|
||||
return n === 2 ? { body: { success: false, errorMessage: "系统繁忙" } } : { body: YEMA_SEARCH_TV_A };
|
||||
},
|
||||
});
|
||||
const page = await adapterWith(def, yemaSpider, m.fetch).list(1, { mtype: "tv" });
|
||||
expect(m.requests.map((r) => JSON.parse(r.body!).categoryId)).toEqual([5, 6, 13, 14, 15, 17]);
|
||||
expect(page.items.map((i) => i.siteReleaseId)).toEqual(["88002"]);
|
||||
expect(page.items[0]!.promotion).toMatchObject({ type: "50%", downloadFactor: 0.5, uploadFactor: 1.5 });
|
||||
});
|
||||
|
||||
it("全部失败才报错;缺 AuthKey;下载 key 拼成 download1?token=", async () => {
|
||||
const bad = createMockFetch({ [URL_SEARCH]: { body: YEMA_SEARCH_ERROR } });
|
||||
await expectSiteError(adapterWith(def, yemaSpider, bad.fetch).list(1), "auth_invalid");
|
||||
await expectSiteError(adapterWith(def, yemaSpider, bad.fetch, {}).list(1), "auth_missing");
|
||||
const d = createMockFetch({ "POST /openApi/torrent/generateDownloadKey.json": { body: YEMA_DOWNLOAD_KEY_OK } });
|
||||
const t = withTorrent(d.fetch, (u) => u.pathname === "/api/torrent/download1");
|
||||
await adapterWith(def, yemaSpider, t.fetch).downloadTarget("88001");
|
||||
expect(d.requests[0]!.url.searchParams.get("id")).toBe("88001");
|
||||
expect(t.hits[0]!.url.toString()).toBe("https://www.yemapt.org/api/torrent/download1?token=DLKEY-123");
|
||||
expect(t.hits[0]!.headers.get("authorization")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||
* @mp-source app/modules/indexer/spider/yema.py @ e6820a8
|
||||
*
|
||||
* YemaPT 开放 API(parser "Yema",Authorization: AuthKey)。函数对照 `YemaSpider`:
|
||||
* - `_size/_movie_category/_tv_category/_music_category/_labels` → `YEMA_*`
|
||||
* - `_request_headers` → `yemaHeaders`;`_build_params` → `yemaBuildParams`
|
||||
* - `_search_category_ids` → `yemaSearchCategoryIds`;`_merge_search_results` → `yemaMergeSearchResults`
|
||||
* - `_parse_result` → `parseYemaResult`;`_process_search_response` → `yemaProcessSearchResponse`
|
||||
* - `_prepare_search_requests/_finalize_search_responses/search` → `yemaSpider.search`(全部分类失败才算失败)
|
||||
* - `_download_factor/_upload_factor` → `yemaDownloadFactor/yemaUploadFactor`
|
||||
* - `_build_download_url`(POST generateDownloadKey.json?id=,success/result=data → api/torrent/download1?token=)→ `resolveDownload`
|
||||
*/
|
||||
import type { MpMediaType } from "../spider/spider.ts";
|
||||
import { SiteError } from "../types.ts";
|
||||
import { normalizeDatetime } from "../user-parsers/common.ts";
|
||||
import {
|
||||
apiRequest,
|
||||
businessError,
|
||||
checkReleaseId,
|
||||
downloadTorrentFile,
|
||||
isoWithZoneToUtc,
|
||||
isPlainObject,
|
||||
resolveIndirectDownloadUrl,
|
||||
} from "./common.ts";
|
||||
import type {
|
||||
SpecializedSiteDefinition,
|
||||
SpecializedSpider,
|
||||
SpecializedTorrent,
|
||||
SpiderContext,
|
||||
SpiderSearchQuery,
|
||||
} from "./types.ts";
|
||||
|
||||
/** YemaPT 开放 API 使用更大的分页容量时会返回空结果 */
|
||||
export const YEMA_PAGE_SIZE = 40;
|
||||
export const YEMA_MOVIE_CATEGORY = [4];
|
||||
export const YEMA_TV_CATEGORY = [5, 6, 13, 14, 15, 17];
|
||||
export const YEMA_MUSIC_CATEGORY = [8, 16];
|
||||
export const YEMA_LABELS: Record<string, string> = {
|
||||
"1": "禁转",
|
||||
"2": "首发",
|
||||
"3": "官方",
|
||||
"4": "自制",
|
||||
"5": "国语",
|
||||
"6": "中字",
|
||||
"7": "粤语",
|
||||
"8": "英字",
|
||||
"9": "HDR10",
|
||||
"10": "杜比视界",
|
||||
"11": "分集",
|
||||
"12": "完结",
|
||||
};
|
||||
|
||||
export function yemaSiteUrl(def: SpecializedSiteDefinition): string {
|
||||
return String(def.umt.apiBaseUrl ?? def.domain ?? "https://www.yemapt.org/").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function yemaHeaders(apikey: string): Record<string, string> {
|
||||
return { Authorization: apikey, "Content-Type": "application/json", Accept: "application/json, text/plain, */*" };
|
||||
}
|
||||
|
||||
/** MP `_build_params` */
|
||||
export function yemaBuildParams(
|
||||
keyword: string | null | undefined,
|
||||
page: number | undefined,
|
||||
categoryId?: number | null,
|
||||
) {
|
||||
const params: Record<string, unknown> = {
|
||||
pageParam: { current: Number(page || 0) + 1, pageSize: YEMA_PAGE_SIZE },
|
||||
sorter: {},
|
||||
};
|
||||
if (keyword) params.keyword = keyword;
|
||||
if (categoryId != null) params.categoryId = categoryId;
|
||||
return params;
|
||||
}
|
||||
|
||||
/** MP `_search_category_ids`:分类参数不支持数组,逐个查询 */
|
||||
export function yemaSearchCategoryIds(mtype?: SpiderSearchQuery["mtype"]): Array<number | null> {
|
||||
if (mtype === "movie") return [...YEMA_MOVIE_CATEGORY];
|
||||
if (mtype === "tv") return [...YEMA_TV_CATEGORY];
|
||||
if (mtype === "music") return [...YEMA_MUSIC_CATEGORY];
|
||||
return [null];
|
||||
}
|
||||
|
||||
/** MP `_merge_search_results`:按详情链接合并去重,保持首次出现顺序 */
|
||||
export function yemaMergeSearchResults(groups: SpecializedTorrent[][]): SpecializedTorrent[] {
|
||||
const merged: SpecializedTorrent[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const results of groups) {
|
||||
for (const t of results) {
|
||||
const identity = t.page_url || t.enclosure;
|
||||
if (identity && seen.has(identity)) continue;
|
||||
if (identity) seen.add(identity);
|
||||
merged.push(t);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function yemaDownloadFactor(promotion: string): number {
|
||||
return ({ free: 0, half: 0.5, none: 1 } as Record<string, number>)[promotion] ?? 1;
|
||||
}
|
||||
|
||||
export function yemaUploadFactor(promotion: string): number {
|
||||
return ({ none: 1, one_half: 1.5, double_upload: 2 } as Record<string, number>)[promotion] ?? 1;
|
||||
}
|
||||
|
||||
export interface YemaItem {
|
||||
id: number;
|
||||
showName?: string;
|
||||
shortDesc?: string;
|
||||
categoryId?: number;
|
||||
tagList?: string[];
|
||||
listingTime?: string;
|
||||
fileSize?: number;
|
||||
seedNum?: number;
|
||||
leechNum?: number;
|
||||
completedNum?: number;
|
||||
downloadPromotion?: string;
|
||||
uploadPromotion?: string;
|
||||
downloadPromotionEndTime?: string;
|
||||
hrPunishEnable?: boolean;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** MP `_parse_result` */
|
||||
export function parseYemaResult(results: unknown[] | null | undefined, def: SpecializedSiteDefinition) {
|
||||
const torrents: SpecializedTorrent[] = [];
|
||||
const site = yemaSiteUrl(def);
|
||||
for (const r of results ?? []) {
|
||||
if (!isPlainObject(r)) continue;
|
||||
const result = r as YemaItem;
|
||||
const cv = result.categoryId;
|
||||
let category: MpMediaType;
|
||||
if (cv != null && YEMA_TV_CATEGORY.includes(cv)) category = "电视剧";
|
||||
else if (cv != null && YEMA_MOVIE_CATEGORY.includes(cv)) category = "电影";
|
||||
else if (cv != null && YEMA_MUSIC_CATEGORY.includes(cv)) category = "音乐";
|
||||
else category = "未知";
|
||||
// MP:label_id in self._labels(键为字符串)
|
||||
const labels = (result.tagList ?? [])
|
||||
.filter((id) => typeof id === "string" && id in YEMA_LABELS)
|
||||
.map((id) => YEMA_LABELS[id]!);
|
||||
const id = result.id;
|
||||
if (typeof id !== "number" || !Number.isInteger(id)) continue;
|
||||
const t: SpecializedTorrent = {
|
||||
title: result.showName,
|
||||
description: result.shortDesc,
|
||||
size: result.fileSize,
|
||||
seeders: result.seedNum,
|
||||
peers: result.leechNum,
|
||||
grabs: result.completedNum,
|
||||
downloadvolumefactor: yemaDownloadFactor(String(result.downloadPromotion || "")),
|
||||
uploadvolumefactor: yemaUploadFactor(String(result.uploadPromotion || "")),
|
||||
page_url: `${site}/#/torrent/detail/${id}/`,
|
||||
labels,
|
||||
hit_and_run: Boolean(result.hrPunishEnable),
|
||||
category,
|
||||
site_release_id: String(id),
|
||||
};
|
||||
const pubdate = normalizeDatetime(result.listingTime);
|
||||
if (pubdate) t.pubdate = pubdate;
|
||||
const pu = isoWithZoneToUtc(result.listingTime);
|
||||
if (pu) t.pubdate_utc = pu;
|
||||
const fd = normalizeDatetime(result.downloadPromotionEndTime);
|
||||
if (fd) t.freedate = fd;
|
||||
const fu = isoWithZoneToUtc(result.downloadPromotionEndTime);
|
||||
if (fu) t.freedate_utc = fu;
|
||||
if (cv != null) t.category_id = String(cv);
|
||||
torrents.push(t);
|
||||
}
|
||||
return torrents;
|
||||
}
|
||||
|
||||
/** MP `_process_search_response` */
|
||||
export function yemaProcessSearchResponse(ctx: SpiderContext, payload: unknown): SpecializedTorrent[] {
|
||||
if (!isPlainObject(payload)) throw businessError(ctx, "搜索", "搜索响应结构无效");
|
||||
if (!payload.success) throw businessError(ctx, "搜索", payload.errorMessage || "未知错误");
|
||||
if (!Array.isArray(payload.data)) throw businessError(ctx, "搜索", "搜索响应 data 不是数组");
|
||||
return parseYemaResult(payload.data, ctx.def);
|
||||
}
|
||||
|
||||
export const yemaSpider: SpecializedSpider = {
|
||||
parser: "Yema",
|
||||
credential: "apiKey",
|
||||
pageSize: () => YEMA_PAGE_SIZE,
|
||||
async search(ctx, q) {
|
||||
const apikey = (await ctx.secret("apiKey", true))!;
|
||||
const url = `${yemaSiteUrl(ctx.def)}/openApi/torrent/fetchOpenTorrentList.json`;
|
||||
const groups: SpecializedTorrent[][] = [];
|
||||
const errors: unknown[] = [];
|
||||
for (const categoryId of yemaSearchCategoryIds(q.mtype)) {
|
||||
try {
|
||||
const payload = await apiRequest(ctx, url, {
|
||||
method: "POST",
|
||||
headers: yemaHeaders(apikey),
|
||||
json: yemaBuildParams(q.keyword, q.page, categoryId),
|
||||
});
|
||||
groups.push(yemaProcessSearchResponse(ctx, payload));
|
||||
} catch (e) {
|
||||
// 凭据错误所有分类都会失败,直接抛出
|
||||
if (e instanceof SiteError && e.isAuthError) throw e;
|
||||
errors.push(e);
|
||||
}
|
||||
}
|
||||
// MP:all(errors) 才算失败
|
||||
if (!groups.length && errors.length) throw errors[0];
|
||||
return yemaMergeSearchResults(groups);
|
||||
},
|
||||
async resolveDownload(ctx, siteReleaseId) {
|
||||
const apikey = (await ctx.secret("apiKey", true))!;
|
||||
const id = checkReleaseId(ctx.def.id, siteReleaseId);
|
||||
const site = yemaSiteUrl(ctx.def);
|
||||
const url = await resolveIndirectDownloadUrl(ctx, `${site}/openApi/torrent/generateDownloadKey.json`, {
|
||||
method: "post",
|
||||
header: { Authorization: apikey, Accept: "application/json" },
|
||||
params: { id: Number(id) },
|
||||
success: "success",
|
||||
result: "data",
|
||||
result_base_url: site,
|
||||
result_path: "api/torrent/download1",
|
||||
result_query_param: "token",
|
||||
});
|
||||
return downloadTorrentFile(ctx, url);
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user