/** * 详情页扩展内容的通用文本规则(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; }