- 促销:详情 h1 是 font.free / font.halfdown 等 + 剩余时间 span[title],新增 umt.detail.promo 覆盖列表促销字段(以前一律判成无促销并覆盖列表的免费) - 标题去掉 h1 里的「剩余时间:」;发布时间不再取标题区的促销到期时间 - 做种 / 下载 / 完成数、种子 Hash:umt.detail.fields.seeders / leechers / snatched / infoHash - 副标题去掉评分徽标;标签只取最内层;上传者取 span.torrent-uploader-wrap,标签精确匹配(避开「发布者带宽」) - 简介:去掉站点声明(page.descriptionStrip)与 MediaInfo 控件(codetop / show / hide),源码换行不再算一行 - 截图排除豆瓣海报与站点横幅 - 列表副标题改取 span.torrent-subtitle-text(以前前面粘着标签文字) - 脱敏真实样本:详情 2 页进 differential/detail.test.ts,列表 1 页进 MP 差分 Co-Authored-By: Claude Opus 5.5 <[email protected]>
115 lines
4.4 KiB
TypeScript
115 lines
4.4 KiB
TypeScript
/**
|
||
* 详情页扩展内容的通用文本规则(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;
|
||
}
|