diff --git a/adapters/mteam/adapter.js b/adapters/mteam/adapter.js index 38d711e..7536953 100644 --- a/adapters/mteam/adapter.js +++ b/adapters/mteam/adapter.js @@ -1,83 +1,29 @@ -// NexusPHP 框架共用脚本:所有 NexusPHP 站点用同一份,站点差异由各自的 definition 选择器提供。 -// mediabot2 经 Jint 沙箱执行,注入:content, url, http, html, json, utils -// 入口函数(与注册中心 7 个标准能力模板一一对应,能力名 = 函数名): -// search(ctx) —— 搜索种子(能力: search) -// detail(ctx) —— 种子详情(能力: detail) -// index(ctx) —— 用户统计(能力: index,解析首页信息块) -// userdetail(ctx) —— 用户详情(能力: userdetail,解析 userdetails.php) -// signIn(ctx) —— 签到(能力: signIn) -// download(ctx) —— 下载 .torrent 种子文件字节(能力: download) -// sync(ctx) —— 站点信息同步(mediabot2 Host 调用:首页统计 + 账号资料) -// parse(content, url) —— 解析用户统计页(校验沙箱 /validate 使用,http 走 mock) -// rss(ctx) —— RSS 刷流(供 RssDiscoveryJob,非标准能力) +// 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)。 // ==================== 运行环境兼容层 ==================== -// mediabot2 宿主注入: http / html / utils / json; -// 注册中心 Jint SDK 注入: parseHtml / httpClient / console / __wrapNode。 -// 以下兼容代码仅在注册中心沙箱中生效(mediabot2 环境已有同名对象,条件不成立时零影响)。 if (typeof http === 'undefined') { var http = { get: function(url) { return httpClient.getString(url); }, - // SDK 未提供 POST,注册中心沙箱中回退为 GET(mock 环境仅用于样本解析验证) post: function(url) { return httpClient.getString(url); }, getBytes: function(url) { return httpClient.getBytes(url); } }; } -if (typeof html === 'undefined') { - var html = { - parse: function(h) { - var doc = parseHtml(h); - var oQS = doc.querySelector, oQSA = doc.querySelectorAll; - var oGEI = doc.getElementById, oGET = doc.getElementsByTagName; - doc.querySelector = function(sel) { return __adapt(oQS.call(doc, sel)); }; - doc.querySelectorAll = function(sel) { - var r = oQSA.call(doc, sel) || [], out = []; - for (var i = 0; i < r.length; i++) out.push(__adapt(r[i])); - return out; - }; - doc.getElementById = function(id) { return __adapt(oGEI.call(doc, id)); }; - doc.getElementsByTagName = function(tag) { - var r = oGET.call(doc, tag) || [], out = []; - for (var j = 0; j < r.length; j++) out.push(__adapt(r[j])); - return out; - }; - doc.queryList = doc.querySelectorAll; - return doc; - } - }; -} -// 为 SDK wrapNode 补齐 mediabot2 DOM 约定: attr / queryList / nextElementSibling -function __adapt(n) { - if (!n || typeof n !== 'object' || n.__adapted) return n; - try { - n.__adapted = true; - if (typeof n.attr !== 'function') n.attr = function(name) { return n.getAttribute(name); }; - if (typeof n.queryList !== 'function') n.queryList = function(sel) { return n.querySelectorAll(sel) || []; }; - if (typeof n.nextElementSibling === 'undefined' && n._node) { - try { n.nextElementSibling = __adapt(__wrapNode(n._node.NextElementSibling)); } - catch (e) { n.nextElementSibling = null; } - } - // 递归: 子查询返回的新包装对象也要经过 __adapt - if (typeof n.querySelector === 'function' && !n.__qsw) { - n.__qsw = true; - var oqs = n.querySelector, oqsa = n.querySelectorAll; - n.querySelector = function(sel) { return __adapt(oqs.call(n, sel)); }; - n.querySelectorAll = function(sel) { - var r = oqsa.call(n, sel) || [], out = []; - for (var i = 0; i < r.length; i++) out.push(__adapt(r[i])); - return out; - }; - } - } catch (e) {} - return n; -} - -// 是否运行在注册中心沙箱(SDK 才有 __wrapNode) +// 是否运行在注册中心沙箱(SDK 才有 parseHtml / __wrapNode) var __inRegistrySandbox = (typeof __wrapNode === 'function'); // 注册中心 ParseInputArgs 把 testInput 原始 JSON 串按位置传参; mediabot2 直接传对象。 -// 入口函数统一经 __asCtx 归一化,两种调用方式都兼容。 function __asCtx(arg) { if (typeof arg === 'string' && arg.length > 0) { try { @@ -89,703 +35,318 @@ function __asCtx(arg) { return (arg && typeof arg === 'object') ? arg : {}; } -// 取 baseUrl;注册中心沙箱中为空时用占位地址(mock http 会回退返回样本 HTML,不影响解析链路) +// 取 API base;注册中心沙箱中为空时用占位地址(mock http 回退,不影响解析链路) function __resolveBase(ctx) { - var b = String(ctx && ctx.baseUrl ? ctx.baseUrl : '').replace(/\/+$/, ''); + var b = String(ctx && (ctx.apiBase || ctx.baseUrl) ? (ctx.apiBase || ctx.baseUrl) : '').replace(/\/+$/, ''); if (!b && __inRegistrySandbox) b = 'http://mock.local'; return b; } -function unitToBytes(unit) { - if (!unit) return 1; - switch (String(unit).toUpperCase()) { - case 'PB': return 1024 * 1024 * 1024 * 1024 * 1024; - case 'TB': return 1024 * 1024 * 1024 * 1024; - case 'GB': return 1024 * 1024 * 1024; - case 'MB': return 1024 * 1024; - case 'KB': return 1024; - default: return 1; +// 声明式安全 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; } } -// 标签后直接匹配数值(兼容 "上传量: 1.23 TB" / "上传量 1.23 TB" / 表格布局) -function parseSize(text, labelPattern) { - var re = new RegExp(labelPattern + '\\s*[::]?\\s*([\\d.,]+)\\s*(TB|GB|MB|KB|PB|B)?', 'i'); - var m = text.match(re); - if (!m) return null; - var num = parseFloat(m[1].replace(/,/g, '')); - if (isNaN(num)) return null; - return Math.round(num * unitToBytes(m[2])); -} - -function parseDecimal(text, labelPattern) { - var re = new RegExp(labelPattern + '\\s*[::]?\\s*([\\d.,]+)', 'i'); - var m = text.match(re); - if (!m) return null; - var v = parseFloat(m[1].replace(/,/g, '')); - return isNaN(v) ? null : v; -} - -function parseInteger(text, labelPattern) { - var re = new RegExp(labelPattern + '\\s*[::]?\\s*([\\d,]+)', 'i'); - var m = text.match(re); - if (!m) return null; - var v = parseInt(m[1].replace(/,/g, ''), 10); - return isNaN(v) ? null : v; -} - -// 从信息块解析 uid / 用户名(userdetails 链接) -function parseIdentity(block) { - var uid = null; - var userName = null; - var link = block ? block.querySelector('a[href*="userdetails"]') : null; - if (link) { - var href = link.attr('href') || ''; - var um = href.match(/[?&]id=(\d+)/i); - if (um) uid = um[1]; - var t = link.text(); - if (t) userName = t; - } - return { uid: uid, userName: userName }; -} - -// 解析首页统计:上传/下载/魔力/分享率/做种/下载活动/H&R -function parseIndexStats(indexHtml) { - var doc = html.parse(indexHtml); - var block = doc.querySelector('#info_block'); - var text = block ? block.text() : indexHtml; - - var up = parseSize(text, '(?:上传量?|上傳量?)'); - var down = parseSize(text, '(?:下载量?|下載量?)'); - if (up === null && down === null) return null; - - var bonus = parseDecimal(text, '魔力值(?:\\[\\s*使用\\s*\\])?'); - if (bonus === null) { - // PTTIME: 魔力值(77.45魔力/小时)[使用&说明]: 799327.2 - var bm = text.match(/魔力值.*?[::]\s*([\d.,]+)/i); - if (bm) bonus = parseFloat(bm[1].replace(/,/g, '')); - } - var ratio = parseDecimal(text, '(?:分享率|分享比率)'); - - var seeding = null, leeching = null; - var act = text.match(/当前活动\s*[::]\s*(\d+)\s+(\d+)/i); - if (!act) act = text.match(/活动\s*[::]\s*[^\d]*(\d+)[^\d]*(\d+)/i); // PTTIME: "活动: ⬆16 ⬇2" - if (act) { - seeding = parseInt(act[1], 10); - leeching = parseInt(act[2], 10); - } else { - seeding = parseInteger(text, '(?:做种|做種)'); - } - - var hr = null; - var hrM = text.match(/H&R\s*[::]\s*\[\s*\d+\s*\/\s*(\d+)\s*\/\s*\d+\s*\]/i); - if (hrM) hr = parseInt(hrM[1], 10); - - var identity = parseIdentity(block || doc); - return { - uploaded: up, - downloaded: down, - bonus: bonus, - ratio: ratio, - seeding: seeding, - leeching: leeching, - hr: hr, - uid: identity.uid, - userName: identity.userName - }; -} - -// 解析 userdetails.php 账号资料:邮箱/等级/注册日期/最近动向/做种积分/封存状态 + uid/用户名 -// 注意:userdetails 的字段在 #info_block 之外的资料表格中,故用整页 body 文本解析 -function parseUserDetails(userHtml) { - var doc = html.parse(userHtml); - var body = doc.querySelector('body') || doc; - var text = body.text() || userHtml; - - var email = null; - // DOM 优先:从 提取最准确 - var mailLink = doc.querySelector('a[href*="mailto:"]'); - if (mailLink) { - var href = mailLink.attr('href') || ''; - var mm = href.match(/mailto:([\w.+-]+@[\w.-]+\.\w+)/i); - if (mm) email = mm[1]; - else email = mailLink.text(); - } - // 退路:正则从 body text 搜 - if (!email) { - var em = text.match(/(?:邮箱|Email|E-mail)\s*[::]?\s*([\w.+-]+@[\w.-]+\.\w+)/i); - if (em) email = em[1]; - } - - // 等级:DOM 解析,遍历每行 tr,找 label td 文本为 等级/级别/Class 的行,取其右边 td 的内容 - var classLevel = null; - var rows = doc.queryList('tr'); - for (var i = 0; i < rows.length && !classLevel; i++) { - var cells = rows[i].queryList('td'); - if (cells.length >= 2) { - var label = cells[0].text(); - if (label === '等级' || label === '级别' || label === 'Class' || label === '用户等级') { - var img = cells[1].querySelector('img'); - var b = cells[1].querySelector('b'); - if (img) { - classLevel = img.attr('alt'); - } else if (b) { - classLevel = b.text(); // PTTIME: 等级在 内 - } else { - classLevel = cells[1].text(); - } - // 去掉前导括号前缀,如 "(幼儿园)User" → "User" - if (classLevel) { - classLevel = classLevel.replace(/^\s*\([^)]*\)\s*/, ''); - } +// 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; } } } - - var registerDate = null; - var regM = text.match(/(?:注册日期|加入日期|Registered)\s*[::]?\s*(\d{4}-\d{2}-\d{2})/i); - if (regM) registerDate = regM[1]; - - // 最近动向:NexusPHP 常为 "xx 天/时 前" 或绝对时间,原样截取供上层判断 - var lastActivityAt = null; - var laM = text.match(/(?:最近动向|最近活动|最后活动|最近上站|Last\s*(?:Seen|Active|Activity))\s*[::]?\s*([^\n\r]{1,40})/i); - if (laM) lastActivityAt = laM[1].trim(); - - // 做种积分 - var seedingScore = null; - var ssM = text.match(/(?:做种积分|做種積分|Seeding\s*(?:Bonus|Points?))\s*[::]?\s*([\d.,]+)/i); - if (ssM) { - var sv = parseFloat(ssM[1].replace(/,/g, '')); - if (!isNaN(sv)) seedingScore = sv; - } - - // 封存/封禁检测 - var isSealed = /封存|帳號已?被封|账号已?被封/.test(text); - - var identity = parseIdentity(doc.querySelector('#info_block') || doc); - return { - uid: identity.uid, - userName: identity.userName, - email: email, - classLevel: classLevel, - registerDate: registerDate, - lastActivityAt: lastActivityAt, - seedingScore: seedingScore, - isSealed: isSealed - }; + return __parseJson(raw); } -// ================= 能力: index(用户统计)================= -// ctx: { baseUrl, ... }。解析首页信息块,返回当前登录用户统计。 -function index(input) { - var ctx = __asCtx(input); - var base = __resolveBase(ctx); - if (!base) return { error: 'no_base_url' }; +// 视数字为 GiB 时换算成字节(M-Team memberCount.uploaded/downloaded 单位) +var __GIB = 1073741824; - var indexHtml = null; - var paths = ['/index.php', '/']; - for (var i = 0; i < paths.length; i++) { - try { - indexHtml = http.get(base + paths[i]); - } catch (e) { - indexHtml = null; +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(','); } - if (indexHtml && indexHtml.length > 0) break; + 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); } - if (!indexHtml) return { error: 'index_fetch_failed' }; - - var stats = parseIndexStats(indexHtml); - if (!stats) return { error: 'index_no_match', html: indexHtml }; - return stats; + return { pageName: '搜索', extracted: rows, items: rows, matched: true, message: '共 ' + rows.length + ' 条', incomplete: false }; } -// ================= 能力: userdetail(用户详情)================= -// ctx: { baseUrl, userId (或 uid) } +// 从 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) return { error: 'no_base_url' }; - var uid = (ctx && (ctx.userId || ctx.uid)) || ''; - if (!uid) return { error: 'no_user_id' }; - - var userHtml; - try { - userHtml = http.get(base + '/userdetails.php?id=' + encodeURIComponent(uid)); - } catch (e) { - return { error: String((e && e.message) || e) }; - } - if (!userHtml) return { error: 'userdetails_fetch_failed' }; - - var profile = parseUserDetails(userHtml); - if (!profile) return { error: 'userdetails_no_match', html: userHtml }; + 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 { - uid: profile.uid || uid, - userName: profile.userName, - userClass: profile.classLevel, - registeredAt: profile.registerDate, - lastActivityAt: profile.lastActivityAt, - seedingScore: profile.seedingScore, - profileEmail: profile.email, - isSealed: profile.isSealed + userName: p.username || null, + invites: p.invites, + uploaded: p.upload, + downloaded: p.download, + bonus: p.bonus, + shareRate: p.shareRate }; } -// ================= 站点信息同步(mediabot2 Host 调用)================= -// ctx: { siteCode, siteId, baseUrl, uid, userName, utcNow, frameworkVersion } -// Success: returns stats object. Failure: returns { error, html?, userHtml? } carrying raw HTML -// so Host can report it verbatim to Registry. NEVER return null; always include `error`. -function sync(input) { - var ctx = __asCtx(input); - var base = __resolveBase(ctx); - if (!base) return { error: 'no_base_url' }; - - // 1. fetch index page (index.php first, root fallback) - var indexHtml = null; - var paths = ['/index.php', '/']; - for (var i = 0; i < paths.length; i++) { - try { - indexHtml = http.get(base + paths[i]); - } catch (e) { - indexHtml = null; - } - if (indexHtml && indexHtml.length > 0) break; - } - if (!indexHtml) return { error: 'index_fetch_failed' }; - - var indexStats = parseIndexStats(indexHtml); - if (!indexStats) return { error: 'index_no_match', html: indexHtml }; - - // 2. if uid known, fetch userdetails.php?id={uid} to parse profile - var uid = indexStats.uid || (ctx && ctx.uid) || null; - var profile = null; - var userHtml = null; - if (uid) { - try { - userHtml = http.get(base + '/userdetails.php?id=' + encodeURIComponent(uid)); - if (userHtml && userHtml.length > 0) { - profile = parseUserDetails(userHtml); - // content fetched but no profile fields parsed (CAPTCHA / login wall): treat as failure - if (profile && profile.email === null && profile.classLevel === null && profile.registerDate === null) { - profile = null; - } - } - } catch (e) { - profile = null; - } - } - // userdetails fetch/parse failed: stats available, attach userHtml for Host reporting - if (uid && !profile) { - return { - uploaded: indexStats.uploaded, - downloaded: indexStats.downloaded, - bonus: indexStats.bonus, - ratio: indexStats.ratio, - seeding: indexStats.seeding, - leeching: indexStats.leeching, - hr: indexStats.hr, - uid: uid, - userName: indexStats.userName || (ctx && ctx.userName) || null, - email: null, - classLevel: null, - registerDate: null, - error: 'userdetails_no_match', - html: indexHtml, - userHtml: userHtml - }; - } - - return { - uploaded: indexStats.uploaded, - downloaded: indexStats.downloaded, - bonus: indexStats.bonus, - ratio: indexStats.ratio, - seeding: indexStats.seeding, - leeching: indexStats.leeching, - hr: indexStats.hr, - uid: uid, - userName: (profile && profile.userName) || indexStats.userName || (ctx && ctx.userName) || null, - email: (profile && profile.email) || null, - classLevel: (profile && profile.classLevel) || null, - registerDate: (profile && profile.registerDate) || null - }; -} - -function parse(content, url) { - var doc = html.parse(content); - return { - uploaded: doc.queryText('#seedup a'), - downloaded: doc.queryText('#seeddown a'), - bonus: doc.queryText('#seedbonus') - }; -} - -// ================= 能力: signIn(签到)================= -// 签到(signInPath 默认 /attendance.php,可在站点 definition 中覆盖,如 BTSCHOOL: /index.php?action=addbonus) -// 成功时若响应页含 #info_block (含魔力值) 则一并返回 bonus 供服务端更新 Site.Statistics.Bonus; -// 站点页面不含 / 解析不到时缺省不返回该字段, 服务端不处理 (report §四 签到奖励自动更新). -function signIn(input) { - var ctx = __asCtx(input); - var signPath = ctx.signInPath || '/attendance.php'; - var res = http.post(signPath); - if (res && res.indexOf('成功') >= 0) { - var out = { signed: true, message: '签到成功' }; - try { - var stats = parseIndexStats(res); - if (stats && stats.bonus !== null && stats.bonus !== undefined && !isNaN(stats.bonus)) { - out.bonus = stats.bonus; - } - } catch (e) { - // bonus 解析失败不影响签到成功结果 - } - return out; - } - if (res && (res.indexOf('已签到') >= 0 || res.indexOf('已经签到') >= 0 || res.indexOf('已經簽到') >= 0 || res.indexOf('重复签到') >= 0 || res.indexOf('重複簽到') >= 0)) { - return { signed: true, message: '今日已签到' }; - } - return { signed: false, message: (res && res.length > 0) ? '未识别签到结果' : '签到请求失败' }; -} - -// 解析裸大小文本 "1.23 GB" / "986.5 MB" / "1.2 TB" → 字节数 -function parseSizeText(text) { - if (!text) return 0; - var m = String(text).match(/([\d.,]+)\s*(TB|GB|MB|KB|PB|B)\b/i); - if (!m) return 0; - var num = parseFloat(m[1].replace(/,/g, '')); - if (isNaN(num)) return 0; - return Math.round(num * unitToBytes(m[2])); -} - -// 提取非负整数(忽略逗号与装饰性字符) -function toInt(text) { - if (text === null || text === undefined) return 0; - var m = String(text).match(/[\d,]+/); - if (!m) return 0; - var v = parseInt(m[0].replace(/,/g, ''), 10); - return isNaN(v) ? 0 : v; -} - -// 从标题里按正则提取首个媒体标签 (分辨率/来源/编码),供订阅偏好过滤与质量评分 -function parseMediaTag(title, re) { - if (!title) return null; - var m = String(title).match(re); - if (!m) return null; - var v = m[1] || m[0]; - return v ? v.toUpperCase() : null; -} - -// ================= 能力: search(搜索种子)================= -// ctx: { siteCode, baseUrl, keyword, page(=1), searchPath(可选, 默认 /torrents.php) } -// 走 http(已注入 Cookie 的 HttpClient),解析 table.torrents 种子行。 +// ===================== 能力: 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) return []; - var page = ctx.page ? parseInt(ctx.page, 10) : 1; - if (isNaN(page) || page < 1) page = 1; - var searchPath = ctx.searchPath || '/torrents.php'; - - // 首页不拼 &page=N:NexusPHP 分页分 0 基/1 基两种,部分站点(如 carpt)在 - // ?search=X&page=1 时实际请求的是"第 2 页",而结果往往只有一页 → 返回空表(仅搜索框)。 - // 统一用裸 ?search=X 取首页,对两种索引方案都正确返回第 1 页。 - var query = '?search=' + encodeURIComponent(keyword); - var url = (page > 1) - ? (base + searchPath + query + '&page=' + page) - : (base + searchPath + query); - var htmlText = http.get(url); - if (!htmlText) return []; - var doc = html.parse(htmlText); - if (!doc) return []; - - // ---- 诊断: 校验返回结构, 便于排查搜索空结果 (源码原始 html 特征) ---- - // 注意: 不能用 "Powered by NexusPHP" 或站名判断登录墙 —— 这些字串每个 NexusPHP 站页面都有. - // 登录墙的可靠特征是: 存在登录表单 (带 password input / action 指向 login 页). - var htmlTag = null; - var titleTag = (htmlText.match(/([^<]*)<\/title>/i) || [])[1] || ''; - var tableCount = (htmlText.match(/<table[^>]*class="torrents"[^>]*>/i) || []).length; - var hasLoginForm = /<input[^>]*type="Password"[^>]*>/i.test(htmlText) - || /<form[^>]*action="[^"]*login[^"]*"[^>]*>/i.test(htmlText) - || /name="(?:username|loginusername|userpasswd)"[^>]*>/i.test(htmlText) - || /登录到|login in|请登陆|请登录/i.test(titleTag); - // 采集页面所有的 table class + 种子详情链接数, 用于判断选择器是否匹配. - var tableClasses = []; - var tcm = htmlText.match(/<table[^>]*class="([^"]*)"[^>]*>/gi); - if (tcm) for (var tc = 0; tc < tcm.length; tc++) { - var cls = (tcm[tc].match(/class="([^"]*)"/i) || [])[1] || ''; - if (cls && tableClasses.indexOf(cls) < 0) tableClasses.push(cls); - } - var detailLinkCount = (htmlText.match(/href="[^"]*details\.php[^"]*"/gi) || []).length; - if (hasLoginForm) { - htmlTag = 'loginwall'; - } else if (detailLinkCount > 0) { - htmlTag = 'has_details_' + detailLinkCount; - } else if (titleTag && /无.*种子|没有|未找到|No|结果/i.test(titleTag)) { - htmlTag = 'noresult:' + titleTag; - } else if (tableCount === 0) { - htmlTag = 'no_torrents_table'; - } else { - htmlTag = 'ok'; - } - // 候选选择器命中数: 定位 carpt 种子列表的真实容器 (标准 nexusphp 是 table.torrents, 自定义皮站常不同) - var selCands = ['table.torrents tr', 'table[class*="torrent"] tr', '#torrents tr', 'tr.rowfollow', '.torrent_name', '.torrenttitle', 'td.rowfollow', 'table tr']; - var selHits = []; - for (var si = 0; si < selCands.length; si++) { - try { selHits.push(selCands[si] + '=' + doc.queryList(selCands[si]).length); } catch (e) { selHits.push(selCands[si] + '=ERR'); } - } - // 原始响应 HTML 关键片段: 后端会将其落盘为快照文件 (search-snapshots/) 便于调试, 此处保留完整内容. - // ---- 诊断结束 ---- - - // 取所有表格行, 再由 td.rowfollow + details 链接 过滤出真实种子行. - // 注意: 不能用 table.torrents —— 不少自定义皮站 (如 carpt) 种子列表父表 class 不是 torrents. - // 侧边搜索/筛选区同样含 td.rowfollow, 故必须额外要求含 details* 链接才算种子行. - var rows = doc.queryList('table tr'); - var results = []; - for (var i = 0; i < rows.length; i++) { - var row = rows[i]; - // 仅取直接子 td:carpt 等站点在标题单元格内嵌套了 torrentname 子表, - // 用 'td' 会把后代 td(embedded) 一并计入,导致列索引错位、体积/做种数全变 0. - var tds = row.queryList(':scope > td'); - // 种子行判定:含 td.rowfollow(排除搜索/筛选/表头) 且拥有多个直接子 td(标准约 9 列)。 - // 仅 1 个直接子 td 的 tr 多为置顶/推荐/嵌套区块的重复条目(数字无法解析),直接跳过. - if (tds.length < 6) continue; - if (row.queryList('td.rowfollow').length === 0) continue; - var titleEl = row.querySelector('a[href*="details"]'); - if (!titleEl) continue; - var title = titleEl.text(); - if (!title) continue; - - var href = titleEl.attr('href'); - var detailUrl = null; - if (href) { - detailUrl = (href.indexOf('http') === 0) ? href : (base + (href[0] === '/' ? href : '/' + href)); - } - - // 行内下载链接: 下载图标指向 download.php?… (passkey 站点已带 passkey/uid 等完整参数, 如 pttime; - // 仅需 Cookie 的站点如 carpt 则不带额外参数). 下载时直接复用该链接, 无需再拼 passkey. - var downloadUrl = null; - var dlLink = row.querySelector('a[href*="download.php"]'); - if (dlLink) { - var dhref = dlLink.attr('href') || ''; - if (dhref) { - downloadUrl = (dhref.indexOf('http') === 0) ? dhref : (base + (dhref[0] === '/' ? dhref : '/' + dhref)); - } - } - - var size = 0, seeders = 0, leechers = 0, completed = 0; - if (tds.length > 7) { - size = parseSizeText(tds[4] ? tds[4].text() : ''); - seeders = toInt(tds[5] ? tds[5].text() : ''); - leechers = toInt(tds[6] ? tds[6].text() : ''); - completed = toInt(tds[7] ? tds[7].text() : ''); - } - - var yearM = title.match(/\b(19|20)\d{2}\b/); - var year = yearM ? parseInt(yearM[0], 10) : null; - - // 促销/免费/H&R 标记:图标位于标题单元格内 (img.pro_free / img.pro_50pctdown / img.hitandrun / alt) - var isFree = false, isHr = false, freeDeadline = null, promo = null; - var imgs = row.queryList('img'); - for (var pi = 0; pi < imgs.length; pi++) { - var pcls = imgs[pi].attr('class') || ''; - var palt = imgs[pi].attr('alt') || ''; - if (/pro_free|pro_100pctdown/i.test(pcls) || /\bfree\b|100%/i.test(palt)) { isFree = true; promo = 'free'; } - else if (/pro_50pctdown|pro_free2up/i.test(pcls) || /50%|2x/i.test(palt)) { promo = 'half'; } - if (/hitandrun/i.test(pcls) || /\bH&?R\b/i.test(palt)) isHr = true; - } - // 免费/促销截止时间 + 发布时间:行内真实存在的 <span title="2027-03-02 10:33:29">("剩余时间:"后), - // 取最大者(未来截止>过去添加时间)为免费截止,取最小者(最早)为种子发布时间. - var spans = row.queryList('span[title]'); - var maxDt = null, minDt = null; - for (var si = 0; si < spans.length; si++) { - var sttl = spans[si].attr('title') || ''; - var sdm = sttl.match(/^(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})/); - if (!sdm) continue; - if (maxDt === null || sdm[1] > maxDt) maxDt = sdm[1]; - if (minDt === null || sdm[1] < minDt) minDt = sdm[1]; - } - if (maxDt) freeDeadline = maxDt.replace(' ', 'T'); - var uploadAt = minDt ? minDt.replace(' ', 'T') : null; - - // 分类:行首分类图标 (class 以 c_ 开头, alt/title=音乐/Music 等),取首个匹配 - var category = null; - var cimgs = row.queryList('td.rowfollow img'); - for (var ci = 0; ci < cimgs.length; ci++) { - var ccls = cimgs[ci].attr('class') || ''; - if (/^c_/i.test(ccls)) { - category = cimgs[ci].attr('title') || cimgs[ci].attr('alt') || null; - break; - } - } - - // 分辨率/来源/编码:从标题文本解析,供订阅偏好过滤 (PreferredResolution/Source/Encoding) 与质量评分 - var resolution = parseMediaTag(title, /\b(\d{3,4}[pi])\b/i); - var source = parseMediaTag(title, /\b(WEB-DL|WEBRip|WEB|HDTV|Blu-?Ray|DVD|Remux|BDrip|BDRip|HDrip|DVDRip)\b/i); - var encoding = parseMediaTag(title, /\b(x264|x265|H\.?265|H\.?264|AVC|HEVC|VC-?1|XviD|AV1)\b/i); - - results.push({ - title: title, - detailUrl: detailUrl, - downloadUrl: downloadUrl, - size: size, - seeders: seeders, - leechers: leechers, - completed: completed, - year: year, - resolution: resolution, - source: source, - encoding: encoding, - category: category, - isFree: isFree, - isHr: isHr, - uploadAt: uploadAt, - freeDeadline: freeDeadline, - promo: promo - }); - } - if (results.length === 0) { - // 无结果时附加诊断信息: 供后端日志区分登录墙/无结果页/选择器不匹配, 前端会按空 title 过滤 - results.push({ - title: '', - __diag: htmlTag || 'unknown', - __url: url, - __len: htmlText ? htmlText.length : 0, - __tables: tableClasses.join(','), - __details: detailLinkCount, - __sel: selHits.join(' | '), - __html: htmlText || '' - }); - } - return results; + 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 || []; } -// 从详情页 DOM 中提取外部 ID (doubanId / imdbId / tmdbId / tvdbId). -// NexusPHP 详情页的外部链接通常放在 #kdescr 描述块、#info_block 标签区或页面底部, -// 通过遍历所有 <a> 的 href 正则命中即可覆盖多数站点布局。 -function extractExternalIds(doc) { - var ids = {}; - try { - var links = doc.queryList('a'); - for (var i = 0; i < links.length; i++) { - var href = links[i].attr('href') || ''; - if (!ids.doubanId) { - var dbm = href.match(/douban\.com\/subject\/(\d+)/i); - if (dbm) ids.doubanId = dbm[1]; - } - if (!ids.imdbId) { - var imm = href.match(/imdb\.com\/title\/(tt\d+)/i); - if (imm) ids.imdbId = imm[1]; - } - if (!ids.tmdbId) { - var tmm = href.match(/themoviedb\.org\/(?:movie|tv)\/(\d+)/i); - if (tmm) ids.tmdbId = parseInt(tmm[1], 10); - } - if (!ids.tvdbId) { - var tvm = href.match(/thetvdb\.com\/series\/(\d+)/i) || href.match(/tvdbid=(\d+)/i); - if (tvm) ids.tvdbId = parseInt(tvm[1], 10); - } - // 四个全齐就提前退出 - if (ids.doubanId && ids.imdbId && ids.tmdbId && ids.tvdbId) break; - } - } catch (e) { /* 任何异常都静默降级, 返回部分结果 */ } - return ids; -} - -// ================= 能力: detail(种子详情)================= -// ctx: { baseUrl, id (或 detailId), detailUrl?(可选, 直接指定详情页地址) } -// 输出字段与能力模板 detail 一致(size/seeders 等保留页面原始文本)。 -function detail(input) { +// ===================== 站点信息同步(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) return { error: 'no_base_url' }; - var url = ctx && ctx.detailUrl ? String(ctx.detailUrl) : ''; - if (!url) { - var id = (ctx && (ctx.id || ctx.detailId)) || ''; - if (!id) return { error: 'no_detail_id' }; - url = base + '/details.php?id=' + encodeURIComponent(id); - } - if (url.indexOf('http') !== 0) url = base + (url[0] === '/' ? url : '/' + url); + if (!base || base === 'http://mock.local') return { error: 'no_base_url' }; + var apiKey = ctx.apiKey || ctx.xApiKey; - var htmlText; - try { - htmlText = http.get(url); - } catch (e) { - return { error: String((e && e.message) || e), url: url }; - } - if (!htmlText) return { error: 'empty_response', url: url }; - var doc = html.parse(htmlText); - if (!doc) return { error: 'parse_failed', url: url, html: htmlText }; + // 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' }; - var out = { url: url }; - - var titleEl = doc.querySelector('#top h1') || doc.querySelector('h1'); - if (titleEl) out.title = titleEl.text(); - var subEl = doc.querySelector('td.embedded'); - if (subEl) out.subtitle = subEl.text(); - var descEl = doc.querySelector('#kdescr'); - if (descEl) out.description = descEl.text(); - - // 行式字段:遍历 td.rowhead,取 label → 下一格文本(兼容表格布局) - var heads = doc.queryList('td.rowhead'); - for (var i = 0; i < heads.length; i++) { - var label = (heads[i].text() || '').trim(); - var next = heads[i].nextElementSibling; - var val = next ? (next.text() || '').trim() : ''; - if (!val) continue; - if (out.size === undefined && /^(大小|尺寸|體積|Size)$/i.test(label)) out.size = val; - else if (out.seeders === undefined && /(做种者|做種者|当前做种|當前做種|Seeders?)/i.test(label)) out.seeders = val; - else if (out.leechers === undefined && /(下载者|下載者|当前下载|當前下載|Leechers?)/i.test(label)) out.leechers = val; - else if (out.completed === undefined && /(完成|Completed?)/i.test(label)) out.completed = val; + // 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; } - // 促销/免费标记(img.pro_free 等,同 search 判定) - var imgs = doc.queryList('img'); - for (var p = 0; p < imgs.length; p++) { - var pcls = imgs[p].attr('class') || ''; - var palt = imgs[p].attr('alt') || ''; - if (/pro_free|pro_100pctdown/i.test(pcls) || /^free$|100%/i.test(palt)) { out.free = 'free'; break; } - if (/pro_50pctdown|pro_free2up/i.test(pcls) || /50%|2x/i.test(palt)) { out.free = '50%'; break; } - } - - // 磁力链(部分站点详情页直接提供) - var links = doc.queryList('a'); - for (var m = 0; m < links.length; m++) { - var mhref = links[m].attr('href') || ''; - if (mhref.indexOf('magnet:') === 0) { out.magnet = mhref; break; } - } - - // 提取外部 ID (doubanId / imdbId / tmdbId / tvdbId) - var extIds = extractExternalIds(doc); - for (var k in extIds) if (extIds[k] !== undefined) out[k] = extIds[k]; - - return out; + return { + uploaded: p.upload, + downloaded: p.download, + bonus: p.bonus, + shareRate: p.shareRate, + invites: p.invites, + userName: p.username, + seeding: seeding + }; } -// fetchDetail: C# DetailVerifyJob 调用入口, 别名到 detail 函数. -// C# 端 Invoke("fetchDetail") 是外部约定, 这里保持兼容. -function fetchDetail(input) { return detail(input); } - -// ================= 能力: download(下载种子)================= -// 优先直接使用 ctx.downloadUrl / ctx.detailUrl (搜索解析时抓取的行内下载链接, 已含 passkey/uid 等完整参数, 如 pttime); -// 无行内链接时由 ctx.id (或 detailId) 构造, 可带 ctx.passkey (仅需 Cookie 的站点如 carpt 不传即可)。 -// 站点会话 (Cookie) 已在 http 客户端注入, Referer 由 Host 设置。 -// 必须走脚本内 http, 不能由外部用裸链接直接 GET。 -// 返回 { downloadUrl, url, bytes, size } 或 { error, url? }. +// ===================== 能力: 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.downloadUrl || ctx.detailUrl) { - url = String(ctx.downloadUrl || ctx.detailUrl); - if (url.indexOf('http') !== 0) url = base + (url[0] === '/' ? url : '/' + url); + 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 { - var detailId = ctx && (ctx.id || ctx.detailId) ? String(ctx.id || ctx.detailId) : ''; - if (!detailId) return { error: 'no_detail_id' }; - if (!base) return { error: 'no_base_url' }; - var path = ctx.downloadPath || '/download.php'; - url = base + path + '?id=' + encodeURIComponent(detailId); - var passkey = ctx.passkey ? String(ctx.passkey) : ''; - if (passkey) url += '&passkey=' + encodeURIComponent(passkey); + return { error: 'no_detail_id' }; } try { var bytes = http.getBytes(url); @@ -794,96 +355,4 @@ function download(input) { } catch (e) { return { error: String((e && e.message) || e), url: url }; } -} - -// 刷流入口:拉取站点 RSS/Atom feed 并解析为标准条目列表,供 RssDiscoveryJob 刷流。 -// 必须走脚本内 http(带站点 Cookie),与 search/download 一致的会话上下文。 -// ctx: { siteCode, baseUrl, feedUrl, rssPath?(可选, 默认 /torrentrss.php) } -// 优先使用 ctx.feedUrl(RssSubscription 配置的完整 RSS 链接,已含 passkey 等参数); -// 缺省时回退到 baseUrl + rssPath 拼装。 -// 返回: [{ title, detailUrl, downloadUrl, description, pubDate, guid }] 或 { error } -function rss(ctx) { - var base = String(ctx.baseUrl || '').replace(/\/+$/, ''); - var url = ctx.feedUrl ? String(ctx.feedUrl) : ''; - if (!url) { - var rssPath = ctx.rssPath || '/torrentrss.php'; - if (!base) return { error: 'no_feed_url' }; - url = base + rssPath; - } - var xml; - try { - xml = http.get(url); - } catch (e) { - return { error: String((e && e.message) || e), url: url }; - } - if (!xml) return { error: 'empty_feed', url: url }; - - // Atom (<entry>) 与 RSS (<item>) 都支持:分别抽取后合并。 - var items = []; - - function tag(block, name) { - var m = block.match(new RegExp('<' + name + '[^>]*>([\\s\\S]*?)<\\/' + name + '>', 'i')); - return m ? m[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').trim() : ''; - } - function attr(block, name, an) { - var m = block.match(new RegExp(name + '\\s+' + an + '\\s*=\\s*["\']([^"\']*)["\']', 'i')); - return m ? m[1] : ''; - } - - // RSS 2.0: <item>…</item> - var blocks = xml.match(/<item[\s\S]*?<\/item>/gi); - if (blocks) { - for (var i = 0; i < blocks.length; i++) { - var b = blocks[i]; - var title = tag(b, 'title'); - var link = tag(b, 'link'); - var desc = tag(b, 'description'); - var guid = tag(b, 'guid') || link; - var pub = tag(b, 'pubDate'); - var enc = (b.match(/<enclosure[^>]*>/i) || [])[0] || ''; - var enclosureUrl = attr(enc, 'enclosure', 'url'); - items.push({ - title: title, - detailUrl: link, - downloadUrl: enclosureUrl || link, - description: desc, - pubDate: pub, - guid: guid - }); - } - } - - // Atom: <entry>…</entry> - var atomBlocks = xml.match(/<entry[\s\S]*?<\/entry>/gi); - if (atomBlocks) { - for (var j = 0; j < atomBlocks.length; j++) { - var a = atomBlocks[j]; - var at = tag(a, 'title'); - var aid = tag(a, 'id'); - var updated = tag(a, 'updated') || tag(a, 'published'); - var summary = tag(a, 'summary') || tag(a, 'content'); - // Atom link:优先 rel="alternate" 的 href;无则取首个 href - var links = a.match(/<link[^>]*>/gi) || []; - var altHref = '', firstHref = ''; - for (var k = 0; k < links.length; k++) { - var lh = attr(links[k], 'link', 'href'); - if (!firstHref) firstHref = lh; - if (/rel=["']alternate["']/i.test(links[k])) altHref = lh; - } - var href = altHref || firstHref; - items.push({ - title: at, - detailUrl: href, - downloadUrl: href, - description: summary, - pubDate: updated, - guid: aid - }); - } - } - - if (items.length === 0) { - return { error: 'no_items', url: url, __len: xml.length, __html: xml }; - } - return items; -} +} \ No newline at end of file