Sync all 2026-09-14 09:42 UTC

This commit is contained in:
2026-09-14 09:42:24 +00:00
parent 55ea23b1c5
commit d7fa30d5e0

333
adapters/mteam/adapter.js Normal file
View File

@@ -0,0 +1,333 @@
// 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 || '',
doubanRating: rating,
imdbUrl: it.imdb || '',
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, downloadPath?(可选, 默认 /api/torrent/download) }
// 返回 { downloadUrl, url, bytes, size } 或 { error, url? }。
function download(input) {
var ctx = __asCtx(input);
var base = __resolveBase(ctx);
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) {
var path = (ctx && ctx.downloadPath) || '/api/torrent/download';
url = base + path + '?id=' + encodeURIComponent(detailId);
} 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 };
}
}