Files
UHD_Media_Tool/packages/adapters/src/sites/detail-description.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

284 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* 详情页简介清洗与结构化(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 };
}