/** * 详情页扩展内容(简介 / 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; type El = ReturnType; 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(); /** 换行占位:先把
/ 块级换行记成它,再去掉 HTML 源码里的换行(浏览器按空白处理),最后还原 */ const BR = "\u0000"; /** * 保留换行的文本:
与块级元素换行。源码换行不算换行(观众的简介是 `
\r\n`, * 两者都算会让每行之间多一空行);
 里的换行保留。
 */
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, " ")
    // `
\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[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; }