feat(sites): 公开 BT 站详情(简介、文件清单、bgm 条目);修 AniBT 资源 ID 带 - 时判非法
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user