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

246 lines
8.0 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.
/**
* 各站点数据源适配器。
*
* 全部优先走站点自己的 JSON API —— 这些接口不挂 Cloudflare,
* 直接返回 seeders,比抓 HTML 又快又准。只有 rargb 没有 API,
* 才退回抓 HTML(而且它列表页没有磁力,得逐个进详情页,所以默认不启用)。
*/
'use strict';
const M = require('./match.js');
const P = require('./parse.js');
const UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function http(url, opts) {
opts = opts || {};
const init = {
method: opts.method || 'GET',
headers: Object.assign({ 'User-Agent': UA, Accept: '*/*' }, opts.headers),
signal: AbortSignal.timeout(opts.timeout || 25000),
};
if (opts.body) init.body = opts.body;
const res = await fetch(url, init);
if (!res.ok) throw new Error('HTTP ' + res.status);
return opts.text ? res.text() : res.json();
}
async function retry(fn, tries) {
let last;
for (let i = 1; i <= (tries || 3); i++) {
try {
return await fn();
} catch (e) {
last = e;
if (i < (tries || 3)) await sleep(700 * i);
}
}
throw last;
}
function isoFromUnix(sec) {
const n = Number(sec);
return Number.isFinite(n) && n > 0 ? new Date(n * 1000).toISOString().slice(0, 10) : '';
}
// ------------------------------------------------------------ The Pirate Bay
// apibay 是 TPB 的官方接口,一次返回全部命中,不分页
async function tpb(query) {
const url = 'https://apibay.org/q.php?q=' + encodeURIComponent(query) + '&cat=0';
const arr = await retry(() => http(url));
if (!Array.isArray(arr)) return [];
return arr
// 没结果时 apibay 会返回一条 id=0 的占位记录
.filter((r) => r && r.id !== '0' && r.info_hash && !/^0+$/.test(r.info_hash))
.map((r) => ({
title: r.name,
hash: String(r.info_hash).toLowerCase(),
magnet: M.magnetFrom(r.info_hash, r.name),
size: Number(r.size) || 0,
seeders: Number(r.seeders) || 0,
leechers: Number(r.leechers) || 0,
date: isoFromUnix(r.added),
imdb: r.imdb || '',
details: r.id ? 'https://thepiratebay.org/description.php?id=' + r.id : '',
source: 'TPB',
}));
}
// ------------------------------------------------------------ therarbg
// RARBG 数据库的延续,?format=json 就是现成的 REST 接口
async function therarbg(query, opts) {
const pages = Math.max(1, (opts && opts.pages) || 2);
// 必须用 %20 而不是 +:多词查询用 + 时 therarbg 会返回 0 条
const kw = encodeURIComponent(query);
let url = 'https://therarbg.com/get-posts/keywords:' + kw + '/?format=json';
const out = [];
for (let i = 0; i < pages && url; i++) {
const j = await retry(() => http(url));
for (const r of j.results || []) {
out.push({
title: r.n,
hash: String(r.h || '').toLowerCase(),
magnet: M.magnetFrom(r.h, r.n),
size: Number(r.s) || 0,
seeders: Number(r.se) || 0,
leechers: Number(r.le) || 0,
date: isoFromUnix(r.a),
imdb: r.i || '',
details: r.pk ? 'https://therarbg.com/post-detail/' + r.pk + '/' : '',
source: 'therarbg',
});
}
url = (j.links && j.links.next) || '';
if (url) await sleep(250);
}
return out;
}
// ------------------------------------------------------------ knaben
// 聚合了几十个站点的索引,返回里带 tracker 字段说明这条来自哪个站
async function knaben(query, opts) {
const size = Math.min(300, Math.max(20, (opts && opts.limit) || 300));
const j = await retry(() =>
http('https://api.knaben.org/v1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search_type: '100%',
search_field: 'title',
query: query,
order_by: 'seeders',
order_direction: 'desc',
size: size,
hide_unsafe: true,
hide_xxx: true,
}),
})
);
return (j.hits || [])
.filter((h) => h && h.title)
.map((h) => ({
title: h.title,
hash: String(h.hash || '').toLowerCase(),
magnet: h.magnetUrl || M.magnetFrom(h.hash, h.title),
size: Number(h.bytes) || 0,
seeders: Number(h.seeders) || 0,
leechers: Number(h.peers) || 0,
date: String(h.date || '').slice(0, 10),
details: h.details || '',
tracker: h.tracker || '',
source: 'knaben',
}));
}
// ------------------------------------------------------------ EZTV
// EZTV 的 API 只能按 imdb_id 查(不支持关键词),
// 所以 id 要么由 --imdb 指定,要么从其它源的结果里推断出来
async function eztv(imdbId, opts) {
const id = String(imdbId || '').replace(/^tt/i, '');
if (!/^\d{5,}$/.test(id)) return [];
const maxPages = Math.max(1, Math.min(30, (opts && opts.pages) || 15));
const out = [];
for (let page = 1; page <= maxPages; page++) {
const j = await retry(() =>
http('https://eztvx.to/api/get-torrents?imdb_id=' + id + '&limit=100&page=' + page)
);
const list = j.torrents || [];
for (const t of list) {
const title = t.title || t.filename || '';
out.push({
title: title,
hash: String(t.hash || '').toLowerCase(),
magnet: t.magnet_url || M.magnetFrom(t.hash, title),
size: Number(t.size_bytes) || 0,
seeders: Number(t.seeds) || 0,
leechers: Number(t.peers) || 0,
date: isoFromUnix(t.date_released_unix),
imdb: t.imdb_id || id,
details: t.episode_url || '',
source: 'EZTV',
});
}
if (list.length < 100) break; // 最后一页
await sleep(250);
}
return out;
}
// ------------------------------------------------------------ rargb(抓 HTML)
// 没有 API;而且列表页不含磁力,必须逐个进详情页拿,所以:
// 1) 先按关键词过滤,2) 跳过其它源已经找到的(按标题比对),3) 剩下的才去抓详情页
async function rargb(query, opts) {
const o = opts || {};
const pages = Math.max(1, o.pages || 2);
const known = o.known || new Set();
const tokens = o.tokens || [];
const rows = [];
for (let n = 1; n <= pages; n++) {
const url =
'https://rargb.to/search/' + n + '/?search=' + encodeURIComponent(query).replace(/%20/g, '+');
let html;
try {
html = await retry(() => http(url, { text: true, headers: { Referer: 'https://rargb.to/' } }), 2);
} catch (e) {
break;
}
if (P.looksBlocked(html)) break;
const posts = P.extractPosts(html, url);
if (!posts.length) break;
for (const p of posts) {
if (!M.matchesTokens(p.title, tokens)) continue;
if (known.has(M.normalize(p.title))) continue;
rows.push(p);
}
await sleep(o.delay || 500);
}
const out = [];
const limit = Math.max(0, o.detailLimit == null ? 40 : o.detailLimit);
for (const p of rows.slice(0, limit)) {
if (p.magnet) {
out.push(toItem(p));
continue;
}
try {
const html = await http(p.url, { text: true, headers: { Referer: 'https://rargb.to/' } });
p.magnet = P.extractMagnets(html)[0] || '';
if (p.magnet) out.push(toItem(p));
} catch (e) {
/* 单条失败就跳过 */
}
await sleep(o.delay || 500);
}
return out;
function toItem(p) {
return {
title: p.title,
hash: P.infoHash(p.magnet),
magnet: p.magnet,
size: parseSize(p.size),
seeders: Number(p.seeders) || 0,
leechers: Number(p.leechers) || 0,
date: '',
details: p.url,
source: 'rargb',
};
}
}
const UNITS = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4 };
/** "15.9 GB" / "188.4 MiB" -> 字节数 */
function parseSize(text) {
const m = /(\d+(?:\.\d+)?)\s*([KMGT]?)i?B/i.exec(String(text || ''));
if (!m) return 0;
const unit = (m[2] || '').toUpperCase() + 'B';
return Math.round(parseFloat(m[1]) * (UNITS[unit] || 1));
}
module.exports = { tpb, therarbg, knaben, eztv, rargb, parseSize, http };