- 促销:详情 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]>
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
/**
|
||
* 详情页扩展内容(简介 / 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;
|
||
}
|