diff --git a/adapters/nexusphp/adapter.js b/adapters/nexusphp/adapter.js new file mode 100644 index 0000000..7e7e6e6 --- /dev/null +++ b/adapters/nexusphp/adapter.js @@ -0,0 +1,849 @@ +// 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/Atom 刷流(供 RssDiscoveryJob,非标准能力) + +// ==================== 运行环境兼容层 ==================== +// 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) +var __inRegistrySandbox = (typeof __wrapNode === 'function'); + +// 注册中心 ParseInputArgs 把 testInput 原始 JSON 串按位置传参; mediabot2 直接传对象。 +// 入口函数统一经 __asCtx 归一化,两种调用方式都兼容。 +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 : {}; +} + +// 取 baseUrl;注册中心沙箱中为空时用占位地址(mock http 会回退返回样本 HTML,不影响解析链路) +function __resolveBase(ctx) { + var b = String(ctx && ctx.baseUrl ? 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; + } +} + +// 标签后直接匹配数值(兼容 "上传量: 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*/, ''); + } + } + } + } + + 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 + }; +} + +// ================= 能力: index(用户统计)================= +// ctx: { baseUrl, ... }。解析首页信息块,返回当前登录用户统计。 +function index(input) { + var ctx = __asCtx(input); + var base = __resolveBase(ctx); + if (!base) return { error: 'no_base_url' }; + + 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 stats = parseIndexStats(indexHtml); + if (!stats) return { error: 'index_no_match', html: indexHtml }; + return stats; +} + +// ================= 能力: userdetail(用户详情)================= +// ctx: { baseUrl, userId (或 uid) } +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 }; + 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 + }; +} + +// ================= 站点信息同步(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)) { + 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 种子行。 +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; +} + +// ================= 能力: detail(种子详情)================= +// ctx: { baseUrl, id (或 detailId), detailUrl?(可选, 直接指定详情页地址) } +// 输出字段与能力模板 detail 一致(size/seeders 等保留页面原始文本)。 +function detail(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); + + 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 }; + + 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; + } + + // 促销/免费标记(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; } + } + + return out; +} + +// ================= 能力: 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? }. +function download(input) { + var ctx = __asCtx(input); + var base = __resolveBase(ctx); + 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); + } 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); + } + 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 }; + } +} + +// 刷流入口:拉取站点 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; +}