Files
par/adapters/nexusphp/adapter.js
2026-09-16 15:17:04 +00:00

850 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 优先:从 <a href="mailto:"> 提取最准确
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: 等级在 <b class="fcr"> 内
} 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 || 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>([^<]*)<\/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;
}