Files
UHD_Media_Tool/packages/adapters/src/sites/spider/detail.ts
T
rootandClaude Opus 5.5 d9f2af2f3c feat(adapters): port MoviePilot open site framework (7 spiders, 20 user parsers) + MovieClaw v0.27.0 site definitions + mp-sync tools (W1-3c)
- sites/spiders: Haidan/HDDolby/RousiPro/SunnyPT/TNode/TorrentLeech/Yema (+mTorrent wrapper), specialized SiteAdapter, indirect download resolved at download time
- sites/user-parsers: SiteParserBase + all 20 MP SiteSchema parsers (lxml XPath via xpath+xmldom), refreshSiteUserData with schema fallback
- sites/definitions: zod SiteDefinition schemas, 23 MovieClaw v0.27.0 (MIT) definitions via scripts/mp-sync/movieclaw-import.mjs, registry getSiteDefinition/listSiteDefinitions, NOTICE
- scripts/mp-sync: ledger.json (upstream sha256), check.mjs, build-ledger.mjs; docs/porting/mp-sync-ledger.md
- license-and-provenance: add copied-mit kind, re-anchor MovieClaw rows to v0.27.0

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 18:39:27 +08:00

120 lines
5.3 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 { 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, " ")
.trim();
const subtitle = f.subtitle ? (mcSelect($, null, f.subtitle) ?? undefined) : row(conf.labels.subtitle)?.text().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);
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)
.filter((k) => fields[k])
.map((k) => [k, fields[k]]),
),
},
browse: undefined,
};
const promo = new SiteSpider(promoDef).infoFrom($, $(conf.headerScope).first());
const timeTitle = $("span[title]")
.filter((_, el) => /\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test($(el).attr("title") ?? ""))
.first()
.attr("title");
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]) : null,
leechers: leechers ? parseCount(leechers[1] ?? leechers[2]) : null,
snatched: null,
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 (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;
return { kind: "ok", detail };
}