Files
UHD_Media_Tool/packages/adapters/src/sites/detail-format.test.ts
T
rootandClaude Opus 5.5 9be9813021 feat(sites): structure release detail pages and rework the detail drawer (DETAIL-1)
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]>
2026-09-29 20:50:42 +08:00

288 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(/<|&nbsp;|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+|&nbsp;|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&amp;b&#65;&#x42;&nbsp;<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);
});
});