1115 lines
46 KiB
JavaScript
1115 lines
46 KiB
JavaScript
// 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,非标准能力)
|
||
|
||
// ==================== 运行环境兼容层 ====================
|
||
// 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
|
||
};
|
||
}
|
||
|
||
// ---- 邮箱提取 ----
|
||
// 资料页的「邮箱」值单元格往往不只有邮箱本身 (PTTIME: 格内还混着 "UID:98067" / "📝修改此项" / 🔐 标记,
|
||
// 且整页还有捐赠/页脚链接), 不同 NexusPHP 变体标签可能是 邮箱/电子邮件/電子郵件/Email,
|
||
// 值可能是 <a href="mailto:"> 也可能是纯文本, 甚至被反爬混淆成 "xxx [at] qq [dot] com"。
|
||
// 故按「标签行定位 → 值内 mailto → 值文本 → 整页 mailto → 标签邻近文本」逐级提取:
|
||
// 既不会抓到页脚的联系邮箱, 也不会因标签与值不在同一行/大小写 mailto 而漏抓。
|
||
var EMAIL_PATTERN = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}';
|
||
var EMAIL_LABELS = '(?:邮箱|电子邮件|電子郵件|邮件|邮件地址|邮箱地址|E-?mail)';
|
||
|
||
// 反混淆: "xxx [at] qq [dot] com" / "xxx (at) qq (dot) com" / "xxx at qq dot com"
|
||
function deobfuscateEmail(s) {
|
||
return String(s || '')
|
||
.replace(/\s*[\[({]\s*(?:at|@)\s*[\])}]\s*/gi, '@')
|
||
.replace(/\s*[\[({]\s*dot\s*[\])}]\s*/gi, '.')
|
||
.replace(/\s+(?:at|@)\s+/gi, '@')
|
||
.replace(/\s+dot\s+/gi, '.');
|
||
}
|
||
|
||
// 从一段文本里取第一个邮箱 (先直取, 失败再反混淆后取)
|
||
function firstEmailIn(s) {
|
||
if (!s) return null;
|
||
var m = String(s).match(new RegExp(EMAIL_PATTERN));
|
||
if (m) return m[0];
|
||
var m2 = deobfuscateEmail(s).match(new RegExp(EMAIL_PATTERN));
|
||
return m2 ? m2[0] : null;
|
||
}
|
||
|
||
// 从节点下的 <a href="mailto:"> 取邮箱 (大小写不敏感 + URL 解码 + 去掉 ?subject= 等参数)
|
||
function emailFromLinks(node) {
|
||
if (!node || typeof node.queryList !== 'function') return null;
|
||
var links = node.queryList('a') || [];
|
||
for (var i = 0; i < links.length; i++) {
|
||
var href = (typeof links[i].attr === 'function' ? links[i].attr('href') : null) || '';
|
||
var m = String(href).match(/^\s*mailto:\s*(\S+)/i);
|
||
if (!m) continue;
|
||
var addr = m[1].split('?')[0];
|
||
try { addr = decodeURIComponent(addr); } catch (e) { /* 非法转义: 保持原值 */ }
|
||
var hit = firstEmailIn(addr);
|
||
if (hit) return hit;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 从单个单元格取邮箱: 先 mailto 链接, 再单元格文本
|
||
function emailFromCell(cell) {
|
||
if (!cell) return null;
|
||
return emailFromLinks(cell) || firstEmailIn(typeof cell.text === 'function' ? cell.text() : null);
|
||
}
|
||
|
||
function extractEmail(doc, text) {
|
||
// 1) 资料表格: 定位标签为「邮箱/邮件/Email」的行, 取右侧值单元格 (最准确)
|
||
var rows = (typeof doc.queryList === 'function' ? doc.queryList('tr') : null) || [];
|
||
for (var i = 0; i < rows.length; i++) {
|
||
var cells = rows[i].queryList('td') || [];
|
||
if (cells.length < 2) continue;
|
||
var label = (cells[0].text() || '').replace(/[\s::]/g, '');
|
||
if (!label) continue;
|
||
if (!new RegExp('^' + EMAIL_LABELS + '$', 'i').test(label)) continue;
|
||
for (var c = 1; c < cells.length; c++) {
|
||
var hit = emailFromCell(cells[c]);
|
||
if (hit) return hit;
|
||
}
|
||
}
|
||
// 2) 整页 mailto 链接 (标签行解析不到时的兜底, 例如标签与值不在同一 tr)
|
||
var byLink = emailFromLinks(doc);
|
||
if (byLink) return byLink;
|
||
// 3) 整页文本: 标签后紧跟邮箱
|
||
var em = String(text || '').match(new RegExp(EMAIL_LABELS + '\\s*[::]?\\s*(' + EMAIL_PATTERN + ')', 'i'));
|
||
if (em) return em[1];
|
||
// 4) 标签与值之间夹了别的内容 (PTTIME: "邮箱27800734@qq.comUID:98067🔐") 时, 在标签后 120 字符内找
|
||
var near = String(text || '').match(new RegExp(EMAIL_LABELS + '[^\\n]{0,120}?(' + EMAIL_PATTERN + ')', 'i'));
|
||
if (near) return near[1];
|
||
// 5) 反混淆兜底 (邮箱被拆成 "xxx [at] qq [dot] com")
|
||
var obf = deobfuscateEmail(text).match(new RegExp(EMAIL_LABELS + '[^\\n]{0,160}?(' + EMAIL_PATTERN + ')', 'i'));
|
||
return obf ? obf[1] : null;
|
||
}
|
||
|
||
// 解析 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 = extractEmail(doc, text);
|
||
|
||
// 等级: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;
|
||
}
|
||
|
||
// 封存/封禁检测:优先看「账号状态/用户状态」行的取值,其次认状态类表述 ("已封存"/"已封禁"/"封存中")。
|
||
// 排除 pttime 个人中心的干扰文本:操作行的「封存用户」按钮、"封存400天…自动封禁" 说明,
|
||
// 以及「已封禁次数」计数行 —— 否则正常账号会被误判为已封存。
|
||
var isSealed = false;
|
||
var statusRows = doc.queryList('tr') || [];
|
||
for (var s = 0; s < statusRows.length && !isSealed; s++) {
|
||
var stCells = statusRows[s].queryList('td') || [];
|
||
if (stCells.length < 2) continue;
|
||
var stLabel = (stCells[0].text() || '').replace(/[\s::]/g, '');
|
||
if (!/^(?:账号状态|帐号状态|用戶狀態|用户状态|状态|狀態)$/.test(stLabel)) continue;
|
||
if (/封存|封禁|停用|禁用/.test(stCells[1].text() || '')) isSealed = true;
|
||
}
|
||
if (!isSealed) {
|
||
var sealedRe = /已(?:被|经|經)?\s*(?:封存|封禁)([\s\S]{0,2})/g;
|
||
var sm;
|
||
while ((sm = sealedRe.exec(text)) !== null) {
|
||
// "已封禁次数" 是计数行, 不代表账号当前被封
|
||
if (!/^次/.test(sm[1] || '')) { isSealed = true; break; }
|
||
}
|
||
}
|
||
// 末级兜底只认"封存中/封禁中"这类进行中状态; 不再写 "账号…封" 这类宽松模式 ——
|
||
// pttime 个人中心的「自ban账号 / 封禁用户」按钮文本会命中它, 造成正常账号误判。
|
||
if (!isSealed) 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
|
||
};
|
||
}
|
||
|
||
// 访客视图(会话失效 / Cookie 域名不匹配 / 未登录)识别:
|
||
// NexusPHP 对未登录访客只给公开字段(等级、注册日期),邮箱等「上锁」字段整个隐藏,
|
||
// 若照常当成功处理,表现就是「同步成功但邮箱一直为空」且没有任何失败样本可排查。
|
||
// 判据:页面出现登录表单,或出现"请先登录/登录后查看"等提示。
|
||
function looksLikeGuestView(userHtml) {
|
||
try {
|
||
var d = html.parse(userHtml);
|
||
if (d.querySelector('form[action*="login"]')) return true;
|
||
if (d.querySelector('input[type="password"]')) return true;
|
||
var b = d.querySelector('body');
|
||
var t = (b && typeof b.text === 'function') ? b.text() : userHtml;
|
||
return /(?:请先登录|請先登入|需要登录|登录后查看|您尚未登录|尚未登录|未登录|Not logged in|Please log in)/i.test(t || '');
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ================= 能力: 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;
|
||
}
|
||
// 访客视图: 只拿到公开字段而上锁字段(邮箱)被隐藏 —— 不算解析成功, 否则会静默回传 email=null,
|
||
// 表现为"同步一直成功但邮箱始终为空"且无样本可排查。
|
||
if (profile && profile.email === null && looksLikeGuestView(userHtml)) {
|
||
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 §四 签到奖励自动更新).
|
||
//
|
||
// 站点特化:
|
||
// URL 含 {uid} 占位符 → 用 ctx.uid (数据库已存) 替换 → GET 请求 (PTTime: /attendance.php?type=sign&uid={uid});
|
||
// 其他情况一律 POST (兼容 BTSCHOOL /index.php?action=addbonus 等带查询参数的 POST 签到).
|
||
function sign(input) {
|
||
var ctx = __asCtx(input);
|
||
var signPath = ctx.signInPath || '/attendance.php';
|
||
var method = 'POST';
|
||
if (signPath.indexOf('{uid}') >= 0) {
|
||
signPath = signPath.replace('{uid}', (ctx && ctx.uid) || '');
|
||
method = 'GET';
|
||
}
|
||
|
||
var res = method === 'GET' ? http.get(signPath) : 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;
|
||
|
||
// torrentnameHtml: 只定位、不解析. 把标题 <a> 所在 td 的 innerHTML 片段原样传给 C#,
|
||
// 由 TorrentNameHtmlParser 用 AngleSharp 精拆 subtitle + tags.
|
||
var torrentnameHtml = null;
|
||
var titleTd = titleEl.closest ? titleEl.closest('td') : null;
|
||
if (!titleTd) {
|
||
var p = titleEl.parentElement;
|
||
while (p && p.tagName && p.tagName.toLowerCase() !== 'td') p = p.parentElement;
|
||
titleTd = p;
|
||
}
|
||
if (titleTd && typeof titleTd.html === 'function') {
|
||
torrentnameHtml = titleTd.html();
|
||
} else if (titleTd && typeof titleTd.innerHTML !== 'undefined') {
|
||
torrentnameHtml = titleTd.innerHTML;
|
||
}
|
||
|
||
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);
|
||
|
||
// 从 torrentnameHtml 正则抠外部 ID (doubanId/imdbId):
|
||
// PTT 模板 (PTTime) 列表页直接带 douban.com/subject/xxx 链接;
|
||
// 其他 NexusPHP 模板列表页通常只有图标没链接 → 正则 miss 就是 null, 安全兜底.
|
||
var doubanIdFromTname = null;
|
||
var imdbIdFromTname = null;
|
||
if (torrentnameHtml) {
|
||
var dbm = torrentnameHtml.match(/douban\.com\/subject\/(\d+)/i);
|
||
if (dbm) doubanIdFromTname = dbm[1];
|
||
var im = torrentnameHtml.match(/imdb\.com\/title\/(tt\d+)/i);
|
||
if (im) imdbIdFromTname = im[1];
|
||
}
|
||
|
||
results.push({
|
||
torrentnameHtml: torrentnameHtml,
|
||
doubanId: doubanIdFromTname,
|
||
imdbId: imdbIdFromTname,
|
||
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;
|
||
}
|
||
|
||
// 从详情页 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) {
|
||
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; }
|
||
}
|
||
|
||
// 提取外部 ID (doubanId / imdbId / tmdbId / tvdbId)
|
||
var extIds = extractExternalIds(doc);
|
||
for (var k in extIds) if (extIds[k] !== undefined) out[k] = extIds[k];
|
||
|
||
return out;
|
||
}
|
||
|
||
// fetchDetail: C# DetailVerifyJob 调用入口, 别名到 detail 函数.
|
||
// C# 端 Invoke("fetchDetail") 是外部约定, 这里保持兼容.
|
||
function fetchDetail(input) { return detail(input); }
|
||
|
||
// ================= 能力: hr(H&R 列表)=================
|
||
// 访问 myhr.php, 解析当前账号 H&R 考察中的种子列表.
|
||
// C# SiteHrSyncJob 约定入口名: "hr".
|
||
// ctx: { baseUrl, hrPath?(可选, 默认 /myhr.php) }
|
||
// 返回 { items: [...] } — 见 SiteScriptTorrentFetcher.ParseHrEntries 契约.
|
||
//
|
||
// #hr-table 表格列:
|
||
// H&R ID | 种子名称 | 上传量 | 下载量 | 分享率 | 还需做种时间 | 下载完成时间 | 剩余考察时间 | 备注 | 操作
|
||
//
|
||
// hrState 值: reviewing (考察中, 默认列表) | satisfied (已达标) | failed (未达标) | pardoned (已免罪)
|
||
// myhr.php 默认页 = status=1 考察中; 其他状态通过 ?status=2/3/4 切换.
|
||
function parseMyhr(htmlStr) {
|
||
var doc = html.parse(htmlStr);
|
||
var items = [];
|
||
|
||
var table = doc.querySelector('#hr-table');
|
||
if (!table) return { items: items };
|
||
|
||
var rows = table.queryList('tr');
|
||
for (var i = 0; i < rows.length; i++) {
|
||
var tds = rows[i].queryList('td');
|
||
if (tds.length < 5) continue;
|
||
if ((tds[0].attr('class') || '').indexOf('colhead') !== -1) continue;
|
||
|
||
var hrId = (tds[0].text() || '').trim();
|
||
if (!/^\d+$/.test(hrId)) continue;
|
||
|
||
// 种子详情链接 → detailId
|
||
var nameTd = tds[1];
|
||
var detailId = '';
|
||
if (nameTd) {
|
||
var a = nameTd.querySelector('a[href*="details.php"]');
|
||
if (a) {
|
||
var idM = (a.attr('href') || '').match(/details\.php\?.*?id=(\d+)/i);
|
||
if (idM) detailId = idM[1];
|
||
}
|
||
}
|
||
if (!detailId) continue; // C# ParseHrEntries 跳过 detailId 为空的条目
|
||
|
||
// 剩余考察时间 → remainingSeconds
|
||
// 格式: "9天09:10:50" 或 "10:19:39" (不足一天时可能只有 HH:mm:ss)
|
||
var remain = tds[7] ? (tds[7].text() || '').trim() : '';
|
||
var remainingSeconds = 0;
|
||
var daysMatch = remain.match(/(\d+)\s*天/);
|
||
var days = daysMatch ? parseInt(daysMatch[1], 10) : 0;
|
||
var hms = remain.match(/(\d{1,2}):(\d{2}):(\d{2})/);
|
||
if (hms) {
|
||
remainingSeconds = days * 86400
|
||
+ parseInt(hms[1], 10) * 3600
|
||
+ parseInt(hms[2], 10) * 60
|
||
+ parseInt(hms[3], 10);
|
||
} else if (days > 0) {
|
||
remainingSeconds = days * 86400;
|
||
}
|
||
|
||
items.push({
|
||
detailId: detailId,
|
||
remainingSeconds: remainingSeconds,
|
||
hrState: remainingSeconds > 0 ? 'reviewing' : 'satisfied',
|
||
hrId: hrId
|
||
});
|
||
}
|
||
|
||
return { items: items };
|
||
}
|
||
|
||
function hr(input) {
|
||
var ctx = __asCtx(input);
|
||
var base = __resolveBase(ctx);
|
||
if (!base || base === 'http://mock.local') return { items: [] };
|
||
var path = ctx && ctx.hrPath ? ctx.hrPath : '/myhr.php';
|
||
var url = base + path;
|
||
var htmlStr = http.get(url);
|
||
if (!htmlStr) return { items: [] };
|
||
return parseMyhr(htmlStr);
|
||
}
|
||
|
||
// ================= 能力: 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) {
|
||
// NexusPHP RSS 入口有两种命名: getrss.php (新) 和 torrentrss.php (旧).
|
||
// 用户没手动填 feedUrl 时, 先试 getrss.php, 失败再回退 torrentrss.php.
|
||
var paths = ctx.rssPaths || ['/getrss.php', '/torrentrss.php'];
|
||
if (!base) return { error: 'no_feed_url' };
|
||
url = base + paths[0];
|
||
}
|
||
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;
|
||
}
|