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

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

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

No files matched your search

@@ -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();
@@ -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/" },
@@ -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 ? (
<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}>
<Input
id="add-feed-name"