Files
par/adapters/mteam/adapter.js
2026-09-20 16:00:32 +00:00

358 lines
15 KiB
JavaScript
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.

// M-Team (mteam) 适配脚本 — JSON API 站点,使用 POST + x-api-key(Authorization 头)。
// 由 mteam.groovy 参考移植;与 mediabot2 共享 Jint 沙箱环境。
// mediabot2 注入:http, html, json, utils;注册中心 Jint SDK 注入:parseHtml / httpClient / console / __wrapNode。
// 入口函数(与注册中心标准能力模板一一对应):
// search(ctx) —— 搜索种子(能力: search,POST /api/torrent/search,JSON 体)
// userdetail(ctx) —— 用户资料(能力: userdetail,POST profile 接口,memberCount/invites/username)
// sync(ctx) —— 站点信息同步(mediabot2 Host 调用:profile + peerStats 合并上报)
// parse(content, url) —— 纯解析入口(校验沙箱 /validate 使用;pageKey 取 url 末段或默认 profile)
// download(ctx) —— 下载 .torrent 种子字节(能力: download,尽力而为)
// 站点配置(definition 选择器中可覆盖):ctx.apiBase(默认为 baseUrl) / ctx.apiKey(或 xApiKey) /
// ctx.profilePath(默认 /api/user/profile) / ctx.peerStatsPath(默认 /api/torrent/seeding) /
// ctx.searchPath(默认 /api/torrent/search) / ctx.downloadPath(默认 /api/torrent/download)。
// ==================== 运行环境兼容层 ====================
if (typeof http === 'undefined') {
var http = {
get: function(url) { return httpClient.getString(url); },
post: function(url) { return httpClient.getString(url); },
getBytes: function(url) { return httpClient.getBytes(url); }
};
}
// 是否运行在注册中心沙箱(SDK 才有 parseHtml / __wrapNode)
var __inRegistrySandbox = (typeof __wrapNode === 'function');
// 注册中心 ParseInputArgs 把 testInput 原始 JSON 串按位置传参; mediabot2 直接传对象。
function __asCtx(arg) {
if (typeof arg === 'string' && arg.length > 0) {
try {
var p = JSON.parse(arg);
if (p && typeof p === 'object') return p;
} catch (e) {}
return {};
}
return (arg && typeof arg === 'object') ? arg : {};
}
// 取 API base;注册中心沙箱中为空时用占位地址(mock http 回退,不影响解析链路)
function __resolveBase(ctx) {
var b = String(ctx && (ctx.apiBase || ctx.baseUrl) ? (ctx.apiBase || ctx.baseUrl) : '').replace(/\/+$/, '');
if (!b && __inRegistrySandbox) b = 'http://mock.local';
return b;
}
// 声明式安全 JSON 解析(不用正则,兼容旧版 Jint)
function __parseJson(text) {
if (text === null || text === undefined) return null;
if (typeof text === 'object') return text;
var s = String(text).trim();
if (!s) return null;
// 登录墙 / 错误页通常是 HTML 或非 JSON 文本
if (s.charAt(0) !== '{' && s.charAt(0) !== '[') return null;
try {
return JSON.parse(s);
} catch (e) {
return null;
}
}
// POST JSON 并返回解析后的对象;失败返回 null。注册中心沙箱回退 GET(仅用于快照解析验证)。
function __apiPost(base, path, body, apiKey) {
var url = path && path.indexOf('http') === 0 ? path : base + path;
var headers = {};
if (apiKey) headers['x-api-key'] = apiKey;
var raw = null;
try {
raw = http.post(url, JSON.stringify(body || {}), headers);
} catch (e1) {
try {
raw = http.post(url, JSON.stringify(body || {}));
} catch (e2) {
try {
raw = http.post(url);
} catch (e3) {
raw = null;
}
}
}
return __parseJson(raw);
}
// 视数字为 GiB 时换算成字节(M-Team memberCount.uploaded/downloaded 单位)
var __GIB = 1073741824;
function __toLong(v) {
if (v === null || v === undefined) return null;
var n = Number(v);
return isNaN(n) ? null : Math.round(n);
}
function __toInt(v) {
var n = __toLong(v);
return n === null ? 0 : n;
}
function __toDouble(v) {
if (v === null || v === undefined) return null;
var n = Number(v);
return isNaN(n) ? null : n;
}
// M-Team discount 促销状态 → 标记
function __promoOf(discount, makeFree) {
var d = String(discount || '').toUpperCase();
if (makeFree || d === 'FREE' || d.indexOf('FREE') >= 0) return 'free';
if (d === '2X' || d === '2XUP' || d.indexOf('2X') >= 0) return '2x';
if (d.indexOf('50') >= 0) return 'half';
return d || null;
}
// ===================== 解析器(移植 mteam.groovy) =====================
// profile: root.data { username, invites, memberCount:{ uploaded, downloaded, bonus, shareRate } }
function parseProfile(d) {
if (!d) return { pageName: '用户信息', matched: false, message: 'data 为空', incomplete: true };
var mc = (d.memberCount && typeof d.memberCount === 'object') ? d.memberCount : {};
var r = { pageName: '用户信息', matched: true, incomplete: false };
if (mc.uploaded !== undefined && mc.uploaded !== null) r.upload = __toInt(mc.uploaded) * __GIB;
if (mc.downloaded !== undefined && mc.downloaded !== null) r.download = __toInt(mc.downloaded) * __GIB;
if (mc.bonus !== undefined && mc.bonus !== null) r.bonus = __toDouble(mc.bonus);
if (mc.shareRate !== undefined && mc.shareRate !== null) r.shareRate = __toDouble(mc.shareRate);
if (d.invites !== undefined && d.invites !== null) r.invites = __toInt(d.invites);
if (d.username) r.username = String(d.username);
if (!r.upload && !r.download) r.incomplete = true;
return r;
}
// peerStats: root.data { seederCount }(兼容 { total } 或 { data:[...] } 取长度)
function parsePeerStats(d) {
if (!d) return { pageName: '做种统计', matched: false, message: 'data 为空', incomplete: true };
var seeding = null;
if (d.seederCount !== undefined && d.seederCount !== null) seeding = __toInt(d.seederCount);
else if (d.total !== undefined && d.total !== null) seeding = __toInt(d.total);
else if (d.data && typeof d.data.length === 'number') seeding = d.data.length;
if (seeding === null) return { pageName: '做种统计', matched: false, message: '无法解析做种数', incomplete: true };
return { pageName: '做种统计', matched: true, incomplete: false, seeding: seeding };
}
// search: root.data.data = [ { id, name, smallDescr, size, status:{...}, createdDate, category,
// author, labelsNew, dobby, douban, doubanRating, imdb, hasChineseSubtitle, standard } ]
function parseSearch(items) {
if (!items || items.length === 0) {
return { pageName: '搜索', matched: true, items: [], message: '无结果', incomplete: false };
}
var rows = [];
for (var i = 0; i < items.length; i++) {
var it = items[i];
if (it === null || typeof it !== 'object') continue;
var st = (it.status && typeof it.status === 'object') ? it.status : {};
var size = __toLong(it.size);
var free = String(st.discount || '').toUpperCase() === 'FREE'
|| String(st.discount || '').toUpperCase() === '_2X_FREE'
|| String(st.discount || '').toUpperCase() === '2X_FREE';
var tags = '';
if (it.labelsNew && typeof it.labelsNew.length === 'number') {
var parts = [];
for (var t = 0; t < it.labelsNew.length; t++) {
var lb = it.labelsNew[t];
if (lb === null || lb === undefined) continue;
var tv = (typeof lb === 'object') ? (lb.name || lb.label || '') : String(lb);
if (tv) parts.push(tv);
}
tags = parts.join(',');
}
var rating = it.doubanRating !== undefined && it.doubanRating !== null ? String(it.doubanRating) : '';
var cnSub = it.hasChineseSubtitle !== undefined && it.hasChineseSubtitle !== null
? String(it.hasChineseSubtitle) : '';
var row = {
id: it.id !== undefined && it.id !== null ? String(it.id) : '',
title: it.name || '',
subTitle: it.smallDescr || '',
size: size,
seeders: __toInt(st.seeders),
leechers: __toInt(st.leechers),
completed: __toInt(st.timesCompleted),
publishDate: it.createdDate || '',
category: it.category || '',
free: free,
freeEndTime: st.discountEndTime || '',
publisher: it.author || '',
tags: tags,
doubanUrl: it.douban || '',
doubanId: (function(){ var m = String(it.douban || '').match(/douban\.com\/subject\/(\d+)/i); return m ? m[1] : ''; })(),
doubanRating: rating,
imdbUrl: it.imdb || '',
imdbId: (function(){ var m = String(it.imdb || '').match(/imdb\.com\/title\/(tt\d+)/i); return m ? m[1] : ''; })(),
cnSub: cnSub,
standard: it.standard || '',
detailUrl: it.id !== undefined && it.id !== null ? 'detail/' + String(it.id) : '',
promo: __promoOf(st.discount, free)
};
rows.push(row);
}
return { pageName: '搜索', extracted: rows, items: rows, matched: true, message: '共 ' + rows.length + ' 条', incomplete: false };
}
// 从 url 末段推断 pageKey(parse 入口用);无匹配时默认 profile
function __pageKeyOf(url) {
var u = String(url || '').replace(/\/+$/, '');
var seg = u.substring(u.lastIndexOf('/') + 1);
if (seg === 'search' || seg === 'torrent-search') return 'search';
if (seg === 'seeding' || seg === 'peerStats' || seg === 'peerstats') return 'peerStats';
return 'profile';
}
// ===================== 纯解析入口(注册中心 /validate 使用) =====================
// content:接口原始 JSON 文本;url:以末段路径携带 pageKey(search/peerStats/profile)。
// 与 groovy parse(content, pageKey) 语义一致。
function parse(content, url) {
var root = __parseJson(content);
if (root === null) {
return { pageName: '解析失败', message: '响应非 JSON(可能是登录墙/错误页)', incomplete: true };
}
var data = root.data !== undefined ? root.data : root;
var pageKey = __pageKeyOf(url);
switch (pageKey) {
case 'profile':
var p = parseProfile(data);
if (!p.matched && root.status !== undefined && String(root.status) !== '0') {
p.message = '接口返回异常: ' + String(root.message || root.msg || root.status);
}
return p;
case 'peerStats':
return parsePeerStats(data);
case 'search':
return parseSearch(data && data.data !== undefined ? data.data : []);
default:
return { pageName: '未知接口', error: '未知 pageKey: ' + pageKey, incomplete: true };
}
}
// ===================== 能力: userdetail(用户资料) =====================
// ctx: { baseUrl, apiKey, ... },POST profile 接口,返回用户资料。
function userdetail(input) {
var ctx = __asCtx(input);
var base = __resolveBase(ctx);
if (!base || base === 'http://mock.local') return { error: 'no_base_url' };
var root = __apiPost(base, ctx.profilePath || '/api/user/profile', {}, ctx.apiKey || ctx.xApiKey);
if (root === null) return { error: 'profile_fetch_failed' };
var data = root.data !== undefined ? root.data : root;
var p = parseProfile(data);
if (!p.matched) return { error: 'profile_no_match' };
return {
userName: p.username || null,
invites: p.invites,
uploaded: p.upload,
downloaded: p.download,
bonus: p.bonus,
shareRate: p.shareRate
};
}
// ===================== 能力: search(搜索种子) =====================
// ctx: { baseUrl, apiKey, keyword, page(=1), pageSize(=100), searchPath(可选) }
// 走 http POST JSON 体 { keyword, pageNumber, pageSize }。
function search(input) {
var ctx = __asCtx(input);
var keyword = ctx && ctx.keyword ? String(ctx.keyword) : '';
if (!keyword) return [];
var base = __resolveBase(ctx);
if (!base || base === 'http://mock.local') return [];
var page = ctx.page ? __toInt(ctx.page) : 1;
if (page < 1) page = 1;
var pageSize = ctx.pageSize ? __toInt(ctx.pageSize) : 100;
var body = { keyword: keyword, pageNumber: page, pageSize: pageSize };
var root = __apiPost(base, ctx.searchPath || '/api/torrent/search', body, ctx.apiKey || ctx.xApiKey);
if (root === null) return [];
var data = root.data !== undefined ? root.data : root;
var list = (data && data.data !== undefined) ? data.data : [];
var res = parseSearch(list);
return res.items || [];
}
// ===================== 站点信息同步(mediabot2 Host 调用) =====================
// ctx: { siteCode, siteId, baseUrl, apiKey, utcNow, frameworkVersion }
// Success: 合并 profile + peerStats 的统计对象;Failure: { error, ... } 便于 Host 上报。
// 绝不返回 null,始终带 error 字段。
function sync(input) {
var ctx = __asCtx(input);
var base = __resolveBase(ctx);
if (!base || base === 'http://mock.local') return { error: 'no_base_url' };
var apiKey = ctx.apiKey || ctx.xApiKey;
// 1. profile
var profileRoot = __apiPost(base, ctx.profilePath || '/api/user/profile', {}, apiKey);
if (profileRoot === null) return { error: 'profile_fetch_failed' };
var profileData = profileRoot.data !== undefined ? profileRoot.data : profileRoot;
var p = parseProfile(profileData);
if (!p.matched) return { error: 'profile_no_match' };
// 2. peerStats
var seeding = null;
var statsRoot = __apiPost(base, ctx.peerStatsPath || '/api/torrent/seeding', {}, apiKey);
if (statsRoot !== null) {
var statsData = statsRoot.data !== undefined ? statsRoot.data : statsRoot;
var ps = parsePeerStats(statsData);
if (ps.matched) seeding = ps.seeding;
}
return {
uploaded: p.upload,
downloaded: p.download,
bonus: p.bonus,
shareRate: p.shareRate,
invites: p.invites,
userName: p.username,
seeding: seeding
};
}
// ===================== 能力: download(下载种子) =====================
// ctx: { baseUrl, apiKey, downloadUrl?(可选) | id/detailId }
// M-Team API 不暴露直接下载接口(swagger 里没有 /torrent/download), 必须两步:
// 1) POST /api/torrent/genDlToken?id={detailId} → 拿到 Result.data (下载 URL 或 token)
// 2) GET 下载 URL (通常是 CDN)
// 返回 { downloadUrl, url, bytes, size } 或 { error, url? }。
function download(input) {
var ctx = __asCtx(input);
var base = __resolveBase(ctx);
var apiKey = ctx && (ctx.apiKey || ctx.xApiKey);
var detailId = ctx && (ctx.id || ctx.detailId) ? String(ctx.id || ctx.detailId) : '';
var url = null;
if (ctx && ctx.downloadUrl) {
url = String(ctx.downloadUrl);
if (url.indexOf('http') !== 0) url = base + (url.charAt(0) === '/' ? url : '/' + url);
} else if (detailId) {
// 先 genDlToken 拿下载 URL / token
var tokenPath = '/api/torrent/genDlToken?id=' + encodeURIComponent(detailId);
var tokenRoot = __apiPost(base, tokenPath, {}, apiKey);
if (tokenRoot === null) return { error: 'gen_dl_token_failed_no_response', url: base + tokenPath };
// genDlToken 返回 Result<T>, T 可能是 string (完整下载 URL) 或对象
var tokenData = (tokenRoot.data !== undefined ? tokenRoot.data : tokenRoot);
var dlUrl = null;
if (typeof tokenData === 'string' && tokenData.length > 0) {
dlUrl = tokenData;
} else if (tokenData && typeof tokenData === 'object') {
dlUrl = tokenData.url || tokenData.downloadUrl || tokenData.link || tokenData.token;
}
if (!dlUrl) {
// 把原始响应带回来供 C# 端日志诊断真实格式
var raw = '';
try { raw = typeof JSON !== 'undefined' ? JSON.stringify(tokenRoot) : String(tokenRoot); } catch(e) { raw = String(tokenRoot); }
if (raw.length > 500) raw = raw.substring(0, 500) + '...';
return { error: 'gen_dl_token_failed_no_download_url', url: base + tokenPath, rawResponse: raw };
}
url = String(dlUrl);
} else {
return { error: 'no_detail_id' };
}
try {
var bytes = http.getBytes(url);
if (!bytes || bytes.length === 0) return { error: 'empty_response', url: url };
return { bytes: bytes, url: url, downloadUrl: url, size: bytes.length };
} catch (e) {
return { error: String((e && e.message) || e), url: url };
}
}