feat(sites): 内置公开 BT 站 Nyaa / 蜜柑 / AniBT / AnimeGarden / 萌番组

- 新专用解析器(spiders/public.ts):RSS / Torznab / JSON API,搜索 + 最新列表 + 按资源 ID 取种子或磁力
- 手写定义(definitions/public.ts,public: true),并入内置定义表;定义视图按 public 标为 public_bt
- 添加站点:公开 BT 站可从内置站一键添加(专用适配器、默认定时抓取、默认不进搜索集合),手填 RSS 保留
- 样例取自 2026-10-02 公开接口,截前两条

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 21:19:19 +08:00
1 parent 9cdf492291
commit b8876542b8
16 files changed
+770 -18

No files matched your search

@@ -26,13 +26,15 @@ import { MOVIECLAW_DEFINITION_JSON } from "./manifest.generated.ts";
const OBSERVED = "2026-09-27T00:00:00.000Z";
const PRIMARY = ["audiences", "chdbits", "hhanclub", "mteam"];
/** 手写的内置公开 BT 站(public.ts),都默认启用 */
const PUBLIC = ["anibt", "animegarden", "bangumimoe", "mikan", "nyaa"];
describe("站点定义:加载时 zod 校验", () => {
const ids = Object.keys(MOVIECLAW_DEFINITION_JSON).sort();
it("MovieClaw v0.27.0 全部 23 站转换结果均在注册表中", () => {
it("MovieClaw v0.27.0 全部 23 站转换结果 + 公开 BT 站均在注册表中", () => {
expect(ids).toHaveLength(23);
expect(listSiteDefinitions().map((d) => d.id)).toEqual(ids);
expect(listSiteDefinitions().map((d) => d.id)).toEqual([...ids, ...PUBLIC].sort());
});
it.each(ids)("%s:schema 校验通过,provenance 指向 MovieClaw v0.27.0 MIT", (id) => {
@@ -44,8 +46,8 @@ describe("站点定义:加载时 zod 校验", () => {
expect(getSiteDefinition(id)).toBe(BUILTIN_SITE_DEFINITIONS[id]);
});
it("enabledOnly 只列 4 个主力站", () => {
expect(listSiteDefinitions({ enabledOnly: true }).map((d) => d.id)).toEqual(PRIMARY);
it("enabledOnly 只列 4 个主力 PT 站与公开 BT 站", () => {
expect(listSiteDefinitions({ enabledOnly: true }).map((d) => d.id)).toEqual([...PRIMARY, ...PUBLIC].sort());
});
it("非法定义被拒绝并指出字段路径", () => {
@@ -8,6 +8,7 @@ import type { MteamDefinition } from "../mteam/definition.ts";
import type { IndexerDefinition } from "../spider/schema.ts";
import type { SpecializedSiteDefinition } from "../spiders/types.ts";
import { MOVIECLAW_DEFINITION_JSON } from "./manifest.generated.ts";
import { PUBLIC_SITE_DEFINITION_JSON } from "./public.ts";
import { parseIndexerDefinition, parseMteamDefinition, parseSpecializedSiteDefinition } from "./schema.ts";
export type SiteDefinition = IndexerDefinition | MteamDefinition | SpecializedSiteDefinition;
@@ -26,9 +27,14 @@ export function loadIndexerDefinition(json: unknown): IndexerDefinition {
return parseIndexerDefinition(json);
}
/** 随产品发布的全部定义(已校验),键为站点 id */
/** 随产品发布的全部定义(已校验),键为站点 id:MovieClaw 转换的 PT 站 + 手写的公开 BT 站 */
export const BUILTIN_SITE_DEFINITIONS: Readonly<Record<string, SiteDefinition>> = Object.freeze(
Object.fromEntries(Object.entries(MOVIECLAW_DEFINITION_JSON).map(([id, json]) => [id, loadSiteDefinition(json)])),
Object.fromEntries(
Object.entries({ ...MOVIECLAW_DEFINITION_JSON, ...PUBLIC_SITE_DEFINITION_JSON }).map(([id, json]) => [
id,
loadSiteDefinition(json),
]),
),
);
export const audiencesDefinition = BUILTIN_SITE_DEFINITIONS.audiences as IndexerDefinition;
@@ -0,0 +1,88 @@
/**
* 公开 BT 站定义(本项目手写,不来自 MovieClaw):解析器在 ../spiders/public.ts。
* 都不需要登录;能力 = 列表(最新资源,供定时抓取)+ 搜索 + 下载,没有详情页解析。
*/
import { NYAA_CATEGORY_NAMES } from "../spiders/public.ts";
const caps = { list: true, search: true, detail: false, download: true, rss: false };
const polite = { minIntervalMs: 2000, burst: 2 };
export const PUBLIC_SITE_DEFINITION_JSON: Record<string, unknown> = {
nyaa: {
id: "nyaa",
name: "Nyaa",
domain: "https://nyaa.si/",
parser: "Nyaa",
public: true,
umt: {
version: "2026-10-02",
kind: "api",
displayName: "Nyaa",
timezone: "+00:00",
rateLimit: polite,
capabilities: caps,
categoryNames: NYAA_CATEGORY_NAMES,
},
},
mikan: {
id: "mikan",
name: "蜜柑计划",
domain: "https://mikanani.me/",
parser: "Mikan",
public: true,
umt: {
version: "2026-10-02",
kind: "api",
displayName: "蜜柑计划 Mikan",
timezone: "+08:00",
rateLimit: polite,
capabilities: caps,
},
},
anibt: {
id: "anibt",
name: "AniBT",
domain: "https://anibt.net/",
parser: "AniBT",
public: true,
umt: {
version: "2026-10-02",
kind: "api",
displayName: "AniBT",
timezone: "+08:00",
rateLimit: polite,
capabilities: caps,
},
},
animegarden: {
id: "animegarden",
name: "AnimeGarden",
domain: "https://animes.garden/",
parser: "AnimeGarden",
public: true,
api_url: "https://api.animes.garden",
umt: {
version: "2026-10-02",
kind: "api",
displayName: "AnimeGarden 动画花园聚合",
timezone: "+08:00",
rateLimit: polite,
capabilities: caps,
},
},
bangumimoe: {
id: "bangumimoe",
name: "萌番组",
domain: "https://bangumi.moe/",
parser: "BangumiMoe",
public: true,
umt: {
version: "2026-10-02",
kind: "api",
displayName: "萌番组 bangumi.moe",
timezone: "+08:00",
rateLimit: polite,
capabilities: caps,
},
},
};
@@ -234,7 +234,20 @@ export const MteamDefinitionSchema = z.strictObject({
/** MP 专用 spider(`SPIDER_PARSER_CLASSES` 中除 mTorrent 外的 7 个)站点定义 */
export const SpecializedSiteDefinitionSchema = z.strictObject({
...BaseDefinitionShape,
parser: z.enum(["Haidan", "HDDolby", "RousiPro", "SunnyPT", "TNodeSpider", "TorrentLeech", "Yema"]),
parser: z.enum([
"Haidan",
"HDDolby",
"RousiPro",
"SunnyPT",
"TNodeSpider",
"TorrentLeech",
"Yema",
"Nyaa",
"Mikan",
"AniBT",
"AnimeGarden",
"BangumiMoe",
]),
public: z.boolean().optional(),
category: CategoryConfigSchema.optional(),
api_url: z.string().url().optional(),
@@ -11,6 +11,7 @@ import { createSpecializedSiteAdapter } from "./adapter.ts";
import { haidanSpider } from "./haidan.ts";
import { hddolbySpider } from "./hddolby.ts";
import { mtorrentSpider } from "./mtorrent.ts";
import { anibtSpider, animeGardenSpider, bangumiMoeSpider, mikanSpider, nyaaSpider } from "./public.ts";
import { rousiSpider } from "./rousi.ts";
import { sunnyptSpider } from "./sunnypt.ts";
import { tnodeSpider } from "./tnode.ts";
@@ -27,6 +28,11 @@ export const SPECIALIZED_SPIDERS: Record<SpecializedParser, SpecializedSpider> =
HDDolby: hddolbySpider,
RousiPro: rousiSpider,
SunnyPT: sunnyptSpider,
Nyaa: nyaaSpider,
Mikan: mikanSpider,
AniBT: anibtSpider,
AnimeGarden: animeGardenSpider,
BangumiMoe: bangumiMoeSpider,
};
export function isSpecializedParser(v: unknown): v is SpecializedParser {
@@ -72,6 +72,7 @@ export function specializedTorrentToRelease(
uploadedAt: t.pubdate_utc ?? parseSiteTime(t.pubdate, tz),
detailUrl: t.page_url ?? `${def.domain}`,
tags: t.labels ?? [],
...(t.infohash && /^[0-9a-f]{40}$/i.test(t.infohash) ? { infoHash: t.infohash.toLowerCase() } : {}),
raw,
observedAt,
};
@@ -0,0 +1,104 @@
import {
ANIBT_RSS_LATEST,
ANIBT_TORZNAB_SEARCH,
ANIMEGARDEN_RESOURCES,
BANGUMIMOE_LATEST,
createMockFetch,
MIKAN_RSS_SEARCH,
NYAA_RSS_SEARCH,
} from "@umt/testkit";
import { describe, expect, it } from "vitest";
import { BUILTIN_SITE_DEFINITIONS } from "../definitions/index.ts";
import { anibtSpider, animeGardenSpider, bangumiMoeSpider, mikanSpider, nyaaSpider } from "./public.ts";
import { adapterWith, withTorrent } from "./testing.ts";
import type { SpecializedSiteDefinition } from "./types.ts";
const def = (id: string) => BUILTIN_SITE_DEFINITIONS[id] as SpecializedSiteDefinition;
const XML = { "content-type": "application/rss+xml; charset=utf-8" };
describe("公开 BT 站(本项目扩展)", () => {
it("Nyaa:RSS 搜索带 q;做种 / 下载 / info hash / 大小;下载按 ID 拼 .torrent", async () => {
const m = createMockFetch({ "GET /": { body: NYAA_RSS_SEARCH, headers: XML } });
const t = withTorrent(m.fetch, (u) => u.pathname.startsWith("/download/"));
const a = adapterWith(def("nyaa"), nyaaSpider, t.fetch);
const page = await a.search("葬送的芙莉莲");
expect(m.requests[0]!.url.searchParams.get("q")).toBe("葬送的芙莉莲");
expect(page.items[0]).toMatchObject({
siteId: "nyaa",
siteReleaseId: "2160092",
detailUrl: "https://nyaa.si/view/2160092",
seeders: 33,
leechers: 3,
snatched: 833,
infoHash: "ef3e7ad1b12bdd9fc341691d8866cd1fa8374a4b",
uploadedAt: "2026-09-12T15:45:00.000Z",
});
expect(page.items[0]!.sizeBytes).toBe(Math.round(6.6 * 1024 ** 3));
const got = await a.downloadTarget("2160092");
expect(got.kind).toBe("torrent");
expect(t.hits[0]!.url.pathname).toBe("/download/2160092.torrent");
});
it("蜜柑:搜索 RSS;资源 ID = info hash,北京时间转 UTC;下载取剧集页里的 .torrent", async () => {
const m = createMockFetch({
"GET /RSS/Search": { body: MIKAN_RSS_SEARCH, headers: XML },
"GET /Home/Episode/f8651444a2c0b3ff7e3da3cbd75da58ef5f09e19": {
body: '<a href="/Download/20260828/f8651444a2c0b3ff7e3da3cbd75da58ef5f09e19.torrent">下载</a>',
},
});
const t = withTorrent(m.fetch, (u) => u.pathname.startsWith("/Download/"));
const a = adapterWith(def("mikan"), mikanSpider, t.fetch);
const page = await a.search("葬送的芙莉莲");
expect(page.items[0]).toMatchObject({
siteReleaseId: "f8651444a2c0b3ff7e3da3cbd75da58ef5f09e19",
infoHash: "f8651444a2c0b3ff7e3da3cbd75da58ef5f09e19",
sizeBytes: 52076478464,
uploadedAt: "2026-08-27T21:32:00.000Z",
});
expect((await a.downloadTarget("f8651444a2c0b3ff7e3da3cbd75da58ef5f09e19")).kind).toBe("torrent");
expect(t.hits[0]!.url.pathname).toBe("/Download/20260828/f8651444a2c0b3ff7e3da3cbd75da58ef5f09e19.torrent");
});
it("AniBT:最新 RSS 带字幕组 / 分辨率标签与 Bangumi ID;搜索走 Torznab", async () => {
const m = createMockFetch({
"GET /rss/magnets.xml": { body: ANIBT_RSS_LATEST, headers: XML },
"GET /torznab/api": { body: ANIBT_TORZNAB_SEARCH, headers: XML },
});
const a = adapterWith(def("anibt"), anibtSpider, m.fetch);
const latest = await a.list(1);
expect(latest.items[0]).toMatchObject({
siteReleaseId: "rel_TC7FrmcvDwIw",
tags: expect.arrayContaining(["Nix-Raws", "1080p"]),
});
expect(latest.items[0]!.raw.bangumi_id).toBe("637339");
const found = await a.search("葬送");
expect(m.requests.at(-1)!.url.searchParams.get("t")).toBe("search");
expect(found.items[0]).toMatchObject({
siteReleaseId: "rel_U6lR5dHzDTZS",
sizeBytes: 52023960599,
infoHash: "f8651444a2c0b3ff7e3da3cbd75da58ef5f09e19",
});
});
it("AnimeGarden:JSON 搜索参数是数组;资源 ID = 来源-来源 ID,只给磁力", async () => {
const m = createMockFetch({ "GET https://api.animes.garden/resources": { body: ANIMEGARDEN_RESOURCES } });
const a = adapterWith(def("animegarden"), animeGardenSpider, m.fetch);
const page = await a.search("葬送");
expect(JSON.parse(m.requests[0]!.url.searchParams.get("search")!)).toEqual(["葬送"]);
expect(page.items[0]).toMatchObject({ siteReleaseId: "dmhy-725809", tags: ["dmhy", "合集"] });
const got = await a.downloadTarget("dmhy-725809");
expect(got).toMatchObject({ kind: "magnet" });
});
it("萌番组:最新 / 磁力;资源 ID 是 24 位 ObjectId", async () => {
const m = createMockFetch({ "GET /api/torrent/latest": { body: BANGUMIMOE_LATEST } });
const a = adapterWith(def("bangumimoe"), bangumiMoeSpider, m.fetch);
const page = await a.list(1);
expect(page.items[0]).toMatchObject({ siteReleaseId: "66f1c2d3e4f5a6b7c8d9e0f1", seeders: 12, snatched: 40 });
expect(page.items[0]!.sizeBytes).toBe(Math.round(345.6 * 1024 ** 2));
});
it("五个内置公开站都是 public 定义", () => {
for (const id of ["nyaa", "mikan", "anibt", "animegarden", "bangumimoe"]) expect(def(id).public).toBe(true);
});
});
@@ -0,0 +1,356 @@
/**
* 公开 BT 站(番剧为主)专用 spider:本项目扩展,MP 无对应实现(MP 只在插件里接这些站)。
* - Nyaa:RSS(?page=rss,搜索加 q),nyaa: 命名空间给做种 / 下载 / info hash / 大小
* - 蜜柑 Mikan:经典 RSS 与搜索 RSS;资源 ID = info hash,下载取剧集页里的 .torrent
* - AniBT:全站 RSS(rss/magnets.xml,带 Bangumi ID)与 Torznab 搜索(torznab/api)
* - AnimeGarden:JSON API(api.animes.garden),聚合动漫花园 / 萌番组 / ANi 等来源,资源只有磁力
* - 萌番组 bangumi.moe:JSON API;部分网络直连不通,可在站点上开「通过浏览器请求」
* 都不需要登录;只读请求走站点限流。资源 ID 设计成「只凭 ID 就能取到种子」(下载时不依赖搜索缓存)。
*/
import { XMLParser } from "fast-xml-parser";
import { parseSizeMp } from "../spider/spider.ts";
import { SiteError } from "../types.ts";
import { apiRequest, downloadTorrentFile, isPlainObject, siteBase, textRequest } from "./common.ts";
import type { SpecializedSpider, SpecializedTorrent, SpiderContext, SpiderSearchQuery } from "./types.ts";
const xml = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
parseTagValue: false,
trimValues: true,
isArray: (name) => name === "item" || name === "torznab:attr" || name === "anibt:customTag",
});
type Node = Record<string, unknown>;
const text = (v: unknown): string | undefined => {
if (v == null) return undefined;
if (typeof v === "string") return v.trim() || undefined;
if (typeof v === "number") return String(v);
if (isPlainObject(v) && typeof v["#text"] === "string") return (v["#text"] as string).trim() || undefined;
return undefined;
};
const num = (v: unknown): number | undefined => {
const t = text(v);
if (t === undefined) return undefined;
const n = Number(t);
return Number.isFinite(n) ? n : undefined;
};
/** RSS 时间(RFC 822 / ISO,带时区)→ UTC ISO */
const utc = (v: unknown): string | undefined => {
const t = text(v);
if (!t) return undefined;
const d = new Date(t);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
};
/** 不带时区的站点时间按东八区 */
const utcCst = (v: unknown): string | undefined => {
const t = text(v);
if (!t) return undefined;
return utc(/[zZ]|[+-]\d{2}:?\d{2}$/.test(t) ? t : `${t}+08:00`);
};
async function rssItems(ctx: SpiderContext, url: string): Promise<Node[]> {
const body = await textRequest(ctx, url, { Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.8" });
let doc: Node;
try {
doc = xml.parse(body) as Node;
} catch {
throw new SiteError(ctx.def.id, "parse_error", `${ctx.def.name} 返回的不是有效 RSS`);
}
const channel = ((doc.rss as Node | undefined)?.channel ?? {}) as Node;
if (!doc.rss) {
const challenge = /cloudflare|cf-chl|just a moment/i.test(body);
throw new SiteError(
ctx.def.id,
challenge ? "challenge" : "parse_error",
`${ctx.def.name} 返回的不是 RSS${challenge ? "(人机验证)" : ""}`,
);
}
return (channel.item as Node[] | undefined) ?? [];
}
const keywordOf = (q: SpiderSearchQuery) => q.keyword?.trim() || null;
const magnetOf = (hash: string | undefined) =>
hash && /^[0-9a-f]{40}$/i.test(hash) ? `magnet:?xt=urn:btih:${hash}` : null;
// ── Nyaa ──
const NYAA_CATEGORY: Record<string, string> = {
"1_1": "Anime - AMV",
"1_2": "Anime - English",
"1_3": "Anime - Non-English",
"1_4": "Anime - Raw",
};
export const nyaaSpider: SpecializedSpider = {
parser: "Nyaa",
credential: "cookie",
pageSize: () => null,
async search(ctx, q) {
const base = siteBase(ctx.def);
const kw = keywordOf(q);
const url = kw ? `${base}?page=rss&f=0&c=0_0&q=${encodeURIComponent(kw)}` : `${base}?page=rss`;
return (await rssItems(ctx, url)).flatMap((it): SpecializedTorrent[] => {
const id = /\/view\/(\d+)/.exec(text(it.guid) ?? "")?.[1] ?? /\/download\/(\d+)/.exec(text(it.link) ?? "")?.[1];
const title = text(it.title);
if (!id || !title) return [];
const cat = text(it["nyaa:categoryId"]);
const labels = [
...(text(it["nyaa:trusted"]) === "Yes" ? ["Trusted"] : []),
...(text(it["nyaa:remake"]) === "Yes" ? ["Remake"] : []),
];
return [
{
site_release_id: id,
title,
page_url: `${base}view/${id}`,
enclosure: `${base}download/${id}.torrent`,
size: parseSizeMp(text(it["nyaa:size"]) ?? ""),
seeders: num(it["nyaa:seeders"]) ?? 0,
peers: num(it["nyaa:leechers"]) ?? 0,
grabs: num(it["nyaa:downloads"]) ?? 0,
pubdate_utc: utc(it.pubDate),
downloadvolumefactor: 0,
uploadvolumefactor: 1,
...(cat ? { category_id: cat } : {}),
labels,
infohash: text(it["nyaa:infoHash"]),
} as SpecializedTorrent,
];
});
},
async resolveDownload(ctx, id) {
const n = /^\d+$/.exec(String(id).trim())?.[0];
if (!n) throw new SiteError(ctx.def.id, "not_found", "资源 ID 非法");
return downloadTorrentFile(ctx, `${siteBase(ctx.def)}download/${n}.torrent`);
},
};
export const NYAA_CATEGORY_NAMES = NYAA_CATEGORY;
// ── 蜜柑 Mikan ──
export const mikanSpider: SpecializedSpider = {
parser: "Mikan",
credential: "cookie",
pageSize: () => null,
async search(ctx, q) {
const base = siteBase(ctx.def);
const kw = keywordOf(q);
const url = kw ? `${base}RSS/Search?searchstr=${encodeURIComponent(kw)}` : `${base}RSS/Classic`;
return (await rssItems(ctx, url)).flatMap((it): SpecializedTorrent[] => {
const link = text(it.link) ?? "";
const id = /\/Episode\/([0-9a-f]{40})/i.exec(link)?.[1]?.toLowerCase();
const title = text(it.title);
if (!id || !title) return [];
const tor = (it.torrent ?? {}) as Node;
const enc = (it.enclosure ?? {}) as Node;
return [
{
site_release_id: id,
title,
page_url: `${base}Home/Episode/${id}`,
enclosure: text(enc["@_url"]) ?? `${base}Home/Episode/${id}`,
size: num(tor.contentLength) ?? num(enc["@_length"]) ?? 0,
pubdate_utc: utcCst(tor.pubDate),
downloadvolumefactor: 0,
uploadvolumefactor: 1,
labels: [],
infohash: id,
} as SpecializedTorrent,
];
});
},
async resolveDownload(ctx, id, enclosure) {
const hash = /[0-9a-f]{40}/i.exec(String(id))?.[0]?.toLowerCase();
if (!hash) throw new SiteError(ctx.def.id, "not_found", "资源 ID 非法");
const base = siteBase(ctx.def);
let url = enclosure && /\.torrent(?:$|\?)/.test(enclosure) ? enclosure : null;
if (!url) {
const page = await textRequest(ctx, `${base}Home/Episode/${hash}`, {});
const m = /href="(\/Download\/[^"]+\.torrent)"/.exec(page);
if (m) url = new URL(m[1]!, base).href;
}
if (url) return downloadTorrentFile(ctx, url);
return { kind: "magnet", uri: magnetOf(hash)!, verifiedAt: ctx.http.now().toISOString() };
},
};
// ── AniBT ──
export const anibtSpider: SpecializedSpider = {
parser: "AniBT",
credential: "cookie",
pageSize: () => null,
async search(ctx, q) {
const base = siteBase(ctx.def);
const kw = keywordOf(q);
if (kw) {
// Torznab 搜索:infohash / size 在 torznab:attr
const items = await rssItems(ctx, `${base}torznab/api?t=search&q=${encodeURIComponent(kw)}`);
return items.flatMap((it): SpecializedTorrent[] => {
const id = text(it.guid);
const title = text(it.title);
if (!id || !/^o?rel_/.test(id) || !title) return [];
const attrs = Object.fromEntries(
((it["torznab:attr"] as Node[] | undefined) ?? []).map((a) => [text(a["@_name"]), text(a["@_value"])]),
);
return [
{
site_release_id: id,
title,
page_url: `${base}release/${id}`,
enclosure: `${base}api/torrent/${id}.torrent`,
size: num(attrs.size) ?? 0,
seeders: num(attrs.seeders) ?? 0,
peers: num(attrs.peers) ?? 0,
pubdate_utc: utc(it.pubDate),
downloadvolumefactor: num(attrs.downloadvolumefactor) ?? 0,
uploadvolumefactor: num(attrs.uploadvolumefactor) ?? 1,
labels: [],
infohash: attrs.infohash,
} as SpecializedTorrent,
];
});
}
return (await rssItems(ctx, `${base}rss/magnets.xml`)).flatMap((it): SpecializedTorrent[] => {
const id = text(it.guid) ?? text(it["anibt:releaseId"]);
const title = text(it.title);
if (!id || !/^o?rel_/.test(id) || !title) return [];
const tor = (it.torrent ?? {}) as Node;
const labels = [
text(it["anibt:groupName"]),
text(it["anibt:resolution"]),
text(it["anibt:language"]),
...((it["anibt:customTag"] as unknown[] | undefined) ?? []).map(text),
].filter((x): x is string => !!x);
const bgm = text(it["anibt:bgmId"]);
return [
{
site_release_id: id,
title,
page_url: `${base}release/${id}`,
enclosure: text(it["anibt:torrentUrl"]) ?? `${base}api/torrent/${id}.torrent`,
size: num(it["anibt:fileSize"]) ?? num(tor.contentLength) ?? 0,
pubdate_utc: utc(it.pubDate),
downloadvolumefactor: 0,
uploadvolumefactor: 1,
labels,
infohash: text(tor.infohash),
...(bgm ? { bangumi_id: bgm } : {}),
...(text(it["anibt:animeTitle"]) ? { description: text(it["anibt:animeTitle"]) } : {}),
} as SpecializedTorrent,
];
});
},
async resolveDownload(ctx, id) {
const rel = /^o?rel_[A-Za-z0-9]+$/.exec(String(id).trim())?.[0];
if (!rel) throw new SiteError(ctx.def.id, "not_found", "资源 ID 非法");
return downloadTorrentFile(ctx, `${siteBase(ctx.def)}api/torrent/${rel}.torrent`);
},
};
// ── AnimeGarden ──
const gardenApi = (ctx: SpiderContext) =>
(ctx.def.umt.apiBaseUrl ?? ctx.def.api_url ?? "https://api.animes.garden").replace(/\/+$/, "");
function gardenTorrent(ctx: SpiderContext, r: Node): SpecializedTorrent | null {
const provider = text(r.provider);
const pid = text(r.providerId);
const title = text(r.title);
if (!provider || !pid || !title || !/^[a-z]+$/.test(provider)) return null;
const type = text(r.type);
const publisher = isPlainObject(r.publisher) ? text(r.publisher.name) : undefined;
return {
site_release_id: `${provider}-${pid}`,
title,
page_url: text(r.href) ?? `${siteBase(ctx.def)}detail/${provider}/${pid}`,
enclosure: text(r.magnet),
size: num(r.size) ?? 0,
pubdate_utc: utc(r.createdAt),
downloadvolumefactor: 0,
uploadvolumefactor: 1,
labels: [provider, ...(type ? [type] : [])],
...(type ? { category_id: type } : {}),
...(publisher ? { uploader: publisher } : {}),
} as SpecializedTorrent;
}
export const animeGardenSpider: SpecializedSpider = {
parser: "AnimeGarden",
credential: "cookie",
pageSize: () => null,
async search(ctx, q) {
const kw = keywordOf(q);
const query: Record<string, string> = { page: "1", pageSize: "100" };
if (kw) query.search = JSON.stringify([kw]);
const res = await apiRequest(ctx, `${gardenApi(ctx)}/resources`, { query });
const rows = isPlainObject(res) && Array.isArray(res.resources) ? (res.resources as Node[]) : null;
if (!rows) throw new SiteError(ctx.def.id, "parse_error", `${ctx.def.name} 返回格式不对`);
return rows.flatMap((r) => {
const t = gardenTorrent(ctx, r);
return t ? [t] : [];
});
},
async resolveDownload(ctx, id, enclosure) {
if (enclosure?.startsWith("magnet:"))
return { kind: "magnet", uri: enclosure, verifiedAt: ctx.http.now().toISOString() };
const m = /^([a-z]+)-([A-Za-z0-9_-]+)$/.exec(String(id).trim());
if (!m) throw new SiteError(ctx.def.id, "not_found", "资源 ID 非法");
const res = await apiRequest(ctx, `${gardenApi(ctx)}/detail/${m[1]}/${m[2]}`);
const magnet = isPlainObject(res) && isPlainObject(res.resource) ? text(res.resource.magnet) : undefined;
if (!magnet?.startsWith("magnet:")) throw new SiteError(ctx.def.id, "not_found", "资源没有磁力链接");
return { kind: "magnet", uri: magnet, verifiedAt: ctx.http.now().toISOString() };
},
};
// ── 萌番组 bangumi.moe ──
function moeTorrent(ctx: SpiderContext, r: Node): SpecializedTorrent | null {
const id = text(r._id);
const title = text(r.title);
if (!id || !/^[0-9a-f]{24}$/i.test(id) || !title) return null;
const base = siteBase(ctx.def);
const size = text(r.size);
return {
site_release_id: id,
title,
page_url: `${base}torrent/${id}`,
enclosure: text(r.magnet) ?? `${base}download/torrent/${id}/${id}.torrent`,
size: size ? parseSizeMp(size) : 0,
seeders: num(r.seeders) ?? 0,
peers: num(r.leechers) ?? 0,
grabs: num(r.finished) ?? 0,
pubdate_utc: utc(r.publish_time),
downloadvolumefactor: 0,
uploadvolumefactor: 1,
labels: [],
infohash: text(r.infoHash),
} as SpecializedTorrent;
}
export const bangumiMoeSpider: SpecializedSpider = {
parser: "BangumiMoe",
credential: "cookie",
pageSize: () => null,
async search(ctx, q) {
const base = siteBase(ctx.def);
const kw = keywordOf(q);
const res = kw
? await apiRequest(ctx, `${base}api/v2/torrent/search`, { json: { query: kw } })
: await apiRequest(ctx, `${base}api/torrent/latest`);
const rows = isPlainObject(res) && Array.isArray(res.torrents) ? (res.torrents as Node[]) : null;
if (!rows) throw new SiteError(ctx.def.id, "parse_error", `${ctx.def.name} 返回格式不对`);
return rows.flatMap((r) => {
const t = moeTorrent(ctx, r);
return t ? [t] : [];
});
},
async resolveDownload(ctx, id, enclosure) {
if (enclosure?.startsWith("magnet:"))
return { kind: "magnet", uri: enclosure, verifiedAt: ctx.http.now().toISOString() };
const oid = /^[0-9a-f]{24}$/i.exec(String(id).trim())?.[0];
if (!oid) throw new SiteError(ctx.def.id, "not_found", "资源 ID 非法");
return downloadTorrentFile(ctx, `${siteBase(ctx.def)}download/torrent/${oid}/${oid}.torrent`);
},
};
+11 -1
View File
@@ -20,7 +20,13 @@ export type SpecializedParser =
| "TNodeSpider"
| "TorrentLeech"
| "Yema"
| "mTorrent";
| "mTorrent"
// 本项目扩展:公开 BT 站(public.ts),MP 无对应
| "Nyaa"
| "Mikan"
| "AniBT"
| "AnimeGarden"
| "BangumiMoe";
export interface SpecializedSiteDefinition {
id: string;
@@ -61,6 +67,10 @@ export interface SpecializedSiteDefinition {
*/
export interface SpecializedTorrent extends SpiderTorrent {
site_release_id?: string;
/** 站点直接给出的 info hash(公开 BT 站) */
infohash?: string;
/** 站点直接给出的 Bangumi 条目 ID(AniBT) */
bangumi_id?: string;
pubdate_utc?: string;
freedate_utc?: string;
}