Files
par/adapters/nexusphp/adapter.js
2026-09-22 16:31:21 +00:00

1115 lines
46 KiB
JavaScript
Raw Permalink 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 刷流(供 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;
}