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

@@ -43,17 +43,35 @@ const normLabel = (t: string) =>
.replace(/[::]+$/, "")
.toLowerCase();
/** 保留换行的文本:<br> 与块级元素换行 */
/** 换行占位:先把 <br> / 块级换行记成它,再去掉 HTML 源码里的换行(浏览器按空白处理),最后还原 */
const BR = "\u0000";
/**
* 保留换行的文本:<br> 与块级元素换行。源码换行不算换行(观众的简介是 `<br />\r\n`,
* 两者都算会让每行之间多一空行);<pre> 里的换行保留。
*/
function blockText($: CheerioAPI, el: El): string {
const c = el.clone();
c.find("script, style").remove();
c.find("br").replaceWith("\n");
c.find("div, p, li, tr, pre, fieldset, blockquote, h1, h2, h3, h4, h5, h6").each((_, n) => {
$(n).append("\n");
c.find("pre, textarea").each((_, n) => {
$(n).text($(n).text().replace(/\r?\n/g, BR));
});
return tidyText(c.text());
c.find("br").replaceWith(BR);
c.find("div, p, li, tr, pre, fieldset, blockquote, h1, h2, h3, h4, h5, h6").each((_, n) => {
$(n).append(BR);
});
const text = c
.text()
.replace(/\r?\n/g, " ")
// `<br />\r\n下一行` 的源码换行变成了行首一个空格
.replace(new RegExp(`${BR} `, "g"), BR)
.replaceAll(BR, "\n");
return tidyText(text);
}
/** NexusPHP MediaInfo 控件的外壳:标题栏 div.codetop、摘要表 div.show、原文 div.hide(观众三者相邻) */
const MI_WIDGET = "div.codetop, div.show, div.hide";
const SIZE_TAIL = /^(.*?)\s*[(([]\s*([\d.,]+\s*[KMGTP]?i?B)\s*[))\]]\s*$/i;
const HAS_EXT = /\.[A-Za-z0-9]{2,5}$/;
@@ -113,8 +131,9 @@ export function extractDetailPage($: CheerioAPI, conf: DetailConf, base: string)
};
addLabels(conf.rowLabel, (e) => e.nextAll(conf.rowValue).first());
for (const sel of p.labelSelectors ?? []) addLabels(sel, (e) => e.next());
const byLabel = (names: string[]): El | null =>
pairs.find((x) => names.some((n) => x.label.startsWith(normLabel(n))))?.value ?? null;
const byLabel = (names: string[], exact = false): El | null =>
pairs.find((x) => names.some((n) => (exact ? x.label === normLabel(n) : x.label.startsWith(normLabel(n)))))
?.value ?? null;
page.probe.labels = [...new Set(pairs.map((x) => x.label))].slice(0, 40);
// 简介容器
@@ -170,10 +189,30 @@ export function extractDetailPage($: CheerioAPI, conf: DetailConf, base: string)
page.probe.imageCount = imgs.length;
page.screenshots = imgs.slice(0, MAX_SCREENSHOTS);
// 简介纯文本:去掉 MediaInfo 块与图片
// 简介纯文本:去掉站点固定内容、MediaInfo 块(连同控件标题栏 / 摘要表)与图片
if (desc?.length) {
const c = desc.clone();
if (miBlockIndex >= 0) c.find(MI_BLOCKS).eq(miBlockIndex).remove();
if (miBlockIndex >= 0) {
const isMi = (n: Parameters<typeof $>[0]) => looksLikeMediaInfo(blockText($, $(n)));
// 同一份 MediaInfo 常出现两次(摘要 div.show 与原文 div.hide):另外去掉最内层的 MediaInfo 块,
// 在控件外壳(div.codetop / div.show / div.hide)里时连同相邻外壳一起去掉
const extra = c
.find(MI_BLOCKS)
.filter((_, n) => isMi(n) && !$(n).find(MI_BLOCKS).toArray().some(isMi))
.map((_, n) => {
const e = $(n);
const shell = e.parent().is(MI_WIDGET) ? e.parent() : e;
const out = [shell.get(0)!];
if (!shell.is(MI_WIDGET)) return out;
for (let x = shell.prev(); x.is(MI_WIDGET) && out.length < 3; x = x.prev()) out.push(x.get(0)!);
for (let x = shell.next(); x.is(MI_WIDGET) && out.length < 4; x = x.next()) out.push(x.get(0)!);
return out;
})
.get();
c.find(MI_BLOCKS).eq(miBlockIndex).remove();
for (const w of extra) $(w).remove();
}
if (p.descriptionStrip) c.find(p.descriptionStrip).remove();
c.find("img").remove();
const t = blockText($, c);
if (t) page.descriptionText = t.slice(0, 20_000);
@@ -221,17 +260,16 @@ export function extractDetailPage($: CheerioAPI, conf: DetailConf, base: string)
// 标签
const tagEl = p.tags ? $(p.tags) : byLabel(LABELS.tags);
if (tagEl?.length) {
const parts = tagEl.find("span, a").length
? tagEl
.find("span, a")
.map((_, n) => $(n).text().trim())
.get()
: tagEl.text().split(/[\s,,|/]+/);
// 只取最内层的 span / a(观众外面还包了一层 span.detail-tag-list,取外层会把全部标签连成一个)
const leaves = tagEl.find("span, a").filter((_, n) => $(n).find("span, a").length === 0);
const parts = leaves.length ? leaves.map((_, n) => $(n).text().trim()).get() : tagEl.text().split(/[\s,,|/]+/);
page.tags = [...new Set(parts.map((t) => t.trim()).filter((t) => t && t.length <= 20))].slice(0, 20);
}
// 上传者
const upEl = p.uploader ? $(p.uploader).first() : byLabel(LABELS.uploader);
// 上传者标签精确匹配:观众另有「发布者带宽」一行
const upSel = p.uploader ? $(p.uploader).first() : null;
const upEl = upSel?.length ? upSel : byLabel(LABELS.uploader, true);
if (upEl?.length) {
const a = upEl.is("a") ? upEl : upEl.find("a[href*='userdetails.php']").first();
const name = (a.length ? a.text() : upEl.text()).replace(/\s+/g, " ").trim();