From c29dbb4376bd16ba413c634c776c805002e1bd48 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 00:43:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(sites):=20=E5=85=AC=E5=BC=80=20BT=20?= =?UTF-8?q?=E7=AB=99=E8=AF=A6=E6=83=85(=E7=AE=80=E4=BB=8B=E3=80=81?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=B8=85=E5=8D=95=E3=80=81bgm=20=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE);=E4=BF=AE=20AniBT=20=E8=B5=84=E6=BA=90=20ID=20?= =?UTF-8?q?=E5=B8=A6=20-=20=E6=97=B6=E5=88=A4=E9=9D=9E=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + .../sources-identity/service/detail-page.ts | 4 + .../sources-identity/id-escalation.test.ts | 7 + .../adapters/src/sites/definitions/public.ts | 4 +- .../adapters/src/sites/spider/detail-page.ts | 2 +- .../adapters/src/sites/spiders/adapter.ts | 6 +- .../src/sites/spiders/public-detail.ts | 69 +++++++ .../adapters/src/sites/spiders/public.test.ts | 62 ++++++ packages/adapters/src/sites/spiders/public.ts | 187 +++++++++++++++++- packages/adapters/src/sites/spiders/types.ts | 17 ++ packages/adapters/src/sites/types.ts | 2 + .../testkit/src/sites/fixtures/public-bt.ts | 107 ++++++++++ 12 files changed, 464 insertions(+), 9 deletions(-) create mode 100644 packages/adapters/src/sites/spiders/public-detail.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 66108bd3..394fcd02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## [Unreleased] +### 新增 +- 公开 BT 站支持详情:Nyaa(简介、文件清单、上传者)、蜜柑(简介去广告、番组页的 bgm 条目、文件清单从种子读)、AniBT(发布说明、关联番剧的 bgm 条目、文件清单从种子读)、AnimeGarden 与萌番组(接口里的简介与文件表)。简介里的 IMDb / 豆瓣 / TMDB / bgm 链接作为识别证据。 + +### 修复 +- AniBT 资源 ID 带 `-` 时被当成非法 ID,详情与下载都会失败。 + ## [0.3.20] - 2026-10-03 ### 调整 diff --git a/apps/server/src/modules/sources-identity/service/detail-page.ts b/apps/server/src/modules/sources-identity/service/detail-page.ts index 7ba5be70..fe82e069 100644 --- a/apps/server/src/modules/sources-identity/service/detail-page.ts +++ b/apps/server/src/modules/sources-identity/service/detail-page.ts @@ -24,6 +24,8 @@ export interface DetailIds { imdbId?: string; doubanId?: string; tmdb?: { type: "movie" | "tv"; id: string }; + /** 公开 BT 站简介 / 番组页里的 bgm.tv 条目 */ + bangumiId?: string; } export async function saveDetailPage( @@ -42,6 +44,7 @@ export async function saveDetailPage( ...(page.imdbId ? { imdbId: page.imdbId } : {}), ...(page.doubanId ? { doubanId: page.doubanId } : {}), ...(page.tmdb ? { tmdb: page.tmdb } : {}), + ...(page.bangumiId ? { bangumiId: page.bangumiId } : {}), }; await sql`update source_release set raw = raw || jsonb_build_object('_detail', ${JSON.stringify(ids)}::jsonb) where id = ${srId}`.execute(exec); @@ -152,6 +155,7 @@ export function siteExplicitIds( add("imdb", r._site?.imdbId); add("douban", r._detail?.doubanId); add("douban", r._site?.doubanId); + add("bangumi", r._detail?.bangumiId); add("bangumi", r._site?.bangumiId); if (r._detail?.tmdb) add("tmdb", r._detail.tmdb.id, r._detail.tmdb.type); return out; diff --git a/apps/server/test/sources-identity/id-escalation.test.ts b/apps/server/test/sources-identity/id-escalation.test.ts index 63ada1ec..544fa970 100644 --- a/apps/server/test/sources-identity/id-escalation.test.ts +++ b/apps/server/test/sources-identity/id-escalation.test.ts @@ -366,6 +366,13 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => { }); await resolveRelease(h.rt, { sourceReleaseId: r2.id, trigger: "manual" }); expect(metaRequests.length).toBe(n); + // 详情里的 bgm 条目(蜜柑番组页 / 简介链接)同样用上 + const rd = await release(pub.id, "rel_A9", "[Nix-Raws] Sousou no Frieren S2 - 07 [1080p]", { + _detail: { bangumiId: "515759" }, + }); + await h.db.updateTable("source_release").set({ detail_fetched_at: new Date() }).where("id", "=", rd.id).execute(); + const resd = await resolveRelease(h.rt, { sourceReleaseId: rd.id, trigger: "manual" }); + expect(await tmdbIdOf(resd.mediaEntityId!)).toBe("990031"); const r3 = await release(pub.id, "rel_A3", "[SubsPlease] Sousou no Frieren - 03 (1080p)"); const res3 = await resolveRelease(h.rt, { sourceReleaseId: r3.id, trigger: "manual" }); diff --git a/packages/adapters/src/sites/definitions/public.ts b/packages/adapters/src/sites/definitions/public.ts index 7ef09af0..74464fe6 100644 --- a/packages/adapters/src/sites/definitions/public.ts +++ b/packages/adapters/src/sites/definitions/public.ts @@ -1,10 +1,10 @@ /** * 公开 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 caps = { list: true, search: true, detail: true, download: true, rss: false }; const polite = { minIntervalMs: 2000, burst: 2 }; export const PUBLIC_SITE_DEFINITION_JSON: Record = { diff --git a/packages/adapters/src/sites/spider/detail-page.ts b/packages/adapters/src/sites/spider/detail-page.ts index 17aaf50d..c34f640c 100644 --- a/packages/adapters/src/sites/spider/detail-page.ts +++ b/packages/adapters/src/sites/spider/detail-page.ts @@ -51,7 +51,7 @@ const BR = "\u0000"; * 保留换行的文本:
与块级元素换行。源码换行不算换行(观众的简介是 `
\r\n`, * 两者都算会让每行之间多一空行);
 里的换行保留。
  */
-function blockText($: CheerioAPI, el: El): string {
+export function blockText($: CheerioAPI, el: El): string {
   const c = el.clone();
   c.find("script, style").remove();
   c.find("pre, textarea").each((_, n) => {
diff --git a/packages/adapters/src/sites/spiders/adapter.ts b/packages/adapters/src/sites/spiders/adapter.ts
index 797495e7..8bbb0d1d 100644
--- a/packages/adapters/src/sites/spiders/adapter.ts
+++ b/packages/adapters/src/sites/spiders/adapter.ts
@@ -22,6 +22,7 @@ import {
   type SitePage,
 } from "../types.ts";
 import { specializedTorrentToRelease } from "./map.ts";
+import { specializedDetail } from "./public-detail.ts";
 import type { SpecializedSiteDefinition, SpecializedSpider, SpiderContext } from "./types.ts";
 
 export function createSpecializedSiteAdapter(
@@ -103,8 +104,9 @@ export function createSpecializedSiteAdapter(
     search(query, filters = {}, opts = {}) {
       return doSearch(filters.page ?? 1, filters, query.trim() || null, opts.signal);
     },
-    async detail() {
-      throw new SiteError(def.id, "unsupported", `${def.name}:专用 API spider 不提供详情接口`);
+    async detail(siteReleaseId, opts = {}) {
+      if (!spider.detail) throw new SiteError(def.id, "unsupported", `${def.name}:专用 API spider 不提供详情接口`);
+      return specializedDetail(ctx(opts.signal), spider, def, siteReleaseId, enclosures.get(String(siteReleaseId)));
     },
     async downloadTarget(siteReleaseId, opts = {}): Promise {
       return spider.resolveDownload(ctx(opts.signal), siteReleaseId, enclosures.get(String(siteReleaseId)));
diff --git a/packages/adapters/src/sites/spiders/public-detail.ts b/packages/adapters/src/sites/spiders/public-detail.ts
new file mode 100644
index 00000000..1a844ed2
--- /dev/null
+++ b/packages/adapters/src/sites/spiders/public-detail.ts
@@ -0,0 +1,69 @@
+/**
+ * 公开 BT 站详情(本项目扩展,MP 无对应):spider.detail 给出简介 HTML / 文本与文件清单 → SiteReleaseDetail。
+ * 简介里的 IMDb / 豆瓣 / TMDB / bgm.tv 链接取作识别证据;页面没有文件清单的站(蜜柑、AniBT)下载种子读文件列表。
+ */
+import { load } from "cheerio";
+import { parseTorrent } from "../../qbittorrent/torrent.ts";
+import { emptyDetailPage, extractTmdb, MAX_FILES, MAX_SCREENSHOTS, screenshotUrl } from "../detail-text.ts";
+import { blockText, imdbFromText } from "../spider/detail-page.ts";
+import { SiteError, type SiteReleaseDetail } from "../types.ts";
+import { extractDoubanId } from "../util.ts";
+import { siteBase } from "./common.ts";
+import { specializedTorrentToRelease } from "./map.ts";
+import type { SpecializedSiteDefinition, SpecializedSpider, SpiderContext } from "./types.ts";
+
+const BGM = /(?:bgm\.tv|bangumi\.tv|chii\.in)\/subject\/(\d{1,8})\b/i;
+
+export async function specializedDetail(
+  ctx: SpiderContext,
+  spider: SpecializedSpider,
+  def: SpecializedSiteDefinition,
+  siteReleaseId: string,
+  enclosure: string | undefined,
+): Promise {
+  if (!spider.detail) throw new SiteError(def.id, "unsupported", `${def.name}:不提供详情`);
+  const d = await spider.detail(ctx, siteReleaseId, enclosure);
+  const r = specializedTorrentToRelease(
+    { site_release_id: siteReleaseId, ...d.torrent },
+    def,
+    ctx.http.now().toISOString(),
+  );
+  if (!r) throw new SiteError(def.id, "parse_error", `${def.name} 详情缺少标题`);
+  const page = emptyDetailPage();
+  const base = siteBase(def);
+  let text = d.text?.trim() || null;
+  if (d.html) {
+    const $ = load(d.html);
+    const imgs: string[] = [];
+    $("img").each((_, n) => {
+      const u = screenshotUrl($(n).attr("data-src") ?? $(n).attr("src"), base);
+      if (u && !imgs.includes(u)) imgs.push(u);
+    });
+    page.screenshots = imgs.slice(0, MAX_SCREENSHOTS);
+    page.probe.imageCount = imgs.length;
+    $("img, script, style").remove();
+    text = blockText($, $("body")) || text;
+  }
+  page.descriptionText = text ? text.slice(0, 20_000) : null;
+  page.probe.hasDescription = !!page.descriptionText;
+  const src = `${d.html ?? ""}\n${d.text ?? ""}`;
+  page.imdbId = imdbFromText(src);
+  page.doubanId = extractDoubanId(src) ?? null;
+  page.tmdb = extractTmdb(src);
+  page.bangumiId = BGM.exec(src)?.[1] ?? d.torrent.bangumi_id ?? null;
+  page.mediaInfo = d.mediaInfo ?? null;
+  page.uploader = d.uploader ?? null;
+  page.tags = d.tags ?? [];
+  let files = d.files ?? [];
+  if (!files.length && d.filesFromTorrent) {
+    try {
+      const t = await spider.resolveDownload(ctx, siteReleaseId, enclosure);
+      if (t.kind === "torrent")
+        files = parseTorrent(t.bytes).files.map((f) => ({ name: f.path, sizeBytes: f.length || null }));
+    } catch {
+      // 种子取不到只是没有文件清单,不影响详情
+    }
+  }
+  page.files = files.slice(0, MAX_FILES);
+  return { ...r, ...(page.descriptionText ? { descriptionText: page.descriptionText } : {}), page };
+}
diff --git a/packages/adapters/src/sites/spiders/public.test.ts b/packages/adapters/src/sites/spiders/public.test.ts
index 8b9db6cb..58a37927 100644
--- a/packages/adapters/src/sites/spiders/public.test.ts
+++ b/packages/adapters/src/sites/spiders/public.test.ts
@@ -1,11 +1,16 @@
 import {
+  ANIBT_RELEASE_PAGE,
   ANIBT_RSS_LATEST,
   ANIBT_TORZNAB_SEARCH,
+  ANIMEGARDEN_DETAIL,
   ANIMEGARDEN_RESOURCES,
+  BANGUMIMOE_FETCH,
   BANGUMIMOE_LATEST,
   createMockFetch,
+  MIKAN_EPISODE_PAGE,
   MIKAN_RSS_SEARCH,
   NYAA_RSS_SEARCH,
+  NYAA_VIEW_PAGE,
 } from "@umt/testkit";
 import { describe, expect, it } from "vitest";
 import { BUILTIN_SITE_DEFINITIONS } from "../definitions/index.ts";
@@ -101,4 +106,61 @@ describe("公开 BT 站(本项目扩展)", () => {
   it("五个内置公开站都是 public 定义", () => {
     for (const id of ["nyaa", "mikan", "anibt", "animegarden", "bangumimoe"]) expect(def(id).public).toBe(true);
   });
+
+  it("详情 Nyaa:Markdown 简介、文件清单(去掉大小括号)、上传者、info hash", async () => {
+    const m = createMockFetch({ "GET /view/2160092": { body: NYAA_VIEW_PAGE } });
+    const d = await adapterWith(def("nyaa"), nyaaSpider, m.fetch).detail("2160092");
+    expect(d.title).toContain("Sousou no Frieren 2nd Season");
+    expect(d.infoHash).toBe("ef3e7ad1b12bdd9fc341691d8866cd1fa8374a4b");
+    expect(d.page!.descriptionText).toBeNull();
+    expect(d.page!.uploader).toBe("VINT64");
+    expect(d.page!.files.length).toBeGreaterThan(5);
+    expect(d.page!.files[0]!.name).toMatch(/Sousou no Frieren 2nd Season - 01 .*\.mkv$/);
+    expect(d.page!.files[0]!.sizeBytes).toBe(Math.round(733.5 * 1024 ** 2));
+  });
+
+  it("详情 蜜柑:去掉广告位;番组页取 bgm 条目;文件清单从种子读", async () => {
+    const hash = "f8651444a2c0b3ff7e3da3cbd75da58ef5f09e19";
+    // 剧集页里的下载链接(种子从这里取)
+    const page = MIKAN_EPISODE_PAGE.replace(
+      "",
+      `下载种子`,
+    );
+    const m = createMockFetch({
+      [`GET /Home/Episode/${hash}`]: { body: page },
+      "GET /Home/Bangumi/3141": { body: 'Bangumi番组计划链接' },
+    });
+    const t = withTorrent(m.fetch, (u) => u.pathname.startsWith("/Download/"), "Frieren.S01E01.mkv");
+    const d = await adapterWith(def("mikan"), mikanSpider, t.fetch).detail(hash);
+    expect(d.title).toContain("葬送的芙莉莲");
+    expect(d.page!.descriptionText).toContain("Content Info");
+    expect(d.page!.descriptionText).not.toMatch(/tmall/);
+    expect(d.page!.bangumiId).toBe("400602");
+    expect(d.page!.uploader).toBe("7³ACG");
+    expect(d.uploadedAt).toBe("2026-08-28T15:15:00.000Z");
+    expect(d.page!.files.map((f) => f.name)).toEqual(["Frieren.S01E01.mkv"]);
+  });
+
+  it("详情 AniBT:发布说明、关联番剧的 bgm 条目", async () => {
+    const m = createMockFetch({ "GET /release/rel_U6lR5dHzDTZS": { body: ANIBT_RELEASE_PAGE } });
+    const d = await adapterWith(def("anibt"), anibtSpider, m.fetch).detail("rel_U6lR5dHzDTZS");
+    expect(d.title).toContain("葬送的芙莉莲");
+    expect(d.page!.descriptionText).toContain("Content Info");
+    expect(d.page!.descriptionText).not.toContain("查看完整图片");
+    expect(d.page!.bangumiId).toBe("400602");
+  });
+
+  it("详情 AnimeGarden / 萌番组:接口给简介 HTML 与文件表", async () => {
+    const g = createMockFetch({ "GET https://api.animes.garden/detail/dmhy/725809": { body: ANIMEGARDEN_DETAIL } });
+    const gd = await adapterWith(def("animegarden"), animeGardenSpider, g.fetch).detail("dmhy-725809");
+    expect(gd.page!.files).toHaveLength(3);
+    expect(gd.page!.files[0]!.sizeBytes).toBe(Math.round(13.9 * 1024 ** 2));
+    expect(gd.page!.descriptionText).toBeTruthy();
+    const id = BANGUMIMOE_FETCH._id;
+    const b = createMockFetch({ "POST /api/torrent/fetch": { body: BANGUMIMOE_FETCH } });
+    const bd = await adapterWith(def("bangumimoe"), bangumiMoeSpider, b.fetch).detail(id);
+    expect(JSON.parse(b.requests[0]!.body!)).toEqual({ _id: id });
+    expect(bd.page!.files[0]!.name).toMatch(/\.mkv$/);
+    expect(bd.page!.bangumiId).toBe("613438");
+  });
 });
diff --git a/packages/adapters/src/sites/spiders/public.ts b/packages/adapters/src/sites/spiders/public.ts
index 269e9644..18c4a17c 100644
--- a/packages/adapters/src/sites/spiders/public.ts
+++ b/packages/adapters/src/sites/spiders/public.ts
@@ -7,11 +7,18 @@
  * - 萌番组 bangumi.moe:JSON API;部分网络直连不通,可在站点上开「通过浏览器请求」
  * 都不需要登录;只读请求走站点限流。资源 ID 设计成「只凭 ID 就能取到种子」(下载时不依赖搜索缓存)。
  */
+import { load } from "cheerio";
 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";
+import type {
+  SpecializedDetail,
+  SpecializedSpider,
+  SpecializedTorrent,
+  SpiderContext,
+  SpiderSearchQuery,
+} from "./types.ts";
 
 const xml = new XMLParser({
   ignoreAttributes: false,
@@ -84,6 +91,7 @@ const NYAA_CATEGORY: Record = {
 
 export const nyaaSpider: SpecializedSpider = {
   parser: "Nyaa",
+  detail: nyaaDetail,
   credential: "cookie",
   pageSize: () => null,
   async search(ctx, q) {
@@ -131,6 +139,7 @@ export const NYAA_CATEGORY_NAMES = NYAA_CATEGORY;
 
 export const mikanSpider: SpecializedSpider = {
   parser: "Mikan",
+  detail: mikanDetail,
   credential: "cookie",
   pageSize: () => null,
   async search(ctx, q) {
@@ -179,6 +188,7 @@ export const mikanSpider: SpecializedSpider = {
 
 export const anibtSpider: SpecializedSpider = {
   parser: "AniBT",
+  detail: anibtDetail,
   credential: "cookie",
   pageSize: () => null,
   async search(ctx, q) {
@@ -190,7 +200,7 @@ export const anibtSpider: SpecializedSpider = {
       return items.flatMap((it): SpecializedTorrent[] => {
         const id = text(it.guid);
         const title = text(it.title);
-        if (!id || !/^o?rel_/.test(id) || !title) return [];
+        if (!id || !/^o?rel_[A-Za-z0-9_-]+$/.test(id) || !title) return [];
         const attrs = Object.fromEntries(
           ((it["torznab:attr"] as Node[] | undefined) ?? []).map((a) => [text(a["@_name"]), text(a["@_value"])]),
         );
@@ -215,7 +225,7 @@ export const anibtSpider: SpecializedSpider = {
     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 [];
+      if (!id || !/^o?rel_[A-Za-z0-9_-]+$/.test(id) || !title) return [];
       const tor = (it.torrent ?? {}) as Node;
       const labels = [
         text(it["anibt:groupName"]),
@@ -243,7 +253,7 @@ export const anibtSpider: SpecializedSpider = {
     });
   },
   async resolveDownload(ctx, id) {
-    const rel = /^o?rel_[A-Za-z0-9]+$/.exec(String(id).trim())?.[0];
+    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`);
   },
@@ -278,6 +288,7 @@ function gardenTorrent(ctx: SpiderContext, r: Node): SpecializedTorrent | null {
 
 export const animeGardenSpider: SpecializedSpider = {
   parser: "AnimeGarden",
+  detail: gardenDetail,
   credential: "cookie",
   pageSize: () => null,
   async search(ctx, q) {
@@ -331,6 +342,7 @@ function moeTorrent(ctx: SpiderContext, r: Node): SpecializedTorrent | null {
 
 export const bangumiMoeSpider: SpecializedSpider = {
   parser: "BangumiMoe",
+  detail: moeDetail,
   credential: "cookie",
   pageSize: () => null,
   async search(ctx, q) {
@@ -354,3 +366,170 @@ export const bangumiMoeSpider: SpecializedSpider = {
     return downloadTorrentFile(ctx, `${siteBase(ctx.def)}download/torrent/${oid}/${oid}.torrent`);
   },
 };
+
+// ── 详情(spider.detail,转成页面内容见 public-detail.ts) ──
+
+const clean = (s: string | undefined) => (s ?? "").replace(/\s+/g, " ").trim();
+const BGM_LINK = /bgm\.tv\/subject\/(\d{1,8})/;
+
+async function nyaaDetail(ctx: SpiderContext, id: string): Promise {
+  const n = /^\d+$/.exec(String(id).trim())?.[0];
+  if (!n) throw new SiteError(ctx.def.id, "not_found", "资源 ID 非法");
+  const base = siteBase(ctx.def);
+  const $ = load(await textRequest(ctx, `${base}view/${n}`, {}));
+  const title = clean($("h3.panel-title").first().text());
+  if (!title) throw new SiteError(ctx.def.id, "not_found", "资源不存在");
+  // 信息表:「Seeders:」标签格的下一格是值
+  const row = (label: string) =>
+    $(".panel-body .row > div")
+      .filter((_, e) => clean($(e).text()) === label)
+      .first()
+      .next();
+  const files = $(".torrent-file-list li")
+    .filter((_, e) => $(e).children("i.fa-file").length > 0)
+    .map((_, e) => {
+      const el = $(e).clone();
+      const size = clean(el.find(".file-size").text()).replace(/[()]/g, "");
+      el.find(".file-size").remove();
+      return { name: clean(el.text()), sizeBytes: size ? parseSizeMp(size) || null : null };
+    })
+    .get()
+    .filter((f) => f.name);
+  const ts = Number(row("Date:").attr("data-timestamp"));
+  const desc = $("#torrent-description").first().text().trim();
+  return {
+    torrent: {
+      title,
+      page_url: `${base}view/${n}`,
+      enclosure: `${base}download/${n}.torrent`,
+      size: parseSizeMp(clean(row("File size:").text())),
+      seeders: num(clean(row("Seeders:").text())) ?? 0,
+      peers: num(clean(row("Leechers:").text())) ?? 0,
+      grabs: num(clean(row("Completed:").text())) ?? 0,
+      ...(ts ? { pubdate_utc: new Date(ts * 1000).toISOString() } : {}),
+      infohash: clean($("kbd").first().text()) || undefined,
+      downloadvolumefactor: 0,
+      uploadvolumefactor: 1,
+      labels: [],
+    } as SpecializedTorrent,
+    // 简介是 Markdown 原文;「No description.」当没有
+    text: /^#*\s*No description\.?$/i.test(desc) ? null : desc,
+    files,
+    uploader: clean(row("Submitter:").text()) || null,
+  };
+}
+
+async function mikanDetail(ctx: SpiderContext, id: string): Promise {
+  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);
+  const $ = load(await textRequest(ctx, `${base}Home/Episode/${hash}`, {}));
+  const title = clean($("p.episode-title").first().text());
+  if (!title) throw new SiteError(ctx.def.id, "not_found", "资源不存在");
+  const info = (label: string) =>
+    clean(
+      $("p.bangumi-info")
+        .filter((_, e) => $(e).text().includes(label))
+        .first()
+        .text(),
+    ).replace(new RegExp(`^.*?${label}[::]\\s*`), "");
+  // 简介开头是站点广告位(带 ga 点击统计的天猫链接),去掉
+  const desc = $(".episode-desc").first().clone();
+  desc
+    .children("div")
+    .filter((_, e) => $(e).find('a[onclick*="clicked"]').length > 0)
+    .remove();
+  const date = /(\d{4})\/(\d{2})\/(\d{2}) (\d{2}:\d{2})/.exec(info("发布日期"));
+  // 番组页上有 bgm.tv 条目链接(识别用);取不到不影响详情
+  let bgm: string | undefined;
+  const bangumiPath = /\/Home\/Bangumi\/\d+/.exec($("p.bangumi-title a").first().attr("href") ?? "")?.[0];
+  if (bangumiPath) {
+    bgm = await textRequest(ctx, new URL(bangumiPath, base).href, {})
+      .then((h) => BGM_LINK.exec(h)?.[1])
+      .catch(() => undefined);
+  }
+  return {
+    torrent: {
+      title,
+      page_url: `${base}Home/Episode/${hash}`,
+      size: parseSizeMp(info("文件大小")),
+      ...(date ? { pubdate_utc: utcCst(`${date[1]}-${date[2]}-${date[3]}T${date[4]}:00`) } : {}),
+      infohash: hash,
+      downloadvolumefactor: 0,
+      uploadvolumefactor: 1,
+      labels: [],
+      ...(bgm ? { bangumi_id: bgm } : {}),
+    } as SpecializedTorrent,
+    html: desc.html(),
+    filesFromTorrent: true,
+    uploader: info("字幕组") || null,
+  };
+}
+
+async function anibtDetail(ctx: SpiderContext, id: string): Promise {
+  const rel = /^o?rel_[A-Za-z0-9_-]+$/.exec(String(id).trim())?.[0];
+  if (!rel) throw new SiteError(ctx.def.id, "not_found", "资源 ID 非法");
+  const base = siteBase(ctx.def);
+  const page = await textRequest(ctx, `${base}release/${rel}`, {});
+  const $ = load(page);
+  const title = clean($("h1").first().text());
+  if (!title) throw new SiteError(ctx.def.id, "not_found", "资源不存在");
+  // 「发布说明」一节;文件列表与 MediaInfo 由前端异步加载,文件从种子里读
+  const desc = $("section h2")
+    .filter((_, e) => clean($(e).text()) === "发布说明")
+    .first()
+    .closest("section")
+    .find(".prose")
+    .first()
+    .clone();
+  desc.find("[aria-hidden], svg").remove();
+  const bgm = BGM_LINK.exec(page)?.[1];
+  return {
+    torrent: {
+      title,
+      page_url: `${base}release/${rel}`,
+      enclosure: `${base}api/torrent/${rel}.torrent`,
+      downloadvolumefactor: 0,
+      uploadvolumefactor: 1,
+      labels: [],
+      ...(bgm ? { bangumi_id: bgm } : {}),
+    } as SpecializedTorrent,
+    html: desc.length ? desc.html() : null,
+    filesFromTorrent: true,
+  };
+}
+
+async function gardenDetail(ctx: SpiderContext, id: string): Promise {
+  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 r = isPlainObject(res) && isPlainObject(res.resource) ? gardenTorrent(ctx, res.resource) : null;
+  if (!r) throw new SiteError(ctx.def.id, "not_found", "资源不存在");
+  const d = isPlainObject(res) && isPlainObject(res.detail) ? res.detail : {};
+  const files = (Array.isArray(d.files) ? (d.files as Node[]) : []).flatMap((f) => {
+    const name = text(f.name);
+    const size = text(f.size);
+    return name ? [{ name, sizeBytes: size ? parseSizeMp(size) || null : null }] : [];
+  });
+  return {
+    torrent: r,
+    html: text(d.description) ?? null,
+    files,
+    ...(r.uploader ? { uploader: r.uploader } : {}),
+  };
+}
+
+async function moeDetail(ctx: SpiderContext, id: string): Promise {
+  const oid = /^[0-9a-f]{24}$/i.exec(String(id).trim())?.[0];
+  if (!oid) throw new SiteError(ctx.def.id, "not_found", "资源 ID 非法");
+  const res = await apiRequest(ctx, `${siteBase(ctx.def)}api/torrent/fetch`, { json: { _id: oid } });
+  const r = isPlainObject(res) ? moeTorrent(ctx, res) : null;
+  if (!r || !isPlainObject(res)) throw new SiteError(ctx.def.id, "not_found", "资源不存在");
+  const files = (Array.isArray(res.content) ? (res.content as unknown[]) : []).flatMap((row) => {
+    if (!Array.isArray(row)) return [];
+    const name = text(row[0]);
+    const size = text(row[1]);
+    return name ? [{ name, sizeBytes: size ? parseSizeMp(size) || null : null }] : [];
+  });
+  return { torrent: r, html: text(res.introduction) ?? null, files };
+}
diff --git a/packages/adapters/src/sites/spiders/types.ts b/packages/adapters/src/sites/spiders/types.ts
index 6f7f7cf9..3e5ae9e1 100644
--- a/packages/adapters/src/sites/spiders/types.ts
+++ b/packages/adapters/src/sites/spiders/types.ts
@@ -107,4 +107,21 @@ export interface SpecializedSpider {
    * `enclosure` 为最近一次搜索结果里的直链(仅内存,不落库);缺失时按站点规则构造。
    */
   resolveDownload(ctx: SpiderContext, siteReleaseId: string, enclosure?: string): Promise;
+  /** 详情(本项目扩展,公开 BT 站):简介 HTML / 文本、文件清单;没给文件清单且 filesFromTorrent 时从种子里读 */
+  detail?(ctx: SpiderContext, siteReleaseId: string, enclosure?: string): Promise;
+}
+
+export interface SpecializedDetail {
+  /** 详情上的资源字段(标题必填;其余缺的沿用列表快照) */
+  torrent: SpecializedTorrent;
+  /** 简介 HTML(取文本、图片、外部链接) */
+  html?: string | null;
+  /** 简介纯文本 / Markdown(没有 HTML 时用) */
+  text?: string | null;
+  files?: import("../types.ts").SiteDetailFile[];
+  /** 页面上没有文件清单:下载种子读文件列表 */
+  filesFromTorrent?: boolean;
+  mediaInfo?: string | null;
+  uploader?: string | null;
+  tags?: string[];
 }
diff --git a/packages/adapters/src/sites/types.ts b/packages/adapters/src/sites/types.ts
index a140106b..9cbe5fe7 100644
--- a/packages/adapters/src/sites/types.ts
+++ b/packages/adapters/src/sites/types.ts
@@ -80,6 +80,8 @@ export interface SiteDetailPage {
   imdbId: string | null;
   doubanId: string | null;
   tmdb: { type: "movie" | "tv"; id: string } | null;
+  /** 简介里的 bgm.tv 条目 ID(公开 BT 站的番剧资源常带) */
+  bangumiId?: string | null;
   files: SiteDetailFile[];
   tags: string[];
   uploader: string | null;
diff --git a/packages/testkit/src/sites/fixtures/public-bt.ts b/packages/testkit/src/sites/fixtures/public-bt.ts
index f992cac8..a3cad0cf 100644
--- a/packages/testkit/src/sites/fixtures/public-bt.ts
+++ b/packages/testkit/src/sites/fixtures/public-bt.ts
@@ -72,3 +72,110 @@ export const BANGUMIMOE_LATEST = {
     },
   ],
 };
+
+/**
+ * 公开 BT 站详情:2026-10-03 取自 Nyaa 详情页、蜜柑剧集页、AniBT 资源页、AnimeGarden 详情接口、萌番组 torrent/fetch(公开数据)。
+ * 页面截取用到的片段,AniBT 去掉了样式属性;接口截短了文件列表与简介。
+ */
+export const NYAA_VIEW_PAGE =
+  't
\n\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

\n\t
\n\t
\n\t\t
\n\t\t\t
Category:
\n\t\t\t
\n\t\t\t\tAnime - English-translated\n\t\t\t
\n\t\t\t
Date:
\n\t\t\t
2026-09-12 15:45 UTC
\n\t\t
\n\t\t
\n\t\t\t
Submitter:
\n\t\t\t
\nVINT64\t\t\t
\n\t\t\t
Seeders:
\n\t\t\t
32
\n\t\t
\n\t\t
\n\t\t\t
Information:
\n\t\t\t\n\t\t\t
Leechers:
\n\t\t\t
5
\n\t\t
\n\t\t
\n\t\t\t
File size:
\n\t\t\t
6.6 GiB
\n\t\t\t
Completed:
\n\t\t\t
837
\n\t\t
\n\t\t
\n\t\t\t
Info hash:
\n\t\t\t
ef3e7ad1b12bdd9fc341691d8866cd1fa8374a4b
\n\t\t
\n\t
\n\t\n
\n
\n\t
#### No description.
\n
\n
\n\t
\n\t\t

File list

\n\t
\n\t
\n\t\t
    \n\t\t\t
  • \n\t\t\t\t[Erai-raws] Sousou no Frieren 2nd Season [1080p CR WEBRip HEVC AAC][MultiSub]\n\t\t\t\t
      \n\t\t\t
    • [Erai-raws] Sousou no Frieren 2nd Season - 01 [1080p CR WEBRip HEVC AAC][MultiSub][DF313249].mkv (733.5 MiB)
    • \n\t\t\t
    • [Erai-raws] Sousou no Frieren 2nd Season - 02 [1080p CR WEBRip HEVC AAC][MultiSub][7DC8365B].mkv (649.7 MiB)
    • \n\t\t\t
    • [Erai-raws] Sousou no Frieren 2nd Season - 03 [1080p CR WEBRip HEVC AAC][MultiSub][4CBD8375].mkv (691.2 MiB)
    • \n\t\t\t
    • [Erai-raws] Sousou no Frieren 2nd Season - 04 [1080p CR WEBRip HEVC AAC][MultiSub][4AC464D8].mkv (784.1 MiB)
    • \n\t\t\t
    • [Erai-raws] Sousou no Frieren 2nd Season - 05 [1080p CR WEBRip HEVC AAC][MultiSub][D3077C54].mkv (741.0 MiB)
    • \n\t\t\t
    • [Erai-raws] Sousou no Frieren 2nd Season - 06 [1080p CR WEBRip HEVC AAC][MultiSub][6E78A397].mkv (554.1 MiB)
    • \n\t\t\t
    • [Erai-raws] Sousou no Frieren 2nd Season - 07 [1080p CR WEBRip HEVC AAC][MultiSub][A3F98EE4].mkv (585.1 MiB)
    • \n\t\t\t
    • [Erai-raws] Sousou no Frieren 2nd Season - 08 [1080p CR WEBRip HEVC AAC][MultiSub][0D217EED].mkv (660.5 MiB)
    • \n\t\t\t
    • [Erai-raws] Sousou no Frieren 2nd Season - 09 [1080p CR WEBRip HEVC AAC][MultiSub][D9A6C94A].mkv (726.2 MiB)
    • \n\t\t\t
    • [Erai-raws] Sousou no Frieren 2nd Season - 10 [1080p CR WEBRip HEVC AAC][MultiSub][8DF1FC34].mkv (661.9 MiB)
    • \n\t\t\t\t
    \n\t\t\t
  • \n\t\t
\n\t
'; +export const MIKAN_EPISODE_PAGE = + 't

葬送的芙莉莲

\n\n

\n 字幕组: 7³ACG\n

\n\n

发布日期:2026/08/28 23:15

\n\n

文件大小:48.5 GB

\n\n
\n
\n

[7³ACG] 葬送的芙莉莲/Sousou no Frieren S01 | 01-28+SPx11 [简繁字幕] BDrip 1080p x265 OPUS 2.0 [48.5 GB]

\n
\n
\n
\n
\n \n\n\n\n\n
\n\n

post via api service

Content Info

SUBS : Nest方案(Haruhana、Nekomoe-kissaten、KitaujiSub、Sakurato、Netflix、Cantonese)
SIZE : 48.451 GiB

Team Info

主發布站 : ktnbytes.com
RSS 頻道 : t.me/ktnbytes
TG 交流群 : t.me/+lOZ13tUZHo8zZWE1
\n
\n\n '; +export const ANIBT_RELEASE_PAGE = + 't

[7³ACG] 葬送的芙莉莲/Sousou no Frieren S01 | 01-28+SPx11 [简繁字幕] BDrip 1080p x265 OPUS 2.0

发布说明

\n

post via api service

\n

Content Info

\n

SUBS : Nest方案(Haruhana、Nekomoe-kissaten、KitaujiSub、Sakurato、Netflix、Cantonese)
SIZE : 48.451 GiB

\n

Team Info

\n

主發布站 : ktnbytes.com
RSS 頻道 : t.me/ktnbytes
TG 交流群 : t.me/+lOZ13tUZHo8zZWE1

\n
Bangumi'; +export const ANIMEGARDEN_DETAIL = { + status: "OK", + resource: { + 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", + tracker: + "&dn=&tr=http%3A%2F%2F104.143.10.186%3A8000%2Fannounce&tr=udp%3A%2F%2F104.143.10.186%3A8000%2Fannounce&tr=http%3A%2F%2Ftracker.openbittorrent.com%3A80%2Fannounce&tr=http%3A%2F%2Ftracker3.itzmx.com%3A6961%2Fannounce&tr=http%3A%2F%2Ftracker4.itzmx.com%3A2710%2Fannounce&tr=http%3A%2F%2Ftracker.publicbt.com%3A80%2Fannounce&tr=http%3A%2F%2Ftracker.prq.to%2Fannounce&tr=http%3A%2F%2Fopen.acgtracker.com%3A1096%2Fannounce&tr=https%3A%2F%2Ft-115.rhcloud.com%2Fonly_for_ylbud&tr=http%3A%2F%2Ftracker1.itzmx.com%3A8080%2Fannounce&tr=http%3A%2F%2Ftracker2.itzmx.com%3A6961%2Fannounce&tr=udp%3A%2F%2Ftracker1.itzmx.com%3A8080%2Fannounce&tr=udp%3A%2F%2Ftracker2.itzmx.com%3A6961%2Fannounce&tr=udp%3A%2F%2Ftracker3.itzmx.com%3A6961%2Fannounce&tr=udp%3A%2F%2Ftracker4.itzmx.com%3A2710%2Fannounce", + 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", + }, + metadata: {}, + }, + detail: { + id: 2456977, + description: + '簡介: 

\n

\n
\n

Uchi no Otouto-domo ga Sumimasen - EP 13

AniList | bangumi.tv | MAL | TMDB

\n
\n

Information:

\n
  • Overall Bit Rate: 5 152 kb/s
  • Subtitle: English, Latin American Spanish, European Spanish, Brazilian Portuguese, French, German, Arabic (Saudi Arabia), Italian, Russian
  • Duration: 00:23:50.350
  • CRC32: C17CCF49
  • MediaInfo
  • \n
\n
\n

In case of any issues with the file, please let us know via the contact details below.
Xunlei has been banned by default.
Seeding after downloading is appreciated.

\n
\n

Contact: