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

250 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* 通用 spider 站点适配器(NexusPHP 等 HTML 站)。
*
* 对应 MoviePilot e6820a8:
* - `app/modules/indexer/__init__.py IndexerModule.search_torrents / refresh_torrents` → `search` / `list`
* (`__search_check` 的英文站不搜中文 → search 前检查;`__clear_search_text` → `clearSearchText`;
* `SitesHelper.check(domain)` 流控 → 站点令牌桶)
* - `SiteSpider.get_torrents`(RequestUtils GET + cookie + ua)→ `fetchPage`
* - `app/chain/download/submission.py download_torrent`(带 cookie GET enclosure,磁力原样返回)→ `downloadTarget`
*
* 本项目调整:登录页不再"返回空列表",而是抛 `auth_expired`(REQ §4.7.1 不展示空列表冒充无内容);
* 3xx 不自动跟随(避免把 cookie 带到站外,并把跳转 login 识别为失效);凭证经 SecretResolver 注入。
*/
import { redactText } from "../../rss/redact.ts";
import { buildHttpContext, type SiteAdapterDeps } from "../deps.ts";
import { readBody, readText, siteFetch } from "../http.ts";
import {
type DownloadTarget,
type SiteAdapter,
type SiteCallOptions,
SiteError,
type SiteHealth,
type SiteListFilters,
type SitePage,
type SiteReleaseDetail,
} from "../types.ts";
import { inspectTorrent } from "../util.ts";
import { parseDetailPage } from "./detail.ts";
import { spiderTorrentToRelease } from "./map.ts";
import type { IndexerDefinition } from "./schema.ts";
import { loadHtml, normalizeSelector, SiteSpider } from "./spider.ts";
/** MP `text_tools.remove_punctuation(text, replacement=" ", allow_space=True)` */
export function clearSearchText(text: string): string {
return text
.replace(/[​-‍]/g, "")
.replace(/[、.。,,·::;;!!??'’"“”()()[\]【】「」\-—―+|\\_/&#~~]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function containsChinese(s: string): boolean {
return /[一-鿿]/.test(s);
}
export function healthFromError(siteId: string, e: unknown, checkedAt: string): SiteHealth {
if (e instanceof SiteError) {
const status: SiteHealth["status"] =
e.kind === "network"
? "unreachable"
: e.kind === "not_found" || e.kind === "unsupported"
? "parse_error"
: e.kind;
return { siteId, status, ok: false, checkedAt, latencyMs: null, message: e.message };
}
return { siteId, status: "unreachable", ok: false, checkedAt, latencyMs: null, message: redactText(String(e)) };
}
export function createSpiderSiteAdapter(input: IndexerDefinition, deps: SiteAdapterDeps): SiteAdapter {
const def: IndexerDefinition = deps.baseUrl ? { ...input, domain: deps.baseUrl } : input;
const ext = def.umt;
const http = buildHttpContext(def.id, deps, ext.rateLimit);
const base = def.domain.endsWith("/") ? def.domain : `${def.domain}/`;
async function cookie(): Promise<string> {
const c = await deps.secrets.resolve({ siteId: def.id, kind: "cookie" });
if (!c) throw new SiteError(def.id, "auth_missing", "未配置站点 Cookie");
return c;
}
async function fetchPage(url: string, what: string, opts: SiteCallOptions): Promise<string> {
const res = await siteFetch(http, url, {
method: "GET",
headers: { cookie: await cookie(), accept: "text/html,application/xhtml+xml", referer: base },
redirect: "manual",
...(opts.signal ? { signal: opts.signal } : {}),
});
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get("location") ?? "";
await res.body?.cancel().catch(() => {});
if (/login/i.test(loc)) throw new SiteError(def.id, "auth_expired", `${what}:跳转登录页,Cookie 已失效`);
throw new SiteError(def.id, "parse_error", redactText(`${what}:意外重定向到 ${loc}`), { httpStatus: res.status });
}
if (res.status === 401 || res.status === 403) {
await res.body?.cancel().catch(() => {});
throw new SiteError(def.id, "auth_invalid", `${what}:HTTP ${res.status}`, { httpStatus: res.status });
}
if (!res.ok) {
await res.body?.cancel().catch(() => {});
throw new SiteError(def.id, res.status === 404 ? "not_found" : "network", `${what}:HTTP ${res.status}`, {
httpStatus: res.status,
});
}
return readText(http, res);
}
async function run(
page: number,
filters: SiteListFilters,
keyword: string | undefined,
opts: SiteCallOptions,
): Promise<SitePage> {
const what = keyword ? "搜索" : `列表第 ${page} 页`;
const spider = new SiteSpider(def, {
...(keyword ? { keyword } : {}),
...(filters.mtype ? { mtype: filters.mtype } : {}),
...(filters.categoryIds?.length ? { cat: filters.categoryIds.join(",") } : {}),
page: Math.max(1, page) - 1, // 对外 1 起,MP 内部 0 起
});
if (!spider.canSearch()) throw new SiteError(def.id, "unsupported", `${what}:站点定义不支持该媒体类型`);
const html = await fetchPage(spider.getSearchUrl(), what, opts);
const parsed = spider.parse(html);
if (parsed.loginPage) throw new SiteError(def.id, "auth_expired", `${what}:返回登录或权限提示页`);
if (parsed.isError || !parsed.$) throw new SiteError(def.id, "parse_error", `${what}:响应为空`);
const $ = parsed.$;
if (parsed.torrents.length === 0) {
const text = $("body").text();
const hasList =
$(normalizeSelector(spider.def.torrents.list.selector)).length > 0 || $("table.torrents").length > 0;
const empty = (ext.emptyMarkers ?? []).some((m) => text.includes(m));
if (!hasList && !empty) throw new SiteError(def.id, "parse_error", `${what}:页面结构无法识别(可能改版)`);
if (ext.loginSuccess && $(ext.loginSuccess).length === 0 && !empty) {
throw new SiteError(def.id, "auth_expired", `${what}:未找到登录标志`);
}
}
const observedAt = http.now().toISOString();
let items = parsed.torrents
.map((t) => spiderTorrentToRelease(t, def, observedAt))
.filter((r): r is NonNullable<typeof r> => r !== null);
if (filters.freeOnly) items = items.filter((r) => r.promotion.downloadFactor === 0);
if (!keyword && filters.categoryIds?.length) {
// MP 浏览模式不带分类参数;本地按原始分类过滤
const set = new Set(filters.categoryIds);
items = items.filter((r) => r.category.id !== null && set.has(r.category.id));
}
let maxRaw: number | null = null;
if (ext.pagination) {
$(ext.pagination).each((_, el) => {
const p = Number(/[?&]page=(\d+)/.exec($(el).attr("href") ?? "")?.[1]);
if (Number.isInteger(p) && (maxRaw === null || p > maxRaw)) maxRaw = p;
});
}
const start = keyword ? 0 : Number(def.browse?.start ?? 0);
return {
items,
page: Math.max(1, page),
totalPages: maxRaw === null ? null : Math.max(page, (maxRaw as number) - start + 1),
totalPagesEstimated: true,
fetchedAt: observedAt,
};
}
return {
id: def.id,
kind: ext.kind,
displayName: ext.displayName ?? def.name,
capabilities: ext.capabilities,
definitionVersion: ext.version,
list(page, filters = {}, opts = {}) {
return run(page, filters, undefined, opts);
},
async search(query, filters = {}, opts = {}) {
const kw = /^tt\d+$/.test(query.trim()) ? query.trim() : clearSearchText(query);
if (kw && def.language === "en" && containsChinese(kw)) {
throw new SiteError(def.id, "unsupported", "该站点不支持中文搜索");
}
return run(filters.page ?? 1, filters, kw, opts);
},
async detail(siteReleaseId, opts = {}): Promise<SiteReleaseDetail> {
if (!/^\d+$/.test(siteReleaseId)) throw new SiteError(def.id, "not_found", "资源 ID 非法");
if (!ext.detail) throw new SiteError(def.id, "unsupported", "站点定义未配置详情页解析");
const url = new URL(`${ext.detail.path}?id=${siteReleaseId}&hit=1`, base).toString();
const html = await fetchPage(url, `详情 ${siteReleaseId}`, opts);
const r = parseDetailPage(html, def, siteReleaseId, http.now().toISOString());
if (r.kind === "login") throw new SiteError(def.id, "auth_expired", `详情 ${siteReleaseId}:返回登录页`);
if (r.kind === "not_found") throw new SiteError(def.id, "not_found", `详情 ${siteReleaseId}:资源不存在`);
if (!r.detail) throw new SiteError(def.id, "parse_error", `详情 ${siteReleaseId}:页面结构无法识别`);
return r.detail;
},
async downloadTarget(siteReleaseId, opts = {}): Promise<DownloadTarget> {
if (!/^\d+$/.test(siteReleaseId)) throw new SiteError(def.id, "not_found", "资源 ID 非法");
const what = `下载 ${siteReleaseId}`;
const url = new URL(`${ext.downloadPath}?id=${siteReleaseId}`, base).toString();
const res = await siteFetch(http, url, {
method: "GET",
headers: { cookie: await cookie(), referer: base },
redirect: "manual",
...(opts.signal ? { signal: opts.signal } : {}),
});
const verifiedAt = http.now().toISOString();
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get("location") ?? "";
await res.body?.cancel().catch(() => {});
// MP download_torrent:磁力链原样返回
if (loc.startsWith("magnet:")) return { kind: "magnet", uri: loc, verifiedAt };
if (/login/i.test(loc)) throw new SiteError(def.id, "auth_expired", `${what}:跳转登录页,Cookie 已失效`);
throw new SiteError(def.id, "parse_error", `${what}:意外重定向`, { httpStatus: res.status });
}
if (!res.ok) {
await res.body?.cancel().catch(() => {});
throw new SiteError(def.id, res.status === 404 ? "not_found" : "network", `${what}:HTTP ${res.status}`, {
httpStatus: res.status,
});
}
const bytes = await readBody(http, res);
const info = inspectTorrent(bytes);
if (!info) {
// 站点在 cookie 失效时常返回 200 + 登录页 HTML
const $ = loadHtml(new TextDecoder().decode(bytes.subarray(0, 200_000)));
if (SiteSpider.isLoginOrPermissionPage($)) throw new SiteError(def.id, "auth_expired", `${what}:返回登录页`);
const text = $("body").text();
if (ext.detail?.notFoundMarkers.some((m) => text.includes(m))) {
throw new SiteError(def.id, "not_found", `${what}:资源不存在`);
}
throw new SiteError(def.id, "parse_error", `${what}:返回内容不是 .torrent`);
}
const disposition = res.headers.get("content-disposition") ?? "";
const fileName =
/filename\*=UTF-8''([^;]+)/i.exec(disposition)?.[1] ?? /filename="?([^";]+)"?/i.exec(disposition)?.[1] ?? null;
return {
kind: "torrent",
bytes,
infoHash: info.infoHash,
name: info.name,
fileName: fileName ? decodeURIComponent(fileName) : null,
verifiedAt,
};
},
async healthCheck(opts = {}): Promise<SiteHealth> {
const started = Date.now();
const checkedAt = http.now().toISOString();
try {
const html = await fetchPage(new URL("index.php", base).toString(), "健康检查", opts);
const $ = loadHtml(html);
if (SiteSpider.isLoginOrPermissionPage($) || (ext.loginSuccess && $(ext.loginSuccess).length === 0)) {
throw new SiteError(def.id, "auth_expired", "健康检查:未登录");
}
return { siteId: def.id, status: "ok", ok: true, checkedAt, latencyMs: Date.now() - started };
} catch (e) {
return healthFromError(def.id, e, checkedAt);
}
},
};
}