/**
* 详情页扩展内容的通用文本规则(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;
/** 按完整地址排除:豆瓣海报(含站点的豆瓣图片代理 `?https://img…doubanio.com/…poster…`)与站点横幅装饰图 */
const NOT_SCREENSHOT_URL = /(doubanio\.com\/view\/photo\/[a-z_]*poster|\/banners?\/)/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;
}
/** TMDB:themoviedb.org 链接,或 MediaInfo / 简介里的「TMDB : tv/294990」一行 */
export function extractTmdb(text: string | null | undefined): SiteDetailPage["tmdb"] {
const m = text
? (/themoviedb\.org\/(movie|tv)\/(\d+)/.exec(text) ?? /\bTMDB\s*(?:ID)?\s*[::]\s*(movie|tv)\/(\d+)/i.exec(text))
: null;
return m ? { type: m[1]!.toLowerCase() 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) || NOT_SCREENSHOT_URL.test(u.href)) 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;
}