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);
});
});