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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 16:21:17 +08:00
1 parent 6e9e8efec2
commit 04cffcd1f1
30 files changed
+1133 -64

No files matched your search

@@ -0,0 +1,107 @@
import { HHANCLUB_DETAIL_PAGE_FULL } from "@umt/testkit";
import { describe, expect, it } from "vitest";
import { hhanclubDefinition } from "../definitions/index.ts";
import { looksLikeMediaInfo, pageFromBbcode } from "../detail-text.ts";
import { getSiteDefinition } from "../registry.ts";
import { parseDetailPage } from "./detail.ts";
import type { IndexerDefinition } from "./schema.ts";
const OBSERVED = "2026-09-29T00:00:00.000Z";
describe("详情页扩展内容(HHClub div 模板)", () => {
const { kind, detail } = parseDetailPage(HHANCLUB_DETAIL_PAGE_FULL, hhanclubDefinition, "91002", OBSERVED);
const page = detail!.page!;
it("外部 ID、上传者、标签", () => {
expect(kind).toBe("ok");
expect(page).toMatchObject({
imdbId: "tt14452776",
doubanId: "36000001",
tmdb: { type: "tv", id: "136315" },
uploader: "uploader01",
tags: ["官方", "中字", "完结"],
});
expect(detail!.imdbId).toBe("tt14452776");
expect(detail!.tags).toEqual(["官方", "中字", "完结"]);
});
it("MediaInfo 从简介的代码块取出,简介里不再重复", () => {
expect(page.mediaInfo).toMatch(/^General\nUnique ID : 1234\n/);
expect(page.mediaInfo).toContain("Format : HEVC");
expect(page.descriptionText).toContain("◎译  名 示例剧集");
expect(page.descriptionText).not.toContain("Unique ID");
});
it("截图:相对地址补全、优先 data-src、过滤表情与占位图", () => {
expect(page.screenshots).toEqual([
"https://hhanclub.net/attachments/202609/poster.jpg",
"https://img.example-host.com/i/shot1.png",
"https://img.example-host.com/i/shot2.jpg",
]);
});
it("文件清单:「名称 (大小)」", () => {
expect(page.files).toEqual([
{ name: "Example.Show.S01E01.mkv", sizeBytes: Math.round(1.04 * 1024 ** 3) },
{ name: "Example.Show.S01E02.mkv", sizeBytes: Math.round(1.02 * 1024 ** 3) },
]);
});
it("诊断:记录页面上的字段标签", () => {
expect(page.probe.labels).toEqual(expect.arrayContaining(["标题", "副标题", "标签", "上传者", "文件列表"]));
expect(page.probe.hasDescription).toBe(true);
});
});
describe("详情页扩展内容(标准 NexusPHP rowhead 表格)", () => {
const def = getSiteDefinition("audiences") as IndexerDefinition;
const html = `<html><body><a href="logout.php">退出</a><h1 id="top">Some.Movie.2024.1080p.BluRay.x264-GRP</h1>
<table>
<tr><td class="rowhead">副标题</td><td class="rowfollow">某电影</td></tr>
<tr><td class="rowhead">基本信息</td><td class="rowfollow"><b>大小:</b>8.00 GB &nbsp; <b>类型:</b> 电影</td></tr>
<tr><td class="rowhead">标签</td><td class="rowfollow"><span>中字</span><span>DIY</span></td></tr>
<tr><td class="rowhead">上传者</td><td class="rowfollow"><i>匿名</i></td></tr>
<tr><td class="rowhead">简介</td><td class="rowfollow"><div id="kdescr">一段简介<br>第二行
<div class="codemain">DISC INFO:<br>Disc Title: X<br>Size: 1<br>Protection: AACS<br>Playlist: 00800.MPLS<br>Length: 2:00:00<br>Video: MPEG-4 AVC<br>Total Bitrate: 30 Mbps</div>
<img src="https://img.example-host.com/a.png"></div></td></tr>
</table>
<table id="filelist"><tr><td>Some.Movie.mkv</td><td>8.00 GB</td></tr></table></body></html>`;
it("rowhead 标签、BDInfo、#filelist 表格", () => {
const { detail } = parseDetailPage(html, def, "1", OBSERVED);
const page = detail!.page!;
expect(page.tags).toEqual(["中字", "DIY"]);
expect(page.uploader).toBe("匿名");
expect(page.mediaInfo).toContain("Disc Title: X");
expect(page.descriptionText).toBe("一段简介\n第二行");
expect(page.screenshots).toEqual(["https://img.example-host.com/a.png"]);
expect(page.files).toEqual([{ name: "Some.Movie.mkv", sizeBytes: 8 * 1024 ** 3 }]);
expect(page.imdbId).toBeNull();
});
});
describe("M-Team BBCode 简介", () => {
it("截图取 [img],MediaInfo 取引用块,外部 ID 从正文取", () => {
const descr = `[img]https://img.example-host.com/p.jpg[/img]
◎简介 这是简介 https://www.imdb.com/title/tt15239678/
[quote]General
Unique ID : 1
Complete name : a.mkv
Format : Matroska
File size : 1 GiB
Video
Format : AVC[/quote]
[img=https://img.example-host.com/s.png]`;
const page = pageFromBbcode(descr, "https://kp.m-team.cc/", { tags: ["中字"] });
expect(page.screenshots).toEqual(["https://img.example-host.com/p.jpg", "https://img.example-host.com/s.png"]);
expect(page.mediaInfo).toMatch(/^General\n/);
expect(page.descriptionText).toBe("◎简介 这是简介 https://www.imdb.com/title/tt15239678/");
expect(page.imdbId).toBe("tt15239678");
expect(page.tags).toEqual(["中字"]);
});
it("MediaInfo 识别不误判普通简介", () => {
expect(looksLikeMediaInfo("General\n一段文字\nVideo")).toBe(false);
});
});
@@ -0,0 +1,208 @@
/**
* 详情页扩展内容(简介 / 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;
}
@@ -6,6 +6,7 @@
import type { CheerioAPI } from "cheerio";
import type { SiteReleaseDetail } from "../types.ts";
import { extractDoubanId, extractImdbId, parseCount, parseSiteTime } from "../util.ts";
import { extractDetailPage } from "./detail-page.ts";
import { categoryName, promotionFromFactors } from "./map.ts";
import { mcSelect } from "./mc-selector.ts";
import type { IndexerDefinition } from "./schema.ts";
@@ -115,5 +116,10 @@ export function parseDetailPage(
if (imdb) detail.imdbId = imdb;
const douban = extractDoubanId(mcSelect($, null, f.douban)) ?? extractDoubanId(descHtml);
if (douban) detail.doubanId = douban;
const page = extractDetailPage($, conf, def.domain);
page.imdbId ??= detail.imdbId ?? null;
page.doubanId ??= detail.doubanId ?? null;
if (page.tags.length) detail.tags = page.tags;
detail.page = page;
return { kind: "ok", detail };
}
@@ -198,6 +198,20 @@ export interface UmtSiteExtension {
fields?: Partial<
Record<"title" | "subtitle" | "size" | "description" | "download" | "imdb" | "douban" | "fileList", string>
>;
/**
* 详情页扩展内容(简介 / MediaInfo / 截图 / 文件 / 标签 / 上传者)的站点特化,均可省略(走通用规则,见 detail-page.ts)。
* 选择器为普通 CSS。
*/
page?: {
/** 除 rowLabel 外的字段标签元素(div 版详情页:标签元素的下一个兄弟元素是值) */
labelSelectors?: string[];
mediaInfo?: string;
screenshots?: string;
tags?: string;
uploader?: string;
/** 文件清单行(每行一个文件,文本形如「名称 (大小)」或两列表格) */
files?: string;
};
};
/** 下载路径(按资源 ID 拼接) */
downloadPath: string;