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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 20:50:42 +08:00
1 parent c846e91cc4
commit 9be9813021
16 files changed
+1992 -175

No files matched your search

@@ -0,0 +1,287 @@
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);
});
});