diff --git a/CHANGELOG.md b/CHANGELOG.md index 74017362..c1e6c3ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### 新增 - 下载器可以单独设水位(硬阈值 / 恢复水位 / 清理目标)和保种天数,不填用全局;多个下载器共用一块缓存盘时取最严格的。公网 BT 专用的 qB 可设保种 0 天(站点 H&R 仍遵守)。 +- 内置公开 BT 站:Nyaa、蜜柑计划、AniBT、AnimeGarden(聚合动漫花园 / 蜜柑 / ANi 等)、萌番组。添加站点 → 公开 BT 站里一键添加,不需要账号;支持资源搜索、定时抓最新资源、按资源 ID 取种子或磁力(蜜柑取剧集页的种子,AnimeGarden 只有磁力)。默认不进资源搜索的默认站点集合;萌番组部分网络直连不通,打开站点的「代理」即可。原来手填 RSS 的方式保留。 ## [0.3.16] - 2026-10-02 diff --git a/apps/server/src/modules/sources-identity/routes/sources.ts b/apps/server/src/modules/sources-identity/routes/sources.ts index 480c4486..a4e387dc 100644 --- a/apps/server/src/modules/sources-identity/routes/sources.ts +++ b/apps/server/src/modules/sources-identity/routes/sources.ts @@ -56,6 +56,11 @@ const T_SITES = ["sites"]; const T_FEEDS = ["feed-sources"]; const T_RELEASES = ["source-releases"]; +/** 定义对应的站点类型:手写的公开 BT 站定义带 public */ +export function definitionKind(def: SiteDefinition): "pt" | "public_bt" { + return "public" in def && def.public === true ? "public_bt" : "pt"; +} + export function adapterOf(def: SiteDefinition): SiteAdapter { return def.umt.kind === "mteam" ? "mteam_api" : def.umt.kind === "api" ? "api" : "nexusphp"; } @@ -90,7 +95,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { items: listSiteDefinitions().map((d) => ({ key: d.id, name: d.umt.kind === "nexusphp" ? (d.umt.displayName ?? d.name) : d.umt.displayName, - kind: "pt" as const, + kind: definitionKind(d), adapter: adapterOf(d), baseUrl: d.domain, shippedEnabled: d.enabled !== false, @@ -140,8 +145,10 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { route(app, D.sites.get, async ({ params }) => siteToApi(await getSite(params.id))); route(app, D.sites.create, async ({ body, actor }) => { const def = body.key ? listSiteDefinitions().find((d) => d.id === body.key) : undefined; - // 类型缺省:注册表里有定义 = PT 站;否则按旧口径(generic_rss = 自定义 RSS 站点) - const kind = body.kind ?? (def ? "pt" : "rss_feed"); + // 类型缺省:注册表里有定义 = 定义的类型(PT / 公开 BT);否则按旧口径(generic_rss = 自定义 RSS 站点) + const kind = body.kind ?? (def ? definitionKind(def) : "rss_feed"); + // 内置公开 BT 站(Nyaa、蜜柑等):用定义的专用适配器,默认定时抓最新资源,不默认进资源搜索 + const publicDef = kind === "public_bt" && def && definitionKind(def) === "public_bt" ? def : undefined; if (!SITE_KINDS[kind].implemented) throw new ApiException("bad_request", `还不支持添加${SITE_KINDS[kind].label}`); if (kind === "pt") { if (!body.key) throw new ApiException("bad_request", "PT 站要从站点定义里选(缺 key)"); @@ -151,6 +158,10 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { if (!def && body.adapter !== "generic_rss") { throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 generic_rss 登记`); } + if (def && definitionKind(def) !== "pt") throw new ApiException("bad_request", `${body.key} 不是 PT 站`); + } else if (publicDef) { + if (body.adapter !== adapterOf(publicDef)) + throw new ApiException("bad_request", `站点 ${body.key} 的适配器类型应为 ${adapterOf(publicDef)}`); } else if (body.adapter !== "generic_rss") { throw new ApiException("bad_request", `${SITE_KINDS[kind].label}只能以 generic_rss 登记`); } @@ -170,11 +181,13 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { enabled: body.enabled ?? true, rate_limit: jsonb(body.rateLimit ?? {}), config: jsonb(body.config ?? {}), - snapshot_enabled: kind === "pt" ? (body.snapshotEnabled ?? false) : false, + snapshot_enabled: + kind === "pt" ? (body.snapshotEnabled ?? false) : publicDef ? (body.snapshotEnabled ?? true) : false, snapshot_interval_sec: body.snapshotIntervalSec ?? null, proxy: body.proxy ?? false, // 不能搜索的站点不进默认搜索集合 - search_default: kind === "pt" ? (body.searchDefault ?? true) : false, + search_default: + kind === "pt" ? (body.searchDefault ?? true) : publicDef ? (body.searchDefault ?? false) : false, }) .returningAll() .executeTakeFirstOrThrow(); diff --git a/apps/server/test/sources-identity/site-kinds.test.ts b/apps/server/test/sources-identity/site-kinds.test.ts index b5aee8c6..deed504a 100644 --- a/apps/server/test/sources-identity/site-kinds.test.ts +++ b/apps/server/test/sources-identity/site-kinds.test.ts @@ -119,9 +119,36 @@ describe.skipIf(!up)("站点类型:API、调度与 RSS", () => { expect(pubWrongAdapter.status).toBe(400); }); - it("站点定义都是 PT 站;PT 站带登录与账号数据", async () => { + it("内置公开 BT 站:定义标为 public_bt;按定义添加用专用适配器,定时抓最新,不默认进搜索,不能登录", async () => { const defs = await json(await h.req("/site-definitions")); - expect(defs.body.data.items.every((d: { kind: string }) => d.kind === "pt")).toBe(true); + const items = defs.body.data.items as { key: string; kind: string; adapter: string }[]; + const pub = ["nyaa", "mikan", "anibt", "animegarden", "bangumimoe"]; + expect( + items + .filter((d) => d.kind === "public_bt") + .map((d) => d.key) + .sort(), + ).toEqual([...pub].sort()); + expect(items.filter((d) => !pub.includes(d.key)).every((d) => d.kind === "pt")).toBe(true); + const nyaa = await json( + await h.req("/sites", { body: { key: "nyaa", name: "Nyaa", adapter: "api", baseUrl: "https://nyaa.si/" } }), + ); + expect(nyaa.status).toBe(200); + expect(nyaa.body.data).toMatchObject({ + kind: "public_bt", + adapter: "api", + snapshotEnabled: true, + searchDefault: false, + }); + expect(nyaa.body.data.capabilities).toEqual(expect.arrayContaining(["search", "list"])); + expect(nyaa.body.data.capabilities).not.toContain("login"); + const wrong = await h.req("/sites", { + body: { key: "mikan", kind: "public_bt", name: "蜜柑", adapter: "generic_rss", baseUrl: "https://mikanani.me/" }, + }); + expect(wrong.status).toBe(400); + }); + + it("PT 站定义带登录与账号数据", async () => { const pt = await json( await h.req("/sites", { body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: "https://audiences.invalid/" }, diff --git a/apps/web/app/components/sites/site-add-dialog.tsx b/apps/web/app/components/sites/site-add-dialog.tsx index 8e49a3bf..e8bff8cb 100644 --- a/apps/web/app/components/sites/site-add-dialog.tsx +++ b/apps/web/app/components/sites/site-add-dialog.tsx @@ -148,10 +148,34 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i !needle || d.name.toLowerCase().includes(needle) || d.key.includes(needle) || d.baseUrl.includes(needle); return GROUP_ORDER.map((a) => ({ adapter: a, - items: items.filter((d) => d.adapter === a && hit(d)).sort((x, y) => x.name.localeCompare(y.name, "zh-CN")), + items: items + .filter((d) => d.kind === "pt" && d.adapter === a && hit(d)) + .sort((x, y) => x.name.localeCompare(y.name, "zh-CN")), })).filter((g) => g.items.length); }, [items, q]); + const publicDefs = items.filter((d) => d.kind === "public_bt"); + /** 内置公开 BT 站:一步建好(专用适配器、定时抓最新),进站点页 */ + const addPublic = async (d: SiteDefinitionView) => { + try { + setPhase("保存中…"); + const site = await create.mutateAsync({ + kind: "public_bt", + key: d.key, + adapter: d.adapter, + name: d.name, + baseUrl: d.baseUrl, + }); + toast.success(`已添加「${site.name}」`); + onClose(); + navigate(consolePath("sites", site.id)); + } catch (err) { + toast.error(errorMessage(err, "保存失败")); + } finally { + setPhase(null); + } + }; + const pick = (key: string) => { const d = items.find((x) => x.key === key); setDefKey(key); @@ -197,6 +221,32 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i }} className="min-w-0" > + {kind === "public_bt" && publicDefs.length ? ( +
+

内置站点

+ +

+ 内置站点支持搜索和定时抓取最新资源,不需要账号。其他公开站可以在下面手动填 RSS 地址。 +

+
+ ) : null} { 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("非法定义被拒绝并指出字段路径", () => { diff --git a/packages/adapters/src/sites/definitions/index.ts b/packages/adapters/src/sites/definitions/index.ts index 72d4c2e0..2384bcf7 100644 --- a/packages/adapters/src/sites/definitions/index.ts +++ b/packages/adapters/src/sites/definitions/index.ts @@ -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> = 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; diff --git a/packages/adapters/src/sites/definitions/public.ts b/packages/adapters/src/sites/definitions/public.ts new file mode 100644 index 00000000..7ef09af0 --- /dev/null +++ b/packages/adapters/src/sites/definitions/public.ts @@ -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 = { + 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, + }, + }, +}; diff --git a/packages/adapters/src/sites/definitions/schema.ts b/packages/adapters/src/sites/definitions/schema.ts index 46186a65..cb03bcb6 100644 --- a/packages/adapters/src/sites/definitions/schema.ts +++ b/packages/adapters/src/sites/definitions/schema.ts @@ -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(), diff --git a/packages/adapters/src/sites/spiders/index.ts b/packages/adapters/src/sites/spiders/index.ts index 5d7d9dd4..761cbaed 100644 --- a/packages/adapters/src/sites/spiders/index.ts +++ b/packages/adapters/src/sites/spiders/index.ts @@ -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 = HDDolby: hddolbySpider, RousiPro: rousiSpider, SunnyPT: sunnyptSpider, + Nyaa: nyaaSpider, + Mikan: mikanSpider, + AniBT: anibtSpider, + AnimeGarden: animeGardenSpider, + BangumiMoe: bangumiMoeSpider, }; export function isSpecializedParser(v: unknown): v is SpecializedParser { diff --git a/packages/adapters/src/sites/spiders/map.ts b/packages/adapters/src/sites/spiders/map.ts index 1d2c6b24..7e0c482c 100644 --- a/packages/adapters/src/sites/spiders/map.ts +++ b/packages/adapters/src/sites/spiders/map.ts @@ -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, }; diff --git a/packages/adapters/src/sites/spiders/public.test.ts b/packages/adapters/src/sites/spiders/public.test.ts new file mode 100644 index 00000000..8b9db6cb --- /dev/null +++ b/packages/adapters/src/sites/spiders/public.test.ts @@ -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: '下载', + }, + }); + 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); + }); +}); diff --git a/packages/adapters/src/sites/spiders/public.ts b/packages/adapters/src/sites/spiders/public.ts new file mode 100644 index 00000000..269e9644 --- /dev/null +++ b/packages/adapters/src/sites/spiders/public.ts @@ -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; +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 { + 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 = { + "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 = { 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`); + }, +}; diff --git a/packages/adapters/src/sites/spiders/types.ts b/packages/adapters/src/sites/spiders/types.ts index f1bc9057..6f7f7cf9 100644 --- a/packages/adapters/src/sites/spiders/types.ts +++ b/packages/adapters/src/sites/spiders/types.ts @@ -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; } diff --git a/packages/contracts/src/sources.ts b/packages/contracts/src/sources.ts index 870628b6..36c8c1a9 100644 --- a/packages/contracts/src/sources.ts +++ b/packages/contracts/src/sources.ts @@ -43,7 +43,7 @@ export const SITE_KINDS: Record = { }, public_bt: { label: "公开 BT 站", - description: "不用登录的公开站。填网址和 RSS 地址,按 RSS 获取资源", + description: "不用登录的公开站。内置 Nyaa、蜜柑、AniBT、AnimeGarden、萌番组,其他站填网址和 RSS 地址", capabilities: ["search", "list", "rss", "detail"], implemented: true, fromDefinition: false, @@ -710,7 +710,7 @@ export type SiteRequestStats = z.infer; export const SiteDefinitionView = z.object({ key: z.string(), name: z.string(), - /** 定义对应的站点类型(现有定义都是 PT 站) */ + /** 定义对应的站点类型(PT 站;Nyaa、蜜柑等内置公开 BT 站为 public_bt) */ kind: SiteKind, adapter: SiteAdapter, baseUrl: z.string(), diff --git a/packages/testkit/src/sites/fixtures/public-bt.ts b/packages/testkit/src/sites/fixtures/public-bt.ts new file mode 100644 index 00000000..f992cac8 --- /dev/null +++ b/packages/testkit/src/sites/fixtures/public-bt.ts @@ -0,0 +1,74 @@ +/** + * 公开 BT 站样例:2026-10-02 从 Nyaa / 蜜柑 / AniBT / AnimeGarden 公开接口各取一次,每份截前两条(公开数据,无凭据)。 + * 萌番组当时网络不通,样例按其公开 API 字段手写。 + */ +export const NYAA_RSS_SEARCH = + '\n\t\n\t\tNyaa - "frieren" - Torrent File RSS\n\t\tRSS Feed for "frieren"\n\t\thttps://nyaa.si/\n\t\t\n\t\t\n\t\t\t[Erai-raws] Sousou no Frieren 2nd Season [1080p CR WEBRip HEVC AAC][MultiSub] (unofficial batch)\n\t\t\t\thttps://nyaa.si/download/2160092.torrent\n\t\t\t\thttps://nyaa.si/view/2160092\n\t\t\t\tSat, 12 Sep 2026 15:45:00 -0000\n\n\t\t\t\t33\n\t\t\t\t3\n\t\t\t\t833\n\t\t\t\tef3e7ad1b12bdd9fc341691d8866cd1fa8374a4b\n\t\t\t1_2\n\t\t\tAnime - English-translated\n\t\t\t6.6 GiB\n\t\t\t0\n\t\t\tNo\n\t\t\tNo\n\t\t\t#2160092 | [Erai-raws] Sousou no Frieren 2nd Season [1080p CR WEBRip HEVC AAC][MultiSub] (unofficial batch) | 6.6 GiB | Anime - English-translated | ef3e7ad1b12bdd9fc341691d8866cd1fa8374a4b]]>\n\t\t\n\n\t\t\tFrieren: Beyond Journey's End (Sousou no Frieren) - S01 (YT WEB-DL 720p VP9 Opus) (Muse/Telesuccess Filipino English dub)\n\t\t\t\thttps://nyaa.si/download/2154329.torrent\n\t\t\t\thttps://nyaa.si/view/2154329\n\t\t\t\tMon, 31 Aug 2026 12:32:25 -0000\n\n\t\t\t\t16\n\t\t\t\t0\n\t\t\t\t273\n\t\t\t\ta47b68971cdb82a4ef05ca239317cdef99bd2ca0\n\t\t\t1_2\n\t\t\tAnime - English-translated\n\t\t\t3.1 GiB\n\t\t\t0\n\t\t\tNo\n\t\t\tNo\n\t\t\t#2154329 | Frieren: Beyond Journey's End (Sousou no Frieren) - S01 (YT WEB-DL 720p VP9 Opus) (Muse/Telesuccess Filipino English dub) | 3.1 GiB | Anime - English-translated | a47b68971cdb82a4ef05ca239317cdef99bd2ca0]]>\n\t\t'; +export const MIKAN_RSS_SEARCH = + 'Mikan Project - 搜索结果:葬送的芙莉莲http://mikanani.me/RSS/Search?searchstr=%E8%91%AC%E9%80%81%E7%9A%84%E8%8A%99%E8%8E%89%E8%8E%B2Mikan Project - 搜索结果:葬送的芙莉莲[7³ACG] 葬送的芙莉莲/Sousou no Frieren S01 | 01-28+SPx11 [简繁字幕] BDrip 1080p x265 OPUS 2.0https://mikanani.me/Home/Episode/f8651444a2c0b3ff7e3da3cbd75da58ef5f09e19[7³ACG] 葬送的芙莉莲/Sousou no Frieren S01 | 01-28+SPx11 [简繁字幕] BDrip 1080p x265 OPUS 2.0[7³ACG] 葬送的芙莉莲/Sousou no Frieren S01 | 01-28+SPx11 [简繁字幕] BDrip 1080p x265 OPUS 2.0[48.5GB]https://mikanani.me/Home/Episode/f8651444a2c0b3ff7e3da3cbd75da58ef5f09e19520764784642026-08-28T05:32:00\n[Shiniori-Raws]葬送的芙莉莲 第二季/Sousou no Frieren S2 4k 2160p x265 10bithttps://mikanani.me/Home/Episode/3532d30205dbc53cdaa7d03285b5cf497258fa7d[Shiniori-Raws]葬送的芙莉莲 第二季/Sousou no Frieren S2 4k 2160p x265 10bit[Shiniori-Raws]葬送的芙莉莲 第二季/Sousou no Frieren S2 4k 2160p x265 10bit[21.1GB]https://mikanani.me/Home/Episode/3532d30205dbc53cdaa7d03285b5cf497258fa7d226559528962026-08-22T21:17:00'; +export const ANIBT_RSS_LATEST = + '\n\n \n AniBT Anime Releases\n https://anibt.net/magnets\n Latest anime releases\n \n 2\n \n [Nix-Raws] 北斗之拳 拳王军杂兵们的挽歌 / Hokuto no Ken Kenou-gun Zako-tachi no Banka S01E12 [CATCHPLAY WEB-DL 1080p AVC AAC][简繁内封]\n https://anibt.net/release/rel_TC7FrmcvDwIw\n rel_TC7FrmcvDwIw\n Fri, 02 Oct 2026 20:25:10 +0800\n anime\n rel_TC7FrmcvDwIw\n https://anibt.net/release/rel_TC7FrmcvDwIw\n https://anibt.net/api/torrent/rel_TC7FrmcvDwIw.torrent\n 637339\n 北斗神拳 拳王军杂兵们的挽歌 第二季\n Animatica "Elegy for the Henchmen: Fist of the North Star"\n [Nix-Raws] 北斗之拳 拳王军杂兵们的挽歌 / Hokuto no Ken Kenou-gun Zako-tachi no Banka S01E12 [CATCHPLAY WEB-DL 1080p AVC AAC][简繁内封]\n 12\n 12\n v1\n Nix-Raws\n nix-raws\n 1080p\n CHS\n INTERNAL\n MKV\n 121370659\n AVC\n WEB-DL\n AAC\n 北斗神拳 拳王军杂兵们的挽歌 第二季 / Animatica "Elegy for the Henchmen: Fist of the North Star" · 12 · v1

\n
\n本种子由自动化从AniBT 同步往其他站点\n
\n

🏷️ Nix-Raws · 📺 1080p · 🌐 CHS · 💬 INTERNAL · 📁 MKV · 💾 115.75 MB

\n

🔖 AVC, WEB-DL, AAC

\n
\n

⬇️ 下载种子

\n

🔗 查看详情

]]>
\n \n https://anibt.net/release/rel_TC7FrmcvDwIw\n 121370659\n 2026-10-02T20:25:10+08:00\n 5c0d61c12c05d21afe494bef153d9908c2aad4ae\n [Nix-Raws] 北斗之拳 拳王军杂兵们的挽歌 / Hokuto no Ken Kenou-gun Zako-tachi no Banka S01E12 [CATCHPLAY WEB-DL 1080p AVC AAC][简繁内封]\n \n \n
\n\n [Nix-Raws] 北斗之拳 拳王军杂兵们的挽歌 / Hokuto no Ken Kenou-gun Zako-tachi no Banka S01E11 [CATCHPLAY WEB-DL 1080p AVC AAC][简繁内封]\n https://anibt.net/release/rel_ECKRhYGx11Hm\n rel_ECKRhYGx11Hm\n Fri, 02 Oct 2026 20:25:06 +0800\n anime\n rel_ECKRhYGx11Hm\n https://anibt.net/release/rel_ECKRhYGx11Hm\n https://anibt.net/api/torrent/rel_ECKRhYGx11Hm.torrent\n 637339\n 北斗神拳 拳王军杂兵们的挽歌 第二季\n Animatica "Elegy for the Henchmen: Fist of the North Star"\n [Nix-Raws] 北斗之拳 拳王军杂兵们的挽歌 / Hokuto no Ken Kenou-gun Zako-tachi no Banka S01E11 [CATCHPLAY WEB-DL 1080p AVC AAC][简繁内封]\n 11\n 11\n v1\n Nix-Raws\n nix-raws\n 1080p\n CHS\n INTERNAL\n MKV\n 122143551\n AVC\n WEB-DL\n AAC\n 北斗神拳 拳王军杂兵们的挽歌 第二季 / Animatica "Elegy for the Henchmen: Fist of the North Star" · 11 · v1

\n
\n本种子由自动化从AniBT 同步往其他站点\n
\n

🏷️ Nix-Raws · 📺 1080p · 🌐 CHS · 💬 INTERNAL · 📁 MKV · 💾 116.49 MB

\n

🔖 AVC, WEB-DL, AAC

\n
\n

⬇️ 下载种子

\n

🔗 查看详情

]]>
\n \n https://anibt.net/release/rel_ECKRhYGx11Hm\n 122143551\n 2026-10-02T20:25:06+08:00\n 04fbc24597fbc3faa1e96fa371fe2c02c3963dba\n [Nix-Raws] 北斗之拳 拳王军杂兵们的挽歌 / Hokuto no Ken Kenou-gun Zako-tachi no Banka S01E11 [CATCHPLAY WEB-DL 1080p AVC AAC][简繁内封]\n \n \n
'; +export const ANIBT_TORZNAB_SEARCH = + '\n\n \n AniBT\n https://anibt.net/\n AniBT anime releases (Torznab)\n zh-cn\n \n \n [7³ACG] 葬送的芙莉莲/Sousou no Frieren S01 | 01-28+SPx11 [简繁字幕] BDrip 1080p x265 OPUS 2.0\n rel_U6lR5dHzDTZS\n https://anibt.net/api/torrent/rel_U6lR5dHzDTZS.torrent\n https://anibt.net/release/rel_U6lR5dHzDTZS\n Fri, 28 Aug 2026 23:15:47 +0800\n \n \n \n \n \n \n \n \n\n [7³ACG] 葬送的芙莉莲/Sousou no Frieren S01 | 01-28+SPx11 [简繁字幕] BDrip 1080p AV1 OPUS 2.0\n rel_YSOcchQzmzO5\n https://anibt.net/api/torrent/rel_YSOcchQzmzO5.torrent\n https://anibt.net/release/rel_YSOcchQzmzO5\n Sat, 15 Aug 2026 12:19:52 +0800\n \n \n \n \n \n \n \n '; +export const ANIMEGARDEN_RESOURCES = { + status: "OK", + resources: [ + { + id: 2456977, + provider: "dmhy", + providerId: "725809", + title: "[7³ACG] 葬送的芙莉莲/Sousou no Frieren S01 | 01-28+SPx11 [简繁字幕] BDrip 1080p x265 OPUS 2.0", + href: "https://share.dmhy.org/topics/view/725809_7_ACG_Sousou_no_Frieren_S01_01-28_SPx11_BDrip_1080p_x265_OPUS_2_0.html", + type: "合集", + magnet: "magnet:?xt=urn:btih:7BSRIRFCYCZ767R5UPF5OXNFR327BHQZ", + size: 52076478464, + createdAt: "2026-08-27T21:32:00.000Z", + fetchedAt: "2026-09-19T05:26:47.016Z", + publisher: { + id: 46, + name: "c8l", + avatar: "https://cdn.discordapp.com/attachments/926889384962318377/1027937252858400778/unknown.png", + }, + }, + { + id: 2455562, + provider: "dmhy", + providerId: "725446", + title: "[Shiniori-Raws]葬送的芙莉莲 第二季/Sousou no Frieren S2 4k 2160p x265 10bit", + href: "https://share.dmhy.org/topics/view/725446_Shiniori-Raws_Sousou_no_Frieren_S2_4k_2160p_x265_10bit.html", + type: "合集", + magnet: "magnet:?xt=urn:btih:GUZNGAQF3PCTZWVH2AZILNOPJFZFR6T5", + size: 22655951872, + createdAt: "2026-08-22T13:17:00.000Z", + fetchedAt: "2026-09-03T03:00:00.007Z", + publisher: { + id: 67, + name: "我是搬运工", + avatar: "https://mzh.moegirl.org.cn/%E7%94%B5%E6%AC%A1(%E7%94%B5%E9%94%AF%E4%BA%BA)", + }, + }, + ], + pagination: { + page: 1, + pageSize: 3, + complete: false, + }, + filter: { + search: [" 葬送 "], + }, +}; +export const BANGUMIMOE_LATEST = { + torrents: [ + { + _id: "66f1c2d3e4f5a6b7c8d9e0f1", + title: "【喵萌奶茶屋】★07月新番★[示例 / Sample][12][1080p][简日双语]", + size: "345.6MB", + publish_time: "2026-10-02T12:00:00.000Z", + magnet: "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", + infoHash: "0123456789abcdef0123456789abcdef01234567", + seeders: 12, + leechers: 3, + finished: 40, + }, + ], +}; diff --git a/packages/testkit/src/sites/index.ts b/packages/testkit/src/sites/index.ts index 367c747b..2672aebc 100644 --- a/packages/testkit/src/sites/index.ts +++ b/packages/testkit/src/sites/index.ts @@ -1,5 +1,6 @@ export * from "./fixtures/definitions/index.ts"; export * from "./fixtures/details/index.ts"; +export * from "./fixtures/public-bt.ts"; export * from "./fixtures/spiders/index.ts"; export * from "./fixtures/user-parsers/index.ts"; export {