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:
1 parent
6e9e8efec2
commit
04cffcd1f1
30 files changed
+1133
-64
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user