Files
UHD_Media_Tool/packages/adapters/src/sites/spider/detail-page.ts
T
rootandClaude Opus 5.5 04cffcd1f1 feat(sites): on-demand site detail page content (INT-2)
- SiteReleaseDetail.page: description, MediaInfo/BDInfo, screenshots,
  IMDb / Douban / TMDB ids, file list, tags, uploader, plus a parse probe
  (labels found) for layout-change diagnosis. Generic NexusPHP rules
  (field label -> next sibling) starting from the MovieClaw detail
  selectors; site overrides under umt.detail.page (HHClub: div labels).
  M-Team builds the same structure from descr BBCode + mediainfo.
- Fetched only on demand (drawer open without cache, "复核详情",
  release.inspect); content in kv_cache region site.detail for the
  site detail cache period (>= 24 h); within the window the site is not
  requested again. External ids persist in source_release.raw._detail,
  which list snapshots do not overwrite.
- Screenshots only as proxy paths /api/v1/img/site-detail/{rid}/{i}:
  public-address check on every hop, no site credentials, magic-byte
  image check, 8 MB cap. Poster proxy shares the same helper.
- Identity: site ids count as explicit-id evidence; when TMDB is
  queried, a TMDB id goes straight to details and an IMDb id to
  /find (no title search on a hit). Douban only for local recall.
- Release drawer "站点详情页" section; fixtures and tests for HHClub div
  layout, rowhead layout, BBCode, cache/proxy/identity end to end.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 16:21:17 +08:00

209 lines
7.6 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"],
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> 与块级元素换行 */
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;
}