Files
blueslmjandClaude Opus 5 0e85433a5d feat: 多站点磁力搜索工具(网页版 + 命令行)
聚合 TPB(apibay) / therarbg / knaben / EZTV 四个站点的 JSON API,
按 infohash 跨站去重,seeders 取各站最大值,0 种子明确标出。

绕过的几个实测问题:
- 站点关键词搜索按整词匹配,"s03" 搜不到(标题里是 S03E07),
  裸季号自动展开成 s03e01..e30 逐集补搜(TPB 实测 0 条 -> 193 条)
- 放宽查询会撞分页天花板,所以三条路互补:knaben 用原查询、
  TPB 用逐集补搜、EZTV 用 IMDb id 列出全剧
- IMDb id 必须从未过滤的结果里推断,只看命中项的话季号一过滤就没了
- therarbg 的 keywords: 必须用 %20 编码,用 + 多词查询返回 0 条

匹配支持智能/完全两种模式,完全匹配按「从标题开头连续对上」判定
(发布名规范是 剧名.SxxExx.质量.小组,剧名永远在开头)。
输入可直接粘发布名,pawn.stars.s24 和 pawn stars s24 等价。

网页版走 SSE 实时推进度,Windows 下双击 .bat 即可启动。
零运行时依赖,48 个测试全部不联网。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 22:42:42 +08:00

591 lines
19 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.
/**
* 纯解析层:只处理 HTML 字符串,不依赖 DOM。
* 这样 content script 和 node 测试可以共用同一份实现。
*/
(function (root) {
'use strict';
const MAGNET_RE = /magnet:\?[^\s"'`<>\\]+/gi;
const BTIH_RE = /xt=urn:btih:([A-Za-z0-9]{32,40})/;
const SIZE_RE = /\b\d+(?:\.\d+)?\s?[KMGT]i?B\b/i;
// 各镜像的帖子详情页路径特征
// 不锚死开头:代理站会把整个站点套在一层路径下,
// 例如 knaben 的 /thepiratebay/description.php、/thepiratebay/torrent/123
const DETAIL_RES = [
/(?:^|\/)torrent\/[^/]+/i, // rargb.to / rarbg.to 等经典布局:/torrent/xxxxxxx
/\/description\.php$/i, // The Pirate Bay 及其代理(knaben 的 /thepiratebay/ 走这个)
/(?:^|\/)post\/detail\/[^/]+/i, // therarbg.com
/(?:^|\/)ep\/\d+\//i, // eztvx.to
/(?:^|\/)torrents\/\d+/i,
];
// 明显不是详情页的路径(列表页、导航等)
const DENY_RES = [
/\.torrent$/i,
/\/torrents\.php$/i,
/\/get-posts\//i,
/^\/search\/?$/i,
/^\/(login|register|about-us|catalog|top10|box-office)\b/i,
];
// 分页链接里的页码 token
// rargb.to 是 /search/2/?search=Pawn%20Stars 这种把页码放路径里的形式
const PAGE_TOKEN_RES = [
/([?&]page=)(\d+)/i,
/([?&]p=)(\d+)/i,
/(\/page\/)(\d+)(?=\/|$|\?)/i,
/(\/page_)(\d+)(?=\/|$|\?)/i, // eztvx.to
/(\/(?:search|browse|list|catalog)\/)(\d+)(?=\/|$|\?)/i,
];
// Cloudflare / DDoS-Guard 之类的校验页特征。撞上这个说明是被拦了,
// 不是页面用 JS 渲染,别去开 iframe 重试。
const BLOCKED_RES = [
/just a moment/i,
/checking your browser/i,
/cf-browser-verification/i,
/attention required/i,
/enable javascript and cookies to continue/i,
/ddos-guard/i,
/protected by go-away/i, // knaben 的反爬
/loading challenge/i,
];
const NAMED_ENTITIES = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
};
const reCache = new Map();
function safeRe(src) {
if (!src) return null;
if (reCache.has(src)) return reCache.get(src);
let re = null;
try {
re = new RegExp(src, 'i');
} catch (e) {
re = null;
}
reCache.set(src, re);
return re;
}
function decodeEntities(s) {
return String(s).replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, function (m, g) {
if (g[0] === '#') {
const code =
g[1] === 'x' || g[1] === 'X' ? parseInt(g.slice(2), 16) : parseInt(g.slice(1), 10);
return Number.isFinite(code) && code > 0 ? String.fromCodePoint(code) : m;
}
const v = NAMED_ENTITIES[g.toLowerCase()];
return v === undefined ? m : v;
});
}
function cleanText(html) {
return decodeEntities(String(html).replace(/<[^>]*>/g, ' '))
.replace(/\s+/g, ' ')
.trim();
}
function attr(attrs, name) {
const re = new RegExp(
'\\b' + name + '\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\'|([^\\s">]+))',
'i'
);
const m = re.exec(attrs || '');
if (!m) return '';
return m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3] || '';
}
/** 抽出所有 <a>,返回 { href, text } */
function extractAnchors(html) {
const out = [];
const re = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi;
let m;
while ((m = re.exec(html))) {
const href = attr(m[1], 'href');
if (!href) continue;
out.push({
href: decodeEntities(href),
text: cleanText(m[2]) || decodeEntities(attr(m[1], 'title')),
});
}
return out;
}
function toAbsolute(href, base) {
if (!href) return '';
if (href.charAt(0) === '#' || /^(javascript|mailto|magnet|data):/i.test(href)) return '';
try {
const u = new URL(href, base);
u.hash = '';
return u.href;
} catch (e) {
return '';
}
}
function isDetailUrl(absUrl, opts) {
let u;
try {
u = new URL(absUrl);
} catch (e) {
return false;
}
const path = u.pathname;
const extra = safeRe((opts && opts.detailPattern) || '');
if (extra && extra.test(path + u.search)) return true;
if (DENY_RES.some((re) => re.test(path))) return false;
return DETAIL_RES.some((re) => re.test(path));
}
/** 是不是反爬校验页(而不是正常内容页) */
function looksBlocked(html) {
const head = String(html).slice(0, 4000);
return BLOCKED_RES.some((re) => re.test(head));
}
function infoHash(magnet) {
const m = BTIH_RE.exec(magnet || '');
return m ? m[1].toLowerCase() : '';
}
/** 从任意 HTML 片段里抽磁力链接,按 infohash 去重 */
function extractMagnets(html) {
const out = [];
const seen = new Set();
const hits = String(html).match(MAGNET_RE) || [];
for (const raw of hits) {
const magnet = decodeEntities(raw).replace(/[&?]+$/, '');
const h = infoHash(magnet);
if (!h || seen.has(h)) continue;
seen.add(h);
out.push(magnet);
}
return out;
}
/** 从 HTML 里抽 .torrent 文件下载地址 */
function extractTorrentFileUrls(html, base) {
const out = [];
const seen = new Set();
for (const a of extractAnchors(html)) {
const abs = toAbsolute(a.href, base);
if (!abs) continue;
let u;
try {
u = new URL(abs);
} catch (e) {
continue;
}
const hit =
/\.torrent$/i.test(u.pathname) ||
/download\.php$/i.test(u.pathname) ||
/\/download\//i.test(u.pathname);
if (hit && !seen.has(abs)) {
seen.add(abs);
out.push(abs);
}
}
return out;
}
/** 把一行切成各个 <td> 的纯文本 */
function rowCells(rowHtml) {
const out = [];
const re = /<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]>/gi;
let m;
while ((m = re.exec(rowHtml))) out.push(cleanText(m[1]));
return out;
}
/**
* 取种子数 / 下载数:定位到「大小」那一列,它后面的前两个纯整数列就是
* S. 和 L. —— rargb、EZTV、TPB 代理的表格都是这个排法。
* 拿不准就返回 0,宁可显示 0 也别瞎猜。
*/
function rowPeers(rowHtml) {
const cells = rowCells(rowHtml);
let at = -1;
for (let i = 0; i < cells.length; i++) {
if (SIZE_RE.test(cells[i])) {
at = i;
break;
}
}
if (at < 0) return { seeders: 0, leechers: 0 };
const nums = [];
for (let i = at + 1; i < cells.length && nums.length < 2; i++) {
const t = cells[i].replace(/,/g, '');
if (/^\d{1,7}$/.test(t)) nums.push(parseInt(t, 10));
}
return { seeders: nums[0] || 0, leechers: nums[1] || 0 };
}
/** 把 HTML 切成 <tr> 块,一行一个帖子 */
function extractRows(html) {
const out = [];
const re = /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi;
let m;
while ((m = re.exec(html))) out.push(m[0]);
return out;
}
function guessTitleFromUrl(url) {
try {
const parts = new URL(url).pathname.split('/').filter(Boolean);
return decodeURIComponent(parts[parts.length - 1] || '').replace(/[-_+]+/g, ' ');
} catch (e) {
return '';
}
}
/**
* 一行里最多提取一个帖子。
* 标题必须来自指向同一个 URL 的链接 —— 否则会被站点塞进结果表的推荐海报条
* (一个 <tr> 里好几部不相干的电影)串成「A 的标题 + B 的链接」。
*/
function postFromRow(rowHtml, base, opts) {
const byUrl = new Map();
let magnetTitle = '';
for (const a of extractAnchors(rowHtml)) {
if (/^magnet:/i.test(a.href)) {
if (a.text.length > magnetTitle.length) magnetTitle = a.text;
continue;
}
const abs = toAbsolute(a.href, base);
if (!abs || !isDetailUrl(abs, opts)) continue;
// 正常结果行里封面链接和标题链接指向同一帖子,取文字最长的当标题
const prev = byUrl.get(abs) || '';
if (a.text.length > prev.length) byUrl.set(abs, a.text);
}
// 一行里出现多个不同帖子,那是推荐海报条,不是搜索结果
if (byUrl.size > 1) return null;
const url = byUrl.size ? byUrl.keys().next().value : '';
const linkTitle = url ? byUrl.get(url) : '';
const magnets = extractMagnets(rowHtml);
if (!url && !magnets.length) return null;
// 三个候选里挑最长的:详情链接文字、磁力链接文字、磁力 dn 参数。
// 谁都可能是对的 —— knaben 的详情链接文字是来源站名「The Pirate Bay」,
// therarbg 的磁力链接文字是「磁力」,rargb 压根没有行内磁力。
const title = pickTitle([
linkTitle,
magnetTitle,
magnets[0] ? titleFromMagnet(magnets[0]) : '',
]);
const sizeHit = SIZE_RE.exec(cleanText(rowHtml));
const peers = rowPeers(rowHtml);
return {
url: url,
title: title || guessTitleFromUrl(url),
size: sizeHit ? sizeHit[0] : '',
magnet: magnets[0] || '',
// EZTV 这类站点行内就有 .torrent 链接;行内已有磁力就不会去抓详情页,
// 这里不收下的话「下载 .torrent」会一直是空的
torrent: extractTorrentFileUrls(rowHtml, base)[0] || '',
seeders: peers.seeders,
leechers: peers.leechers,
};
}
// 「磁力」「Get this torrent」这类按钮文字不是标题
const GENERIC_TITLE_RE =
/^(?:magnet(?:s*link)?|磁力(?:链接)?|下载|download(?:s+thiss+torrent)?(?:s+usings+magnet)?|gets+thiss+torrent|torrent|dl)$/i;
/** 候选标题里挑最靠谱的:去掉按钮文字,取最长的 */
function pickTitle(cands) {
let best = '';
for (const c of cands) {
const t = String(c || '').trim();
if (!t || GENERIC_TITLE_RE.test(t)) continue;
if (t.length > best.length) best = t;
}
return best;
}
function titleFromMagnet(magnet) {
const m = /[?&]dn=([^&]+)/.exec(magnet || '');
if (!m) return '';
try {
return decodeURIComponent(m[1].replace(/\+/g, ' '));
} catch (e) {
return m[1];
}
}
/**
* 从列表页 HTML 抽出所有帖子。
* 优先按 <tr> 分组(能把标题/大小/内联磁力配对准确),
* 没有表格结构时退化为「按详情链接去重」。
*/
function extractPosts(html, base, opts) {
const rows = extractRows(html);
const out = [];
const seen = new Set();
const push = (p) => {
if (!p) return;
const key = p.url || 'ih:' + infoHash(p.magnet);
if (key === 'ih:' || seen.has(key)) return;
seen.add(key);
out.push(p);
};
if (rows.length) {
for (const row of rows) push(postFromRow(row, base, opts));
if (out.length) return out;
}
for (const a of extractAnchors(html)) {
const abs = toAbsolute(a.href, base);
if (!abs || !isDetailUrl(abs, opts)) continue;
if (seen.has(abs)) {
// 已存在则补一个更长的标题
const prev = out.find((p) => p.url === abs);
if (prev && a.text.length > prev.title.length) prev.title = a.text;
continue;
}
push({
url: abs,
title: a.text || guessTitleFromUrl(abs),
size: '',
magnet: '',
torrent: '',
seeders: 0,
leechers: 0,
});
}
return out;
}
/** 从 URL 里解析页码,并给出把页码换成 {n} 的模板(差异比对失败时的兜底) */
function pageInfo(url) {
for (const re of PAGE_TOKEN_RES) {
const m = re.exec(url);
if (!m) continue;
return {
n: parseInt(m[2], 10),
template: url.slice(0, m.index) + m[1] + '{n}' + url.slice(m.index + m[0].length),
};
}
return null;
}
const isDigits = (s) => typeof s === 'string' && /^\d+$/.test(s);
/**
* 把候选链接和当前页 URL 逐 token(路径段 + 查询参数)比对,
* 只有「恰好一个 token 不同、且是数字」的才算同一批列表的分页链接。
*
* 这样才躲得开真实站点的干扰:knaben 侧栏的 /browse/2001000/1 分类链接、
* rargb 表头的 ?order=data&by=ASC 排序链接,都不会再被当成页码。
* 返回 { n: 链接指向第几页, curN: 当前页是第几页(未知为 null), template }
*/
function diffPageInfo(currentUrl, linkUrl) {
let cur, link;
try {
cur = new URL(currentUrl);
link = new URL(linkUrl);
} catch (e) {
return null;
}
if (cur.origin !== link.origin) return null;
const curSegs = cur.pathname.split('/');
const linkSegs = link.pathname.split('/');
if (curSegs.length !== linkSegs.length) return null;
let hit = null;
for (let i = 0; i < curSegs.length; i++) {
if (sameToken(curSegs[i], linkSegs[i])) continue;
if (hit || !isDigits(curSegs[i]) || !isDigits(linkSegs[i])) return null;
hit = { kind: 'seg', key: i, raw: linkSegs[i], curRaw: curSegs[i] };
}
const keys = new Set();
cur.searchParams.forEach((v, k) => keys.add(k));
link.searchParams.forEach((v, k) => keys.add(k));
for (const k of keys) {
const a = cur.searchParams.get(k);
const b = link.searchParams.get(k);
if (a !== null && b !== null && sameToken(a, b)) continue;
if (a === b) continue;
// 链接里新增/改动的那个数字参数才是页码,其它差异一律否决
if (hit || !isDigits(b) || (a !== null && !isDigits(a))) return null;
hit = { kind: 'param', key: k, raw: b, curRaw: a };
}
if (!hit) return null;
// 模板用字符串替换生成,不走 URL 序列化 —— 否则 %20 会被改写成 +
let template = null;
if (hit.kind === 'seg') {
const segs = link.pathname.split('/');
segs[hit.key] = '{n}';
const at = link.href.indexOf(link.pathname);
template = link.href.slice(0, at) + segs.join('/') + link.href.slice(at + link.pathname.length);
} else {
const re = new RegExp('([?&]' + escapeRe(hit.key) + '=)' + escapeRe(hit.raw) + '(?=&|#|$)');
if (!re.test(link.href)) return null;
template = link.href.replace(re, '$1{n}');
}
return {
n: parseInt(hit.raw, 10),
curN: hit.curRaw === null || hit.curRaw === undefined ? null : parseInt(hit.curRaw, 10),
template: template,
};
}
/** token 比较忽略大小写和百分号转义 —— 站点分页链接常把关键词小写化 */
function sameToken(a, b) {
if (a === b) return true;
if (typeof a !== 'string' || typeof b !== 'string') return false;
const norm = (v) => {
try {
return decodeURIComponent(v).toLowerCase();
} catch (e) {
return v.toLowerCase();
}
};
return norm(a) === norm(b);
}
const NAV_TEXT_RE =
/^(?:»|›|>>|>|«|‹|<<|<|next|prev|previous|last|first|下一页|上一页|下页|上页|末页|首页|尾页)$/i;
/**
* 分页链接的文字要么是页码,要么是「下一页」这类导航词。
* 光看 URL 不够:TPB 的 ?orderby=7(排序)、knaben 的 /browse/2001000/1(分类)
* 在 URL 上都长得像「只差一个数字」,只有文字能把它们和真分页区分开。
*/
function looksLikePageLink(text) {
const t = String(text || '').replace(/[\[\]()()\s]/g, '');
if (!t) return false;
return /^\d{1,6}$/.test(t) || NAV_TEXT_RE.test(t);
}
function escapeRe(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* 收集页面里的分页链接,返回 Map<页码, { url, template, curN }>。
* 优先用差异比对的结果;一个都没比对上时(例如分页指向另一个路径的脚本页),
* 才退回正则识别的宽松结果。
*/
function collectPageLinks(html, base, currentUrl) {
const strict = new Map();
const loose = new Map();
for (const a of extractAnchors(html)) {
const abs = toAbsolute(a.href, base);
if (!abs || isDetailUrl(abs)) continue;
if (!looksLikePageLink(a.text)) continue;
const d = diffPageInfo(currentUrl, abs);
if (d && Number.isFinite(d.n)) {
if (!strict.has(d.n)) strict.set(d.n, { url: abs, template: d.template, curN: d.curN });
continue;
}
const info = pageInfo(abs);
if (info && Number.isFinite(info.n) && !loose.has(info.n)) {
loose.set(info.n, { url: abs, template: info.template, curN: null });
}
}
return strict.size ? strict : loose;
}
/**
* 探测分页:{ maxPage, template, current, pageBase }。
* 页码一律是站点自己的编号 —— 有的站从 0 开始(TPB 的 ?page=0 就是第一页),
* pageBase 告诉调用方该怎么换算成给人看的「第 1 页」。
*/
function findPagination(html, base, currentUrl) {
const cur = pageInfo(currentUrl);
const links = collectPageLinks(html, base, currentUrl);
let current = cur && Number.isFinite(cur.n) ? cur.n : null;
for (const hit of links.values()) {
if (hit.curN !== null && Number.isFinite(hit.curN)) {
current = hit.curN;
break;
}
}
let maxPage = current === null ? 1 : current;
let minPage = current === null ? 1 : current;
let template = cur ? cur.template : '';
for (const [n, hit] of links) {
if (n < minPage) minPage = n;
if (n >= maxPage) {
maxPage = n;
template = hit.template;
} else if (!template) {
template = hit.template;
}
}
return {
maxPage: maxPage,
template: template,
current: current === null ? (minPage <= 0 ? 0 : 1) : current,
pageBase: minPage <= 0 ? 0 : 1,
};
}
/**
* 在某一页的 HTML 里找出指向第 n 页的链接(n 是站点自己的编号)。
* 用站点自己的链接翻页,比照模板拼 URL 靠谱得多。
*/
function findPageUrl(html, base, currentUrl, n) {
const hit = collectPageLinks(html, base, currentUrl).get(n);
return hit ? hit.url : '';
}
function currentPageNo(url) {
const info = pageInfo(url);
return info && Number.isFinite(info.n) ? info.n : 1;
}
/** 拼出第 n 页的 URL */
function buildPageUrl(currentUrl, n, template) {
if (template) return template.replace('{n}', String(n));
const info = pageInfo(currentUrl);
if (info) return info.template.replace('{n}', String(n));
try {
const u = new URL(currentUrl);
u.searchParams.set('page', String(n));
return u.href;
} catch (e) {
return currentUrl;
}
}
const api = {
decodeEntities: decodeEntities,
cleanText: cleanText,
extractAnchors: extractAnchors,
extractRows: extractRows,
extractPosts: extractPosts,
rowPeers: rowPeers,
extractMagnets: extractMagnets,
extractTorrentFileUrls: extractTorrentFileUrls,
isDetailUrl: isDetailUrl,
infoHash: infoHash,
looksBlocked: looksBlocked,
titleFromMagnet: titleFromMagnet,
pageInfo: pageInfo,
currentPageNo: currentPageNo,
findPagination: findPagination,
findPageUrl: findPageUrl,
collectPageLinks: collectPageLinks,
buildPageUrl: buildPageUrl,
};
root.RarbgParse = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof globalThis !== 'undefined' ? globalThis : self);