Files
UHD_Media_Tool/scripts/mp-sync/movieclaw-import.mjs
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

469 lines
19 KiB
JavaScript

#!/usr/bin/env node
/**
* MovieClaw v0.27.0(MIT)站点 YAML → 本项目 SiteDefinition JSON 转换器(可重复执行)。
*
* 用法:
* node scripts/mp-sync/movieclaw-import.mjs [--repo spikes/movieclaw-src/repo] [--ref v0.27.0] [--check]
*
* - 只用 `git show <ref>:<path>` 读取文件(固定 tag,不读工作区,避免混入许可切换后的内容)。
* - 校验 <ref> 解析到的提交与 LICENSE 为 MIT;ref 不是 v0.27.0 时要求 --allow-ref(许可需另行核对)。
* - 输出:packages/adapters/src/sites/definitions/<site_id>.json 与 manifest.generated.ts。
* - 手工覆盖层:scripts/mp-sync/movieclaw-overrides/<site_id>.json 深合并到转换结果之上(覆盖层优先;
* `torrents.fields.<name>` 与数组整体替换)。
* - `--check`:只比较,不写文件;有差异时退出码 1(CI/跟随更新用)。
*
* 选择器默认值来自 MovieClaw `movieclaw_tracker/selectors.py` NexusPHPSelectors(MIT,见
* packages/adapters/src/sites/definitions/NOTICE-MovieClaw.md)。
*/
import { execFileSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
/** yaml 解析器:Deno(根 deno.json import map)或 Node(node_modules)均可 */
async function loadYaml() {
try {
return (await import("yaml")).default;
} catch {
for (const base of ["packages/adapters/package.json", "package.json"]) {
try {
return createRequire(join(ROOT, base))("yaml");
} catch {
/* next */
}
}
}
throw new Error("找不到 yaml 包:请用 `deno run -A scripts/mp-sync/movieclaw-import.mjs` 运行");
}
const YAML = await loadYaml();
const args = process.argv.slice(2);
const opt = (name, def) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : def;
};
const REPO = resolve(ROOT, opt("--repo", "spikes/movieclaw-src/repo"));
const REF = opt("--ref", "v0.27.0");
const CHECK = args.includes("--check");
const EXPECTED = { "v0.27.0": "c79bb1b065c826823fc1c15bcb99b3c282c3fce0" };
const OUT_DIR = join(ROOT, "packages/adapters/src/sites/definitions");
const OVERRIDES = join(ROOT, "scripts/mp-sync/movieclaw-overrides");
const CONFIG_DIR = "src/movieclaw_tracker/sites/configs";
/** 主力站:默认启用;其余 enabled:false(未实测) */
const PRIMARY = new Set(["audiences", "hhanclub", "chdbits", "mteam"]);
/** MP 用户信息解析器 schema(app/modules/indexer/parser SiteSchema 值) */
const MP_SCHEMA = { audiences: "NexusAudiences", hhanclub: "NexusHhanclub", mteam: "MTorrent", hddolby: "HDDolby" };
const CATEGORY_LABELS = {
movie: "电影",
tv: "剧集",
documentary: "纪录片",
anime: "动漫",
music: "音乐",
game: "游戏",
av: "成人",
other: "其他",
};
const TZ = { "Asia/Shanghai": "+08:00", "Asia/Hong_Kong": "+08:00", "Asia/Taipei": "+08:00", UTC: "+00:00" };
// ---- MovieClaw NexusPHPSelectors 默认值(v0.27.0 selectors.py) ----
const MC_DEFAULTS = {
login_path: "login.php",
login_username_field: "username",
login_password_field: "password",
login_error_css: "td.text",
login_success_css: "a[href*='logout']",
login_extra_form_data: [],
login_select_defaults: [],
torrent_list_path: "torrents.php",
torrent_row_css: "table.torrents > tr:not(:first-child)",
torrent_id_css: "a[href*='details.php']::attr(href)",
torrent_title_css: "a[href*='details.php']",
torrent_subtitle_css: "td.embedded > span",
torrent_category_css: "td:nth-child(1) img::attr(class)",
torrent_size_css: "td:nth-child(5)",
torrent_seeders_css: "td:nth-child(6)",
torrent_leechers_css: "td:nth-child(7)",
torrent_snatched_css: "td:nth-child(8)",
torrent_time_css: "td:nth-child(4) > span::attr(title)",
torrent_time_fallback_css: "td:nth-child(4)",
torrent_time_fmt: "%Y-%m-%d %H:%M:%S",
torrent_uploader_css: "td:nth-child(9) a",
promo_download_rules: [
["img.pro_free", 0.0],
["img.pro_free2up", 0.0],
["img.pro_50pctdown", 0.5],
["img.pro_50pctdown2up", 0.5],
["img.pro_30pctdown", 0.3],
],
promo_upload_rules: [
["img.pro_free2up", 2.0],
["img.pro_50pctdown2up", 2.0],
["img.pro_2up", 2.0],
],
torrent_hr_css: "",
torrent_promo_deadline_css: "",
torrent_promo_deadline_attr: "title",
torrent_promo_deadline_re: "\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}",
torrent_promo_deadline_fmt: "%Y-%m-%d %H:%M:%S",
torrent_detail_url_css: "a[href*='details.php']::attr(href)",
torrent_download_url_css: "a[href*='download.php']::attr(href)",
page_offset: 0,
search_path: "torrents.php",
search_keyword_param: "search",
detail_title_css: "#top::text",
detail_subtitle_css: "td.rowhead:contains('副标题') + td::text",
detail_description_css: "#kdescr",
detail_download_css: "a[href*='download.php']::attr(href)",
detail_imdb_css: "a[href*='imdb.com']::attr(href)",
detail_douban_css: "a[href*='douban.com']::attr(href)",
detail_size_css: "td.rowhead:contains('大小') + td::text",
detail_file_list_css: "td.rowfollow > ul > li::text",
profile_path: "userdetails.php",
profile_uid_css: "",
profile_username_css: "h1::text",
profile_class_css: "td.rowhead:contains('等级') + td::text",
profile_uploaded_css: "td.rowhead:contains('上传量') + td::text",
profile_downloaded_css: "td.rowhead:contains('下载量') + td::text",
profile_ratio_css: "td.rowhead:contains('分享率') + td::text",
profile_bonus_css: "td.rowhead:contains('魔力') + td::text",
profile_join_date_css: "td.rowhead:contains('加入日期') + td::text",
profile_seeding_css: "td.rowhead:contains('当前做种') + td::text",
profile_leeching_css: "td.rowhead:contains('当前下载') + td::text",
profile_vip_css: "",
download_path: "download.php",
};
/** MovieClaw custom_class 的行为差异 → 定义层表达(只收录能用定义表达的部分) */
const CUSTOM_CLASS_RULES = {
"movieclaw_tracker.sites.custom.ourbits.OurBitsSite": (def) => {
// 分类参数为重复的 cat[](custom/ourbits.py _apply_category_params)
def.category.param = "cat[]";
return true; // 已完全由定义表达
},
"movieclaw_tracker.sites.custom.ttg.TTGSite": () => false, // 分类/ID/做种列均非标准,需自定义适配器
"movieclaw_tracker.sites.custom.mteam.MTeamSite": () => true, // 由 mteam/* 实现
};
function git(...a) {
return execFileSync("git", ["-C", REPO, ...a], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
}
function stripPseudo(css) {
return css.replace(/::(attr\([^)]*\)|text|next_sibling_text)$/, "");
}
/** MovieClaw `X::attr(a)` / `X::text` / `X` → SiteSpider 字段选择器 */
function field(css, extra = {}) {
if (!css) return undefined;
const m = /^(.*)::attr\(([^)]+)\)$/.exec(css);
if (m) return { selector: m[1].trim(), attribute: m[2].trim(), ...extra };
return { selector: stripPseudo(css).trim(), ...extra };
}
function caseTable(rules) {
const out = {};
for (const [css, factor] of rules) out[css] = Number(factor);
out["*"] = 1;
return out;
}
function rulesFrom(v, fallback) {
if (v == null) return fallback;
if (Array.isArray(v)) return v;
return Object.entries(v);
}
function convertNexus(y, file) {
const s = { ...MC_DEFAULTS, ...(y.selectors ?? {}) };
s.promo_download_rules = rulesFrom(y.selectors?.promo_download_rules, MC_DEFAULTS.promo_download_rules);
s.promo_upload_rules = rulesFrom(y.selectors?.promo_upload_rules, MC_DEFAULTS.promo_upload_rules);
const id = y.site_id;
const tz = TZ[y.timezone ?? "Asia/Shanghai"];
if (!tz) throw new Error(`${file}: 未知时区 ${y.timezone}`);
const cats = y.categories ?? {};
const item = (k) => (cats[k] ?? []).map((cid) => ({ id: String(cid), desc: CATEGORY_LABELS[k] }));
const categoryNames = {};
for (const [k, ids] of Object.entries(cats))
for (const cid of ids ?? []) categoryNames[String(cid)] ??= CATEGORY_LABELS[k] ?? k;
const titleCss = stripPseudo(s.torrent_title_css);
const fields = {
title_default: { selector: titleCss },
title_optional: { selector: `${titleCss}[title]`, attribute: "title" },
title: {
text: "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}",
},
details: field(s.torrent_detail_url_css),
download: field(s.torrent_download_url_css),
imdbid: {
selector: 'a[href*="imdb.com/title/tt"]',
attribute: "href",
filters: [{ name: "re_search", args: ["tt\\d+", 0] }],
},
date_elapsed: field(s.torrent_time_fallback_css),
date_added: field(s.torrent_time_css),
date: {
text: "{% if fields['date_elapsed'] or fields['date_added'] %}{{ fields['date_added'] if fields['date_added'] else fields['date_elapsed'] }}{% else %}now{% endif %}",
filters: [{ name: "dateparse", args: s.torrent_time_fmt }],
},
size: field(s.torrent_size_css),
seeders: field(s.torrent_seeders_css),
leechers: field(s.torrent_leechers_css),
grabs: field(s.torrent_snatched_css),
downloadvolumefactor: { case: caseTable(s.promo_download_rules) },
uploadvolumefactor: { case: caseTable(s.promo_upload_rules) },
description: field(s.torrent_subtitle_css),
category: field(s.torrent_category_css, { filters: [{ name: "re_search", args: ["\\d+", 0] }] }),
};
if (s.torrent_promo_deadline_css) {
fields.freedate = {
selector: stripPseudo(s.torrent_promo_deadline_css),
attribute: s.torrent_promo_deadline_attr || "title",
filters: [{ name: "re_search", args: [s.torrent_promo_deadline_re, 0] }],
};
}
if (s.torrent_hr_css) fields.hr = { selector: s.torrent_hr_css };
const profile = {};
for (const [k, v] of Object.entries(s)) {
if (k.startsWith("profile_") && typeof v === "string" && v) profile[k.slice(8)] = v;
}
const def = {
id,
name: y.display_name,
enabled: PRIMARY.has(id),
schema: MP_SCHEMA[id] ?? "NexusPhp",
provenance: {
source: "movieclaw",
ref: REF,
commit: COMMIT.slice(0, 8),
license: "MIT",
file: `${CONFIG_DIR}/${file}`,
},
domain: `${String(y.base_url).replace(/\/+$/, "")}/`,
encoding: "UTF-8",
public: false,
proxy: false,
language: "zh",
result_num: 100,
timeout: Number(y.http?.timeout ?? 30),
search: { paths: [{ path: s.search_path, type: "all" }], params: { [s.search_keyword_param]: "{keyword}" } },
batch: { delimiter: " ", space_replace: "_" },
browse: { path: `${s.torrent_list_path}?page={page}`, start: Number(s.page_offset) === -1 ? 0 : 1 },
category: { movie: item("movie"), tv: item("tv"), music: item("music") },
torrents: { list: { selector: s.torrent_row_css }, fields },
umt: {
version: `${id}@mc-${REF}`,
kind: "nexusphp",
displayName: y.display_name,
timezone: tz,
rateLimit: { minIntervalMs: Math.round(Number(y.min_request_interval ?? 3) * 1000), burst: 1 },
capabilities: { list: true, search: true, detail: true, download: true, rss: false },
categoryNames,
loginSuccess: s.login_success_css,
emptyMarkers: ["没有种子", "没有结果", "Nothing found"],
pagination: 'a[href*="page="]',
detailPath: "details.php",
downloadPath: s.download_path,
...(y.auth?.supported ? { authSupported: y.auth.supported } : {}),
login: {
path: s.login_path,
usernameField: s.login_username_field,
passwordField: s.login_password_field,
errorCss: s.login_error_css,
...(s.login_extra_form_data?.length ? { extraFormData: pairs(s.login_extra_form_data) } : {}),
...(s.login_select_defaults && Object.keys(s.login_select_defaults).length
? { selectDefaults: pairs(s.login_select_defaults) }
: {}),
},
profile,
detail: {
path: "details.php",
title: stripPseudo(s.detail_title_css),
rowLabel: "td.rowhead",
rowValue: "td.rowfollow",
labels: {
subtitle: ["副标题", "Small Description"],
basicInfo: ["基本信息", "Basic Info"],
peers: ["同伴", "Peers"],
},
description: s.detail_description_css,
headerScope: stripPseudo(s.detail_title_css),
notFoundMarkers: ["没有该ID的种子", "No torrent with ID"],
fields: {
title: s.detail_title_css,
subtitle: s.detail_subtitle_css,
size: s.detail_size_css,
description: s.detail_description_css,
download: s.detail_download_css,
imdb: s.detail_imdb_css,
douban: s.detail_douban_css,
fileList: s.detail_file_list_css,
},
},
},
};
if (y.custom_class) {
def.umt.customClass = y.custom_class;
const rule = CUSTOM_CLASS_RULES[y.custom_class];
if (!rule) throw new Error(`${file}: 未登记的 custom_class ${y.custom_class}`);
if (!rule(def)) def.enabled = false;
}
if (y.hr_seed_hours) def.umt.hrSeedHours = Number(y.hr_seed_hours);
return def;
}
function pairs(v) {
if (Array.isArray(v)) return v.map((p) => [String(p[0]), String(p[1])]);
return Object.entries(v).map(([k, x]) => [k, String(x)]);
}
function convertApi(y, file) {
if (y.site_id !== "mteam") throw new Error(`${file}: 暂只支持 mteam 这一 API 站`);
const categoryNames = {};
for (const [k, ids] of Object.entries(y.categories ?? {}))
for (const cid of ids ?? []) categoryNames[String(cid)] ??= CATEGORY_LABELS[k] ?? k;
return {
id: y.site_id,
name: y.display_name,
enabled: PRIMARY.has(y.site_id),
schema: MP_SCHEMA[y.site_id],
provenance: {
source: "movieclaw",
ref: REF,
commit: COMMIT.slice(0, 8),
license: "MIT",
file: `${CONFIG_DIR}/${file}`,
},
domain: `${String(y.web_base_url ?? y.base_url).replace(/\/+$/, "")}/`,
parser: "mTorrent",
timeout: Number(y.http?.timeout ?? 15),
umt: {
version: `${y.site_id}@mc-${REF}`,
kind: "mteam",
displayName: y.display_name,
apiBaseUrl: String(y.base_url).replace(/\/+$/, ""),
timezone: TZ[y.timezone ?? "Asia/Shanghai"],
rateLimit: { minIntervalMs: Math.round(Number(y.min_request_interval ?? 3) * 1000), burst: 1 },
capabilities: { list: true, search: true, detail: true, download: true, rss: false },
categoryNames,
...(y.auth?.supported ? { authSupported: y.auth.supported } : {}),
...(y.custom_class ? { customClass: y.custom_class } : {}),
},
};
}
function isObj(v) {
return v && typeof v === "object" && !Array.isArray(v);
}
function deepMerge(base, over, path = "") {
if (!isObj(base) || !isObj(over)) return over;
const out = { ...base };
for (const [k, v] of Object.entries(over)) {
const p = path ? `${path}.${k}` : k;
if (v === null) delete out[k];
else if (/^torrents\.fields\.[^.]+$/.test(p) || /^umt\.detail\.fields$/.test(p))
out[k] = v; // 字段整体替换
else out[k] = isObj(v) && isObj(out[k]) ? deepMerge(out[k], v, p) : v;
}
return out;
}
/** 用仓库的 biome 格式化(与 lint 一致,保证 --check 幂等);biome 不可用时退回 JSON.stringify */
function biomeFormat(text, fakePath) {
const bins = [join(ROOT, "node_modules/.bin/biome")];
for (const bin of bins) {
if (!existsSync(bin)) continue;
try {
return execFileSync(bin, ["format", `--stdin-file-path=${fakePath}`], {
input: text,
encoding: "utf8",
cwd: ROOT,
});
} catch {
/* fallthrough */
}
}
return text;
}
/** 输出键顺序固定,方便 diff */
function stable(v, fakePath) {
return biomeFormat(`${JSON.stringify(v, null, 2)}\n`, fakePath);
}
// ---- main ----
const COMMIT = git("rev-parse", `${REF}^{commit}`).trim();
if (EXPECTED[REF] && EXPECTED[REF] !== COMMIT)
throw new Error(`${REF} 解析到 ${COMMIT},与登记的 ${EXPECTED[REF]} 不符`);
if (!EXPECTED[REF] && !args.includes("--allow-ref"))
throw new Error(`ref ${REF} 未登记;许可需另行核对后加 --allow-ref`);
const license = git("show", `${REF}:LICENSE`);
if (!/^MIT License/m.test(license)) throw new Error(`${REF}:LICENSE 不是 MIT,停止(许可切换后的内容不得复制)`);
const files = git("ls-tree", "--name-only", `${REF}:${CONFIG_DIR}`)
.split("\n")
.filter((f) => f.endsWith(".yaml") && !f.startsWith("_"));
const ids = [];
let diffs = 0;
for (const file of files) {
const y = YAML.parse(git("show", `${REF}:${CONFIG_DIR}/${file}`));
let def = y.framework === "api" ? convertApi(y, file) : convertNexus(y, file);
const ovPath = join(OVERRIDES, `${y.site_id}.json`);
if (existsSync(ovPath)) {
const ov = JSON.parse(readFileSync(ovPath, "utf8"));
delete ov.$comment;
def = deepMerge(def, ov);
def.provenance = { ...def.provenance, overrides: `scripts/mp-sync/movieclaw-overrides/${y.site_id}.json` };
}
// 分类项 desc 以最终 categoryNames 为准(覆盖层可能给出更细的站点分类名)
for (const k of ["movie", "tv", "music"]) {
for (const c of def.category?.[k] ?? []) {
const n = def.umt?.categoryNames?.[String(c.id)];
if (n) c.desc = n;
}
}
const out = join(OUT_DIR, `${y.site_id}.json`);
const text = stable(def, `packages/adapters/src/sites/definitions/${y.site_id}.json`);
const old = existsSync(out) ? readFileSync(out, "utf8") : null;
if (old !== text) {
diffs++;
if (CHECK) console.log(`差异:${y.site_id}.json`);
else writeFileSync(out, text);
}
ids.push(y.site_id);
}
const ident = (id) => `${id.replace(/[^A-Za-z0-9_]/g, "_")}Json`;
const manifest = `// 由 scripts/mp-sync/movieclaw-import.mjs 生成,请勿手改(MovieClaw ${REF} ${COMMIT.slice(0, 8)},MIT)
${ids
.sort()
.map((id) => `import ${ident(id)} from "./${id}.json" with { type: "json" };`)
.join("\n")}
/** MovieClaw 转换来的站点定义原始 JSON(未校验;经 loadSiteDefinition 校验后使用) */
export const MOVIECLAW_DEFINITION_JSON: Record<string, unknown> = {
${ids
.sort()
.map((id) => ` ${JSON.stringify(id)}: ${ident(id)},`)
.join("\n")}
};
`;
const manifestPath = join(OUT_DIR, "manifest.generated.ts");
const manifestText = biomeFormat(manifest, "packages/adapters/src/sites/definitions/manifest.generated.ts");
if ((existsSync(manifestPath) ? readFileSync(manifestPath, "utf8") : null) !== manifestText) {
diffs++;
if (CHECK) console.log("差异:manifest.generated.ts");
else writeFileSync(manifestPath, manifestText);
}
const stale = readdirSync(OUT_DIR).filter((f) => f.endsWith(".json") && !ids.includes(f.replace(/\.json$/, "")));
if (stale.length) console.log(`注意:定义目录中存在非 MovieClaw 来源的 JSON(保留不动):${stale.join(", ")}`);
console.log(
`${CHECK ? "检查" : "转换"}完成:${ids.length} 个站点,${diffs} 个文件${CHECK ? "有差异" : "已更新"}(${REF} ${COMMIT.slice(0, 8)})`,
);
if (CHECK && diffs) process.exit(1);