Files
UHD_Media_Tool/packages/adapters/src/sites/spider/detail-page.ts
T
rootandClaude Opus 5.5 92830c3ef3 fix(adapters): 观众详情页按真实页面适配(DET-2)
- 促销:详情 h1 是 font.free / font.halfdown 等 + 剩余时间 span[title],新增 umt.detail.promo 覆盖列表促销字段(以前一律判成无促销并覆盖列表的免费)
- 标题去掉 h1 里的「剩余时间:」;发布时间不再取标题区的促销到期时间
- 做种 / 下载 / 完成数、种子 Hash:umt.detail.fields.seeders / leechers / snatched / infoHash
- 副标题去掉评分徽标;标签只取最内层;上传者取 span.torrent-uploader-wrap,标签精确匹配(避开「发布者带宽」)
- 简介:去掉站点声明(page.descriptionStrip)与 MediaInfo 控件(codetop / show / hide),源码换行不再算一行
- 截图排除豆瓣海报与站点横幅
- 列表副标题改取 span.torrent-subtitle-text(以前前面粘着标签文字)
- 脱敏真实样本:详情 2 页进 differential/detail.test.ts,列表 1 页进 MP 差分

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 20:49:29 +08:00

289 lines
12 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.
/**
* 详情页扩展内容(简介 / MediaInfo / 截图 / IMDb·豆瓣·TMDB / 文件清单 / 标签 / 上传者)—— 本项目扩展。
* 以 MovieClaw detail_*_css(description / imdb / douban / file_list)为起点,其余按 NexusPHP details.php 公开结构
* 做通用规则:字段标签(td.rowhead 或站点配置的 div 标签)的下一个兄弟元素是值。站点特化写在定义 `umt.detail.page`。
* 文件清单只取页面上已有的内容,不为它额外请求 viewfilelist.php(请求克制)。
*/
import type { CheerioAPI } from "cheerio";
import {
emptyDetailPage,
extractTmdb,
looksLikeMediaInfo,
MAX_FILES,
MAX_SCREENSHOTS,
screenshotUrl,
tidyText,
} from "../detail-text.ts";
import type { SiteDetailFile, SiteDetailPage } from "../types.ts";
import { extractDoubanId, extractImdbId } from "../util.ts";
import { mcSelect } from "./mc-selector.ts";
import type { IndexerDefinition } from "./schema.ts";
import { parseSizeMp } from "./spider.ts";
type DetailConf = NonNullable<IndexerDefinition["umt"]["detail"]>;
type El = ReturnType<CheerioAPI>;
const LABELS = {
description: ["简介", "Description"],
/** 没有简介容器时的次选:豆瓣 / IMDb 信息块(憨憨把豆瓣资料拆成独立一节) */
descriptionFallback: ["豆瓣", "豆瓣信息", "影片信息", "IMDb信息", "IMDb"],
screenshots: ["截图", "Screenshots", "Screenshot"],
mediaInfo: ["MediaInfo", "媒体信息", "BDInfo"],
tags: ["标签", "Tags"],
uploader: ["上传者", "发布者", "发布人", "上传人", "Uploader"],
files: ["文件列表", "文件清单", "种子文件", "File list", "Files"],
};
/** 简介里可能装 MediaInfo 的块 */
const MI_BLOCKS = "fieldset, div.codemain, pre, code, blockquote, .spoiler-content, .hide, div.nexus-media-info-raw";
const normLabel = (t: string) =>
t
.replace(/\s+/g, "")
.replace(/[::]+$/, "")
.toLowerCase();
/** 换行占位:先把 <br> / 块级换行记成它,再去掉 HTML 源码里的换行(浏览器按空白处理),最后还原 */
const BR = "\u0000";
/**
* 保留换行的文本:<br> 与块级元素换行。源码换行不算换行(观众的简介是 `<br />\r\n`,
* 两者都算会让每行之间多一空行);<pre> 里的换行保留。
*/
function blockText($: CheerioAPI, el: El): string {
const c = el.clone();
c.find("script, style").remove();
c.find("pre, textarea").each((_, n) => {
$(n).text($(n).text().replace(/\r?\n/g, BR));
});
c.find("br").replaceWith(BR);
c.find("div, p, li, tr, pre, fieldset, blockquote, h1, h2, h3, h4, h5, h6").each((_, n) => {
$(n).append(BR);
});
const text = c
.text()
.replace(/\r?\n/g, " ")
// `<br />\r\n下一行` 的源码换行变成了行首一个空格
.replace(new RegExp(`${BR} `, "g"), BR)
.replaceAll(BR, "\n");
return tidyText(text);
}
/** NexusPHP MediaInfo 控件的外壳:标题栏 div.codetop、摘要表 div.show、原文 div.hide(观众三者相邻) */
const MI_WIDGET = "div.codetop, div.show, div.hide";
const SIZE_TAIL = /^(.*?)\s*[(([]\s*([\d.,]+\s*[KMGTP]?i?B)\s*[))\]]\s*$/i;
const HAS_EXT = /\.[A-Za-z0-9]{2,5}$/;
function fileFromText(text: string): SiteDetailFile | null {
const t = text.replace(/\s+/g, " ").trim();
if (!t) return null;
const m = SIZE_TAIL.exec(t);
if (m?.[1]) return { name: m[1], sizeBytes: parseSizeMp(m[2]!) || null };
return HAS_EXT.test(t) ? { name: t, sizeBytes: null } : null;
}
/** 文本里的 IMDb ID:imdb.com/title 链接或「IMDB : tt…」一行;不接受孤立的 tt 串(避免误中哈希) */
export function imdbFromText(text: string): string | null {
const m =
/imdb\.com\/title\/(tt\d{7,8})\b/i.exec(text) ??
/\bIMDb\s*(?:ID|链接|編號|编号)?\s*[::]?\s*(tt\d{7,8})\b/i.exec(text);
return m?.[1] ?? null;
}
/** NexusPHP viewfilelist.php 返回的文件表(按需 AJAX 取到的 HTML 片段) */
export function filesFromFileListHtml($: CheerioAPI): SiteDetailFile[] {
return filesFrom($, $("tr"));
}
function filesFrom($: CheerioAPI, rows: El): SiteDetailFile[] {
const out: SiteDetailFile[] = [];
rows.each((_, n) => {
if (out.length >= MAX_FILES) return;
const row = $(n);
const cells = row.children("td");
if (cells.length >= 2) {
const name = cells.first().text().replace(/\s+/g, " ").trim();
const size = cells.eq(1).text().replace(/\s+/g, " ").trim();
if (name && HAS_EXT.test(name) && /\d/.test(size)) out.push({ name, sizeBytes: parseSizeMp(size) || null });
return;
}
const f = fileFromText(row.text());
if (f) out.push(f);
});
return out;
}
export function extractDetailPage($: CheerioAPI, conf: DetailConf, base: string): SiteDetailPage {
const page = emptyDetailPage();
const p = conf.page ?? {};
// 字段标签 → 值元素
const pairs: { label: string; value: El }[] = [];
const addLabels = (sel: string, valueFor: (e: El) => El) => {
$(sel).each((_, n) => {
const e = $(n);
const label = normLabel(e.text());
if (!label || label.length > 16) return;
const value = valueFor(e);
if (value.length) pairs.push({ label, value });
});
};
addLabels(conf.rowLabel, (e) => e.nextAll(conf.rowValue).first());
for (const sel of p.labelSelectors ?? []) addLabels(sel, (e) => e.next());
const byLabel = (names: string[], exact = false): El | null =>
pairs.find((x) => names.some((n) => (exact ? x.label === normLabel(n) : x.label.startsWith(normLabel(n)))))
?.value ?? null;
page.probe.labels = [...new Set(pairs.map((x) => x.label))].slice(0, 40);
// 简介容器
const descSel = conf.fields?.description ?? conf.description;
let desc: El | null = descSel ? $(descSel).first() : null;
if (!desc?.length) desc = byLabel(LABELS.description);
page.probe.hasDescription = !!desc?.length;
const infoBlock = desc?.length ? null : byLabel(LABELS.descriptionFallback);
// MediaInfo:站点选择器 → 字段标签 → 简介里的引用 / 代码块(取最内层文本,整块从简介里去掉)
let mediaInfoEl: El | null = null;
let miBlockIndex = -1;
if (p.mediaInfo) {
const el = $(p.mediaInfo).first();
if (el.length) mediaInfoEl = el;
}
mediaInfoEl ??= byLabel(LABELS.mediaInfo);
const blocks = desc?.length ? desc.find(MI_BLOCKS) : null;
if (!mediaInfoEl && blocks) {
blocks.each((i, n) => {
if (miBlockIndex >= 0) return;
if (looksLikeMediaInfo(blockText($, $(n)))) miBlockIndex = i;
});
if (miBlockIndex >= 0) {
const outer = blocks.eq(miBlockIndex);
const inner = outer
.find(MI_BLOCKS)
.filter((_, n) => looksLikeMediaInfo(blockText($, $(n))))
.last();
mediaInfoEl = inner.length ? inner : outer;
}
}
if (mediaInfoEl) {
const t = blockText($, mediaInfoEl);
if (t) page.mediaInfo = t.slice(0, 50_000);
}
// 截图
const imgs: string[] = [];
const pushImgs = (els: El | null) =>
els?.each((_, n) => {
const e = $(n);
const u = screenshotUrl(e.attr("data-src") ?? e.attr("data-original") ?? e.attr("src"), base);
if (u && !imgs.includes(u)) imgs.push(u);
});
if (p.screenshots) pushImgs($(p.screenshots));
else {
// 独立的「截图」一节(憨憨)优先;没有再取简介里的图
const shotBlock = byLabel(LABELS.screenshots);
if (shotBlock) pushImgs(shotBlock.is("img") ? shotBlock : shotBlock.find("img"));
if (!imgs.length && desc?.length) pushImgs(desc.find("img"));
}
page.probe.imageCount = imgs.length;
page.screenshots = imgs.slice(0, MAX_SCREENSHOTS);
// 简介纯文本:去掉站点固定内容、MediaInfo 块(连同控件标题栏 / 摘要表)与图片
if (desc?.length) {
const c = desc.clone();
if (miBlockIndex >= 0) {
const isMi = (n: Parameters<typeof $>[0]) => looksLikeMediaInfo(blockText($, $(n)));
// 同一份 MediaInfo 常出现两次(摘要 div.show 与原文 div.hide):另外去掉最内层的 MediaInfo 块,
// 在控件外壳(div.codetop / div.show / div.hide)里时连同相邻外壳一起去掉
const extra = c
.find(MI_BLOCKS)
.filter((_, n) => isMi(n) && !$(n).find(MI_BLOCKS).toArray().some(isMi))
.map((_, n) => {
const e = $(n);
const shell = e.parent().is(MI_WIDGET) ? e.parent() : e;
const out = [shell.get(0)!];
if (!shell.is(MI_WIDGET)) return out;
for (let x = shell.prev(); x.is(MI_WIDGET) && out.length < 3; x = x.prev()) out.push(x.get(0)!);
for (let x = shell.next(); x.is(MI_WIDGET) && out.length < 4; x = x.next()) out.push(x.get(0)!);
return out;
})
.get();
c.find(MI_BLOCKS).eq(miBlockIndex).remove();
for (const w of extra) $(w).remove();
}
if (p.descriptionStrip) c.find(p.descriptionStrip).remove();
c.find("img").remove();
const t = blockText($, c);
if (t) page.descriptionText = t.slice(0, 20_000);
} else if (infoBlock) {
const c = infoBlock.clone();
c.find("img").remove();
// 块里的「标签」span 与值之间补上冒号 / 换行,读起来是「片名:xxx」一行一项
c.find(p.labelSelectors?.join(", ") || "span.font-bold").each((_, n) => {
const e = $(n);
const t = e.text().trim();
if (t && t.length <= 16 && !/[::]$/.test(t)) e.text(`${t}:`);
e.before("\n");
});
const t = blockText($, c)
.replace(/[ \t]*\n[ \t]*/g, "\n")
.replace(/:\s*\n(?=\S)/g, ":");
if (t) page.descriptionText = t.slice(0, 20_000);
}
// 外部 ID:页面上的链接优先,其次简介 / 信息块正文,再次 MediaInfo 里的 IMDB / TMDB 行(憨憨的 MediaInfo 带这两行)
const descHtml = `${desc?.html() ?? ""}\n${infoBlock?.html() ?? ""}`;
const idText = `${descHtml}\n${page.mediaInfo ?? ""}`;
const f = conf.fields ?? {};
page.imdbId =
extractImdbId(mcSelect($, null, f.imdb ?? "a[href*='imdb.com']::attr(href)")) ??
imdbFromText(idText) ??
extractImdbId(descHtml) ??
null;
page.doubanId =
extractDoubanId(mcSelect($, null, f.douban ?? "a[href*='douban.com']::attr(href)")) ??
extractDoubanId(idText) ??
null;
page.tmdb = extractTmdb($("a[href*='themoviedb.org']").first().attr("href")) ?? extractTmdb(idText);
// 文件清单:站点选择器 → MovieClaw file_list(去伪元素)→ 字段标签 → #filelist 表格
const fileSel = p.files ?? f.fileList?.replace(/::text$/, "");
let files = fileSel ? filesFrom($, $(fileSel)) : [];
if (!files.length) {
const v = byLabel(LABELS.files);
if (v) files = filesFrom($, v.find("li, tr"));
}
if (!files.length) files = filesFrom($, $("#filelist tr"));
page.files = files;
// 标签
const tagEl = p.tags ? $(p.tags) : byLabel(LABELS.tags);
if (tagEl?.length) {
// 只取最内层的 span / a(观众外面还包了一层 span.detail-tag-list,取外层会把全部标签连成一个)
const leaves = tagEl.find("span, a").filter((_, n) => $(n).find("span, a").length === 0);
const parts = leaves.length ? leaves.map((_, n) => $(n).text().trim()).get() : tagEl.text().split(/[\s,,|/]+/);
page.tags = [...new Set(parts.map((t) => t.trim()).filter((t) => t && t.length <= 20))].slice(0, 20);
}
// 上传者
// 上传者标签精确匹配:观众另有「发布者带宽」一行
const upSel = p.uploader ? $(p.uploader).first() : null;
const upEl = upSel?.length ? upSel : byLabel(LABELS.uploader, true);
if (upEl?.length) {
const a = upEl.is("a") ? upEl : upEl.find("a[href*='userdetails.php']").first();
const name = (a.length ? a.text() : upEl.text()).replace(/\s+/g, " ").trim();
if (name) page.uploader = name.slice(0, 64);
}
if (!page.uploader) {
for (const x of pairs) {
const m = /(?:上传者|发布者|发布人|Uploader)\s*[::]\s*(\S+)/i.exec(x.value.text());
if (m?.[1]) {
page.uploader = m[1].slice(0, 64);
break;
}
}
}
return page;
}