Files
UHD_Media_Tool/packages/adapters/src/sites/detail-text.ts
T
rootandClaude Opus 5.5 92830c3ef3 fix(adapters): 观众详情页按真实页面适配(DET-2)
- 促销:详情 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]>
2026-09-30 20:49:29 +08:00

115 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 详情页扩展内容的通用文本规则(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;
}