// 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 可能是 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 }; } }