/** * 详情页解析 —— 本项目扩展。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 | 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 | 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 }; }