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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 20:49:29 +08:00
1 parent 322ecb2f8c
commit 92830c3ef3
17 files changed
+1301 -69

No files matched your search

+23 -7
View File
@@ -51,8 +51,15 @@ export function parseDetailPage(
.text()
)
.replace(/\s+/g, " ")
// 促销到期的「剩余时间:」是 h1 的直接文本节点(观众),不属于标题
.replace(/\s*(?:剩余时间|剩餘時間|Remaining(?: time)?)\s*[::]?\s*$/i, "")
.trim();
const subtitle = f.subtitle ? (mcSelect($, null, f.subtitle) ?? undefined) : row(conf.labels.subtitle)?.text().trim();
const subtitleEl = f.subtitle ? null : row(conf.labels.subtitle)?.clone();
// 副标题行右侧的豆瓣 / IMDb / 烂番茄等评分徽标(观众)不算副标题
subtitleEl?.find("[title*='评分'], [title*='Rating'], img").remove();
const subtitle = f.subtitle
? (mcSelect($, null, f.subtitle) ?? undefined)
: subtitleEl?.text().replace(/\s+/g, " ").trim();
const basicText = row(conf.labels.basicInfo)?.text().replace(/\s+/g, " ") ?? "";
const sizeText = f.size
? (/([\d.,]+\s*[KMGTP]?i?B)/i.exec(mcSelect($, null, f.size) ?? "")?.[1] ?? null)
@@ -64,6 +71,9 @@ export function parseDetailPage(
const peersText = row(conf.labels.peers)?.text().replace(/\s+/g, " ") ?? "";
const seeders = /(\d[\d,]*)\s*个?做种者|(\d[\d,]*)\s*seeder/i.exec(peersText);
const leechers = /(\d[\d,]*)\s*个?下载者|(\d[\d,]*)\s*leecher/i.exec(peersText);
// 站点把做种 / 下载 / 完成数放在其它位置时(观众:基本信息行右侧的 #seeders / #leechers / 完成链接)按选择器取
const countOf = (sel: string | undefined) => (sel ? parseCount(mcSelect($, null, sel) ?? undefined) : null);
const infoHash = mcSelect($, null, f.infoHash)?.toLowerCase();
const descEl = $(conf.description).first();
const descText = descEl.text().trim();
const descHtml = descEl.html() ?? "";
@@ -76,17 +86,22 @@ export function parseDetailPage(
list: def.torrents.list,
fields: Object.fromEntries(
(["downloadvolumefactor", "uploadvolumefactor", "freedate", "hr"] as const)
.filter((k) => fields[k])
.map((k) => [k, fields[k]]),
.map((k) => [k, conf.promo?.[k] ?? fields[k]] as const)
.filter(([, v]) => v),
),
},
browse: undefined,
};
const promo = new SiteSpider(promoDef).infoFrom($, $(conf.headerScope).first());
// 发布时间:带完整时间的 span[title] → 「发布时间 / 上传时间 / 添加时间」字段的文本(憨憨是 div 标签 + 值)
// 标题区(headerScope)里的 span[title] 是促销到期时间,不是发布时间
const timeTitle =
$("span[title]")
.filter((_, el) => /\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test($(el).attr("title") ?? ""))
.filter(
(_, el) =>
/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test($(el).attr("title") ?? "") &&
$(el).closest(conf.headerScope).length === 0,
)
.first()
.attr("title") ??
/(?:发布时间|上传时间|添加时间|Added)\s*[::]?\s*(\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?)/.exec(
@@ -99,9 +114,9 @@ export function parseDetailPage(
title,
category: { id: catId, name: catText ?? categoryName(def, catId ?? undefined) },
sizeBytes: sizeText ? parseSizeMp(sizeText) : null,
seeders: seeders ? parseCount(seeders[1] ?? seeders[2]) : null,
leechers: leechers ? parseCount(leechers[1] ?? leechers[2]) : null,
snatched: null,
seeders: seeders ? parseCount(seeders[1] ?? seeders[2]) : countOf(f.seeders),
leechers: leechers ? parseCount(leechers[1] ?? leechers[2]) : countOf(f.leechers),
snatched: countOf(f.snatched),
promotion: promotionFromFactors(
promo.downloadvolumefactor,
promo.uploadvolumefactor,
@@ -116,6 +131,7 @@ export function parseDetailPage(
observedAt,
};
if (subtitle) detail.subtitle = subtitle;
if (infoHash && /^[0-9a-f]{40}$/.test(infoHash)) detail.infoHash = infoHash;
if (descText) detail.descriptionText = descText.slice(0, 20_000);
const imdb = extractImdbId(mcSelect($, null, f.imdb)) ?? extractImdbId(descHtml) ?? extractImdbId(descText);
if (imdb) detail.imdbId = imdb;