Adapters (pure functions + tests): - detail-description: strip HTML/BBCode residue, drop site template blocks and ad/signature lines (configurable rules), parse PT info blocks (◎ / 【】 / 『』 / 「键:」) into infoBlock and body paragraphs - mediainfo: MediaInfo and BDInfo (quick summary + full report) into a summary of container/duration/bitrate/size, video (HDR/DV), audio and subtitle tracks - file-tree: file list into a flattened directory tree with sizes and main/sample/subtitle/extra roles - formatDetailPage combines them; the server computes it when reading the cached detail page, so existing caches benefit without a refetch Contracts: SiteReleasePage gains infoBlock, paragraphs, media, fileTree. Web: drawer now reads top-down: meta row (promo/H&R, size, peers, upload time, IMDb/TMDB/豆瓣 tags) → 资料 → 简介 (collapsible) → 技术规格 (raw folded) → 截图 grid with keyboard lightbox → 文件 tree; inventory overlay moved into the fixed footer next to 下载. Fixtures: 6 desensitized samples from the local cache (site.detail and RSS descriptions) plus synthetic multi-track MediaInfo, BDInfo, bracket blocks and directory file lists. Co-Authored-By: Claude Opus 5.5 <[email protected]>
288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
import {
|
||
REAL_DETAIL_HH_DV_ONLY,
|
||
REAL_DETAIL_HH_INFO_CARD,
|
||
REAL_RSS_DESCR_AWARDS,
|
||
REAL_RSS_DESCR_CAST,
|
||
REAL_RSS_DESCR_INFO_BLOCK,
|
||
REAL_RSS_DESCR_PLAIN,
|
||
SYNTH_BDINFO_FULL,
|
||
SYNTH_BDINFO_QUICK,
|
||
SYNTH_DESCR_BRACKETS,
|
||
SYNTH_FILES_BDMV,
|
||
SYNTH_FILES_DIRECTORY,
|
||
SYNTH_MEDIAINFO_MULTI_TRACK,
|
||
} from "@umt/testkit";
|
||
import { describe, expect, it } from "vitest";
|
||
import {
|
||
cleanDescription,
|
||
DEFAULT_DESCRIPTION_RULES,
|
||
stripMarkup,
|
||
structureDescription,
|
||
} from "./detail-description.ts";
|
||
import { formatDetailPage } from "./detail-format.ts";
|
||
import { buildFileTree } from "./file-tree.ts";
|
||
import { parseMediaSummary } from "./mediainfo.ts";
|
||
|
||
const info = (text: string | null) =>
|
||
Object.fromEntries(structureDescription(text).infoBlock.map((x) => [x.key, x.value]));
|
||
|
||
describe("简介结构化:真实缓存样本", () => {
|
||
it("憨憨豆瓣信息块:「片名:」换行取值,卡片摘要与更新提示去掉,主演拆开", () => {
|
||
const d = structureDescription(REAL_DETAIL_HH_INFO_CARD.descriptionText);
|
||
expect(Object.fromEntries(d.infoBlock.map((x) => [x.key, x.value]))).toMatchObject({
|
||
片名: "一瓯春",
|
||
年代: "2026",
|
||
片长: "45分钟",
|
||
类别: "爱情,古装",
|
||
导演: "潘安子 Anzi Pan",
|
||
编剧: "尤四姐",
|
||
主演: "许凯 Kai Xu / 周也 Ye Zhou / 白澍 Shu Bai",
|
||
});
|
||
// 空值键(别名 / 标语)不出现
|
||
expect(d.infoBlock.map((x) => x.key)).not.toContain("别名");
|
||
expect(d.infoBlock.map((x) => x.key)).not.toContain("标语");
|
||
expect(d.paragraphs).toHaveLength(2);
|
||
expect(d.paragraphs[0]).toMatch(/^“杀伐千面腹黑男”/);
|
||
expect(d.paragraphs.join("\n")).not.toMatch(/该信息最后更新于|更新信息|^-{5,}|\(11053\)/m);
|
||
});
|
||
|
||
it("◎ 信息块 + ◎简介:链接键去掉,简介进段落", () => {
|
||
const d = structureDescription(REAL_RSS_DESCR_INFO_BLOCK);
|
||
expect(d.infoBlock.map((x) => x.key)).toEqual([
|
||
"译名",
|
||
"片名",
|
||
"年代",
|
||
"产地",
|
||
"类别",
|
||
"语言",
|
||
"上映日期",
|
||
"集数",
|
||
"片长",
|
||
"主演",
|
||
]);
|
||
expect(info(REAL_RSS_DESCR_INFO_BLOCK).译名).toBe("Stay Low Profile, Sect Chief / 掌门低调点 3D动画版");
|
||
expect(d.paragraphs).toHaveLength(1);
|
||
expect(d.paragraphs[0]).toMatch(/^大雪覆门/);
|
||
expect(JSON.stringify(d)).not.toMatch(/<| |douban\.com/);
|
||
});
|
||
|
||
it("主演多行续行合并;◎获奖情况进信息块;站点截断的半个标签去掉", () => {
|
||
const i = info(REAL_RSS_DESCR_AWARDS);
|
||
expect(i.主演).toBe(
|
||
"张维为 Weiwei Zhang / 亚历山大·杜金 Alexander Dugin / 李世默 Eric Li / 范勇鹏 Yongpeng Fan / 余亮 Liang Yu",
|
||
);
|
||
expect(i.获奖情况).toBe("第28届上海电视节白玉兰奖 (2023) / 综艺节目 最佳电视综艺节目(提名)…");
|
||
const d = structureDescription(REAL_RSS_DESCR_AWARDS);
|
||
expect(d.paragraphs).toHaveLength(2);
|
||
expect(d.paragraphs[1]).toMatch(/^《这就是中国》/);
|
||
});
|
||
|
||
it("豆瓣评分 / 导演 / 编剧,长主演列表", () => {
|
||
const i = info(REAL_RSS_DESCR_CAST);
|
||
expect(i).toMatchObject({ 豆瓣评分: "5.4/10 from 11075 users", 导演: "潘安子 Anzi Pan", 编剧: "尤四姐" });
|
||
expect(i.主演!.split(" / ")).toHaveLength(16);
|
||
expect(structureDescription(REAL_RSS_DESCR_CAST).paragraphs).toEqual([]);
|
||
});
|
||
|
||
it("没有信息块:整段是正文,海报图去掉", () => {
|
||
const d = structureDescription(REAL_RSS_DESCR_PLAIN);
|
||
expect(d.infoBlock).toEqual([]);
|
||
expect(d.paragraphs).toHaveLength(1);
|
||
expect(d.paragraphs[0]).toMatch(/^21岁的玉雕师方雾/);
|
||
});
|
||
|
||
it("只有 MediaInfo 的详情页:信息块和段落都为空", () => {
|
||
expect(structureDescription(REAL_DETAIL_HH_DV_ONLY.descriptionText)).toEqual({ infoBlock: [], paragraphs: [] });
|
||
});
|
||
});
|
||
|
||
describe("简介清洗:【】『』、BBCode / HTML 残留、广告签名", () => {
|
||
const d = structureDescription(SYNTH_DESCR_BRACKETS);
|
||
|
||
it("【】『』键认得,正文里的「【第一版】」不当键", () => {
|
||
expect(Object.fromEntries(d.infoBlock.map((x) => [x.key, x.value]))).toMatchObject({
|
||
译名: "示例电影 / Example Movie",
|
||
字幕: "中英双语",
|
||
IMDb评分: "8.1/10 from 12,345 users",
|
||
导演: "某导演 Some Director",
|
||
主演: "演员甲 Actor A / 演员乙 Actor B",
|
||
});
|
||
expect(d.infoBlock.map((x) => x.key)).not.toContain("IMDb链接");
|
||
expect(structureDescription("◎简 介\n\n 【第一版】仙界至暗,众生皆材。").paragraphs).toEqual([
|
||
"【第一版】仙界至暗,众生皆材。",
|
||
]);
|
||
});
|
||
|
||
it("段落:标记、实体、多余空行、转载声明 / 群号 / 感谢签名 / 分隔线都去掉", () => {
|
||
expect(d.paragraphs).toEqual([
|
||
"一位科学家在沙漠深处发现了改变世界的秘密。<他必须>在时间耗尽前做出选择。",
|
||
"影片获得多项提名,查看获奖名单。",
|
||
]);
|
||
const cleaned = cleanDescription(SYNTH_DESCR_BRACKETS);
|
||
expect(cleaned).not.toMatch(/\[\/?\w+|<\/?\w+| |QQ群|转载|感谢|\n{3,}|-{5,}/);
|
||
});
|
||
|
||
it("规则可配置:追加整行规则", () => {
|
||
const rules = { ...DEFAULT_DESCRIPTION_RULES, dropLines: [...DEFAULT_DESCRIPTION_RULES.dropLines, /^影片获得/] };
|
||
expect(structureDescription(SYNTH_DESCR_BRACKETS, rules).paragraphs).toHaveLength(1);
|
||
});
|
||
|
||
it("英文硬换行接回一段", () => {
|
||
expect(
|
||
structureDescription("A scientist discovers a secret\nthat changes the world.\n\nSecond one.").paragraphs,
|
||
).toEqual(["A scientist discovers a secret that changes the world.", "Second one."]);
|
||
});
|
||
|
||
it("stripMarkup:实体与数字引用", () => {
|
||
expect(stripMarkup("a&bAB <b>c</b>[b]d[/b]")).toBe("a&bAB cd");
|
||
});
|
||
});
|
||
|
||
describe("MediaInfo / BDInfo 概要", () => {
|
||
it("真实样本:中文摘要头 + General / Video / Audio", () => {
|
||
expect(parseMediaSummary(REAL_DETAIL_HH_INFO_CARD.mediaInfo)).toEqual({
|
||
kind: "mediainfo",
|
||
general: {
|
||
fileName: "Spring.of.the.Blade.S01E18.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv",
|
||
container: "Matroska",
|
||
duration: "43 min 55 s",
|
||
overallBitrate: "15.6 Mb/s",
|
||
size: "4.79 GiB",
|
||
},
|
||
video: [
|
||
{
|
||
codec: "HEVC",
|
||
resolution: "3840×1608",
|
||
frameRate: "60 fps",
|
||
bitrate: "15.3 Mb/s",
|
||
bitDepth: "10 bit",
|
||
hdr: [],
|
||
},
|
||
],
|
||
audio: [
|
||
{
|
||
language: "中文",
|
||
codec: "AAC",
|
||
channels: "2.0",
|
||
bitrate: "256 kb/s",
|
||
title: "中文 [AAC 2.0]",
|
||
isDefault: true,
|
||
},
|
||
],
|
||
subtitles: [],
|
||
});
|
||
});
|
||
|
||
it("真实样本:杜比视界 Profile 5", () => {
|
||
const m = parseMediaSummary(REAL_DETAIL_HH_DV_ONLY.mediaInfo)!;
|
||
expect(m.video[0]).toMatchObject({ hdr: ["Dolby Vision P5"], frameRate: "25 fps" });
|
||
});
|
||
|
||
it("多音轨多字幕:编码短名、声道、默认 / 强制、语言译名", () => {
|
||
const m = parseMediaSummary(SYNTH_MEDIAINFO_MULTI_TRACK)!;
|
||
expect(m.video[0]).toMatchObject({
|
||
resolution: "3840×2160",
|
||
frameRate: "23.976 fps",
|
||
hdr: ["Dolby Vision P7.6", "HDR10"],
|
||
});
|
||
expect(m.audio.map((a) => [a.language, a.codec, a.channels, a.bitrate, a.isDefault])).toEqual([
|
||
["英语", "TrueHD Atmos", "7.1", "4561 kb/s", true],
|
||
["中文", "AC-3", "5.1", "640 kb/s", false],
|
||
["英语", "AAC", "2.0", "192 kb/s", false],
|
||
]);
|
||
expect(m.subtitles.map((s) => [s.language, s.format, s.isDefault, s.forced])).toEqual([
|
||
["英语", "PGS", false, true],
|
||
["中文", "UTF-8", true, false],
|
||
["繁体中文", "ASS", false, false],
|
||
["英语", "PGS", false, false],
|
||
]);
|
||
});
|
||
|
||
it("BDInfo 简版", () => {
|
||
const m = parseMediaSummary(SYNTH_BDINFO_QUICK)!;
|
||
expect(m.kind).toBe("bdinfo");
|
||
expect(m.general).toMatchObject({
|
||
container: "Blu-ray",
|
||
duration: "2 h 22 min",
|
||
size: "57.57 GiB",
|
||
overallBitrate: "55.20 Mbps",
|
||
});
|
||
expect(m.video.map((v) => [v.resolution, v.hdr])).toEqual([
|
||
["2160p", ["HDR10"]],
|
||
["1080p", ["Dolby Vision FEL"]],
|
||
]);
|
||
expect(m.audio[0]).toMatchObject({
|
||
language: "英语",
|
||
codec: "TrueHD Atmos",
|
||
channels: "7.1",
|
||
bitrate: "4561 kbps",
|
||
});
|
||
expect(m.subtitles.map((s) => s.language)).toEqual(["英语", "中文", "中文"]);
|
||
});
|
||
|
||
it("BDInfo 完整报告表格", () => {
|
||
const m = parseMediaSummary(SYNTH_BDINFO_FULL)!;
|
||
expect(m.general.duration).toBe("52 min 10 s");
|
||
expect(m.video).toEqual([
|
||
{
|
||
codec: "MPEG-4 AVC",
|
||
resolution: "1080p",
|
||
frameRate: "23.976 fps",
|
||
bitrate: "31456 kbps",
|
||
bitDepth: null,
|
||
hdr: [],
|
||
},
|
||
]);
|
||
expect(m.audio.map((a) => [a.language, a.codec, a.channels])).toEqual([
|
||
["日语", "DTS-HD MA", "5.1"],
|
||
["日语", "LPCM", "2.0"],
|
||
]);
|
||
expect(m.subtitles).toHaveLength(2);
|
||
});
|
||
|
||
it("认不出返回 null", () => {
|
||
expect(parseMediaSummary("just some text")).toBeNull();
|
||
expect(parseMediaSummary(null)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("文件清单 → 目录树", () => {
|
||
it("真实样本:8 集平铺,全是主视频", () => {
|
||
const t = buildFileTree(REAL_DETAIL_HH_INFO_CARD.files);
|
||
expect(t).toHaveLength(REAL_DETAIL_HH_INFO_CARD.files.length);
|
||
expect(t.every((r) => !r.dir && r.depth === 0 && r.role === "main")).toBe(true);
|
||
});
|
||
|
||
it("目录型:目录在前、大小求和,主视频 / sample / 字幕 / 附件", () => {
|
||
const t = buildFileTree(SYNTH_FILES_DIRECTORY);
|
||
expect(t[0]).toMatchObject({ dir: true, depth: 0, fileCount: 6, sizeBytes: 15_085_574_144 });
|
||
const role = (name: string) => t.find((r) => r.name === name)?.role;
|
||
expect(role("Example.Movie.2023.1080p.BluRay.x264-GROUP.mkv")).toBe("main");
|
||
expect(role("example.movie.sample.mkv")).toBe("sample");
|
||
expect(role("Example.Movie.2023.chs.srt")).toBe("subtitle");
|
||
expect(role("poster.jpg")).toBe("extra");
|
||
expect(t.map((r) => `${" ".repeat(r.depth)}${r.name}`).slice(0, 4)).toEqual([
|
||
"Example.Movie.2023.1080p.BluRay.x264-GROUP",
|
||
" Sample",
|
||
" example.movie.sample.mkv",
|
||
" Subs",
|
||
]);
|
||
});
|
||
|
||
it("原盘:最大的 m2ts 是正片,小片段不是", () => {
|
||
const t = buildFileTree(SYNTH_FILES_BDMV);
|
||
expect(t.find((r) => r.name === "00001.m2ts")?.role).toBe("main");
|
||
expect(t.find((r) => r.name === "00002.m2ts")?.role).toBe("video");
|
||
});
|
||
});
|
||
|
||
describe("formatDetailPage", () => {
|
||
it("组合四部分", () => {
|
||
const f = formatDetailPage(REAL_DETAIL_HH_INFO_CARD);
|
||
expect(f.infoBlock.length).toBeGreaterThan(5);
|
||
expect(f.paragraphs).toHaveLength(2);
|
||
expect(f.media?.kind).toBe("mediainfo");
|
||
expect(f.fileTree).toHaveLength(8);
|
||
});
|
||
});
|