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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user