diff --git a/adapters/mteam/adapter.js b/adapters/mteam/adapter.js new file mode 100644 index 0000000..7b34b48 --- /dev/null +++ b/adapters/mteam/adapter.js @@ -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 }; + } +} \ No newline at end of file