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,283 @@
/**
* 详情页简介清洗与结构化(DETAIL-1):纯函数,输入是抓取时得到的简介文本(可能残留 HTML / BBCode),
* 输出「信息块」键值对(◎片名 / 【年代】/ 『导演』/ 片名: 等)与正文段落。
* 清洗规则可配置:站点模板段、广告签名按正则整段或整行去掉,调用方可在默认规则上追加。
*/
export interface DetailInfoItem {
key: string;
value: string;
}
export interface StructuredDescription {
infoBlock: DetailInfoItem[];
paragraphs: string[];
}
export interface DescriptionRules {
/** 整段去掉(对整篇文本匹配,可跨行):站点模板段、更新提示等 */
dropBlocks: RegExp[];
/** 整行去掉:广告、群号、转载声明、感谢签名、分隔线 */
dropLines: RegExp[];
}
export const DEFAULT_DESCRIPTION_RULES: DescriptionRules = {
dropBlocks: [
// 憨憨豆瓣信息块末尾:「注意:该信息最后更新于…点击 这里 更新信息」
/注意[::]\s*该信息最后更新于[\s\S]{0,80}?更新信息/g,
],
dropLines: [
/^[-=_*~—─━·.]{5,}$/,
/^(?:QQ|qq|微信|TG|Telegram|电报|Discord)\s*群?(?:号)?\s*[::]/,
/(?:禁止|严禁|请勿|不得).{0,12}转载|转载.{0,8}(?:请注明|须注明)/,
/^(?:感谢|鸣谢|Thanks?\s+to)\s*.{0,20}(?:分享|发布|制作|压制|上传)/i,
/^(?:本资源|本种子?|资源)?由\s*\S{1,24}\s*(?:小组|组)?\s*(?:制作|压制|发布|分享)\S{0,4}$/,
/^(?:点击|更多).{0,10}(?:下载|资源).{0,20}(?:访问|请到|见)/,
],
};
/** 常见信息块键(去掉空白后比较);冒号式 / 【】/『』 只认这些,避免把正文里的「【第一版】」当键 */
const KNOWN_KEYS = [
"译名",
"片名",
"原名",
"又名",
"别名",
"中文名",
"英文名",
"年代",
"年份",
"产地",
"国家",
"地区",
"国家地区",
"制片国家/地区",
"类别",
"类型",
"语言",
"对白语言",
"字幕",
"上映日期",
"首播",
"首播日期",
"IMDb评分",
"IMDB评分",
"IMDb链接",
"IMDB链接",
"豆瓣评分",
"豆瓣链接",
"TMDB",
"TMDb链接",
"评分",
"票数",
"集数",
"季数",
"片长",
"单集片长",
"视频尺寸",
"文件格式",
"文件大小",
"导演",
"编剧",
"主演",
"演员",
"配音",
"制片人",
"标签",
"标语",
"简介",
"剧情简介",
"内容简介",
"剧情概要",
"剧情介绍",
"获奖情况",
];
const KNOWN = new Set(KNOWN_KEYS.map((k) => k.toLowerCase()));
/** 值就是正文的键:内容进段落 */
const DESC_KEYS = new Set(["简介", "剧情简介", "内容简介", "剧情概要", "剧情介绍"]);
/** 人名列表:续行合并,修复「Kai Xu周也」这类元素拼接 */
const PEOPLE_KEYS = new Set(["导演", "编剧", "主演", "演员", "配音", "制片人"]);
const WS = "[\\s\\u3000]";
const KEY_ALTERNATION = [...KNOWN_KEYS]
.sort((a, b) => b.length - a.length)
.map((k) => [...k].map((c) => c.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")).join(`${WS}*`))
.join("|");
/** ◎ 标记:认已知键,否则取到第一个空白 / 冒号为止的短词 */
const MARK_KNOWN = new RegExp(`^◎${WS}*(${KEY_ALTERNATION})${WS}*[::]?${WS}*(.*)$`, "i");
const MARK_ANY = /^◎[\s ]*([^\s ::]{1,12})[\s ]*[::]?[\s ]*(.*)$/;
const BRACKET = new RegExp(`^(?:【|『|\\[)${WS}*(${KEY_ALTERNATION})${WS}*(?:】|』|\\])${WS}*[::]?${WS}*(.*)$`, "i");
const COLON = new RegExp(`^(${KEY_ALTERNATION})${WS}*[::]${WS}*(.*)$`, "i");
const ENTITIES: Record<string, string> = {
nbsp: " ",
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
ensp: " ",
emsp: " ",
};
function decodeEntities(s: string): string {
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, e: string) => {
if (e[0] === "#") {
const code = e[1] === "x" || e[1] === "X" ? Number.parseInt(e.slice(2), 16) : Number.parseInt(e.slice(1), 10);
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : all;
}
return ENTITIES[e.toLowerCase()] ?? all;
});
}
/** 去 HTML / BBCode 残留:图片整段去掉,链接留文字,块级标签换行,实体解码 */
export function stripMarkup(text: string): string {
return decodeEntities(
text
.replace(/\r/g, "")
.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, "")
// 站点截断在标签中间:「…<br…」
.replace(/<[a-z/][^>\n]*…?$/i, "…")
.replace(/<img\b[^>]*>/gi, "")
.replace(/<br\s*\/?>[ \t]*\n?/gi, "\n")
.replace(/<\/(?:p|div|li|tr|h[1-6]|blockquote|fieldset|pre)>[ \t]*\n?/gi, "\n")
.replace(/<[a-z/!][^>]*>/gi, "")
.replace(/\[img(?:=[^\]]*)?\][^[]*\[\/img\]|\[img=[^\]]+\]/gi, "")
.replace(/\[url=[^\]]*\]([\s\S]*?)\[\/url\]/gi, "$1")
.replace(
/\[\/?(?:b|i|u|s|color|size|font|align|center|left|right|url|quote|code|spoiler|hide|box|list|\*|pre|table|tr|td)(?:=[^\]]*)?\]/gi,
"",
),
).replace(/ /g, " ");
}
/** 清洗:去标记、按规则删段删行、行尾空白、连续空行压成一行 */
export function cleanDescription(text: string, rules: DescriptionRules = DEFAULT_DESCRIPTION_RULES): string {
let t = stripMarkup(text);
for (const re of rules.dropBlocks) t = t.replace(re, "");
return t
.split("\n")
.map((l) => l.replace(/[\s ]+$/, ""))
.filter((l) => {
const s = l.trim().replace(/^ +/, "");
return !s || !rules.dropLines.some((re) => re.test(s));
})
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
interface KeyHit {
key: string;
value: string;
}
const normKey = (k: string) => k.replace(/[\s ]+/g, "");
function matchKey(line: string): KeyHit | null {
const m = MARK_KNOWN.exec(line) ?? MARK_ANY.exec(line) ?? BRACKET.exec(line) ?? COLON.exec(line);
if (!m) return null;
const key = normKey(m[1]!);
if (!key) return null;
// 冒号式:键后面要么没东西(值在下一行),要么是冒号分隔的值;「片名」开头的正文句子不当键
if (!line.startsWith("◎") && !KNOWN.has(key.toLowerCase())) return null;
return { key, value: (m[2] ?? "").trim() };
}
const isSeparator = (l: string) => /^[-=_*~—─━·.]{5,}$/.test(l);
const SENTENCE_END = /[。!?!?.…」』"”))]$/;
/** 相邻行合并成段:英文硬换行(下一行小写开头且上一行没有句末标点)接成一行,其余每行一段 */
function toParagraphs(lines: string[]): string[] {
const out: string[] = [];
let prevJoinable = false;
for (const raw of lines) {
const l = raw.replace(/^[\s ]+/, "").trim();
if (!l) {
prevJoinable = false;
continue;
}
if (prevJoinable && /^[a-z]/.test(l) && out.length) out[out.length - 1] += ` ${l}`;
else out.push(l);
prevJoinable = /[A-Za-z,]$/.test(l) && !SENTENCE_END.test(l);
}
return out;
}
function tidyValue(key: string, lines: string[]): string {
const parts = lines.map((l) => l.replace(/[\s ]+/g, " ").trim()).filter(Boolean);
if (PEOPLE_KEYS.has(key)) {
return parts
.flatMap((p) => p.replace(/([a-z])(?=[一-鿿])/g, "$1\u0000").split("\u0000"))
.map((p) => p.trim())
.filter(Boolean)
.join(" / ");
}
return parts.every((p) => p.length <= 40) ? parts.join(" / ") : parts.join("\n");
}
const URL_ONLY = /^(?:https?:\/\/\S+|www\.\S+)$/i;
const isLinkKey = (k: string) => /(?:链接|网址|link)$/i.test(k) || /^tmdb$/i.test(k);
/**
* 信息块 + 段落。信息块的键:◎ 开头任意短键;【】『』[] 包裹或「键:」开头只认常见键。
* 键后的值可在同一行,也可在下一个非空行(憨憨豆瓣块);续行(主演多行)并入上一键,空行结束。
* 简介类键的内容进段落;信息块之前的零碎短行(海报卡片的片名 / 年份 / 评分)在有信息块时丢弃。
*/
export function structureDescription(
text: string | null | undefined,
rules: DescriptionRules = DEFAULT_DESCRIPTION_RULES,
): StructuredDescription {
if (!text?.trim()) return { infoBlock: [], paragraphs: [] };
const lines = cleanDescription(text, rules).split("\n");
const info: { key: string; lines: string[] }[] = [];
const lead: string[] = [];
const body: string[] = [];
let cur: { key: string; lines: string[] } | "desc" | null = null;
const pushBody = (l: string) => (info.length ? body : lead).push(l);
for (const line of lines) {
const trimmed = line.trim();
if (isSeparator(trimmed)) {
if (cur && cur !== "desc" && cur.lines.length) cur = null;
if (cur === "desc") body.push("");
continue;
}
const hit = trimmed ? matchKey(trimmed) : null;
if (hit) {
if (DESC_KEYS.has(hit.key)) {
cur = "desc";
body.push("");
if (hit.value) body.push(hit.value);
} else {
const entry = { key: hit.key, lines: hit.value ? [hit.value] : [] };
info.push(entry);
cur = entry;
}
continue;
}
if (!trimmed) {
if (cur === "desc") body.push("");
else if (cur?.lines.length) cur = null;
else if (!cur) pushBody("");
continue;
}
if (cur && cur !== "desc") cur.lines.push(line);
else pushBody(line);
}
const seen = new Set<string>();
const infoBlock: DetailInfoItem[] = [];
for (const e of info) {
const value = tidyValue(e.key, e.lines);
if (!value || seen.has(e.key)) continue;
if (isLinkKey(e.key) && value.split(/\s+/).every((v) => URL_ONLY.test(v) || !v)) continue;
seen.add(e.key);
infoBlock.push({ key: e.key, value });
}
// 信息块前面只有零碎短行(卡片摘要)就丢掉;有像样的句子则保留为正文
const leadParas = toParagraphs(lead);
const keepLead = !infoBlock.length || leadParas.some((p) => p.length > 24 || /[。!?!?]$/.test(p));
const paragraphs = [...(keepLead ? leadParas : []), ...toParagraphs(body)];
return { infoBlock, paragraphs };
}
@@ -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);
});
});
@@ -0,0 +1,33 @@
/**
* 详情页扩展内容的展示结构(DETAIL-1):简介 → 信息块 + 段落,MediaInfo / BDInfo → 技术规格概要,文件清单 → 目录树。
* 纯函数;服务端读缓存时现算(旧缓存也能用),原文(descriptionText / mediaInfo / files)照旧保留。
*/
import {
DEFAULT_DESCRIPTION_RULES,
type DescriptionRules,
type DetailInfoItem,
structureDescription,
} from "./detail-description.ts";
import { buildFileTree, type FileTreeRow } from "./file-tree.ts";
import { type MediaSummary, parseMediaSummary } from "./mediainfo.ts";
import type { SiteDetailPage } from "./types.ts";
export interface FormattedDetailPage {
infoBlock: DetailInfoItem[];
paragraphs: string[];
media: MediaSummary | null;
fileTree: FileTreeRow[];
}
export function formatDetailPage(
page: Pick<SiteDetailPage, "descriptionText" | "mediaInfo" | "files">,
rules: DescriptionRules = DEFAULT_DESCRIPTION_RULES,
): FormattedDetailPage {
const { infoBlock, paragraphs } = structureDescription(page.descriptionText, rules);
return {
infoBlock,
paragraphs,
media: parseMediaSummary(page.mediaInfo),
fileTree: buildFileTree(page.files ?? []),
};
}
+103
View File
@@ -0,0 +1,103 @@
/**
* 详情页文件清单 → 目录树(DETAIL-1,纯函数)。按「/」或「\」拆路径,目录大小为子项之和;
* 标出主视频(最大视频的 30% 以上,排除 sample)、sample、字幕与附件。输出按先序展平,前端按 depth 缩进。
*/
import type { SiteDetailFile } from "./types.ts";
export type FileRole = "main" | "sample" | "subtitle" | "extra" | "video" | "other";
export interface FileTreeRow {
/** 完整路径(目录也有),作 key 与折叠状态 */
path: string;
name: string;
depth: number;
dir: boolean;
/** 目录:子项之和;有未知大小的子项时仍给已知部分之和 */
sizeBytes: number | null;
/** 目录为 null */
role: FileRole | null;
/** 目录下的文件数(递归);文件为 null */
fileCount: number | null;
}
const VIDEO = /\.(mkv|mp4|m2ts|ts|avi|wmv|mov|m4v|iso|rmvb|flv|webm|vob|mpg|mpeg)$/i;
const SUB = /\.(srt|ass|ssa|sup|sub|idx|vtt|smi)$/i;
const EXTRA = /\.(nfo|txt|jpe?g|png|gif|webp|bmp|md5|sfv|sha1|sha256|url|html?|pdf|cue|log|torrent)$/i;
const SAMPLE = /(^|[\\/._\-\s])sample([\\/._\-\s]|$)/i;
interface Node {
name: string;
path: string;
children: Map<string, Node> | null;
sizeBytes: number | null;
}
export function buildFileTree(files: SiteDetailFile[]): FileTreeRow[] {
const root: Node = { name: "", path: "", children: new Map(), sizeBytes: null };
for (const f of files) {
const parts = f.name.split(/[\\/]+/).filter(Boolean);
if (!parts.length) continue;
let node = root;
parts.forEach((part, i) => {
const last = i === parts.length - 1;
const path = node.path ? `${node.path}/${part}` : part;
let child = node.children!.get(part);
if (!child) {
child = { name: part, path, children: last ? null : new Map(), sizeBytes: last ? f.sizeBytes : null };
node.children!.set(part, child);
} else if (!last && !child.children) {
child.children = new Map();
}
node = child;
});
}
const videoSizes = files.filter((f) => VIDEO.test(f.name) && !SAMPLE.test(f.name)).map((f) => f.sizeBytes ?? 0);
const maxVideo = Math.max(0, ...videoSizes);
const roleOf = (n: Node): FileRole => {
if (SUB.test(n.name)) return "subtitle";
if (VIDEO.test(n.name)) {
if (SAMPLE.test(n.path)) return "sample";
// 大小未知时所有视频都当主视频;已知时按最大视频的 30% 划线(剧集各集、原盘正片)
if (!maxVideo || n.sizeBytes === null || n.sizeBytes >= maxVideo * 0.3) return "main";
return "video";
}
if (EXTRA.test(n.name)) return "extra";
return "other";
};
const out: FileTreeRow[] = [];
const walk = (n: Node, depth: number): { size: number | null; count: number } => {
const kids = [...n.children!.values()].sort((a, b) =>
!!a.children === !!b.children ? a.name.localeCompare(b.name, "zh-CN", { numeric: true }) : a.children ? -1 : 1,
);
let size: number | null = null;
let count = 0;
for (const k of kids) {
if (k.children) {
const idx = out.length;
out.push({ path: k.path, name: k.name, depth, dir: true, sizeBytes: null, role: null, fileCount: null });
const sub = walk(k, depth + 1);
out[idx]!.sizeBytes = sub.size;
out[idx]!.fileCount = sub.count;
if (sub.size !== null) size = (size ?? 0) + sub.size;
count += sub.count;
} else {
out.push({
path: k.path,
name: k.name,
depth,
dir: false,
sizeBytes: k.sizeBytes,
role: roleOf(k),
fileCount: null,
});
if (k.sizeBytes !== null) size = (size ?? 0) + k.sizeBytes;
count += 1;
}
}
return { size, count };
};
walk(root, 0);
return out;
}
+19
View File
@@ -16,12 +16,23 @@ export {
SpiderFilterSchema,
} from "./definitions/index.ts";
export type { SiteAdapterDeps } from "./deps.ts";
export {
cleanDescription,
DEFAULT_DESCRIPTION_RULES,
type DescriptionRules,
type DetailInfoItem,
type StructuredDescription,
stripMarkup,
structureDescription,
} from "./detail-description.ts";
export { type FormattedDetailPage, formatDetailPage } from "./detail-format.ts";
export {
type FeedMediaType,
normalizeSiteFeedItem,
type SiteFeedItemFields,
siteCategoryMediaType,
} from "./feed-item.ts";
export { buildFileTree, type FileRole, type FileTreeRow } from "./file-tree.ts";
export {
classifyLoginError,
loginFailureText,
@@ -33,6 +44,14 @@ export {
type SiteLoginFailure,
totpCode,
} from "./login/nexusphp.ts";
export {
type MediaAudioTrack,
type MediaGeneral,
type MediaSummary,
type MediaTextTrack,
type MediaVideoTrack,
parseMediaSummary,
} from "./mediainfo.ts";
export { createMteamAdapter, mteamTorrentToRelease } from "./mteam/adapter.ts";
export { type MteamApiPath, mteamPost } from "./mteam/client.ts";
export { type MteamDefinition, mteamApiBase, mteamDefinition } from "./mteam/definition.ts";
+405
View File
@@ -0,0 +1,405 @@
/**
* MediaInfo / BDInfo 文本 → 技术规格概要(DETAIL-1,纯函数)。原文另存,这里只取展示需要的字段:
* 概要(容器、时长、总码率、大小)、视频轨、音频轨、字幕轨。认不出的字段留 null,不猜。
*/
export interface MediaGeneral {
fileName: string | null;
container: string | null;
duration: string | null;
overallBitrate: string | null;
size: string | null;
}
export interface MediaVideoTrack {
codec: string | null;
resolution: string | null;
frameRate: string | null;
bitrate: string | null;
bitDepth: string | null;
/** HDR10 / HDR10+ / Dolby Vision P5 / HLG … 空数组 = SDR 或未知 */
hdr: string[];
}
export interface MediaAudioTrack {
language: string | null;
codec: string | null;
channels: string | null;
bitrate: string | null;
title: string | null;
isDefault: boolean | null;
}
export interface MediaTextTrack {
language: string | null;
format: string | null;
title: string | null;
isDefault: boolean | null;
forced: boolean | null;
}
export interface MediaSummary {
kind: "mediainfo" | "bdinfo";
general: MediaGeneral;
video: MediaVideoTrack[];
audio: MediaAudioTrack[];
subtitles: MediaTextTrack[];
}
const LANG: Record<string, string> = {
chinese: "中文",
mandarin: "国语",
cantonese: "粤语",
zh: "中文",
chi: "中文",
zho: "中文",
"zh-cn": "简体中文",
"zh-hans": "简体中文",
"zh-tw": "繁体中文",
"zh-hk": "繁体中文",
"zh-hant": "繁体中文",
english: "英语",
en: "英语",
eng: "英语",
japanese: "日语",
ja: "日语",
jpn: "日语",
korean: "韩语",
ko: "韩语",
kor: "韩语",
french: "法语",
fr: "法语",
german: "德语",
de: "德语",
spanish: "西班牙语",
es: "西班牙语",
italian: "意大利语",
russian: "俄语",
thai: "泰语",
portuguese: "葡萄牙语",
hindi: "印地语",
};
function language(v: string | null | undefined): string | null {
if (!v) return null;
const t = v.trim();
return LANG[t.toLowerCase()] ?? t;
}
function yesNo(v: string | undefined): boolean | null {
if (!v) return null;
if (/^(yes|是)$/i.test(v.trim())) return true;
if (/^(no|否)$/i.test(v.trim())) return false;
return null;
}
/** 「3 840 pixels」→ 3840 */
const num = (v: string | undefined): number | null => {
const m = v ? /([\d\s]+)/.exec(v.replace(/(\d)\s+(?=\d)/g, "$1")) : null;
return m ? Number.parseInt(m[1]!.replace(/\s/g, ""), 10) || null : null;
};
/** 码率:去掉数字里的千分位空格 「4 561 kb/s」→「4561 kb/s」 */
const rate = (v: string | undefined): string | null => (v ? v.replace(/(\d)\s+(?=\d)/g, "$1").trim() : null);
function frameRate(v: string | undefined): string | null {
const m = v ? /([\d.]+)/.exec(v) : null;
if (!m) return null;
const n = Number.parseFloat(m[1]!);
return Number.isFinite(n) ? `${Number.isInteger(n) ? n : Number(n.toFixed(3))} fps` : null;
}
/** 声道数 + 布局 → 2.0 / 5.1 / 7.1 */
function channels(count: string | undefined, layout: string | undefined): string | null {
const n = num(count);
if (!n) return null;
const lfe = layout ? /\bLFE\b/.test(layout) : n === 6 || n === 8;
return lfe ? `${n - 1}.1` : `${n}.0`;
}
function hdrOf(fields: Record<string, string>): string[] {
const out: string[] = [];
const f = `${fields["HDR format"] ?? ""} ${fields.HDR_Format ?? ""}`;
const dv = /Dolby Vision[^,]*,[^,]*,\s*Profile\s*([\d.]+)/i.exec(f);
if (dv) out.push(`Dolby Vision P${dv[1]}`);
else if (/Dolby Vision/i.test(f)) out.push("Dolby Vision");
if (/HDR10\+|SMPTE ST 2094/i.test(f)) out.push("HDR10+");
if (
/HDR10(?!\+)|SMPTE ST 2086/i.test(f) ||
(!out.length && /PQ|SMPTE ST 2084/.test(fields["Transfer characteristics"] ?? ""))
)
out.push("HDR10");
if (/HLG/i.test(`${f} ${fields["Transfer characteristics"] ?? ""}`)) out.push("HLG");
return [...new Set(out)];
}
/** 音频编码短名:MLP FBA → TrueHD,E-AC-3 → DDP,DTS XLL → DTS-HD MA;商业名带 Atmos 的加上 Atmos */
function audioCodec(f: Record<string, string>): string | null {
const fmt = f.Format?.trim();
if (!fmt) return f["Commercial name"] ?? null;
const commercial = f["Commercial name"] ?? "";
let name = fmt;
if (/^MLP FBA/i.test(fmt)) name = "TrueHD";
else if (/^E-AC-3/i.test(fmt)) name = "DDP";
else if (/^AC-3/i.test(fmt)) name = "AC-3";
else if (/^DTS/i.test(fmt))
name = /XLL X|DTS:X/i.test(`${fmt} ${commercial}`)
? "DTS:X"
: /XLL|Master Audio/i.test(`${fmt} ${commercial}`)
? "DTS-HD MA"
: "DTS";
else if (/^AAC/i.test(fmt)) name = "AAC";
else if (/^PCM/i.test(fmt)) name = "LPCM";
if (/Atmos/i.test(`${fmt} ${commercial}`) && !/Atmos/i.test(name)) name += " Atmos";
return name;
}
/** 中文版 MediaInfo 键 → 英文键 */
const ZH_KEYS: Record<string, string> = {
完整名称: "Complete name",
格式: "Format",
文件大小: "File size",
时长: "Duration",
总体码率: "Overall bit rate",
码率: "Bit rate",
宽度: "Width",
高度: "Height",
帧率: "Frame rate",
位深: "Bit depth",
声道数: "Channel(s)",
声道布局: "Channel layout",
语言: "Language",
标题: "Title",
默认: "Default",
强制: "Forced",
商业名称: "Commercial name",
"HDR 格式": "HDR format",
传输特质: "Transfer characteristics",
};
type Section = { type: "general" | "video" | "audio" | "text" | "other"; fields: Record<string, string> };
const SECTION_HEAD =
/^(General|Video|Audio|Text|Menu|Chapters|Image|Other|概览|常规|概要|视频|音频|文本|字幕|菜单|章节)(?:\s*#\s*\d+)?\s*$/i;
function sectionType(name: string): Section["type"] {
const n = name.toLowerCase();
if (/^(general|概览|常规|概要)/.test(n)) return "general";
if (/^(video|视频)/.test(n)) return "video";
if (/^(audio|音频)/.test(n)) return "audio";
if (/^(text|文本|字幕)/.test(n)) return "text";
return "other";
}
function parseSections(text: string): Section[] {
const out: Section[] = [];
let cur: Section | null = null;
for (const raw of text.replace(/\r/g, "").split("\n")) {
const line = raw.trim();
if (!line) continue;
const head = SECTION_HEAD.exec(line);
if (head) {
cur = { type: sectionType(head[1]!), fields: {} };
out.push(cur);
continue;
}
if (!cur) continue;
const m = /^(.+?)\s*[::]\s(.*)$/.exec(line) ?? /^(.+?)\s{2,}[::]\s*(.*)$/.exec(line);
if (!m) continue;
const key = ZH_KEYS[m[1]!.trim()] ?? m[1]!.trim();
if (!(key in cur.fields)) cur.fields[key] = m[2]!.trim();
}
return out;
}
function baseName(p: string | undefined): string | null {
return p ? (p.split(/[\\/]/).pop() ?? p).trim() || null : null;
}
function parseMediaInfoText(text: string): MediaSummary | null {
const sections = parseSections(text);
const g = sections.find((s) => s.type === "general")?.fields;
const videos = sections.filter((s) => s.type === "video");
if (!g && !videos.length) return null;
const general: MediaGeneral = {
fileName: baseName(g?.["Complete name"] ?? g?.["Movie name"]),
container: g?.Format ?? null,
duration: g?.Duration ?? null,
overallBitrate: rate(g?.["Overall bit rate"]),
size: g?.["File size"] ?? null,
};
const video = videos.map(({ fields: f }): MediaVideoTrack => {
const w = num(f.Width);
const h = num(f.Height);
return {
codec: f.Format ?? null,
resolution: w && h ? `${w}×${h}` : null,
frameRate: frameRate(f["Frame rate"] ?? f["Original frame rate"]),
bitrate: rate(f["Bit rate"] ?? f["Nominal bit rate"]),
bitDepth: num(f["Bit depth"]) ? `${num(f["Bit depth"])} bit` : null,
hdr: hdrOf(f),
};
});
const audio = sections
.filter((s) => s.type === "audio")
.map(
({ fields: f }): MediaAudioTrack => ({
language: language(f.Language),
codec: audioCodec(f),
channels: channels(f["Channel(s)"], f["Channel layout"] ?? f["Channel positions"]),
bitrate: rate(f["Bit rate"]),
title: f.Title ?? null,
isDefault: yesNo(f.Default),
}),
);
const subtitles = sections
.filter((s) => s.type === "text")
.map(
({ fields: f }): MediaTextTrack => ({
language: language(f.Language),
format: f.Format ?? null,
title: f.Title ?? null,
isDefault: yesNo(f.Default),
forced: yesNo(f.Forced),
}),
);
return { kind: "mediainfo", general, video, audio, subtitles };
}
// ---------------- BDInfo ----------------
function bdVideo(codec: string, desc: string, bitrate: string | null): MediaVideoTrack {
const parts = desc.split(/\s+\/\s+/).map((s) => s.trim());
const res = parts.find((p) => /^\d{3,4}[pi]$/i.test(p)) ?? null;
const hdr: string[] = [];
for (const p of parts) {
if (/^Dolby Vision/i.test(p)) hdr.push(p.replace(/^Dolby Vision\s*/i, "Dolby Vision ").trim());
else if (/^HDR10\+?$/i.test(p) || /^HLG$/i.test(p)) hdr.push(p.toUpperCase());
}
return {
codec: codec.replace(/\s*Video$/i, "").trim() || null,
resolution: res,
frameRate: frameRate(parts.find((p) => /fps$/i.test(p))),
bitrate: bitrate ?? parts.find((p) => /bps$/i.test(p)) ?? null,
bitDepth: parts.find((p) => /^\d+ bits?$/i.test(p))?.replace(/bits?$/i, "bit") ?? null,
hdr,
};
}
function bdAudioCodec(codec: string): string | null {
const c = codec.replace(/\s*Audio$/i, "").trim();
if (!c) return null;
if (/TrueHD\/Atmos/i.test(c)) return "TrueHD Atmos";
if (/TrueHD/i.test(c)) return "TrueHD";
if (/Digital Plus/i.test(c)) return /Atmos/i.test(c) ? "DDP Atmos" : "DDP";
if (/^Dolby Digital$/i.test(c)) return "AC-3";
if (/DTS:X/i.test(c)) return "DTS:X";
if (/DTS-HD Master/i.test(c)) return "DTS-HD MA";
return c;
}
function bdAudio(codec: string, lang: string | null, desc: string, bitrate: string | null): MediaAudioTrack {
const parts = desc.split(/\s+\/\s+/).map((s) => s.trim());
return {
language: language(lang),
codec: bdAudioCodec(codec),
channels: parts.find((p) => /^\d\.\d(?:\s|$|-)/.test(p))?.slice(0, 3) ?? null,
bitrate: bitrate ?? parts.find((p) => /kbps$/i.test(p)) ?? null,
title: null,
isDefault: null,
};
}
/** 「61,812,345,678 bytes」→「57.57 GiB」;认不出原样返回 */
function bdSize(v: string | null): string | null {
const m = v ? /^([\d,]+)\s*bytes$/i.exec(v.trim()) : null;
if (!m) return v;
const n = Number(m[1]!.replace(/,/g, ""));
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
let x = n;
let i = 0;
while (x >= 1024 && i < units.length - 1) {
x /= 1024;
i++;
}
return `${i ? x.toFixed(2) : x} ${units[i]}`;
}
/** 「2:22:48.521」→「2 h 22 min」,与 MediaInfo 写法一致 */
function bdDuration(v: string | null): string | null {
const m = v ? /^(\d+):(\d{2}):(\d{2})/.exec(v.trim()) : null;
if (!m) return v;
const h = Number(m[1]);
const min = Number(m[2]);
return h ? `${h} h ${min} min` : `${min} min ${Number(m[3])} s`;
}
function parseBdInfoText(text: string): MediaSummary | null {
const lines = text.replace(/\r/g, "").split("\n");
const kv = (label: RegExp) => {
for (const l of lines) {
const m = new RegExp(`^\\s*${label.source}\\s*:\\s*(.+)$`, "i").exec(l);
if (m) return m[1]!.trim();
}
return null;
};
const general: MediaGeneral = {
fileName: kv(/(?:Disc Title|Disc Label)/),
container: "Blu-ray",
duration: bdDuration(kv(/Length/)),
overallBitrate: kv(/Total Bitrate/),
size: bdSize(kv(/(?:Disc Size|Size)/)),
};
const video: MediaVideoTrack[] = [];
const audio: MediaAudioTrack[] = [];
const subtitles: MediaTextTrack[] = [];
// 简版:「Video: codec / bitrate / 2160p / …」「Audio: lang / codec / 7.1 / …」「Subtitle: lang / bitrate」
for (const l of lines) {
const m = /^\s*(Video|Audio|Subtitle)\s*:\s*(.+)$/i.exec(l);
if (!m) continue;
const parts = m[2]!.split(/\s+\/\s+/).map((s) => s.trim());
const kind = m[1]!.toLowerCase();
if (kind === "video") video.push(bdVideo(parts[0] ?? "", parts.slice(1).join(" / "), parts[1] ?? null));
else if (kind === "audio") audio.push(bdAudio(parts[1] ?? "", parts[0] ?? null, parts.slice(2).join(" / "), null));
else subtitles.push({ language: language(parts[0]), format: "PGS", title: null, isDefault: null, forced: null });
}
// 完整报告:VIDEO: / AUDIO: / SUBTITLES: 下的表格,列之间两个以上空格
if (!video.length && !audio.length) {
let section: "video" | "audio" | "subs" | null = null;
for (const l of lines) {
const head =
/^\s*(VIDEO|AUDIO|SUBTITLES|FILES|CHAPTERS|STREAM DIAGNOSTICS|PLAYLIST REPORT|DISC INFO)\s*:\s*$/.exec(l);
if (head) {
section =
head[1] === "VIDEO" ? "video" : head[1] === "AUDIO" ? "audio" : head[1] === "SUBTITLES" ? "subs" : null;
continue;
}
if (!section || !l.trim() || /^\s*(Codec|-----)/.test(l)) continue;
const cols = l.trim().split(/\s{2,}/);
if (section === "video") video.push(bdVideo(cols[0] ?? "", cols[2] ?? "", cols[1] ?? null));
else if (section === "audio") audio.push(bdAudio(cols[0] ?? "", cols[1] ?? null, cols[3] ?? "", cols[2] ?? null));
else
subtitles.push({
language: language(cols[1]),
format: /Presentation Graphics/i.test(cols[0] ?? "") ? "PGS" : (cols[0] ?? null),
title: null,
isDefault: null,
forced: null,
});
}
}
if (!video.length && !audio.length) return null;
return { kind: "bdinfo", general, video, audio, subtitles };
}
const isBdInfo = (t: string) =>
/(^|\n)\s*(DISC INFO:|Disc Title\s*:|Disc Label\s*:|BDInfo\s*:|PLAYLIST REPORT:)/i.test(t);
/** MediaInfo 或 BDInfo 原文 → 概要;两种都认不出返回 null(前端只显示原文) */
export function parseMediaSummary(text: string | null | undefined): MediaSummary | null {
if (!text?.trim()) return null;
if (isBdInfo(text)) return parseBdInfoText(text) ?? parseMediaInfoText(text);
return parseMediaInfoText(text);
}