Files
UHD_Media_Tool/packages/adapters/src/sites/spider/detail.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

150 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
/**
* 详情页解析 —— 本项目扩展。MP 通用 SiteSpider 不解析详情页(MP 只在列表/搜索结果上工作),
* 这里按 NexusPHP details.php 公开结构(h1#top + td.rowhead/td.rowfollow)实现,选择器在站点定义 `umt.detail` 中配置。
* 促销/H&R 复用站点定义中 downloadvolumefactor / uploadvolumefactor / freedate / hr 字段(与列表同语义)。
*/
import type { CheerioAPI } from "cheerio";
import type { SiteReleaseDetail } from "../types.ts";
import { extractDoubanId, extractImdbId, parseCount, parseSiteTime } from "../util.ts";
import { extractDetailPage } from "./detail-page.ts";
import { categoryName, promotionFromFactors } from "./map.ts";
import { mcSelect } from "./mc-selector.ts";
import type { IndexerDefinition } from "./schema.ts";
import { loadHtml, parseSizeMp, SiteSpider } from "./spider.ts";
export type DetailPageKind = "ok" | "login" | "not_found" | "unknown";
export function parseDetailPage(
html: string,
def: IndexerDefinition,
id: string,
observedAt: string,
): { kind: DetailPageKind; detail: SiteReleaseDetail | null } {
const conf = def.umt.detail;
if (!conf) return { kind: "unknown", detail: null };
const $ = loadHtml(html);
if (SiteSpider.isLoginOrPermissionPage($)) return { kind: "login", detail: null };
const bodyHead = $("body").text().replace(/\s+/g, " ").slice(0, 2000);
if (conf.notFoundMarkers.some((m) => bodyHead.includes(m))) return { kind: "not_found", detail: null };
const h1 = $(conf.title).first();
if (h1.length === 0) return { kind: "unknown", detail: null };
const row = (labels: string[]) => {
let found: ReturnType<CheerioAPI> | null = null;
$(conf.rowLabel).each((_, el) => {
const label = $(el).text().replace(/\s+/g, "");
if (!found && labels.some((l) => label.startsWith(l.replace(/\s+/g, "")))) {
found = $(el).nextAll(conf.rowValue).first();
}
});
return found as ReturnType<CheerioAPI> | null;
};
// 逐字段选择器(MovieClaw detail_*_css)优先于 rowhead 表格模型
const f = conf.fields ?? {};
const title = (
f.title
? (mcSelect($, null, f.title) ?? "")
: h1
.contents()
.filter((_, n) => n.type === "text")
.text()
)
.replace(/\s+/g, " ")
// 促销到期的「剩余时间:」是 h1 的直接文本节点(观众),不属于标题
.replace(/\s*(?:剩余时间|剩餘時間|Remaining(?: time)?)\s*[::]?\s*$/i, "")
.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)
: (/(?:大小|Size)\s*[::]\s*([\d.,]+\s*[KMGTP]?i?B)/i.exec(basicText)?.[1] ?? null);
const catText = /(?:类型|Type)\s*[::]\s*(\S+)/i.exec(basicText)?.[1] ?? null;
const catId = catText
? (Object.entries(def.umt.categoryNames ?? {}).find(([, name]) => name === catText)?.[0] ?? null)
: null;
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() ?? "";
// 促销/HR:只用这四个字段在详情页头部取值
const fields = def.torrents.fields;
const promoDef: IndexerDefinition = {
...def,
torrents: {
list: def.torrents.list,
fields: Object.fromEntries(
(["downloadvolumefactor", "uploadvolumefactor", "freedate", "hr"] as const)
.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") ?? "") &&
$(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(
$("body").text().replace(/\s+/g, " "),
)?.[1];
const detail: SiteReleaseDetail = {
siteId: def.id,
siteReleaseId: id,
title,
category: { id: catId, name: catText ?? categoryName(def, catId ?? undefined) },
sizeBytes: sizeText ? parseSizeMp(sizeText) : 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,
promo.freedate ? parseSiteTime(promo.freedate, def.umt.timezone) : null,
),
hr: promo.hit_and_run === undefined ? "unknown" : promo.hit_and_run,
uploadedAt: parseSiteTime(timeTitle, def.umt.timezone),
detailUrl: new URL(`${def.umt.detailPath}?id=${id}`, def.domain).toString(),
downloadUrl: new URL(`${def.umt.downloadPath}?id=${id}`, def.domain).toString(),
tags: [],
raw: { basicInfoText: basicText.trim().slice(0, 500), peersText: peersText.trim(), ...promo },
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;
const douban = extractDoubanId(mcSelect($, null, f.douban)) ?? extractDoubanId(descHtml);
if (douban) detail.doubanId = douban;
const page = extractDetailPage($, conf, def.domain);
page.imdbId ??= detail.imdbId ?? null;
page.doubanId ??= detail.doubanId ?? null;
// 详情页扩展内容里兜底出来的外部 ID(MediaInfo 的 IMDB 行、豆瓣信息块)也回填到资源本身
if (!detail.imdbId && page.imdbId) detail.imdbId = page.imdbId;
if (!detail.doubanId && page.doubanId) detail.doubanId = page.doubanId;
if (page.tags.length) detail.tags = page.tags;
detail.page = page;
return { kind: "ok", detail };
}