feat(adapters): RSS and PT site adapters ported from MoviePilot v3 + mocks (W1-3b)

- rss: port of MP RssHelper._parse_impl/get_rss_link semantics with safe
  XML (entities off, DTD/ENTITY rejected, size/item caps), ETag/Last-Modified,
  backoff + Retry-After, circuit breaker with manual reset, passkey redaction,
  overlap-window GUID+digest dedupe
- sites/spider: TS port of MP SiteSpider (indexer schema, search URL builder,
  field DSL, filters, Jinja templates via nunjucks, login-page detection);
  audiences definition hand-written as JSON; raw category id preserved;
  shared per-site token bucket
- sites/mteam: port of MP MTorrentSpider (params, result mapping, promo
  factors, genDlToken flow) without embedding the API key in enclosures;
  business code/message checks
- testkit: mock RSS server, mock NexusPHP site, mock M-Team API
- docs: W1-3b migration mapping appended to porting ledger

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 18:10:34 +08:00
1 parent ef2e504a94
commit a6d049db81
47 files changed
+5414

No files matched your search

@@ -0,0 +1,211 @@
/**
* 馒头(M-Team)API Key 适配器。
*
* 对应 MoviePilot e6820a8:
* - `MTorrentSpider.search`(POST /api/torrent/search,x-api-key + UA + JSON)→ `list` / `search`
* - `MTorrentSpider.__process_response` → `mteamPost`(另加业务 code/message 检查,迁移账本 MT2)
* - `MTorrentSpider.__get_download_url` + `submission.py _resolve_indirect_download_url` + `download_torrent`
* (POST genDlToken?id= → data 为临时直链 → 不带 cookie GET)→ `downloadTarget`
* - `app/modules/indexer/parser/mtorrent.py`(api/member/profile)→ `healthCheck`
* 详情 `/api/torrent/detail` 为本项目扩展(MP spider 无详情调用,字段结构参考 MovieClaw mteam.py,data-ref)。
*/
import { redactUrl } from "../../rss/redact";
import { buildHttpContext, type SiteAdapterDeps } from "../deps";
import { readBody, siteFetch } from "../http";
import { healthFromError } from "../spider/adapter";
import { promotionFromFactors } from "../spider/map";
import type { SpiderTorrent } from "../spider/spider";
import {
type DownloadTarget,
type SiteAdapter,
SiteError,
type SiteHealth,
type SiteListFilters,
type SitePage,
type SiteRelease,
type SiteReleaseDetail,
} from "../types";
import { extractDoubanId, inspectTorrent, parseSiteTime } from "../util";
import { mteamPost } from "./client";
import { type MteamDefinition, mteamApiBase } from "./definition";
import { type MteamTorrentJson, mteamSearchParams, parseMteamResult } from "./spider";
interface MteamSearchData {
data?: MteamTorrentJson[] | null;
totalPages?: string | number | null;
total?: string | number | null;
}
export function mteamTorrentToRelease(
t: SpiderTorrent,
src: MteamTorrentJson,
def: MteamDefinition,
observedAt: string,
): SiteRelease {
const tz = def.umt.timezone;
const id = String(src.id);
const release: SiteRelease = {
siteId: def.id,
siteReleaseId: id,
title: (t.title ?? "").trim(),
category: {
id: t.category_id ?? null,
name: t.category_id ? (def.umt.categoryNames[t.category_id] ?? null) : null,
},
sizeBytes: t.size ?? null,
seeders: t.seeders ?? null,
leechers: t.peers ?? null,
snatched: t.grabs ?? null,
promotion: promotionFromFactors(t.downloadvolumefactor, t.uploadvolumefactor, parseSiteTime(t.freedate, tz)),
// API 搜索结果无 H&R 字段(MP 未映射)→ 三态 unknown
hr: "unknown",
uploadedAt: parseSiteTime(t.pubdate, tz),
detailUrl: t.page_url ?? `${def.domain.replace(/\/$/, "")}/detail/${id}`,
tags: t.labels ?? [],
raw: { ...t, status: src.status ?? null, labelsRaw: src.labels ?? null },
observedAt,
};
if (t.description) release.subtitle = String(t.description).trim();
if (t.imdbid) release.imdbId = t.imdbid;
const douban =
extractDoubanId(src.douban ?? null) ??
(src.douban && /^\d+$/.test(String(src.douban)) ? String(src.douban) : undefined);
if (douban) release.doubanId = douban;
return release;
}
export function createMteamAdapter(input: MteamDefinition, deps: SiteAdapterDeps): SiteAdapter {
const def: MteamDefinition = deps.baseUrl ? { ...input, umt: { ...input.umt, apiBaseUrl: deps.baseUrl } } : input;
const http = buildHttpContext(def.id, { timeoutMs: def.timeout * 1000, ...deps }, def.umt.rateLimit);
const api = mteamApiBase(def);
async function apiKey(): Promise<string> {
const k = await deps.secrets.resolve({ siteId: def.id, kind: "apiKey" });
// MP:无 apikey 直接返回错误
if (!k) throw new SiteError(def.id, "auth_missing", "未配置 M-Team API Key");
return k;
}
async function doSearch(
page: number,
filters: SiteListFilters,
keyword: string | null,
signal?: AbortSignal,
): Promise<SitePage> {
const params = mteamSearchParams(keyword, filters.mtype, Math.max(1, page) - 1);
// 本项目扩展:显式分类 ID 优先于 mtype 分类组
if (filters.categoryIds?.length) params.categories = filters.categoryIds;
const data = await mteamPost<MteamSearchData>(
http,
api,
await apiKey(),
"/api/torrent/search",
{ json: params },
signal,
);
const observedAt = http.now().toISOString();
const src = data?.data ?? [];
const rows = parseMteamResult(src, def.domain);
let items = rows.map((t, i) => mteamTorrentToRelease(t, src[i]!, def, observedAt));
if (filters.freeOnly) items = items.filter((r) => r.promotion.downloadFactor === 0);
const tp = Number(data?.totalPages);
return {
items,
page: Math.max(1, page),
totalPages: Number.isFinite(tp) && tp >= 0 ? tp : null,
totalPagesEstimated: false,
fetchedAt: observedAt,
};
}
function checkId(id: string): string {
// 兼容详情页 URL(MP get_subtitle_links 从 page_url 取末段)
const m = /(\d+)\/?$/.exec(id.trim());
if (!m) throw new SiteError(def.id, "not_found", "资源 ID 非法");
return m[1]!;
}
return {
id: def.id,
kind: "mteam",
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(siteReleaseId, opts = {}): Promise<SiteReleaseDetail> {
const id = checkId(siteReleaseId);
const data = await mteamPost<MteamTorrentJson | null>(
http,
api,
await apiKey(),
"/api/torrent/detail",
{ form: { id } },
opts.signal,
);
if (!data || data.id == null) throw new SiteError(def.id, "not_found", `详情 ${id}:无数据`);
const [row] = parseMteamResult([data], def.domain);
const r: SiteReleaseDetail = mteamTorrentToRelease(row!, data, def, http.now().toISOString());
if (data.descr) r.descriptionText = String(data.descr).slice(0, 20_000);
return r;
},
async downloadTarget(siteReleaseId, opts = {}): Promise<DownloadTarget> {
const id = checkId(siteReleaseId);
// MP __get_download_url: method=post, params={id}(query), header 含 x-api-key, result="data"
const url = await mteamPost<string | null>(
http,
api,
await apiKey(),
"/api/torrent/genDlToken",
{ query: { id } },
opts.signal,
);
if (!url || typeof url !== "string") {
throw new SiteError(def.id, "rate_limited", `genDlToken ${id}:未返回下载链接(可能未认证或达到下载频率限制)`);
}
// MP:涉及解析地址的不使用 Cookie 下载;直链含一次性签名,只在内存中使用
const res = await siteFetch(http, url, { method: "GET", ...(opts.signal ? { signal: opts.signal } : {}) });
if (!res.ok) {
await res.body?.cancel().catch(() => {});
throw new SiteError(
def.id,
res.status === 404 ? "not_found" : "network",
`下载 ${redactUrl(url)}:HTTP ${res.status}`,
{
httpStatus: res.status,
},
);
}
const bytes = await readBody(http, res);
const info = inspectTorrent(bytes);
if (!info) throw new SiteError(def.id, "parse_error", `下载 ${id}:返回内容不是 .torrent`);
return {
kind: "torrent",
bytes,
infoHash: info.infoHash,
name: info.name,
fileName: null,
verifiedAt: http.now().toISOString(),
};
},
async healthCheck(opts = {}): Promise<SiteHealth> {
const started = Date.now();
const checkedAt = http.now().toISOString();
try {
await mteamPost<unknown>(http, api, await apiKey(), "/api/member/profile", { form: {} }, opts.signal);
return { siteId: def.id, status: "ok", ok: true, checkedAt, latencyMs: Date.now() - started };
} catch (e) {
return healthFromError(def.id, e, checkedAt);
}
},
};
}
+109
View File
@@ -0,0 +1,109 @@
/**
* M-Team API 调用。对应 MP `MTorrentSpider.search/__process_response`(MP 只看 HTTP 200);
* 业务 code/message 检查与凭据/解析错误分层参考 MovieClaw `_post_api`(behavior-ref,迁移账本 MT2)。
*/
import { redactText } from "../../rss/redact";
import { readText, type SiteHttpContext, siteFetch } from "../http";
import { SiteError } from "../types";
export type MteamApiPath =
| "/api/torrent/search"
| "/api/torrent/detail"
| "/api/torrent/genDlToken"
| "/api/member/profile"
| "/api/tracker/myPeerStatus";
const SUCCESS_MESSAGES = new Set(["SUCCESS", "OK", "0", ""]);
const AUTH_KEYWORDS = [
"停用",
"封禁",
"禁用",
"banned",
"disabled",
"無效",
"无效",
"invalid",
"unauthorized",
"未授權",
"未授权",
"api key",
"apikey",
"key無效",
"key无效",
];
const NOT_FOUND_KEYWORDS = ["不存在", "not found", "not exist"];
const RATE_KEYWORDS = ["頻繁", "频繁", "too many", "rate limit"];
/**
* M-Team API 调用:x-api-key 头 + 业务 code/message 检查(MT2)。
* 凭据类错误 → auth_invalid(停止重试);非 JSON → site_maintenance/parse_error。
*/
export async function mteamPost<T>(
http: SiteHttpContext,
apiBase: string,
apiKey: string,
path: MteamApiPath,
body: { json?: unknown; form?: Record<string, string>; query?: Record<string, string> },
signal?: AbortSignal,
): Promise<T> {
const headers: Record<string, string> = { "x-api-key": apiKey, accept: "application/json, text/plain, */*" };
let payload: string | undefined;
if (body.json !== undefined) {
headers["content-type"] = "application/json";
payload = JSON.stringify(body.json);
} else if (body.form) {
headers["content-type"] = "application/x-www-form-urlencoded";
payload = new URLSearchParams(body.form).toString();
}
const qs = body.query ? `?${new URLSearchParams(body.query).toString()}` : "";
const url = `${apiBase.replace(/\/$/, "")}${path}${qs}`;
const res = await siteFetch(http, url, {
method: "POST",
headers,
...(payload !== undefined ? { body: payload } : {}),
...(signal ? { signal } : {}),
});
if (res.status === 401 || res.status === 403) {
await res.body?.cancel().catch(() => {});
throw new SiteError(http.siteId, "auth_invalid", `${path}:HTTP ${res.status},API Key 无效或无权限`, {
httpStatus: res.status,
});
}
if (!res.ok) {
await res.body?.cancel().catch(() => {});
throw new SiteError(http.siteId, res.status === 404 ? "not_found" : "network", `${path}:HTTP ${res.status}`, {
httpStatus: res.status,
});
}
const text = await readText(http, res);
let json: { code?: unknown; message?: unknown; data?: unknown };
try {
json = JSON.parse(text) as typeof json;
} catch {
const maintenance = /維護|维护|maintenance/i.test(text);
throw new SiteError(
http.siteId,
maintenance ? "site_maintenance" : "parse_error",
`${path}:响应不是 JSON${maintenance ? "(站点维护)" : ""}`,
{ httpStatus: res.status },
);
}
const message = json.message == null ? "" : String(json.message);
const code = json.code == null ? "0" : String(json.code);
if (code !== "0" || !SUCCESS_MESSAGES.has(message.toUpperCase())) {
const lower = message.toLowerCase();
const kind = RATE_KEYWORDS.some((k) => lower.includes(k))
? "rate_limited"
: NOT_FOUND_KEYWORDS.some((k) => lower.includes(k))
? "not_found"
: AUTH_KEYWORDS.some((k) => lower.includes(k.toLowerCase()))
? "auth_invalid"
: "parse_error";
throw new SiteError(
http.siteId,
kind,
redactText(`${path}:业务错误 code=${code} message=${message.slice(0, 200)}`),
);
}
return json.data as T;
}
@@ -0,0 +1,70 @@
import type { SiteCapabilities } from "../types";
/**
* 馒头站点定义:键名沿用 MP indexer dict(id/name/domain/parser/timeout),
* `umt` 为本项目扩展。API Key 不在定义里,经 SecretResolver 注入(MP 放在 indexer['apikey'])。
*/
export interface MteamDefinition {
id: string;
name: string;
/** 网页域(MP `_url`,详情页 = `${domain}detail/${id}`) */
domain: string;
parser: "mTorrent";
timeout: number;
umt: {
version: string;
kind: "mteam";
displayName: string;
/** API 基址;缺省为 `https://api.<根域>`(MP `_searchurl % _domain`) */
apiBaseUrl?: string;
timezone: string;
rateLimit: { minIntervalMs: number; burst: number };
capabilities: SiteCapabilities;
/** 原始分类 ID → 名称(自行编制,待核验) */
categoryNames: Record<string, string>;
};
}
export const mteamDefinition: MteamDefinition = {
id: "mteam",
name: "馒头",
domain: "https://tp.m-team.cc/",
parser: "mTorrent",
timeout: 15,
umt: {
version: "[email protected]",
kind: "mteam",
displayName: "馒头",
timezone: "+08:00",
rateLimit: { minIntervalMs: 1000, burst: 1 },
capabilities: { list: true, search: true, detail: true, download: true, rss: false },
categoryNames: {
"401": "电影",
"419": "电影",
"420": "电影",
"421": "电影",
"439": "电影",
"402": "剧集",
"403": "综艺",
"435": "剧集",
"438": "剧集",
"404": "纪录",
"405": "动画",
"407": "体育",
"406": "音乐",
"408": "音乐",
"434": "音乐",
},
},
};
/** MP `site_rules.extract_domain` 的简化:取主机名最后两级。 */
export function rootDomain(url: string): string {
const host = new URL(url).hostname;
const parts = host.split(".");
return parts.length >= 2 ? parts.slice(-2).join(".") : host;
}
export function mteamApiBase(def: MteamDefinition): string {
return (def.umt.apiBaseUrl ?? `https://api.${rootDomain(def.domain)}`).replace(/\/$/, "");
}
@@ -0,0 +1,196 @@
import { createMockMteamApi, type MockMteamApi } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import {
createMteamAdapter,
createSiteRateLimiter,
getDownloadVolumeFactor,
mteamDefinition,
type SiteAdapter,
SiteError,
type SiteRateLimiter,
staticSecretResolver,
} from "../index";
let api: MockMteamApi | undefined;
afterEach(async () => {
await api?.close();
api = undefined;
});
function adapterFor(m: MockMteamApi, key: string | null = m.apiKey, limiter?: SiteRateLimiter): SiteAdapter {
return createMteamAdapter(mteamDefinition, {
baseUrl: m.baseUrl,
secrets: staticSecretResolver(key ? { mteam: { apiKey: key } } : {}),
limiter: limiter ?? createSiteRateLimiter({ minIntervalMs: 0 }),
});
}
async function expectSiteError(p: Promise<unknown>, kind: string): Promise<SiteError> {
try {
await p;
} catch (e) {
expect(e).toBeInstanceOf(SiteError);
expect((e as SiteError).kind).toBe(kind);
return e as SiteError;
}
throw new Error(`expected SiteError ${kind}`);
}
describe("M-Team API 适配器(mock)", () => {
it("列表:x-api-key 头 + 搜索体,映射 MV1 字段,结果不含 API Key", async () => {
api = await createMockMteamApi();
const page = await adapterFor(api).list(1);
const req = api.requests[0]!;
expect(req.path).toBe("/api/torrent/search");
expect(req.headers["x-api-key"]).toBe(api.apiKey);
expect(api.lastSearchBody).toEqual({ keyword: null, categories: [], pageNumber: 1, pageSize: 100, visible: 1 });
expect(page).toMatchObject({ page: 1, totalPages: 1, totalPagesEstimated: false });
const shogun = page.items.find((i) => i.siteReleaseId === "912233")!;
expect(shogun).toMatchObject({
siteId: "mteam",
title: "Shogun.2024.S01.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX",
subtitle: "幕府将军 第一季 全10集 杜比视界",
category: { id: "402", name: "剧集" },
raw: expect.objectContaining({ category: "电视剧", category_id: "402" }),
sizeBytes: 61234567890,
seeders: 532,
leechers: 4,
snatched: 2011,
promotion: { type: "50%", downloadFactor: 0.5, uploadFactor: 1, expiresAt: "2026-09-28T00:15:00.000Z" },
hr: "unknown",
uploadedAt: "2026-09-20T00:15:00.000Z",
detailUrl: "https://tp.m-team.cc/detail/912233",
imdbId: "tt2788316",
tags: ["中字", "DV"],
});
expect(shogun.downloadUrl).toBeUndefined();
expect(JSON.stringify(page)).not.toContain(api.apiKey);
});
it("促销优先级:商城单种免费 > 全站促销 > 基础折扣;2x 免费;标签位图", async () => {
api = await createMockMteamApi();
const { items } = await adapterFor(api).list(1);
const by = Object.fromEntries(items.map((i) => [i.siteReleaseId, i]));
expect(by["912234"]!.promotion).toEqual({
type: "free",
downloadFactor: 0,
uploadFactor: 1,
expiresAt: "2026-09-27T12:00:00.000Z",
});
expect(by["912234"]!.tags).toEqual(["国配", "中字"]);
expect(by["912235"]!.promotion).toMatchObject({
type: "free",
downloadFactor: 0,
expiresAt: "2026-10-07T15:59:59.000Z",
});
expect(by["912236"]!.promotion).toMatchObject({ type: "2xfree", downloadFactor: 0, uploadFactor: 2 });
expect(by["912237"]!.promotion).toEqual({ type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null });
// MP __get_downloadvolumefactor:未知折扣代码按 1 处理
expect(getDownloadVolumeFactor("SOMETHING_NEW")).toBe(1);
});
it("分类原始 ID 保留(404 纪录 / 405 动画 / 407 体育)", async () => {
api = await createMockMteamApi();
const { items } = await adapterFor(api).list(1);
const cats = Object.fromEntries(items.map((i) => [i.siteReleaseId, i.category]));
expect(cats["912235"]).toEqual({ id: "405", name: "动画" });
expect(cats["912236"]).toEqual({ id: "404", name: "纪录" });
expect(cats["912237"]).toEqual({ id: "407", name: "体育" });
});
it("搜索:IMDb 关键词改写为完整 URL;分类与免费筛选下推", async () => {
api = await createMockMteamApi();
const a = adapterFor(api);
const r = await a.search("tt15398776");
expect(api.lastSearchBody!.keyword).toBe("https://www.imdb.com/title/tt15398776");
expect(r.items.map((i) => i.siteReleaseId)).toEqual(["912234"]);
const free = await a.search("", { freeOnly: true, categoryIds: ["404", "405"] });
expect(api.lastSearchBody).toMatchObject({ keyword: null, categories: ["404", "405"] });
const tv = await a.search("Shogun", { mtype: "tv" });
expect(api.lastSearchBody!.categories).toEqual(["403", "402", "435", "438", "404", "405"]);
expect(tv.items.map((i) => i.siteReleaseId)).toEqual(["912233"]);
// 912235 基础 NORMAL 但全站促销 FREE → 下载系数 0
expect(free.items.map((i) => i.siteReleaseId)).toEqual(["912235", "912236"]);
const kw = await a.search("芙莉莲");
expect(kw.items.map((i) => i.siteReleaseId)).toEqual(["912235"]);
});
it("详情:表单 id,带简介;不存在 → not_found", async () => {
api = await createMockMteamApi();
const a = adapterFor(api);
const d = await a.detail("912233");
expect(api.requests[0]!.body).toBe("id=912233");
expect(d.descriptionText).toContain("幕府将军");
expect(d.imdbId).toBe("tt2788316");
const byUrl = await a.detail("https://tp.m-team.cc/detail/912233");
expect(byUrl.siteReleaseId).toBe("912233");
await expectSiteError(a.detail("1"), "not_found");
});
it("下载令牌:现场 genDlToken → 一次性直链 → .torrent,infohash 校验", async () => {
api = await createMockMteamApi();
const a = adapterFor(api);
const t = await a.downloadTarget("912233");
expect(api.issuedTokens).toBe(1);
expect(api.requests.map((r) => r.path.split("?")[0])).toEqual(["/api/torrent/genDlToken", "/api/rss/dlv2"]);
// MP:genDlToken 的 id 走 query 参数;直链下载不带 x-api-key
expect(api.requests[0]!.path).toBe("/api/torrent/genDlToken?id=912233");
expect(api.requests[1]!.headers["x-api-key"]).toBeUndefined();
expect(t.kind).toBe("torrent");
if (t.kind === "torrent") expect(t.infoHash).toBe(api.infoHashOf("912233"));
// 每次下载都重新签发令牌
await a.downloadTarget("912233");
expect(api.issuedTokens).toBe(2);
api.setFailMode("no_dl_token");
await expectSiteError(a.downloadTarget("912233"), "rate_limited");
});
it("Key 失效/停用 → auth_invalid(停止重试),健康检查报告;缺 Key → auth_missing", async () => {
api = await createMockMteamApi();
const wrong = await expectSiteError(adapterFor(api, "wrong-key").list(1), "auth_invalid");
expect(wrong.isAuthError).toBe(true);
api.setFailMode("banned_key");
const banned = await expectSiteError(adapterFor(api).search("x"), "auth_invalid");
expect(banned.message).not.toContain(api.apiKey);
expect(await adapterFor(api).healthCheck()).toMatchObject({ ok: false, status: "auth_invalid" });
api.setFailMode(null);
expect((await adapterFor(api).healthCheck()).status).toBe("ok");
const before = api.requests.length;
await expectSiteError(adapterFor(api, null).list(1), "auth_missing");
expect(api.requests.length).toBe(before);
});
it("非 JSON 维护页 → site_maintenance;业务限流/HTTP 429 → rate_limited 并顺延限流器", async () => {
api = await createMockMteamApi();
const limiter = createSiteRateLimiter({ minIntervalMs: 0 });
const a = adapterFor(api, api.apiKey, limiter);
api.setFailMode("non_json");
await expectSiteError(a.list(1), "site_maintenance");
api.setFailMode("rate_limit");
await expectSiteError(a.list(1), "rate_limited");
api.setFailMode("http_429");
const e = await expectSiteError(a.list(1), "rate_limited");
expect(e.retryAfterMs).toBe(5000);
expect(limiter.tryAcquire()).toBe(false);
});
it("站点级限流 1s:列表/搜索/详情/下载共享", async () => {
api = await createMockMteamApi();
let clock = 0;
const sleeps: number[] = [];
const limiter = createSiteRateLimiter({
minIntervalMs: mteamDefinition.umt.rateLimit.minIntervalMs,
now: () => clock,
sleep: async (ms) => {
sleeps.push(ms);
clock += ms;
},
});
const a = adapterFor(api, api.apiKey, limiter);
await a.list(1);
await a.search("Shogun");
await a.detail("912233");
await a.downloadTarget("912233"); // genDlToken + GET 直链 = 2 次
expect(sleeps).toEqual([1000, 1000, 1000, 1000]);
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* M-Team API 解析。
*
* 迁移自 MoviePilot e6820a8 `app/modules/indexer/spider/mtorrent.py` `MTorrentSpider`:
* - `_movie_category/_tv_category/_music_category/_labels` → 同名常量
* - `__get_params` → `mteamSearchParams`
* - `__parse_result` → `parseMteamResult`
* - `__find_imdbid / __get_downloadvolumefactor / __get_uploadvolumefactor` → 同名函数
* - `_pageurl` → `page_url`
*
* 与 MP 的差异:
* 1. 不生成 `[base64(json 含 x-api-key)]genDlToken` 形式的 enclosure(凭据不得进入候选对象,迁移账本 MT3),
* 下载时由 adapter 现场调用 genDlToken(行为同 MP `_resolve_indirect_download_url`)。
* 2. 额外输出 `category_id`(原始分类 ID),`category` 仍是 MP 的媒体类型投影。
* 3. 额外保留 `status` 原文到 raw,便于核验。
*/
import type { MediaKind, MpMediaType, SpiderTorrent } from "../spider/spider";
export const MOVIE_CATEGORY = ["401", "419", "420", "421", "439", "405", "404"];
export const TV_CATEGORY = ["403", "402", "435", "438", "404", "405"];
export const MUSIC_CATEGORY = ["434", "406"];
export const LABELS: Record<string, string> = {
"0": "",
"1": "DIY",
"2": "国配",
"3": "DIY 国配",
"4": "中字",
"5": "DIY 中字",
"6": "国配 中字",
"7": "DIY 国配 中字",
};
export const PAGE_SIZE = 100;
/** M-Team 种子 JSON(字段名按公开 API,待真实账号核验)。 */
export interface MteamTorrentJson {
id: string | number;
name?: string | null;
smallDescr?: string | null;
category?: string | number | null;
size?: string | number | null;
createdDate?: string | null;
imdb?: string | null;
douban?: string | null;
labels?: string | number | null;
labelsNew?: string[] | null;
descr?: string | null;
status?: {
seeders?: string | number | null;
leechers?: string | number | null;
timesCompleted?: string | number | null;
discount?: string | null;
discountEndTime?: string | null;
promotionRule?: { discount?: string | null; endTime?: string | null } | null;
mallSingleFree?: { status?: string | null; endDate?: string | null } | null;
} | null;
[k: string]: unknown;
}
/** MP `__get_params`(page 为 0 起) */
export function mteamSearchParams(keyword: string | null | undefined, mtype: MediaKind | undefined, page = 0) {
let categories: string[];
if (!mtype) categories = [];
else if (mtype === "tv") categories = TV_CATEGORY;
else if (mtype === "music") categories = MUSIC_CATEGORY;
else categories = MOVIE_CATEGORY;
// mtorrent 搜索 imdb 需要输入完整 imdb 链接(MP 注释引 wiki.m-team.cc/zh-tw/imdbtosearch)
const kw = keyword?.startsWith("tt") ? `https://www.imdb.com/title/${keyword}` : (keyword ?? null);
return { keyword: kw, categories, pageNumber: Number(page) + 1, pageSize: PAGE_SIZE, visible: 1 };
}
/** MP `__find_imdbid` */
export function findImdbid(imdb: string | null | undefined): string {
return imdb ? (/tt\d+/.exec(imdb)?.[0] ?? "") : "";
}
/** MP `__get_downloadvolumefactor` */
export function getDownloadVolumeFactor(discount: string | null | undefined): number {
const map: Record<string, number> = { FREE: 0, PERCENT_50: 0.5, PERCENT_70: 0.3, _2X_FREE: 0, _2X_PERCENT_50: 0.5 };
return discount ? (map[discount] ?? 1) : 1;
}
/** MP `__get_uploadvolumefactor` */
export function getUploadVolumeFactor(discount: string | null | undefined): number {
const map: Record<string, number> = { _2X: 2.0, _2X_FREE: 2.0, _2X_PERCENT_50: 2.0 };
return discount ? (map[discount] ?? 1) : 1;
}
/** MP `time_tools.format_timestamp`:非数字字符串原样返回,Unix 秒转本地时间(此处按站点时区交给调用方)。 */
function formatTimestamp(v: string | null | undefined): string | undefined {
if (v == null) return undefined;
return String(v);
}
function toInt(v: unknown): number {
const n = Number.parseInt(String(v ?? "0") || "0", 10);
return Number.isFinite(n) ? n : 0;
}
/** MP `__parse_result` */
export function parseMteamResult(results: MteamTorrentJson[] | null | undefined, webUrl: string): SpiderTorrent[] {
const out: SpiderTorrent[] = [];
if (!results) return out;
const url = webUrl.endsWith("/") ? webUrl : `${webUrl}/`;
for (const result of results) {
const categoryValue = result.category == null ? undefined : String(result.category);
let category: MpMediaType;
if (categoryValue && MUSIC_CATEGORY.includes(categoryValue)) category = "音乐";
else if (categoryValue && TV_CATEGORY.includes(categoryValue) && !MOVIE_CATEGORY.includes(categoryValue))
category = "电视剧";
else if (categoryValue && MOVIE_CATEGORY.includes(categoryValue)) category = "电影";
else category = "未知";
let labels: string[] = [];
if (result.labelsNew && result.labelsNew.length > 0) labels = result.labelsNew.map(String);
else {
const v = LABELS[String(result.labels || "0")] ?? "";
if (v) labels = v.split(/\s+/);
}
const status = result.status ?? {};
const torrent: SpiderTorrent = {
title: result.name ?? undefined,
description: result.smallDescr ?? undefined,
size: toInt(result.size),
seeders: toInt(status.seeders),
peers: toInt(status.leechers),
grabs: toInt(status.timesCompleted),
downloadvolumefactor: getDownloadVolumeFactor(status.discount),
uploadvolumefactor: getUploadVolumeFactor(status.discount),
page_url: `${url}detail/${result.id}`,
imdbid: findImdbid(result.imdb),
labels,
category,
};
const pubdate = formatTimestamp(result.createdDate);
if (pubdate) torrent.pubdate = pubdate;
if (categoryValue) torrent.category_id = categoryValue;
if (status.discountEndTime) torrent.freedate = formatTimestamp(status.discountEndTime);
// 全站促销规则(当前馒头只有下载促销)
if (status.promotionRule) {
torrent.downloadvolumefactor = getDownloadVolumeFactor(status.promotionRule.discount ?? "NORMAL");
if (status.promotionRule.endTime) torrent.freedate = formatTimestamp(status.promotionRule.endTime);
}
if (status.mallSingleFree && status.mallSingleFree.status === "ONGOING") {
torrent.downloadvolumefactor = getDownloadVolumeFactor("FREE");
if (status.mallSingleFree.endDate) torrent.freedate = formatTimestamp(status.mallSingleFree.endDate);
}
out.push(torrent);
}
return out;
}