feat(sites): 公开 BT 站详情(简介、文件清单、bgm 条目);修 AniBT 资源 ID 带 - 时判非法

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 00:43:25 +08:00
1 parent 06ba0711e2
commit c29dbb4376
12 files changed
+464 -9

No files matched your search

@@ -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<string, unknown> = {
@@ -51,7 +51,7 @@ const BR = "\u0000";
* 保留换行的文本:<br> 与块级元素换行。源码换行不算换行(观众的简介是 `<br />\r\n`,
* 两者都算会让每行之间多一空行);<pre> 里的换行保留。
*/
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) => {
@@ -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<DownloadTarget> {
return spider.resolveDownload(ctx(opts.signal), siteReleaseId, enclosures.get(String(siteReleaseId)));
@@ -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<SiteReleaseDetail> {
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 };
}
@@ -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(
"</body>",
`<a href="/Download/20260828/${hash}.torrent">下载种子</a></body>`,
);
const m = createMockFetch({
[`GET /Home/Episode/${hash}`]: { body: page },
"GET /Home/Bangumi/3141": { body: '<a href="https://bgm.tv/subject/400602">Bangumi番组计划链接</a>' },
});
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");
});
});
+183 -4
View File
@@ -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<string, string> = {
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<SpecializedDetail> {
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<SpecializedDetail> {
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<SpecializedDetail> {
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<SpecializedDetail> {
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<SpecializedDetail> {
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 };
}
@@ -107,4 +107,21 @@ export interface SpecializedSpider {
* `enclosure` 为最近一次搜索结果里的直链(仅内存,不落库);缺失时按站点规则构造。
*/
resolveDownload(ctx: SpiderContext, siteReleaseId: string, enclosure?: string): Promise<DownloadTarget>;
/** 详情(本项目扩展,公开 BT 站):简介 HTML / 文本、文件清单;没给文件清单且 filesFromTorrent 时从种子里读 */
detail?(ctx: SpiderContext, siteReleaseId: string, enclosure?: string): Promise<SpecializedDetail>;
}
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[];
}
+2
View File
@@ -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;