- 促销:详情 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]>
150 lines
7.1 KiB
TypeScript
150 lines
7.1 KiB
TypeScript
/**
|
||
* 详情页解析 —— 本项目扩展。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 };
|
||
}
|