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]>
This commit is contained in:
1 parent
cf85481d95
commit
d9f2af2f3c
166 files changed
+16577
-186
No files matched your search
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||
* @mp-source app/modules/indexer/spider/rousi.py @ e6820a8
|
||||
*
|
||||
* Rousi.pro PeerGo 兼容 API(parser "RousiPro",Bearer 个人 API Key,需 torrent:read + torrent:download)。
|
||||
* 函数对照 `RousiSpider`:
|
||||
* - `_movie_category/_tv_category/_music_category` → `ROUSI_*_CATEGORY`
|
||||
* - `get_search_page_size` → `pageSize`(100)
|
||||
* - `__get_params` → `rousiSearchParams`;`__get_category_names_by_ids` → `rousiCategoryNamesByIds`
|
||||
* - `__process_response` → `rousiSpider.search` 错误分支;`__parse_result` → `parseRousiResult`
|
||||
* - `__get_download_url`(GET 详情接口,result=data.download_url)→ `rousiSpider.resolveDownload`(现场换票,不生成含 key 的 enclosure)
|
||||
*/
|
||||
import type { MpMediaType } from "../spider/spider.ts";
|
||||
import { extractDomain, normalizeDatetime } from "../user-parsers/common.ts";
|
||||
import {
|
||||
apiRequest,
|
||||
businessError,
|
||||
checkReleaseId,
|
||||
downloadTorrentFile,
|
||||
isoWithZoneToUtc,
|
||||
isPlainObject,
|
||||
resolveIndirectDownloadUrl,
|
||||
toFloat,
|
||||
toInt,
|
||||
} from "./common.ts";
|
||||
import type { SpecializedSiteDefinition, SpecializedSpider, SpecializedTorrent, SpiderSearchQuery } from "./types.ts";
|
||||
|
||||
/** API 不支持多分类搜索,每次只使用一个分类 */
|
||||
export const ROUSI_MOVIE_CATEGORY = "movie";
|
||||
export const ROUSI_TV_CATEGORY = "tv";
|
||||
export const ROUSI_MUSIC_CATEGORY = "music";
|
||||
|
||||
const ROUSI_ID_TO_NAME: Record<string, string> = {
|
||||
"1": "movie",
|
||||
"2": "tv",
|
||||
"3": "documentary",
|
||||
"4": "animation",
|
||||
"5": "music",
|
||||
"6": "variety",
|
||||
};
|
||||
|
||||
/** 站点 origin(MP `https://%s` % extract_domain(domain)) */
|
||||
export function rousiBase(def: SpecializedSiteDefinition): string {
|
||||
return (def.umt.apiBaseUrl ?? `https://${extractDomain(def.domain)}`).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** MP `__get_category_names_by_ids` */
|
||||
export function rousiCategoryNamesByIds(cat: string | null | undefined): string[] | null {
|
||||
if (!cat) return null;
|
||||
const ids = cat
|
||||
.split(",")
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
const names = ids.filter((id) => id in ROUSI_ID_TO_NAME).map((id) => ROUSI_ID_TO_NAME[id]!);
|
||||
return names.length ? names : null;
|
||||
}
|
||||
|
||||
/** MP `__get_params` */
|
||||
export function rousiSearchParams(q: SpiderSearchQuery): Record<string, string | number> {
|
||||
const params: Record<string, string | number> = { page: Number(q.page ?? 0) + 1, page_size: 100 };
|
||||
if (q.keyword) params.keyword = q.keyword;
|
||||
if (q.cat) {
|
||||
const names = rousiCategoryNamesByIds(q.cat);
|
||||
if (names) params.category = names[0]!;
|
||||
} else if (q.mtype) {
|
||||
if (q.mtype === "movie") params.category = ROUSI_MOVIE_CATEGORY;
|
||||
else if (q.mtype === "tv") params.category = ROUSI_TV_CATEGORY;
|
||||
else if (q.mtype === "music") params.category = ROUSI_MUSIC_CATEGORY;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export interface RousiItem {
|
||||
id: number | string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
category?: string | { slug?: string; name?: string } | null;
|
||||
size?: number | string;
|
||||
seeders?: number | string;
|
||||
leechers?: number | string;
|
||||
downloads?: number | string;
|
||||
created_at?: string;
|
||||
promotion?: { is_active?: boolean; down_multiplier?: number; up_multiplier?: number; until?: string } | null;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** MP `__parse_result` */
|
||||
export function parseRousiResult(results: RousiItem[] | null | undefined, def: SpecializedSiteDefinition) {
|
||||
const torrents: SpecializedTorrent[] = [];
|
||||
if (!results?.length) return torrents;
|
||||
const base = rousiBase(def);
|
||||
for (const result of results) {
|
||||
const rawCat = result.category;
|
||||
let catVal: string | null | undefined = null;
|
||||
let category: MpMediaType = "未知";
|
||||
if (rawCat && typeof rawCat === "object") catVal = rawCat.slug || rawCat.name;
|
||||
else if (typeof rawCat === "string") catVal = rawCat;
|
||||
if (catVal) {
|
||||
catVal = String(catVal).toLowerCase();
|
||||
if (catVal === ROUSI_MOVIE_CATEGORY) category = "电影";
|
||||
else if (catVal === ROUSI_TV_CATEGORY) category = "电视剧";
|
||||
else if (catVal === ROUSI_MUSIC_CATEGORY) category = "音乐";
|
||||
}
|
||||
// API 后端已处理全站促销优先级,直接使用返回的 promotion 数据
|
||||
let dl = 1.0;
|
||||
let ul = 1.0;
|
||||
let freedate: string | undefined;
|
||||
let freedateUtc: string | undefined;
|
||||
const promotion = result.promotion;
|
||||
if (promotion?.is_active) {
|
||||
dl = toFloat(promotion.down_multiplier ?? 1.0);
|
||||
ul = toFloat(promotion.up_multiplier ?? 1.0);
|
||||
if (promotion.until) {
|
||||
freedate = normalizeDatetime(promotion.until) ?? undefined;
|
||||
freedateUtc = isoWithZoneToUtc(promotion.until);
|
||||
}
|
||||
}
|
||||
const id = result.id;
|
||||
const t: SpecializedTorrent = {
|
||||
title: result.title,
|
||||
description: result.subtitle,
|
||||
size: toInt(result.size),
|
||||
seeders: toInt(result.seeders),
|
||||
peers: toInt(result.leechers),
|
||||
grabs: toInt(result.downloads),
|
||||
downloadvolumefactor: dl,
|
||||
uploadvolumefactor: ul,
|
||||
page_url: `${base}/torrents/${id}`,
|
||||
labels: [],
|
||||
category,
|
||||
site_release_id: String(id),
|
||||
};
|
||||
const pubdate = normalizeDatetime(result.created_at);
|
||||
if (pubdate) t.pubdate = pubdate;
|
||||
const pubUtc = isoWithZoneToUtc(result.created_at);
|
||||
if (pubUtc) t.pubdate_utc = pubUtc;
|
||||
if (freedate) t.freedate = freedate;
|
||||
if (freedateUtc) t.freedate_utc = freedateUtc;
|
||||
if (catVal) t.category_id = catVal;
|
||||
torrents.push(t);
|
||||
}
|
||||
return torrents;
|
||||
}
|
||||
|
||||
export const rousiSpider: SpecializedSpider = {
|
||||
parser: "RousiPro",
|
||||
credential: "apiKey",
|
||||
pageSize: () => 100,
|
||||
async search(ctx, q) {
|
||||
const apikey = await ctx.secret("apiKey", true);
|
||||
const data = await apiRequest(ctx, `${rousiBase(ctx.def)}/api/v1/torrents`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${apikey}`, Accept: "application/json" },
|
||||
query: rousiSearchParams(q),
|
||||
});
|
||||
if (!isPlainObject(data)) throw businessError(ctx, "搜索", "搜索响应结构无效");
|
||||
if (data.code === 0) {
|
||||
const d = isPlainObject(data.data) ? data.data : {};
|
||||
return parseRousiResult((d.torrents as RousiItem[]) ?? [], ctx.def);
|
||||
}
|
||||
throw businessError(ctx, "搜索", data.message);
|
||||
},
|
||||
async resolveDownload(ctx, siteReleaseId) {
|
||||
const apikey = await ctx.secret("apiKey", true);
|
||||
const id = checkReleaseId(ctx.def.id, siteReleaseId);
|
||||
// MP __get_download_url:method=get, cookie=False, Bearer, result=data.download_url
|
||||
const url = await resolveIndirectDownloadUrl(ctx, `${rousiBase(ctx.def)}/api/v1/torrents/${id}`, {
|
||||
method: "get",
|
||||
header: { Authorization: `Bearer ${apikey}`, Accept: "application/json" },
|
||||
result: "data.download_url",
|
||||
});
|
||||
return downloadTorrentFile(ctx, url);
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user