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

+108
View File
@@ -0,0 +1,108 @@
/**
* 详情页扩展内容的通用文本规则(NexusPHP HTML 与 M-Team BBCode 共用):MediaInfo 识别、截图过滤、TMDB 链接。
*/
import type { SiteDetailPage } from "./types.ts";
import { extractDoubanId, extractImdbId } from "./util.ts";
/** 表情 / 图标 / 旗帜 / 头像等小图,不算截图 */
const NOT_SCREENSHOT =
/(smilies|smiley|emoji|\/icons?\/|pic\/(?:trans|cattrans|flag)|\/flags?\/|avatar|\.svg(?:$|\?))/i;
export const MAX_SCREENSHOTS = 20;
export const MAX_FILES = 500;
/** MediaInfo / BDInfo 文本:有概览段落 + 视频段落,且至少 5 行「键 : 值」 */
export function looksLikeMediaInfo(text: string): boolean {
if (!/(^|\n)\s*(General|概览|常规|DISC INFO|Disc Title|Complete name)\b/i.test(text)) return false;
if (!/(^|\n)\s*(Video|视频)\b/i.test(text)) return false;
return (text.match(/^[^\n::]{2,40}[ \t]*[::][ \t]*\S/gm) ?? []).length >= 5;
}
export function extractTmdb(text: string | null | undefined): SiteDetailPage["tmdb"] {
const m = text ? /themoviedb\.org\/(movie|tv)\/(\d+)/.exec(text) : null;
return m ? { type: m[1] as "movie" | "tv", id: m[2]! } : null;
}
/** 图片地址转绝对 http(s) 地址并过滤非截图;无效返回 null */
export function screenshotUrl(src: string | null | undefined, base: string): string | null {
if (!src || src.startsWith("data:")) return null;
let u: URL;
try {
u = new URL(src.trim(), base);
} catch {
return null;
}
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
if (NOT_SCREENSHOT.test(u.pathname)) return null;
return u.toString();
}
/** 多行文本规整:去 \r、行尾空白,连续空行压成一行 */
export function tidyText(text: string): string {
return text
.replace(/\r/g, "")
.replace(/ /g, " ")
.split("\n")
.map((l) => l.replace(/\s+$/, ""))
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export function emptyDetailPage(): SiteDetailPage {
return {
descriptionText: null,
mediaInfo: null,
screenshots: [],
imdbId: null,
doubanId: null,
tmdb: null,
files: [],
tags: [],
uploader: null,
probe: { labels: [], hasDescription: false, imageCount: 0 },
};
}
/**
* M-Team 详情(BBCode 简介 + 独立 mediainfo 字段)→ 扩展内容。
* 截图取 [img] 标签;简介去掉 BBCode 标记与 MediaInfo 引用块。
*/
export function pageFromBbcode(
descr: string | null | undefined,
base: string,
extra: { mediaInfo?: string | null; imdbId?: string | null; doubanId?: string | null; tags?: string[] } = {},
): SiteDetailPage {
const page = emptyDetailPage();
const src = descr ?? "";
const imgs: string[] = [];
for (const m of src.matchAll(/\[img(?:=([^\]]+))?\]([^[]*)\[\/img\]|\[img=([^\]]+)\]/gi)) {
const u = screenshotUrl((m[2] || m[1] || m[3] || "").trim(), base);
if (u && !imgs.includes(u)) imgs.push(u);
}
page.probe.imageCount = imgs.length;
page.screenshots = imgs.slice(0, MAX_SCREENSHOTS);
let mediaInfo = extra.mediaInfo?.trim() || null;
let body = src.replace(/\[img(?:=[^\]]+)?\][^[]*\[\/img\]|\[img=[^\]]+\]/gi, "");
// [quote] / [code] / [mediainfo] 块里的 MediaInfo
body = body.replace(
/\[(quote|code|mediainfo|box|hide|spoiler)(=[^\]]*)?\]([\s\S]*?)\[\/\1\]/gi,
(all, _t, _a, inner) => {
const text = tidyText(String(inner).replace(/\[\/?[a-z*]+(?:=[^\]]*)?\]/gi, ""));
if (looksLikeMediaInfo(text)) {
mediaInfo ??= text;
return "";
}
return all;
},
);
const text = tidyText(body.replace(/\[\/?[a-z*]+(?:=[^\]]*)?\]/gi, ""));
page.probe.hasDescription = !!src.trim();
page.descriptionText = text ? text.slice(0, 20_000) : null;
page.mediaInfo = mediaInfo ? mediaInfo.slice(0, 50_000) : null;
page.imdbId = extra.imdbId ?? extractImdbId(src) ?? null;
page.doubanId = extra.doubanId ?? extractDoubanId(src) ?? null;
page.tmdb = extractTmdb(src);
page.tags = [...new Set(extra.tags ?? [])].slice(0, 20);
return page;
}