/** * 详情页扩展内容(简介 / 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"], 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(); /** 保留换行的文本:
与块级元素换行 */ function blockText($: CheerioAPI, el: El): string { const c = el.clone(); c.find("script, style").remove(); c.find("br").replaceWith("\n"); c.find("div, p, li, tr, pre, fieldset, blockquote, h1, h2, h3, h4, h5, h6").each((_, n) => { $(n).append("\n"); }); return tidyText(c.text()); } 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; } 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().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[]): El | null => pairs.find((x) => names.some((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; // 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 imgEls = p.screenshots ? $(p.screenshots) : desc?.length ? desc.find("img") : null; imgEls?.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); }); page.probe.imageCount = imgs.length; page.screenshots = imgs.slice(0, MAX_SCREENSHOTS); // 简介纯文本:去掉 MediaInfo 块与图片 if (desc?.length) { const c = desc.clone(); if (miBlockIndex >= 0) c.find(MI_BLOCKS).eq(miBlockIndex).remove(); c.find("img").remove(); const t = blockText($, c); if (t) page.descriptionText = t.slice(0, 20_000); } // 外部 ID:页面上的链接优先,其次简介正文 const descHtml = desc?.html() ?? ""; const f = conf.fields ?? {}; page.imdbId = extractImdbId(mcSelect($, null, f.imdb ?? "a[href*='imdb.com']::attr(href)")) ?? extractImdbId(descHtml) ?? null; page.doubanId = extractDoubanId(mcSelect($, null, f.douban ?? "a[href*='douban.com']::attr(href)")) ?? extractDoubanId(descHtml) ?? null; page.tmdb = extractTmdb($("a[href*='themoviedb.org']").first().attr("href")) ?? extractTmdb(descHtml); // 文件清单:站点选择器 → 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) { const parts = tagEl.find("span, a").length ? tagEl .find("span, a") .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 upEl = p.uploader ? $(p.uploader).first() : byLabel(LABELS.uploader); 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; }