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:
1 parent
9cdf492291
commit
b8876542b8
16 files changed
+770
-18
No files matched your search
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
- 下载器可以单独设水位(硬阈值 / 恢复水位 / 清理目标)和保种天数,不填用全局;多个下载器共用一块缓存盘时取最严格的。公网 BT 专用的 qB 可设保种 0 天(站点 H&R 仍遵守)。
|
- 下载器可以单独设水位(硬阈值 / 恢复水位 / 清理目标)和保种天数,不填用全局;多个下载器共用一块缓存盘时取最严格的。公网 BT 专用的 qB 可设保种 0 天(站点 H&R 仍遵守)。
|
||||||
|
- 内置公开 BT 站:Nyaa、蜜柑计划、AniBT、AnimeGarden(聚合动漫花园 / 蜜柑 / ANi 等)、萌番组。添加站点 → 公开 BT 站里一键添加,不需要账号;支持资源搜索、定时抓最新资源、按资源 ID 取种子或磁力(蜜柑取剧集页的种子,AnimeGarden 只有磁力)。默认不进资源搜索的默认站点集合;萌番组部分网络直连不通,打开站点的「代理」即可。原来手填 RSS 的方式保留。
|
||||||
|
|
||||||
## [0.3.16] - 2026-10-02
|
## [0.3.16] - 2026-10-02
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ const T_SITES = ["sites"];
|
|||||||
const T_FEEDS = ["feed-sources"];
|
const T_FEEDS = ["feed-sources"];
|
||||||
const T_RELEASES = ["source-releases"];
|
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 {
|
export function adapterOf(def: SiteDefinition): SiteAdapter {
|
||||||
return def.umt.kind === "mteam" ? "mteam_api" : def.umt.kind === "api" ? "api" : "nexusphp";
|
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) => ({
|
items: listSiteDefinitions().map((d) => ({
|
||||||
key: d.id,
|
key: d.id,
|
||||||
name: d.umt.kind === "nexusphp" ? (d.umt.displayName ?? d.name) : d.umt.displayName,
|
name: d.umt.kind === "nexusphp" ? (d.umt.displayName ?? d.name) : d.umt.displayName,
|
||||||
kind: "pt" as const,
|
kind: definitionKind(d),
|
||||||
adapter: adapterOf(d),
|
adapter: adapterOf(d),
|
||||||
baseUrl: d.domain,
|
baseUrl: d.domain,
|
||||||
shippedEnabled: d.enabled !== false,
|
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.get, async ({ params }) => siteToApi(await getSite(params.id)));
|
||||||
route(app, D.sites.create, async ({ body, actor }) => {
|
route(app, D.sites.create, async ({ body, actor }) => {
|
||||||
const def = body.key ? listSiteDefinitions().find((d) => d.id === body.key) : undefined;
|
const def = body.key ? listSiteDefinitions().find((d) => d.id === body.key) : undefined;
|
||||||
// 类型缺省:注册表里有定义 = PT 站;否则按旧口径(generic_rss = 自定义 RSS 站点)
|
// 类型缺省:注册表里有定义 = 定义的类型(PT / 公开 BT);否则按旧口径(generic_rss = 自定义 RSS 站点)
|
||||||
const kind = body.kind ?? (def ? "pt" : "rss_feed");
|
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 (!SITE_KINDS[kind].implemented) throw new ApiException("bad_request", `还不支持添加${SITE_KINDS[kind].label}`);
|
||||||
if (kind === "pt") {
|
if (kind === "pt") {
|
||||||
if (!body.key) throw new ApiException("bad_request", "PT 站要从站点定义里选(缺 key)");
|
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") {
|
if (!def && body.adapter !== "generic_rss") {
|
||||||
throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 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") {
|
} else if (body.adapter !== "generic_rss") {
|
||||||
throw new ApiException("bad_request", `${SITE_KINDS[kind].label}只能以 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,
|
enabled: body.enabled ?? true,
|
||||||
rate_limit: jsonb(body.rateLimit ?? {}),
|
rate_limit: jsonb(body.rateLimit ?? {}),
|
||||||
config: jsonb(body.config ?? {}),
|
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,
|
snapshot_interval_sec: body.snapshotIntervalSec ?? null,
|
||||||
proxy: body.proxy ?? false,
|
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()
|
.returningAll()
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|||||||
@@ -119,9 +119,36 @@ describe.skipIf(!up)("站点类型:API、调度与 RSS", () => {
|
|||||||
expect(pubWrongAdapter.status).toBe(400);
|
expect(pubWrongAdapter.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("站点定义都是 PT 站;PT 站带登录与账号数据", async () => {
|
it("内置公开 BT 站:定义标为 public_bt;按定义添加用专用适配器,定时抓最新,不默认进搜索,不能登录", async () => {
|
||||||
const defs = await json(await h.req("/site-definitions"));
|
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(
|
const pt = await json(
|
||||||
await h.req("/sites", {
|
await h.req("/sites", {
|
||||||
body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: "https://audiences.invalid/" },
|
body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: "https://audiences.invalid/" },
|
||||||
|
|||||||
@@ -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);
|
!needle || d.name.toLowerCase().includes(needle) || d.key.includes(needle) || d.baseUrl.includes(needle);
|
||||||
return GROUP_ORDER.map((a) => ({
|
return GROUP_ORDER.map((a) => ({
|
||||||
adapter: 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);
|
})).filter((g) => g.items.length);
|
||||||
}, [items, q]);
|
}, [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 pick = (key: string) => {
|
||||||
const d = items.find((x) => x.key === key);
|
const d = items.find((x) => x.key === key);
|
||||||
setDefKey(key);
|
setDefKey(key);
|
||||||
@@ -197,6 +221,32 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
|
|||||||
}}
|
}}
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
>
|
>
|
||||||
|
{kind === "public_bt" && publicDefs.length ? (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="mb-2 text-caption font-medium text-foreground">内置站点</p>
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{publicDefs.map((d) => (
|
||||||
|
<li key={d.key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || !!d.siteId}
|
||||||
|
onClick={() => void addPublic(d)}
|
||||||
|
className="flex w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-border/70 px-3 py-2 text-left transition-colors hover:border-foreground/40 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-caption font-medium text-foreground">{d.name}</span>
|
||||||
|
<span className="block truncate text-meta text-muted-foreground">{hostOf(d.baseUrl)}</span>
|
||||||
|
</span>
|
||||||
|
{d.siteId ? <StatusBadge className="shrink-0">已添加</StatusBadge> : null}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-3 mb-2 text-meta text-muted-foreground">
|
||||||
|
内置站点支持搜索和定时抓取最新资源,不需要账号。其他公开站可以在下面手动填 RSS 地址。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<FormField label="名称" htmlFor="add-feed-name" required error={errors.name}>
|
<FormField label="名称" htmlFor="add-feed-name" required error={errors.name}>
|
||||||
<Input
|
<Input
|
||||||
id="add-feed-name"
|
id="add-feed-name"
|
||||||
|
|||||||
@@ -26,13 +26,15 @@ import { MOVIECLAW_DEFINITION_JSON } from "./manifest.generated.ts";
|
|||||||
|
|
||||||
const OBSERVED = "2026-09-27T00:00:00.000Z";
|
const OBSERVED = "2026-09-27T00:00:00.000Z";
|
||||||
const PRIMARY = ["audiences", "chdbits", "hhanclub", "mteam"];
|
const PRIMARY = ["audiences", "chdbits", "hhanclub", "mteam"];
|
||||||
|
/** 手写的内置公开 BT 站(public.ts),都默认启用 */
|
||||||
|
const PUBLIC = ["anibt", "animegarden", "bangumimoe", "mikan", "nyaa"];
|
||||||
|
|
||||||
describe("站点定义:加载时 zod 校验", () => {
|
describe("站点定义:加载时 zod 校验", () => {
|
||||||
const ids = Object.keys(MOVIECLAW_DEFINITION_JSON).sort();
|
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(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) => {
|
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]);
|
expect(getSiteDefinition(id)).toBe(BUILTIN_SITE_DEFINITIONS[id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enabledOnly 只列 4 个主力站", () => {
|
it("enabledOnly 只列 4 个主力 PT 站与公开 BT 站", () => {
|
||||||
expect(listSiteDefinitions({ enabledOnly: true }).map((d) => d.id)).toEqual(PRIMARY);
|
expect(listSiteDefinitions({ enabledOnly: true }).map((d) => d.id)).toEqual([...PRIMARY, ...PUBLIC].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("非法定义被拒绝并指出字段路径", () => {
|
it("非法定义被拒绝并指出字段路径", () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { MteamDefinition } from "../mteam/definition.ts";
|
|||||||
import type { IndexerDefinition } from "../spider/schema.ts";
|
import type { IndexerDefinition } from "../spider/schema.ts";
|
||||||
import type { SpecializedSiteDefinition } from "../spiders/types.ts";
|
import type { SpecializedSiteDefinition } from "../spiders/types.ts";
|
||||||
import { MOVIECLAW_DEFINITION_JSON } from "./manifest.generated.ts";
|
import { MOVIECLAW_DEFINITION_JSON } from "./manifest.generated.ts";
|
||||||
|
import { PUBLIC_SITE_DEFINITION_JSON } from "./public.ts";
|
||||||
import { parseIndexerDefinition, parseMteamDefinition, parseSpecializedSiteDefinition } from "./schema.ts";
|
import { parseIndexerDefinition, parseMteamDefinition, parseSpecializedSiteDefinition } from "./schema.ts";
|
||||||
|
|
||||||
export type SiteDefinition = IndexerDefinition | MteamDefinition | SpecializedSiteDefinition;
|
export type SiteDefinition = IndexerDefinition | MteamDefinition | SpecializedSiteDefinition;
|
||||||
@@ -26,9 +27,14 @@ export function loadIndexerDefinition(json: unknown): IndexerDefinition {
|
|||||||
return parseIndexerDefinition(json);
|
return parseIndexerDefinition(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 随产品发布的全部定义(已校验),键为站点 id */
|
/** 随产品发布的全部定义(已校验),键为站点 id:MovieClaw 转换的 PT 站 + 手写的公开 BT 站 */
|
||||||
export const BUILTIN_SITE_DEFINITIONS: Readonly<Record<string, SiteDefinition>> = Object.freeze(
|
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;
|
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 个)站点定义 */
|
/** MP 专用 spider(`SPIDER_PARSER_CLASSES` 中除 mTorrent 外的 7 个)站点定义 */
|
||||||
export const SpecializedSiteDefinitionSchema = z.strictObject({
|
export const SpecializedSiteDefinitionSchema = z.strictObject({
|
||||||
...BaseDefinitionShape,
|
...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(),
|
public: z.boolean().optional(),
|
||||||
category: CategoryConfigSchema.optional(),
|
category: CategoryConfigSchema.optional(),
|
||||||
api_url: z.string().url().optional(),
|
api_url: z.string().url().optional(),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createSpecializedSiteAdapter } from "./adapter.ts";
|
|||||||
import { haidanSpider } from "./haidan.ts";
|
import { haidanSpider } from "./haidan.ts";
|
||||||
import { hddolbySpider } from "./hddolby.ts";
|
import { hddolbySpider } from "./hddolby.ts";
|
||||||
import { mtorrentSpider } from "./mtorrent.ts";
|
import { mtorrentSpider } from "./mtorrent.ts";
|
||||||
|
import { anibtSpider, animeGardenSpider, bangumiMoeSpider, mikanSpider, nyaaSpider } from "./public.ts";
|
||||||
import { rousiSpider } from "./rousi.ts";
|
import { rousiSpider } from "./rousi.ts";
|
||||||
import { sunnyptSpider } from "./sunnypt.ts";
|
import { sunnyptSpider } from "./sunnypt.ts";
|
||||||
import { tnodeSpider } from "./tnode.ts";
|
import { tnodeSpider } from "./tnode.ts";
|
||||||
@@ -27,6 +28,11 @@ export const SPECIALIZED_SPIDERS: Record<SpecializedParser, SpecializedSpider> =
|
|||||||
HDDolby: hddolbySpider,
|
HDDolby: hddolbySpider,
|
||||||
RousiPro: rousiSpider,
|
RousiPro: rousiSpider,
|
||||||
SunnyPT: sunnyptSpider,
|
SunnyPT: sunnyptSpider,
|
||||||
|
Nyaa: nyaaSpider,
|
||||||
|
Mikan: mikanSpider,
|
||||||
|
AniBT: anibtSpider,
|
||||||
|
AnimeGarden: animeGardenSpider,
|
||||||
|
BangumiMoe: bangumiMoeSpider,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isSpecializedParser(v: unknown): v is SpecializedParser {
|
export function isSpecializedParser(v: unknown): v is SpecializedParser {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export function specializedTorrentToRelease(
|
|||||||
uploadedAt: t.pubdate_utc ?? parseSiteTime(t.pubdate, tz),
|
uploadedAt: t.pubdate_utc ?? parseSiteTime(t.pubdate, tz),
|
||||||
detailUrl: t.page_url ?? `${def.domain}`,
|
detailUrl: t.page_url ?? `${def.domain}`,
|
||||||
tags: t.labels ?? [],
|
tags: t.labels ?? [],
|
||||||
|
...(t.infohash && /^[0-9a-f]{40}$/i.test(t.infohash) ? { infoHash: t.infohash.toLowerCase() } : {}),
|
||||||
raw,
|
raw,
|
||||||
observedAt,
|
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`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -20,7 +20,13 @@ export type SpecializedParser =
|
|||||||
| "TNodeSpider"
|
| "TNodeSpider"
|
||||||
| "TorrentLeech"
|
| "TorrentLeech"
|
||||||
| "Yema"
|
| "Yema"
|
||||||
| "mTorrent";
|
| "mTorrent"
|
||||||
|
// 本项目扩展:公开 BT 站(public.ts),MP 无对应
|
||||||
|
| "Nyaa"
|
||||||
|
| "Mikan"
|
||||||
|
| "AniBT"
|
||||||
|
| "AnimeGarden"
|
||||||
|
| "BangumiMoe";
|
||||||
|
|
||||||
export interface SpecializedSiteDefinition {
|
export interface SpecializedSiteDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -61,6 +67,10 @@ export interface SpecializedSiteDefinition {
|
|||||||
*/
|
*/
|
||||||
export interface SpecializedTorrent extends SpiderTorrent {
|
export interface SpecializedTorrent extends SpiderTorrent {
|
||||||
site_release_id?: string;
|
site_release_id?: string;
|
||||||
|
/** 站点直接给出的 info hash(公开 BT 站) */
|
||||||
|
infohash?: string;
|
||||||
|
/** 站点直接给出的 Bangumi 条目 ID(AniBT) */
|
||||||
|
bangumi_id?: string;
|
||||||
pubdate_utc?: string;
|
pubdate_utc?: string;
|
||||||
freedate_utc?: string;
|
freedate_utc?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const SITE_KINDS: Record<SiteKind, SiteKindSpec> = {
|
|||||||
},
|
},
|
||||||
public_bt: {
|
public_bt: {
|
||||||
label: "公开 BT 站",
|
label: "公开 BT 站",
|
||||||
description: "不用登录的公开站。填网址和 RSS 地址,按 RSS 获取资源",
|
description: "不用登录的公开站。内置 Nyaa、蜜柑、AniBT、AnimeGarden、萌番组,其他站填网址和 RSS 地址",
|
||||||
capabilities: ["search", "list", "rss", "detail"],
|
capabilities: ["search", "list", "rss", "detail"],
|
||||||
implemented: true,
|
implemented: true,
|
||||||
fromDefinition: false,
|
fromDefinition: false,
|
||||||
@@ -710,7 +710,7 @@ export type SiteRequestStats = z.infer<typeof SiteRequestStats>;
|
|||||||
export const SiteDefinitionView = z.object({
|
export const SiteDefinitionView = z.object({
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
/** 定义对应的站点类型(现有定义都是 PT 站) */
|
/** 定义对应的站点类型(PT 站;Nyaa、蜜柑等内置公开 BT 站为 public_bt) */
|
||||||
kind: SiteKind,
|
kind: SiteKind,
|
||||||
adapter: SiteAdapter,
|
adapter: SiteAdapter,
|
||||||
baseUrl: z.string(),
|
baseUrl: z.string(),
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 公开 BT 站样例:2026-10-02 从 Nyaa / 蜜柑 / AniBT / AnimeGarden 公开接口各取一次,每份截前两条(公开数据,无凭据)。
|
||||||
|
* 萌番组当时网络不通,样例按其公开 API 字段手写。
|
||||||
|
*/
|
||||||
|
export const NYAA_RSS_SEARCH =
|
||||||
|
'<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:nyaa="https://nyaa.si/xmlns/nyaa" version="2.0">\n\t<channel>\n\t\t<title>Nyaa - "frieren" - Torrent File RSS</title>\n\t\t<description>RSS Feed for "frieren"</description>\n\t\t<link>https://nyaa.si/</link>\n\t\t<atom:link href="https://nyaa.si/?page=rss" rel="self" type="application/rss+xml" />\n\t\t<item>\n\t\t\t<title>[Erai-raws] Sousou no Frieren 2nd Season [1080p CR WEBRip HEVC AAC][MultiSub] (unofficial batch)</title>\n\t\t\t\t<link>https://nyaa.si/download/2160092.torrent</link>\n\t\t\t\t<guid isPermaLink="true">https://nyaa.si/view/2160092</guid>\n\t\t\t\t<pubDate>Sat, 12 Sep 2026 15:45:00 -0000</pubDate>\n\n\t\t\t\t<nyaa:seeders>33</nyaa:seeders>\n\t\t\t\t<nyaa:leechers>3</nyaa:leechers>\n\t\t\t\t<nyaa:downloads>833</nyaa:downloads>\n\t\t\t\t<nyaa:infoHash>ef3e7ad1b12bdd9fc341691d8866cd1fa8374a4b</nyaa:infoHash>\n\t\t\t<nyaa:categoryId>1_2</nyaa:categoryId>\n\t\t\t<nyaa:category>Anime - English-translated</nyaa:category>\n\t\t\t<nyaa:size>6.6 GiB</nyaa:size>\n\t\t\t<nyaa:comments>0</nyaa:comments>\n\t\t\t<nyaa:trusted>No</nyaa:trusted>\n\t\t\t<nyaa:remake>No</nyaa:remake>\n\t\t\t<description><![CDATA[<a href="https://nyaa.si/view/2160092">#2160092 | [Erai-raws] Sousou no Frieren 2nd Season [1080p CR WEBRip HEVC AAC][MultiSub] (unofficial batch)</a> | 6.6 GiB | Anime - English-translated | ef3e7ad1b12bdd9fc341691d8866cd1fa8374a4b]]></description>\n\t\t</item>\n<item>\n\t\t\t<title>Frieren: Beyond Journey's End (Sousou no Frieren) - S01 (YT WEB-DL 720p VP9 Opus) (Muse/Telesuccess Filipino English dub)</title>\n\t\t\t\t<link>https://nyaa.si/download/2154329.torrent</link>\n\t\t\t\t<guid isPermaLink="true">https://nyaa.si/view/2154329</guid>\n\t\t\t\t<pubDate>Mon, 31 Aug 2026 12:32:25 -0000</pubDate>\n\n\t\t\t\t<nyaa:seeders>16</nyaa:seeders>\n\t\t\t\t<nyaa:leechers>0</nyaa:leechers>\n\t\t\t\t<nyaa:downloads>273</nyaa:downloads>\n\t\t\t\t<nyaa:infoHash>a47b68971cdb82a4ef05ca239317cdef99bd2ca0</nyaa:infoHash>\n\t\t\t<nyaa:categoryId>1_2</nyaa:categoryId>\n\t\t\t<nyaa:category>Anime - English-translated</nyaa:category>\n\t\t\t<nyaa:size>3.1 GiB</nyaa:size>\n\t\t\t<nyaa:comments>0</nyaa:comments>\n\t\t\t<nyaa:trusted>No</nyaa:trusted>\n\t\t\t<nyaa:remake>No</nyaa:remake>\n\t\t\t<description><![CDATA[<a href="https://nyaa.si/view/2154329">#2154329 | Frieren: Beyond Journey's End (Sousou no Frieren) - S01 (YT WEB-DL 720p VP9 Opus) (Muse/Telesuccess Filipino English dub)</a> | 3.1 GiB | Anime - English-translated | a47b68971cdb82a4ef05ca239317cdef99bd2ca0]]></description>\n\t\t</item></channel></rss>';
|
||||||
|
export const MIKAN_RSS_SEARCH =
|
||||||
|
'<?xml version="1.0" encoding="utf-8"?><rss version="2.0"><channel><title>Mikan Project - 搜索结果:葬送的芙莉莲</title><link>http://mikanani.me/RSS/Search?searchstr=%E8%91%AC%E9%80%81%E7%9A%84%E8%8A%99%E8%8E%89%E8%8E%B2</link><description>Mikan Project - 搜索结果:葬送的芙莉莲</description><item><guid isPermaLink="false">[7³ACG] 葬送的芙莉莲/Sousou no Frieren S01 | 01-28+SPx11 [简繁字幕] BDrip 1080p x265 OPUS 2.0</guid><link>https://mikanani.me/Home/Episode/f8651444a2c0b3ff7e3da3cbd75da58ef5f09e19</link><title>[7³ACG] 葬送的芙莉莲/Sousou no Frieren S01 | 01-28+SPx11 [简繁字幕] BDrip 1080p x265 OPUS 2.0</title><description>[7³ACG] 葬送的芙莉莲/Sousou no Frieren S01 | 01-28+SPx11 [简繁字幕] BDrip 1080p x265 OPUS 2.0[48.5GB]</description><torrent xmlns="https://mikanani.me/0.1/"><link>https://mikanani.me/Home/Episode/f8651444a2c0b3ff7e3da3cbd75da58ef5f09e19</link><contentLength>52076478464</contentLength><pubDate>2026-08-28T05:32:00</pubDate></torrent><enclosure type="application/x-bittorrent" length="52076478464" url="https://mikanani.me/Download/20260828/f8651444a2c0b3ff7e3da3cbd75da58ef5f09e19.torrent" /></item>\n<item><guid isPermaLink="false">[Shiniori-Raws]葬送的芙莉莲 第二季/Sousou no Frieren S2 4k 2160p x265 10bit</guid><link>https://mikanani.me/Home/Episode/3532d30205dbc53cdaa7d03285b5cf497258fa7d</link><title>[Shiniori-Raws]葬送的芙莉莲 第二季/Sousou no Frieren S2 4k 2160p x265 10bit</title><description>[Shiniori-Raws]葬送的芙莉莲 第二季/Sousou no Frieren S2 4k 2160p x265 10bit[21.1GB]</description><torrent xmlns="https://mikanani.me/0.1/"><link>https://mikanani.me/Home/Episode/3532d30205dbc53cdaa7d03285b5cf497258fa7d</link><contentLength>22655952896</contentLength><pubDate>2026-08-22T21:17:00</pubDate></torrent><enclosure type="application/x-bittorrent" length="22655952896" url="https://mikanani.me/Download/20260822/3532d30205dbc53cdaa7d03285b5cf497258fa7d.torrent" /></item></channel></rss>';
|
||||||
|
export const ANIBT_RSS_LATEST =
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:anibt="https://anibt.net/xmlns/rss/1.0/">\n <channel>\n <title>AniBT Anime Releases</title>\n <link>https://anibt.net/magnets</link>\n <description>Latest anime releases</description>\n <atom:link href="https://anibt.net/rss/magnets.xml" rel="self" type="application/rss+xml" />\n <ttl>2</ttl>\n <item>\n <title>[Nix-Raws] 北斗之拳 拳王军杂兵们的挽歌 / Hokuto no Ken Kenou-gun Zako-tachi no Banka S01E12 [CATCHPLAY WEB-DL 1080p AVC AAC][简繁内封]</title>\n <link>https://anibt.net/release/rel_TC7FrmcvDwIw</link>\n <guid isPermaLink="false">rel_TC7FrmcvDwIw</guid>\n <pubDate>Fri, 02 Oct 2026 20:25:10 +0800</pubDate>\n <anibt:type>anime</anibt:type>\n <anibt:releaseId>rel_TC7FrmcvDwIw</anibt:releaseId>\n <anibt:releasePageUrl>https://anibt.net/release/rel_TC7FrmcvDwIw</anibt:releasePageUrl>\n <anibt:torrentUrl>https://anibt.net/api/torrent/rel_TC7FrmcvDwIw.torrent</anibt:torrentUrl>\n <anibt:bgmId>637339</anibt:bgmId>\n <anibt:animeTitle>北斗神拳 拳王军杂兵们的挽歌 第二季</anibt:animeTitle>\n <anibt:animeTitleEnglish>Animatica "Elegy for the Henchmen: Fist of the North Star"</anibt:animeTitleEnglish>\n <anibt:releaseTitle>[Nix-Raws] 北斗之拳 拳王军杂兵们的挽歌 / Hokuto no Ken Kenou-gun Zako-tachi no Banka S01E12 [CATCHPLAY WEB-DL 1080p AVC AAC][简繁内封]</anibt:releaseTitle>\n <anibt:episode>12</anibt:episode>\n <anibt:episodeKey>12</anibt:episodeKey>\n <anibt:version>v1</anibt:version>\n <anibt:groupName>Nix-Raws</anibt:groupName>\n <anibt:groupSlug>nix-raws</anibt:groupSlug>\n <anibt:resolution>1080p</anibt:resolution>\n <anibt:language>CHS</anibt:language>\n <anibt:subtitle>INTERNAL</anibt:subtitle>\n <anibt:format>MKV</anibt:format>\n <anibt:fileSize>121370659</anibt:fileSize>\n <anibt:customTag>AVC</anibt:customTag>\n <anibt:customTag>WEB-DL</anibt:customTag>\n <anibt:customTag>AAC</anibt:customTag>\n <description><![CDATA[<p><strong>北斗神拳 拳王军杂兵们的挽歌 第二季 / Animatica "Elegy for the Henchmen: Fist of the North Star"</strong> · 12 · v1</p>\n<hr/>\n本种子由自动化从AniBT 同步往其他站点\n<hr/>\n<p>🏷️ Nix-Raws · 📺 1080p · 🌐 CHS · 💬 INTERNAL · 📁 MKV · 💾 115.75 MB</p>\n<p>🔖 AVC, WEB-DL, AAC</p>\n<hr/>\n<p>⬇️ <a href="https://anibt.net/api/torrent/rel_TC7FrmcvDwIw.torrent">下载种子</a></p>\n<p>🔗 <a href="https://anibt.net/release/rel_TC7FrmcvDwIw">查看详情</a></p>]]></description>\n <torrent xmlns="https://anibt.moe/xmlns/0.1/">\n <link>https://anibt.net/release/rel_TC7FrmcvDwIw</link>\n <contentLength>121370659</contentLength>\n <pubDate>2026-10-02T20:25:10+08:00</pubDate>\n <infohash>5c0d61c12c05d21afe494bef153d9908c2aad4ae</infohash>\n <filename>[Nix-Raws] 北斗之拳 拳王军杂兵们的挽歌 / Hokuto no Ken Kenou-gun Zako-tachi no Banka S01E12 [CATCHPLAY WEB-DL 1080p AVC AAC][简繁内封]</filename>\n </torrent>\n <enclosure url="https://anibt.net/api/torrent/rel_TC7FrmcvDwIw.torrent" length="121370659" type="application/x-bittorrent" />\n </item>\n<item>\n <title>[Nix-Raws] 北斗之拳 拳王军杂兵们的挽歌 / Hokuto no Ken Kenou-gun Zako-tachi no Banka S01E11 [CATCHPLAY WEB-DL 1080p AVC AAC][简繁内封]</title>\n <link>https://anibt.net/release/rel_ECKRhYGx11Hm</link>\n <guid isPermaLink="false">rel_ECKRhYGx11Hm</guid>\n <pubDate>Fri, 02 Oct 2026 20:25:06 +0800</pubDate>\n <anibt:type>anime</anibt:type>\n <anibt:releaseId>rel_ECKRhYGx11Hm</anibt:releaseId>\n <anibt:releasePageUrl>https://anibt.net/release/rel_ECKRhYGx11Hm</anibt:releasePageUrl>\n <anibt:torrentUrl>https://anibt.net/api/torrent/rel_ECKRhYGx11Hm.torrent</anibt:torrentUrl>\n <anibt:bgmId>637339</anibt:bgmId>\n <anibt:animeTitle>北斗神拳 拳王军杂兵们的挽歌 第二季</anibt:animeTitle>\n <anibt:animeTitleEnglish>Animatica "Elegy for the Henchmen: Fist of the North Star"</anibt:animeTitleEnglish>\n <anibt:releaseTitle>[Nix-Raws] 北斗之拳 拳王军杂兵们的挽歌 / Hokuto no Ken Kenou-gun Zako-tachi no Banka S01E11 [CATCHPLAY WEB-DL 1080p AVC AAC][简繁内封]</anibt:releaseTitle>\n <anibt:episode>11</anibt:episode>\n <anibt:episodeKey>11</anibt:episodeKey>\n <anibt:version>v1</anibt:version>\n <anibt:groupName>Nix-Raws</anibt:groupName>\n <anibt:groupSlug>nix-raws</anibt:groupSlug>\n <anibt:resolution>1080p</anibt:resolution>\n <anibt:language>CHS</anibt:language>\n <anibt:subtitle>INTERNAL</anibt:subtitle>\n <anibt:format>MKV</anibt:format>\n <anibt:fileSize>122143551</anibt:fileSize>\n <anibt:customTag>AVC</anibt:customTag>\n
Line truncated
|
||||||
|
export const ANIBT_TORZNAB_SEARCH =
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:torznab="http://torznab.com/schemas/2015/feed" xmlns:newznab="http://www.newznab.com/DTD/2010/feeds/attributes/">\n <channel>\n <title>AniBT</title>\n <link>https://anibt.net/</link>\n <description>AniBT anime releases (Torznab)</description>\n <language>zh-cn</language>\n <newznab:response offset="0" total="9" />\n <item>\n <title>[7³ACG] 葬送的芙莉莲/Sousou no Frieren S01 | 01-28+SPx11 [简繁字幕] BDrip 1080p x265 OPUS 2.0</title>\n <guid isPermaLink="false">rel_U6lR5dHzDTZS</guid>\n <link>https://anibt.net/api/torrent/rel_U6lR5dHzDTZS.torrent</link>\n <comments>https://anibt.net/release/rel_U6lR5dHzDTZS</comments>\n <pubDate>Fri, 28 Aug 2026 23:15:47 +0800</pubDate>\n <enclosure url="https://anibt.net/api/torrent/rel_U6lR5dHzDTZS.torrent" length="52023960599" type="application/x-bittorrent" />\n <torznab:attr name="size" value="52023960599" />\n <torznab:attr name="category" value="5000" />\n <torznab:attr name="category" value="5070" />\n <torznab:attr name="infohash" value="f8651444a2c0b3ff7e3da3cbd75da58ef5f09e19" />\n <torznab:attr name="downloadvolumefactor" value="0" />\n <torznab:attr name="uploadvolumefactor" value="1" />\n </item>\n<item>\n <title>[7³ACG] 葬送的芙莉莲/Sousou no Frieren S01 | 01-28+SPx11 [简繁字幕] BDrip 1080p AV1 OPUS 2.0</title>\n <guid isPermaLink="false">rel_YSOcchQzmzO5</guid>\n <link>https://anibt.net/api/torrent/rel_YSOcchQzmzO5.torrent</link>\n <comments>https://anibt.net/release/rel_YSOcchQzmzO5</comments>\n <pubDate>Sat, 15 Aug 2026 12:19:52 +0800</pubDate>\n <enclosure url="https://anibt.net/api/torrent/rel_YSOcchQzmzO5.torrent" length="27714372145" type="application/x-bittorrent" />\n <torznab:attr name="size" value="27714372145" />\n <torznab:attr name="category" value="5000" />\n <torznab:attr name="category" value="5070" />\n <torznab:attr name="infohash" value="9a7b7d3a08d4889b44f0ed6d701468802b766f28" />\n <torznab:attr name="downloadvolumefactor" value="0" />\n <torznab:attr name="uploadvolumefactor" value="1" />\n </item></channel></rss>';
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./fixtures/definitions/index.ts";
|
export * from "./fixtures/definitions/index.ts";
|
||||||
export * from "./fixtures/details/index.ts";
|
export * from "./fixtures/details/index.ts";
|
||||||
|
export * from "./fixtures/public-bt.ts";
|
||||||
export * from "./fixtures/spiders/index.ts";
|
export * from "./fixtures/spiders/index.ts";
|
||||||
export * from "./fixtures/user-parsers/index.ts";
|
export * from "./fixtures/user-parsers/index.ts";
|
||||||
export {
|
export {
|
||||||
|
|||||||
Reference in new issue
Block a user