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>
This commit is contained in:
blueslmjandClaude Opus 5 committed 2026-09-10 22:42:42 +08:00
1 parent 559065b415
commit 0e85433a5d
18 files changed
+3399

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
# 仓库里统一存 LF,检出时按平台转换。
# 没有这个文件的话,Windows 和 Linux 的贡献者会互相看到「整个文件都改了」的假 diff。
* text=auto eol=lf
# 批处理文件必须是 CRLF,否则 Windows 上 cmd.exe 可能解析异常。
# (另外 .bat 内容必须是纯 ASCII —— cmd 按系统 ANSI 代码页解析批处理文件,
# 不是 UTF-8,中文字节会破坏行边界。中文提示都放在 node 里打印。)
*.bat text eol=crlf
# 二进制,别碰
*.png binary
*.jpg binary
*.gif binary
*.ico binary
+21
View File
@@ -0,0 +1,21 @@
# 依赖(本项目零依赖,但别人 clone 后可能会装工具链)
node_modules/
# 搜索工具的导出文件
*.csv
*.magnets.txt
magnets-*.txt
out.json
# 编辑器 / 系统
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
desktop.ini
# 日志和临时文件
*.log
npm-debug.log*
.patch-*.js
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 blueslmj
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+179
View File
@@ -1 +1,180 @@
# magnet-finder
> 多站点磁力搜索:输入关键词,同时问 4 个站点的 API,按种子数排序去重。
> Multi-site torrent search with seeder counts. Web UI + CLI, zero dependencies.
**零依赖**(连 `node_modules` 都没有),只要 Node.js 18+。有网页版和命令行两种用法。
核心问题很简单:**搜出来一堆结果,但大半是 0 种子下不动的。** 这个工具把各站结果合并去重,
把种子数摆在最显眼的位置,让你一眼看出哪些还活着。
```
种子 下载 大小 来源 标题
46 29 146 MB therarbg+EZTV Pawn Stars S03E06 Ready Set Pawn iNTERNAL HDTV x264 W4F
42 46 95.4 MB therarbg+EZTV Pawn Stars S03E26 Wise Guys iNTERNAL 480p x264 mSD
41 11 126 MB therarbg+EZTV Pawn Stars S03E13 Never Surrender iNTERNAL HDTV x264 W4F
```
## 快速开始
**Windows 用户**:双击 `启动搜索.bat`,它会起服务并自动打开浏览器。没装 Node.js 会提示你去装。
其他系统 / 手动启动:
```bash
git clone https://github.com/blueslmj/magnet-finder.git
cd magnet-finder
npm start # 网页版,浏览器打开 http://127.0.0.1:5173
npm run search -- "pawn stars s03" # 命令行版
```
不需要 `npm install` —— 这个项目真的没有依赖。
## 网页版
界面是本地网页,**抓取在服务端做**(浏览器直接调 apibay 等会被 CORS 拦)。搜索过程通过
Server-Sent Events 实时推进度 —— 一次搜索要跑几十秒(逐集补搜 + EZTV 翻十几页),
不用憋着干等。只监听 `127.0.0.1`,不会暴露给局域网。
- **数据源开关**在顶部一排:TPB / therarbg / knaben / EZTV(rargb 慢,默认关)
- **匹配模式**:智能(关键词出现即可)/ 完全(从标题开头连续对上)。
搜 `friends s01` 时选「完全」,就不会再混进 `Your.Friends.and.Neighbors` 了
- **存活条**:绿=≥5 种子、黄=1-4、红=0。点某一段只看那一类
- 表格点表头换排序;点标题**直接复制磁力**;勾选后可批量复制
- 「至少 N 种子」输入框过滤死种
想换端口:`node cli/serve.js --port 8080`
## 命令行版
```bash
node cli/search.js "pawn stars s03"
node cli/search.js "friends s01" --exact --min-seeds 1
node cli/search.js "pawn stars s03" --csv out.csv
node cli/search.js "pawn stars s03" --magnets m.txt # 只导磁力,整段粘进 qBittorrent
node cli/search.js --help
```
进度信息走 stderr、表格走 stdout,所以 `node cli/search.js "x" > list.txt` 只会拿到结果。
### 匹配模式
输入不区分大小写,`.` `_` `-` `+` 等分隔符都当空格 —— 所以**可以直接粘发布名**:
`pawn.stars.s24` 和 `pawn stars s24` 等价。
| 模式 | 规则 | 适合 |
| --- | --- | --- |
| 智能(默认) | 每个关键词在标题里出现即可 | 不确定完整片名,想广撒网 |
| `--exact` | 从标题**开头**连续对上 | 明确要找某个剧,别被别的剧干扰 |
| `--loose` | 标题含第一个关键词即可 | 只看剧名、不限定季 |
完全匹配为什么按「开头连续」判定:发布名的规范是 `剧名.SxxExx.质量.小组`,**剧名永远在开头**。
开头的 `the/a/an` 会先剥掉,所以 `big bang theory` 照样命中 `The.Big.Bang.Theory.S01E01`。
实测 `friends s01`:智能模式 297 条(其中 258 条是别的剧),完全模式 35 条、**0 条误命中**。
### 数据源
| 源 | 接口 | 说明 |
| --- | --- | --- |
| `tpb` | apibay.org | The Pirate Bay 官方接口,一次返回全部命中 |
| `therarbg` | therarbg.com `?format=json` | RARBG 数据库的延续,带分页 |
| `knaben` | api.knaben.org/v1 | 聚合几十个站点的索引,返回里带 `tracker` 说明来自哪个站 |
| `eztv` | eztvx.to/api | 只能按 IMDb id 查,id 会从其它源的结果里自动推断 |
| `rargb` | 抓 HTML | 没有 API,列表页也没磁力,要逐个进详情页,**默认不启用**(`--sites all` 才开) |
前四个都走站点自己的 JSON API —— 这些接口**不挂 Cloudflare**(而 eztvx.to 的网页是挂的),
而且直接返回 seeders,比抓 HTML 又快又准。
## 为什么不是又一个种子搜索脚本
这些站点各有各的坑。代码里每一处绕路都对应一个实测踩出来的问题,且都有回归测试:
**1. 站点的关键词搜索按整词匹配。** 标题里是 `S03E07`,所以搜 `s03` 一条都搜不到,搜 `s03e07` 就有。
工具检测到裸季号会自动展开成 `s03e01..e30` 逐集补搜(`--no-expand` 关掉)。
实测 `pawn stars s03`:TPB 直接搜 **0 条**,逐集补搜后 **193 条**。
**2. 放宽查询会撞上分页天花板。** 把查询放宽成 `pawn stars` 确实有结果,但站点按新到旧
只给前 100 条,S03 这种老剧集根本翻不到。所以三条路互补:knaben 用原查询(它的索引能匹配季号)、
TPB 用逐集补搜、EZTV 用 IMDb id 直接列出全剧所有集。
**3. IMDb id 必须从未过滤的结果里推断。** 一开始只从「命中项」里找 id,结果季号一过滤就啥都不剩,
EZTV 永远拿不到 id 直接跳过。剧集的身份和季号无关。
**4. therarbg 的 `keywords:` 必须用 `%20` 编码**,用 `+` 的话多词查询直接返回 0 条。
**5. 结果按 infohash 跨站去重**,seeders 取各站最大值,`来源` 列显示 `therarbg+EZTV` 这样 ——
多站都收录通常也更容易连上。
## 输出
终端表格按种子数降序(`--sort size|date|title` 可改),末尾提示有多少条是 0 种子。
导出选项:
| 参数 | 内容 |
| --- | --- |
| `--csv` | 种子数/大小/来源/磁力/详情页全都有,带 BOM,Excel 不乱码 |
| `--txt` | 标题 + Tab + 磁力 |
| `--magnets` | 只有磁力,一行一个,可整段粘进 qBittorrent |
| `--json` | 完整字段 |
## 关于种子数
页面上的「种子」是**正在做种的人数**(seeders),「下载」是**正在下载的人数**(leechers)。
两者都是实时快照,不是累计下载次数。
这些数字来自各站点自己抓的 tracker 统计,所以**可能过时、也可能不全**(DHT 和 PEX 里的节点不算在内)。
0 种子不等于绝对下不动,但确实是很强的信号。
## 项目结构
```
cli/
search.js 命令行入口
serve.js 网页版 HTTP 服务(SSE 推进度)
launch.js 双击启动器(起服务 + 开浏览器)
engine.js 搜索流程本体,命令行和网页共用
sources.js 各站点适配器
match.js 关键词匹配、跨站去重合并(纯函数)
format.js 终端表格 / CSV / txt 输出(纯函数)
parse.js HTML 解析层(rargb 源用;这是个通用的列表页解析器,搜索工具只用到其中一部分)
web/ 网页界面
tests/ 48 个测试,全部不联网
```
## 测试
```bash
npm test
```
48 个测试,覆盖关键词匹配、放宽查询、逐集展开、跨站合并、体积格式化、表格对齐、HTML 解析层。
全部不联网,改规则前先跑一遍 —— 这些测试是拿真实页面的坑固化下来的。
## 贡献
站点结构变化很快。如果某个站点搜不出结果,欢迎提 issue 并附上:
1. 你搜的关键词和用的数据源
2. 终端输出或网页版的进度日志
3. 如果是 rargb(HTML 抓取),再附上那个页面 F12 控制台跑这行的输出:
```js
(()=>{const r=document.querySelectorAll('tr'),m=document.querySelectorAll('a[href^="magnet:"]');console.log('行数',r.length,'磁力',m.length);console.log(r[1]?.outerHTML.slice(0,800))})()
```
## 免责声明
本项目只做一件事:**聚合这些站点自己公开的搜索 API,把结果整理后展示出来**。
它不托管、不分发、不索引任何文件内容,也不参与任何数据传输 —— 拿到磁力链接之后的事情,
跟本项目无关。
请自行确认你下载的内容在你所在的司法管辖区是合法的。BitTorrent 本身是中性技术,
有大量正当用途(Linux 发行版、公共领域影音、开放数据集分发等)。
**使用者自行承担全部责任。**
## 许可证
[MIT](LICENSE)
+247
View File
@@ -0,0 +1,247 @@
/**
* 搜索流程本体。命令行(search.js)和网页服务(serve.js)共用这一份。
*
* 三个源的关键词能力不一样,所以走三条互补的路:
* - knaben 的索引能匹配季号,用原查询就行
* - TPB / therarbg 按整词匹配,"s03" 搜不到,需要展开成 s03e01..e30 逐集补搜
* - EZTV 只认 IMDb id,用它能一次列出整部剧
* 最后按 infohash 跨站去重,seeders 取各站最大值。
*/
'use strict';
const M = require('./match.js');
const S = require('./sources.js');
const ALL_SITES = ['tpb', 'therarbg', 'knaben', 'eztv', 'rargb'];
const DEFAULT_SITES = ['tpb', 'therarbg', 'knaben', 'eztv'];
const DEFAULTS = {
query: '',
sites: DEFAULT_SITES.slice(),
minSeeds: 0,
pages: 2,
eztvPages: 15,
limit: 300,
episodes: 30,
expand: true,
sort: 'seeds',
imdb: '',
match: 'smart', // smart(子串,召回过宽)| exact(从标题开头连续对,精准)
};
const SORTS = ['seeds', 'size', 'date', 'title'];
/**
* 把用户输入归一化成发给站点 API 的查询串。
* 直接粘发布名(pawn.stars.s24 / Pawn_Stars_S24)和手打(pawn stars s24)等价。
* 各站的关键词搜索都按词匹配,带点的串会被当成一个整词而搜不到。
*/
function normalizeQuery(s) {
return M.tokenize(s).join(' ');
}
function clamp(v, lo, hi, dft) {
const n = typeof v === 'number' ? v : parseInt(v, 10);
if (!Number.isFinite(n)) return dft;
return Math.min(hi, Math.max(lo, n));
}
/** 把外部传进来的参数收拾干净;不认识的数据源直接报错,别悄悄忽略 */
function normalizeOptions(raw) {
const r = raw || {};
const opt = Object.assign({}, DEFAULTS, {
// 用户可能直接粘标题: "pawn.stars.s24"、"Pawn_Stars_S24"、"Pawn-Stars-S24"。
// 本地匹配已经能把 . _ - + 当分隔符,但发给各站 API 的查询串必须也归一化,
// 否则 therarbg 的 keywords:pawn.stars.s03 会返回 0 条。
query: normalizeQuery(String(r.query == null ? '' : r.query)),
minSeeds: clamp(r.minSeeds, 0, 100000, 0),
pages: clamp(r.pages, 1, 30, 2),
eztvPages: clamp(r.eztvPages, 1, 30, 15),
limit: clamp(r.limit, 20, 300, 300),
episodes: clamp(r.episodes, 1, 99, 30),
expand: r.expand === undefined ? true : !!r.expand,
sort: SORTS.indexOf(String(r.sort || '')) >= 0 ? String(r.sort) : 'seeds',
imdb: String(r.imdb || '').replace(/^tt/i, ''),
match: r.match === 'exact' ? 'exact' : r.match === 'loose' ? 'loose' : 'smart',
});
let sites = r.sites;
if (typeof sites === 'string') {
sites = sites.toLowerCase() === 'all' ? ALL_SITES.slice() : sites.split(',');
}
if (Array.isArray(sites) && sites.length) {
opt.sites = sites.map((s) => String(s).trim().toLowerCase()).filter(Boolean);
}
const bad = opt.sites.filter((s) => ALL_SITES.indexOf(s) < 0);
if (bad.length) {
throw new Error('未知数据源: ' + bad.join(',') + '(可选: ' + ALL_SITES.join(',') + ')');
}
if (!opt.query.trim()) throw new Error('请输入关键词');
opt.__normalized = true;
return opt;
}
/** 限并发跑一批任务,结果顺序与输入一致 */
async function pool(inputs, size, fn) {
const out = new Array(inputs.length);
let next = 0;
const workers = [];
for (let i = 0; i < Math.max(1, size); i++) {
workers.push(
(async () => {
while (next < inputs.length) {
const at = next++;
out[at] = await fn(inputs[at]);
}
})()
);
}
await Promise.all(workers);
return out;
}
/** 依次用几个查询去问同一个源,拿到非空结果就停 */
async function firstNonEmpty(fn, queries) {
let list = [];
let used = queries[0];
for (const q of queries) {
used = q;
list = await fn(q);
if (list.length) break;
}
return { list: list, used: used };
}
/**
* 跑一次搜索。
* @param {object} opt 原始选项(会先过 normalizeOptions),或已归一化的结果
* @param {function} onEvent 进度回调,收到 { kind, message, ... }
* @returns {{results: Array, tokens: Array, broad: string, sources: Array}}
*/
async function runSearch(opt, onEvent) {
const emit = (kind, message, extra) =>
typeof onEvent === 'function' && onEvent(Object.assign({ kind: kind, message: message }, extra));
// 兼容两种调用:网页/CLI 传原始对象(这里归一化),测试可传已归一化的
opt = opt && opt.__normalized ? opt : normalizeOptions(opt);
const tokens = M.tokenize(opt.query);
if (!tokens.length) throw new Error('请输入关键词');
const broad = M.broadQuery(opt.query);
const queries = broad && broad !== tokens.join(' ') ? [opt.query, broad] : [opt.query];
emit('start', '关键词: ' + tokens.join(' + ') + (queries.length > 1 ? '(放宽备用: ' + broad + ')' : ''));
const jobs = [];
if (opt.sites.includes('tpb')) jobs.push(['TPB', (q) => S.tpb(q)]);
if (opt.sites.includes('therarbg')) jobs.push(['therarbg', (q) => S.therarbg(q, { pages: opt.pages })]);
if (opt.sites.includes('knaben')) jobs.push(['knaben', (q) => S.knaben(q, { limit: opt.limit })]);
let items = [];
const raw = []; // 未过滤的原始结果,只用来推断 IMDb id
const hitCount = {};
const report = [];
const settled = await Promise.allSettled(jobs.map(([, fn]) => firstNonEmpty(fn, queries)));
settled.forEach((r, i) => {
const name = jobs[i][0];
if (r.status === 'rejected') {
hitCount[name] = 0;
report.push({ source: name, ok: false, error: msgOf(r.reason) });
emit('source', name + ' 失败: ' + msgOf(r.reason), { source: name, ok: false });
return;
}
const hit = r.value.list.filter((it) => M.matchesAny(it.title, tokens, opt.match));
hitCount[name] = hit.length;
raw.push.apply(raw, r.value.list);
items = items.concat(hit);
report.push({ source: name, ok: true, returned: r.value.list.length, hit: hit.length });
emit(
'source',
name + ' 返回 ' + r.value.list.length + ' 条,命中 ' + hit.length + ' 条' +
(r.value.used === opt.query ? '' : '(用了放宽查询)'),
{ source: name, ok: true, returned: r.value.list.length, hit: hit.length }
);
});
// 裸季号逐集补搜:这些站按整词匹配,标题里是 S03E07,搜 s03 一条都没有
const season = M.bareSeason(tokens);
if (season && opt.expand) {
const need = jobs.filter(([name]) => !hitCount[name]);
if (need.length) {
const eq = M.episodeQueries(opt.query, season, opt.episodes);
emit(
'expand',
'逐集补搜 ' + season + 'e01..e' + String(opt.episodes).padStart(2, '0') +
'(' + need.map((j) => j[0]).join('/') + ' 搜不到裸季号)'
);
for (const [name, fn] of need) {
const lists = await pool(eq, 5, (q) => fn(q).catch(() => []));
const flat = [].concat.apply([], lists);
const hit = flat.filter((it) => M.matchesAny(it.title, tokens, opt.match));
raw.push.apply(raw, flat);
items = items.concat(hit);
report.push({ source: name + '(逐集)', ok: true, returned: flat.length, hit: hit.length });
emit('source', name + ' 逐集补搜命中 ' + hit.length + ' 条', { source: name, ok: true, hit: hit.length });
}
}
}
// EZTV 只能按 imdb 查。id 要从**未过滤**的结果里推断 ——
// 剧集身份跟季号无关,只看命中项的话季号一过滤就啥都不剩了
if (opt.sites.includes('eztv')) {
const imdb = opt.imdb || M.guessImdb(raw.concat(items));
if (!imdb) {
emit('source', 'EZTV 跳过: 没拿到 IMDb id(可以手填)', { source: 'EZTV', ok: false });
report.push({ source: 'EZTV', ok: false, error: '没有 IMDb id' });
} else {
try {
const list = await S.eztv(imdb, { pages: opt.eztvPages });
const hit = list.filter((it) => M.matchesAny(it.title, tokens, opt.match));
items = items.concat(hit);
report.push({ source: 'EZTV', ok: true, returned: list.length, hit: hit.length, imdb: 'tt' + imdb });
emit('source', 'EZTV(tt' + imdb + ') 返回 ' + list.length + ' 条,命中 ' + hit.length + ' 条', {
source: 'EZTV',
ok: true,
returned: list.length,
hit: hit.length,
});
} catch (e) {
report.push({ source: 'EZTV', ok: false, error: msgOf(e) });
emit('source', 'EZTV 失败: ' + msgOf(e), { source: 'EZTV', ok: false });
}
}
}
// rargb 放最后:它得逐个进详情页,先把已知标题传进去好跳过重复的
if (opt.sites.includes('rargb')) {
emit('source', 'rargb 抓 HTML(慢,要逐个进详情页取磁力)…');
const known = new Set(items.map((it) => M.normalize(it.title)));
try {
const list = await S.rargb(opt.query, { pages: opt.pages, known: known, tokens: tokens });
items = items.concat(list);
report.push({ source: 'rargb', ok: true, returned: list.length, hit: list.length });
emit('source', 'rargb 新增 ' + list.length + ' 条', { source: 'rargb', ok: true, hit: list.length });
} catch (e) {
report.push({ source: 'rargb', ok: false, error: msgOf(e) });
emit('source', 'rargb 失败: ' + msgOf(e), { source: 'rargb', ok: false });
}
}
let results = M.mergeResults(items);
const total = results.length;
if (opt.minSeeds > 0) results = results.filter((it) => it.seeders >= opt.minSeeds);
results = M.sortResults(results, opt.sort);
emit('summary', '去重后 ' + total + ' 条' + (opt.minSeeds > 0 ? ',过滤后 ' + results.length + ' 条' : ''), {
total: total,
kept: results.length,
});
return { results: results, tokens: tokens, broad: broad, sources: report, total: total };
}
function msgOf(e) {
return e && e.message ? e.message : String(e);
}
module.exports = { runSearch, normalizeOptions, ALL_SITES, DEFAULT_SITES, DEFAULTS, SORTS };
+132
View File
@@ -0,0 +1,132 @@
/**
* 输出格式化:终端表格、CSV、txt。纯函数。
*/
'use strict';
function humanSize(bytes) {
const n = Number(bytes);
if (!Number.isFinite(n) || n <= 0) return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return (v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)) + ' ' + units[i];
}
/** 终端显示宽度:中日韩字符占两格,不然表格会歪 */
function dispWidth(s) {
let w = 0;
for (const ch of String(s == null ? '' : s)) {
const c = ch.codePointAt(0);
w +=
(c >= 0x1100 && c <= 0x115f) ||
(c >= 0x2e80 && c <= 0xa4cf) ||
(c >= 0xac00 && c <= 0xd7a3) ||
(c >= 0xf900 && c <= 0xfaff) ||
(c >= 0xfe30 && c <= 0xfe6f) ||
(c >= 0xff00 && c <= 0xff60) ||
(c >= 0xffe0 && c <= 0xffe6)
? 2
: 1;
}
return w;
}
/** 截断到指定显示宽度;省略号本身也占一格,不然表格会被挤歪 */
function clip(s, max) {
const str = String(s == null ? '' : s);
if (dispWidth(str) <= max) return str;
let out = '';
let w = 0;
for (const ch of str) {
const cw = dispWidth(ch);
if (w + cw > max - 1) break;
out += ch;
w += cw;
}
return out + '…';
}
function padEnd(s, w) {
const pad = w - dispWidth(s);
return s + (pad > 0 ? ' '.repeat(pad) : '');
}
function padStart(s, w) {
const pad = w - dispWidth(s);
return (pad > 0 ? ' '.repeat(pad) : '') + s;
}
const COLS = [
{ key: 'seeders', head: '种子', align: 'right' },
{ key: 'leechers', head: '下载', align: 'right' },
{ key: 'size', head: '大小', align: 'right', map: (v) => humanSize(v) },
{ key: 'sources', head: '来源', align: 'left', map: (v) => (v || []).join('+') },
{ key: 'title', head: '标题', align: 'left' },
];
function renderTable(list, titleWidth) {
const maxTitle = titleWidth || 74;
const rows = list.map((it) =>
COLS.map((c) => {
const raw = c.map ? c.map(it[c.key]) : it[c.key];
const s = raw == null ? '' : String(raw);
return c.key === 'title' ? clip(s, maxTitle) : s;
})
);
const widths = COLS.map((c, i) =>
Math.max(dispWidth(c.head), ...rows.map((r) => dispWidth(r[i])), 2)
);
const line = (cells) =>
cells
.map((s, i) => (COLS[i].align === 'right' ? padStart(s, widths[i]) : padEnd(s, widths[i])))
.join(' ')
.replace(/\s+$/, '');
const out = [line(COLS.map((c) => c.head)), widths.map((w) => '-'.repeat(w)).join(' ')];
for (const r of rows) out.push(line(r));
return out.join('\n');
}
function csvCell(v) {
return '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"';
}
function toCsv(list) {
const head = ['种子', '下载', '大小', '字节', '来源', '发布时间', '标题', '磁力', 'infohash', '详情页'];
const rows = list.map((it) =>
[
it.seeders,
it.leechers,
humanSize(it.size),
it.size || '',
(it.sources || []).join('+'),
it.date || '',
it.title,
it.magnet || '',
it.hash || '',
it.details || '',
]
.map(csvCell)
.join(',')
);
// 带 BOM,Excel 打开中文才不乱码
return '' + head.map(csvCell).join(',') + '\r\n' + rows.join('\r\n') + '\r\n';
}
/** 每行「标题 + Tab + 磁力」,去掉标题就能整段粘进 qBittorrent */
function toTxt(list, withTitle) {
return (
list
.filter((it) => it.magnet)
.map((it) => (withTitle === false ? it.magnet : it.title + '\t' + it.magnet))
.join('\r\n') + '\r\n'
);
}
module.exports = { humanSize, dispWidth, clip, renderTable, toCsv, toTxt };
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
/**
* 双击启动器:起服务 + 自动打开浏览器。
* Windows 上双击「启动搜索.bat」就能用,不用手动敲命令。
*
* node cli/launch.js # 默认端口 5173
* node cli/launch.js 8080 # 指定端口
* node cli/launch.js --no-open # 只起服务,不自动开浏览器(测试用)
*/
'use strict';
const os = require('os');
const child = require('child_process');
const { createServer } = require('./serve.js');
const DEFAULT_PORT = 5173;
const NO_OPEN = process.argv.includes('--no-open');
function readPort() {
const fromEnv = parseInt(process.env.PORT, 10);
if (Number.isFinite(fromEnv) && fromEnv > 0) return fromEnv;
const fromArg = parseInt(process.argv.filter((a) => /^\d+$/.test(a))[0], 10);
return Number.isFinite(fromArg) && fromArg > 0 ? fromArg : DEFAULT_PORT;
}
function openBrowser(url) {
if (NO_OPEN) return;
try {
const p = os.platform();
const cmd =
p === 'win32'
? 'start "" "' + url + '"'
: p === 'darwin'
? 'open "' + url + '"'
: 'xdg-open "' + url + '"';
child.exec(cmd, () => {});
} catch (e) {
console.log('\n打不开浏览器?手动打开: ' + url + '\n');
}
}
const port = readPort();
const url = 'http://127.0.0.1:' + port;
const server = createServer();
function onListenError(e) {
if (e.code === 'EADDRINUSE') {
console.log('端口 ' + port + ' 已被占用 —— 搜索服务可能已经在运行。');
console.log('直接打开: ' + url);
openBrowser(url);
setTimeout(() => process.exit(0), 3000);
} else {
console.error('启动失败: ' + (e.message || e));
process.exit(1);
}
}
// 必须先挂 handler 再 listen,否则 EADDRINUSE 会以未捕获异常的方式崩掉整个进程
server.on('error', onListenError);
server.listen(port, '127.0.0.1', () => {
console.log('');
console.log(' 磁力搜索');
console.log(' ' + url);
console.log(' 关闭这个窗口就停止服务');
console.log('');
openBrowser(url);
});
// 进程被 SIGINT (Ctrl+C) 关闭时也给个提示
process.on('SIGINT', () => {
console.log('\n停止服务');
process.exit(0);
});
+215
View File
@@ -0,0 +1,215 @@
/**
* 关键词匹配与结果聚合。纯函数,不碰网络,方便测试。
*/
'use strict';
// 拼裸 infohash 磁力时补上的公共 tracker(apibay 只给 hash,不给磁力)
const TRACKERS = [
'udp://tracker.opentrackr.org:1337/announce',
'udp://open.stealth.si:80/announce',
'udp://tracker.torrent.eu.org:451/announce',
'udp://exodus.desync.com:6969/announce',
'udp://tracker.dler.org:6969/announce',
];
/**
* 标题归一化:小写,把各种分隔符统一成空格。
* 这样 "Pawn.Stars.S03E01.720p" 和 "pawn stars s03" 才能对上。
*/
function normalize(s) {
return String(s == null ? '' : s)
.toLowerCase()
.replace(/[._+\-[\](){}:,!?'"~/\\|@#$%^&*=<>;]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/** 关键词切成 token,每个都必须出现在标题里 */
function tokenize(query) {
return normalize(query).split(' ').filter(Boolean);
}
/** 标题是否包含全部关键词(子串匹配,所以 s03 能命中 S03E07) */
function matchesTokens(title, tokens) {
if (!tokens.length) return true;
const t = normalize(title);
return tokens.every((tok) => t.indexOf(tok) >= 0);
}
/**
* 完全匹配:查询必须从标题「开头」连续对上。
*
* 发布名的规范是 `剧名.SxxExx.质量.小组`,剧名永远在开头 ——
* 所以按「开头连续匹配」就能挡掉那些只是中间夹了关键词的剧
* (搜 friends 不再命中 Your.Friends.and.Neighbors)。
*
* 两个宽容点,避免误伤真实发布名:
* - 开头的 the / a / an 先剥掉:The.Big.Bang.Theory 仍命中 big bang theory
* - 查询里的裸季号 (s01) 允许命中 s01e01 这种带集号的 token
*
* 标题在匹配点后面的部分(分辨率、编码、小组名)不受限。
*/
function matchesExact(title, tokens) {
if (!tokens.length) return true;
let tt = tokenize(title);
if (/^(the|a|an)$/.test(tt[0] || '')) tt = tt.slice(1);
let at = 0;
for (const q of tokens) {
if (at >= tt.length) return false;
const t = tt[at];
const ok =
t === q ||
(t.length > q.length && t.indexOf(q) === 0); // s01 对上 s01e01 / s01e012
if (!ok) return false;
at++;
}
return true;
}
function matchesAny(title, tokens, mode) {
if (mode === 'exact') return matchesExact(title, tokens);
if (mode === 'loose') return tokens.length ? normalize(title).indexOf(tokens[0]) >= 0 : true;
return matchesTokens(title, tokens);
}
// 季集号、分辨率、编码、来源这类词,站点的关键词搜索往往匹配不到
// (标题里是 S03E07,"s03" 不是一个独立的词),发给 API 前先摘掉
const NOISE_RE =
/^(?:s\d{1,2}(?:e\d{1,3})?|e\d{1,3}|\d{3,4}p|x26[45]|h26[45]|hevc|xvid|divx|web|webrip|webdl|dl|hdtv|bluray|brrip|bdrip|dvdrip|remux|repack|proper|internal|amzn|dsnp|nf|hmax|aac|ac3|eac3|ddp\d?|dts|10bit|hdr|sdr|multi|complete|season|episode)$/i;
/**
* 放宽后的查询:只留下「片名」部分发给站点 API,
* 季集号等条件留给本地按标题过滤 —— 这样召回率高得多。
* "pawn stars s03 1080p" -> "pawn stars"
*/
function broadQuery(query) {
const tokens = tokenize(query);
const kept = tokens.filter((t) => !NOISE_RE.test(t));
return (kept.length ? kept : tokens).join(' ');
}
/** 取出裸季号(s03 这种不带集号的),没有就返回空串 */
function bareSeason(tokens) {
for (const t of tokens) if (/^s\d{1,2}$/i.test(t)) return t;
return '';
}
/**
* 把裸季号展开成逐集查询。
* apibay 和 therarbg 的关键词搜索都按整词匹配 —— 标题里是 "S03E07",
* 所以搜 "s03" 一条都没有,搜 "s03e07" 就有。
*/
function episodeQueries(query, season, count) {
const rest = tokenize(query).filter((t) => t !== season);
const base = rest.join(' ');
const out = [];
for (let i = 1; i <= count; i++) {
out.push((base ? base + ' ' : '') + season + 'e' + String(i).padStart(2, '0'));
}
return out;
}
function magnetFrom(hash, name) {
if (!hash) return '';
let m = 'magnet:?xt=urn:btih:' + String(hash).toLowerCase();
if (name) m += '&dn=' + encodeURIComponent(name);
for (const tr of TRACKERS) m += '&tr=' + encodeURIComponent(tr);
return m;
}
function hashOf(item) {
return String(item.hash || '').toLowerCase();
}
/**
* 按 infohash 去重合并。
* 同一个种子在不同站点的 seeders 是各站各自抓的,取最大值,
* 并记下它出现在哪些站 —— 多站都有通常意味着更容易连上。
*/
function mergeResults(items) {
const byKey = new Map();
for (const it of items) {
if (!it || !it.title) continue;
const key = hashOf(it) || 'title:' + normalize(it.title);
const prev = byKey.get(key);
if (!prev) {
byKey.set(key, Object.assign({}, it, {
hash: hashOf(it),
seeders: num(it.seeders),
leechers: num(it.leechers),
size: num(it.size),
sources: [it.source],
seedFrom: it.source,
}));
continue;
}
if (prev.sources.indexOf(it.source) < 0) prev.sources.push(it.source);
if (num(it.seeders) > prev.seeders) {
prev.seeders = num(it.seeders);
prev.leechers = num(it.leechers);
prev.seedFrom = it.source;
}
if (!prev.magnet && it.magnet) prev.magnet = it.magnet;
if (!prev.hash && hashOf(it)) prev.hash = hashOf(it);
if (!prev.size && num(it.size)) prev.size = num(it.size);
if (!prev.date && it.date) prev.date = it.date;
if (!prev.details && it.details) prev.details = it.details;
if (!prev.imdb && it.imdb) prev.imdb = it.imdb;
// 标题取更长的那个,通常信息更全(带组名、分辨率)
if (it.title.length > prev.title.length) prev.title = it.title;
}
return Array.from(byKey.values());
}
function num(v) {
const n = typeof v === 'number' ? v : parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : 0;
}
const SORTERS = {
seeds: (a, b) => b.seeders - a.seeders || b.leechers - a.leechers || b.size - a.size,
size: (a, b) => b.size - a.size,
date: (a, b) => String(b.date || '').localeCompare(String(a.date || '')),
title: (a, b) => normalize(a.title).localeCompare(normalize(b.title)),
};
function sortResults(list, by) {
const cmp = SORTERS[by] || SORTERS.seeds;
return list.slice().sort(cmp);
}
/** 从结果里推断最可能的 IMDb id(EZTV 只能按 imdb 搜) */
function guessImdb(list) {
const tally = new Map();
for (const it of list) {
const id = String(it.imdb || '').replace(/^tt/i, '');
if (!/^\d{5,}$/.test(id)) continue;
tally.set(id, (tally.get(id) || 0) + 1);
}
let best = '';
let bestN = 0;
for (const [id, n] of tally) {
if (n > bestN) {
best = id;
bestN = n;
}
}
return best;
}
module.exports = {
TRACKERS,
normalize,
tokenize,
matchesTokens,
matchesExact,
matchesAny,
broadQuery,
bareSeason,
episodeQueries,
magnetFrom,
mergeResults,
sortResults,
guessImdb,
};
+590
View File
@@ -0,0 +1,590 @@
/**
* 纯解析层:只处理 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);
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* 多站点磁力搜索 CLI(独立于插件,只需要 Node)。
*
* 搜索流程本体在 engine.js(和网页版共用),这里只管参数解析和输出。
*
* node cli/search.js "pawn stars s03"
* node cli/search.js "pawn stars s03" --min-seeds 1 --csv out.csv
* node cli/search.js --help
*/
'use strict';
const fs = require('fs');
const F = require('./format.js');
const { runSearch, normalizeOptions, ALL_SITES, DEFAULT_SITES } = require('./engine.js');
const USAGE = [
'用法: node cli/search.js "关键词" [选项]',
'',
' 标题必须包含全部关键词(空格分隔,不区分大小写和 . _ - 等分隔符)。',
' 例: "pawn stars s03" 能命中 Pawn.Stars.S03E07.720p.x264',
'',
'选项:',
' --sites a,b 数据源,默认 ' + DEFAULT_SITES.join(',') + '(可选: ' + ALL_SITES.join(',') + ',或 all)',
' --min-seeds N 只保留种子数 >= N 的(默认 0,全部显示)',
' --pages N 分页型数据源翻几页(therarbg/rargb,默认 2)',
' --eztv-pages N EZTV 翻几页,每页 100 条(默认 15)',
' --episodes N 逐集补搜到第 N 集(默认 30)',
' --no-expand 关掉逐集补搜',
' --exact 完全匹配:查询必须从标题开头连续对上。搜 friends 不再命中',
' Your.Friends.and.Neighbors 这种中间夹关键词的剧',
' --loose 宽松匹配:只要标题包含第一个关键词(适合只看剧名、不限季)',
' --top N 表格只显示前 N 行(默认 40,0 = 全部)',
' --sort X seeds(默认) | size | date | title',
' --imdb ttXXXXXXX 指定 IMDb id 给 EZTV 用;不指定从其它源结果里自动推断',
' --csv 文件 导出 CSV(种子数、来源、磁力、详情页都在里面)',
' --txt 文件 导出「标题<Tab>磁力」',
' --magnets 文件 只导出磁力,一行一个,可整段粘进 qBittorrent',
' --json 文件 导出完整 JSON',
' -h, --help 显示本帮助',
'',
'说明: rargb 没有 API,需要抓 HTML 且逐个进详情页取磁力,很慢也容易 429,',
' 默认不启用;要用就 --sites all 或 --sites rargb。',
' 网页版: node cli/serve.js',
].join('\n');
function parseArgs(argv) {
const opt = {
query: '',
sites: undefined,
minSeeds: 0,
pages: 2,
eztvPages: 15,
episodes: 30,
expand: true,
top: 40,
sort: 'seeds',
imdb: '',
csv: '',
txt: '',
magnets: '',
json: '',
help: false,
};
const words = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const next = () => argv[++i];
if (a === '-h' || a === '--help') opt.help = true;
else if (a === '--sites') opt.sites = String(next() || '');
else if (a === '--min-seeds') opt.minSeeds = int(next(), 0);
else if (a === '--pages') opt.pages = int(next(), 2);
else if (a === '--eztv-pages') opt.eztvPages = int(next(), 15);
else if (a === '--episodes') opt.episodes = int(next(), 30);
else if (a === '--no-expand') opt.expand = false;
else if (a === '--exact') opt.match = 'exact';
else if (a === '--loose') opt.match = 'loose';
else if (a === '--top') opt.top = int(next(), 40);
else if (a === '--sort') opt.sort = String(next() || 'seeds');
else if (a === '--imdb') opt.imdb = String(next() || '');
else if (a === '--csv') opt.csv = String(next() || '');
else if (a === '--txt') opt.txt = String(next() || '');
else if (a === '--magnets') opt.magnets = String(next() || '');
else if (a === '--json') opt.json = String(next() || '');
else if (a.startsWith('-')) throw new Error('未知选项: ' + a);
else words.push(a);
}
opt.query = words.join(' ').trim();
return opt;
}
function int(v, dft) {
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : dft;
}
// 进度走 stderr,表格走 stdout,重定向时只拿到结果
const info = (msg) => process.stderr.write(msg + '\n');
async function main() {
let raw;
try {
raw = parseArgs(process.argv.slice(2));
} catch (e) {
info(e.message + '\n' + USAGE);
process.exit(2);
}
if (raw.help || !raw.query) {
process.stdout.write(USAGE + '\n');
process.exit(raw.query ? 0 : 1);
}
const opt = normalizeOptions(raw);
info('搜索: ' + opt.query);
const out = await runSearch(opt, (ev) => {
if (ev.kind === 'source' || ev.kind === 'expand') info(' ' + ev.message);
if (ev.kind === 'summary' && ev.message) info(ev.message);
});
const dead = out.results.filter((it) => it.seeders === 0).length;
info(
'删除结果: ' +
out.results.length +
' 条' +
(opt.minSeeds > 0 ? '(已过滤到 >= ' + opt.minSeeds + ' 种子)' : '') +
(dead ? ',其中 ' + dead + ' 条 0 种子(下不动)' : '')
);
if (!out.results.length) {
info('没有命中。试试更短的关键词,或 --sites all,或 --no-expand');
process.exit(0);
}
const shown = raw.top > 0 ? out.results.slice(0, raw.top) : out.results;
process.stdout.write(F.renderTable(shown) + '\n');
if (shown.length < out.results.length) {
info('(只显示前 ' + shown.length + ' 条,共 ' + out.results.length + ' 条;--top 0 显示全部)');
}
write(raw.csv, () => F.toCsv(out.results), 'CSV');
write(raw.txt, () => F.toTxt(out.results, true), 'txt');
write(raw.magnets, () => F.toTxt(out.results, false), '磁力');
write(raw.json, () => JSON.stringify(out.results, null, 2) + '\n', 'JSON');
}
function write(file, build, label) {
if (!file) return;
try {
fs.writeFileSync(file, build());
info('已写入 ' + label + ': ' + file);
} catch (e) {
info('写 ' + label + ' 失败: ' + (e.message || e));
}
}
main().catch((e) => {
info('出错: ' + (e && e.stack ? e.stack : e));
process.exit(1);
});
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* 本地搜索网页(独立于插件,零依赖 —— 只用 Node 内置模块)。
*
* node cli/serve.js # 默认 http://127.0.0.1:5173
* node cli/serve.js --port 8080
*
* 页面在浏览器里用,抓取在服务端做(浏览器跨域调 apibay 等会被 CORS 拦)。
* 搜索走 Server-Sent Events 流式返回进度,一次跑 30~60 秒也不憋着。
*/
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const { URL } = require('url');
const { runSearch, normalizeOptions } = require('./engine.js');
const WEB_DIR = path.join(__dirname, 'web');
function parsePort(argv) {
let port = 5173;
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--port') port = parseInt(argv[i + 1], 10) || port;
}
return port;
}
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
};
function serveStatic(req, res, pathname) {
if (pathname === '/') pathname = '/index.html';
const file = path.normalize(path.join(WEB_DIR, pathname));
if (!file.startsWith(path.normalize(WEB_DIR))) {
res.writeHead(403);
return res.end('forbidden');
}
fs.readFile(file, (err, data) => {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
return res.end('404 — 没找到这个文件');
}
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
res.end(data);
});
}
/**
* 搜索接口:Server-Sent Events。
* 一次搜索要跑 30~60 秒(逐集补搜 + EZTV 翻十几页),一次性返回的话页面只能干等,
* 所以每个源出结果就推一条 event,最后推一条 done 带完整结果。
*/
function streamSearch(req, res, url) {
const headers = {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
};
res.writeHead(200, headers);
res.write('retry: 3000\n\n');
let opt;
try {
opt = normalizeOptions({
query: url.searchParams.get('query') || '',
sites: url.searchParams.get('sites') || undefined,
minSeeds: url.searchParams.get('minSeeds') || 0,
pages: url.searchParams.get('pages') || 2,
eztvPages: url.searchParams.get('eztvPages') || 15,
episodes: url.searchParams.get('episodes') || 30,
expand: url.searchParams.get('expand') !== '0',
sort: url.searchParams.get('sort') || 'seeds',
imdb: url.searchParams.get('imdb') || '',
match: url.searchParams.get('match') || 'smart',
});
} catch (e) {
res.write('event: error\ndata: ' + JSON.stringify({ message: e.message }) + '\n\n');
res.end();
return;
}
const send = (event, payload) => {
res.write('event: ' + event + '\ndata: ' + JSON.stringify(payload) + '\n\n');
};
let aborted = false;
req.on('close', () => (aborted = true));
runSearch(opt, (ev) => {
if (aborted) return;
if (ev.kind === 'start' || ev.kind === 'source' || ev.kind === 'expand' || ev.kind === 'summary') {
send(ev.kind, { message: ev.message, source: ev.source, ok: ev.ok, total: ev.total, kept: ev.kept });
}
})
.then((out) => send('done', out))
.catch((e) => send('error', { message: e && e.message ? e.message : String(e) }))
.finally(() => res.end());
}
function createServer() {
return http.createServer((req, res) => {
let url;
try {
url = new URL(req.url, 'http://' + (req.headers.host || 'localhost'));
} catch (e) {
res.writeHead(400);
return res.end('bad request');
}
if (url.pathname === '/api/stream') return streamSearch(req, res, url);
return serveStatic(req, res, url.pathname);
});
}
// 只监听回环地址:这东西本质是个能替你发请求的代理,别暴露到局域网
if (require.main === module) {
const port = parsePort(process.argv.slice(2));
createServer().listen(port, '127.0.0.1', () => {
process.stdout.write('磁力搜索已启动 http://127.0.0.1:' + port + '\n');
process.stdout.write('Ctrl+C 停止\n');
});
}
module.exports = { createServer };
+245
View File
@@ -0,0 +1,245 @@
/**
* 各站点数据源适配器。
*
* 全部优先走站点自己的 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 };
+671
View File
@@ -0,0 +1,671 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>磁力搜索 · 多站点聚合</title>
<!-- 内联图标:省掉浏览器那次 /favicon.ico 请求(否则控制台一直报 404) -->
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ctext y='26' font-size='26'%3E%F0%9F%A7%B2%3C/text%3E%3C/svg%3E">
<style>
/* ---------- tokens ---------- */
:root {
--bg: #0e1521;
--panel: #141d2e;
--panel-2: #1a2538;
--line: #26334a;
--ink: #e8ecf4;
--muted: #8a97ab;
--faint: #5c6a80;
--seed: #3fbf7f; /* 活:>=5 种子 */
--weak: #e0a93e; /* 弱:1-4 */
--dead: #e05656; /* 死:0 */
--accent: #4c8dff; /* 品牌蓝,取自 rarbg 那种深蓝 */
--accent-dim: #2b4a80;
--sel: rgba(76,141,255,.16);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #eef1f6;
--panel: #ffffff;
--panel-2: #f3f5fa;
--line: #d5dbe6;
--ink: #1b2733;
--muted: #5c6a80;
--faint: #8a97ab;
--seed: #1f9d63;
--weak: #b07d17;
--dead: #c33c3c;
--accent: #2b6ce0;
--accent-dim: #dfe8fb;
--sel: rgba(43,108,224,.14);
}
}
/* ---------- base ---------- */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--ink);
font: 14px/1.45 -apple-system, "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
.mono, .title-cell, .hash, #query, .swarm-num, .seed-num {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Consolas, "Courier New", monospace;
}
button { font: inherit; cursor: pointer; }
a { color: var(--accent); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* ---------- layout ---------- */
.wrap { max-width: 1180px; margin: 0 auto; padding: 20px 22px 64px; }
.brand {
display: flex; align-items: baseline; gap: 10px; margin-bottom: 18px;
}
.brand h1 { font-size: 20px; margin: 0; font-weight: 650; letter-spacing: .2px; }
.brand .sub { color: var(--muted); font-size: 13px; }
.searchbar {
display: flex; gap: 10px; align-items: stretch; margin-bottom: 14px;
}
.searchbar input {
flex: 1; min-width: 0;
font-size: 15px; color: var(--ink);
background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
padding: 11px 14px;
}
.searchbar input::placeholder { color: var(--faint); }
.searchbar input:focus { border-color: var(--accent); outline: none; }
.searchbar button {
background: var(--accent); color: #fff; border: 0; border-radius: 8px;
padding: 0 22px; font-size: 15px; font-weight: 600;
}
.searchbar button:hover { background: #3d7df0; }
.searchbar button:disabled { opacity: .55; cursor: default; }
.searchbar button.running { background: var(--accent-dim); }
/* ---------- source chips ---------- */
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.matchrow {
display: flex; gap: 18px; flex-wrap: wrap; align-items: baseline;
margin-bottom: 14px; padding: 8px 14px;
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
}
.match-opt { display: inline-flex; align-items: baseline; gap: 6px; font-size: 13.5px; color: var(--muted); cursor: pointer; user-select: none; }
.match-opt input { accent-color: var(--accent); }
.match-opt .sub { font-size: 12px; color: var(--faint); }
.match-opt:has(input:checked) { color: var(--ink); }
.chip {
display: inline-flex; align-items: center; gap: 6px;
background: var(--panel); border: 1px solid var(--line); border-radius: 999px;
padding: 5px 12px; color: var(--muted); font-size: 13px; user-select: none;
cursor: pointer;
}
.chip[data-on] { color: var(--ink); border-color: var(--accent); background: var(--sel); }
.chip .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .9; }
.chip[data-on] .dot { color: var(--accent); }
/* ---------- toolbar: swarm bar + controls ---------- */
.toolbar {
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
margin-bottom: 14px; padding: 12px 14px;
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
}
.swarm { flex: 1 1 260px; min-width: 220px; }
.swarm-label { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.swarm-bar { display: flex; gap: 3px; height: 24px; }
.seg {
display: flex; align-items: center; justify-content: center; gap: 6px;
border-radius: 5px; color: #0e1521; font-size: 12px; font-weight: 700;
min-width: 34px; cursor: pointer; padding: 0 8px;
transition: filter .12s;
}
.seg[data-n="0"] { display: none; }
.seg:hover { filter: brightness(1.12); }
.seg[data-f="alive"] { background: var(--seed); }
.seg[data-f="weak"] { background: var(--weak); }
.seg[data-f="dead"] { background: var(--dead); }
.seg.off { filter: grayscale(1) opacity(.45); }
.seg-label { font-weight: 500; opacity: .9; }
.seg-count { }
.controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.mini { font-size: 13px; color: var(--muted); display: inline-flex; align-items: center; gap: 6px; }
.mini input[type="number"] {
width: 58px; background: var(--panel-2); color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; padding: 4px 6px; font-size: 13px;
}
.count { font-size: 13px; color: var(--muted); }
.count b { color: var(--ink); font-weight: 650; }
/* ---------- table ---------- */
.tblwrap {
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
overflow: auto; max-height: 72vh;
}
table { border-collapse: collapse; width: 100%; min-width: 900px; }
thead th {
position: sticky; top: 0; z-index: 2;
background: var(--panel-2); text-align: left;
font-size: 12px; font-weight: 600; color: var(--muted);
padding: 9px 12px; border-bottom: 1px solid var(--line); white-space: nowrap;
user-select: none;
}
thead th.sortable { cursor: pointer; }
thead th.sortable:hover { color: var(--ink); }
thead th .dir { color: var(--accent); }
tbody td { padding: 7px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; }
tbody tr:hover { background: var(--sel); }
tbody tr.sel { background: var(--sel); }
tbody tr:last-child td { border-bottom: 0; }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.seed-num { font-weight: 700; }
.seed-num.s-alive { color: var(--seed); }
.seed-num.s-weak { color: var(--weak); }
.seed-num.s-dead { color: var(--dead); }
.td-size { color: var(--muted); font-variant-numeric: tabular-nums; }
.td-src { color: var(--muted); font-size: 12.5px; white-space: nowrap; }
.title-cell {
font-size: 13px; max-width: 460px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.title-cell a { color: var(--ink); text-decoration: none; }
.title-cell a:hover { color: var(--accent); }
.title-cell .hash { color: var(--faint); font-size: 11px; margin-left: 8px; }
.td-date { color: var(--faint); font-size: 12.5px; white-space: nowrap; }
td.cb { width: 34px; padding-right: 2px; }
td.cb input { accent-color: var(--accent); width: 15px; height: 15px; cursor: pointer; }
td.op { width: 30px; text-align: center; }
.copybtn {
background: none; border: 0; color: var(--faint); font-size: 14px; opacity: 0;
transition: opacity .1s;
}
tr:hover .copybtn, tr.sel .copybtn { opacity: 1; }
.copybtn:hover { color: var(--accent); }
/* ---------- progress ---------- */
.progress {
display: none; margin-bottom: 14px; padding: 12px 14px;
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
font-size: 13px;
}
.progress.show { display: block; }
.progress .line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; line-height: 1.9; }
.progress .spinner {
width: 14px; height: 14px; border-radius: 50%;
border: 2px solid var(--line); border-top-color: var(--accent);
animation: spin .8s linear infinite; flex: none;
}
@keyframes spin { to { transform: rotate(360deg); } }
.progress .src-ok { color: var(--ink); }
.progress .src-bad { color: var(--dead); }
.progress .note { color: var(--muted); font-size: 12px; }
/* ---------- empty / error ---------- */
.empty { padding: 60px 20px; text-align: center; color: var(--muted); }
.empty .big { font-size: 15px; margin-bottom: 6px; }
.empty .hint { font-size: 13px; color: var(--faint); }
.empty code { background: var(--panel-2); padding: 2px 6px; border-radius: 4px; font-family: ui-monospace, monospace; }
/* ---------- toast ---------- */
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--ink); color: var(--bg); padding: 8px 16px; border-radius: 8px;
font-size: 13px; opacity: 0; pointer-events: none; transition: opacity .15s;
z-index: 10; white-space: nowrap;
}
.toast.show { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.progress .spinner { animation: none; }
* { transition: none !important; }
}
</style>
</head>
<body>
<div class="wrap">
<div class="brand">
<h1>磁力搜索</h1>
<span class="sub">一次问多个站点 · 去重后按最活跃的来源计种子数</span>
</div>
<form class="searchbar" id="searchForm">
<input id="query" type="search" placeholder="输入名字,例如 pawn stars s03" autocomplete="off" spellcheck="false">
<button id="go" type="submit">搜索</button>
</form>
<div class="chips" id="chips"></div>
<div class="matchrow">
<label class="match-opt">
<input type="radio" name="match" id="matchSmart" value="smart" checked>
智能 <span class="sub">关键词出现在标题里即可</span>
</label>
<label class="match-opt">
<input type="radio" name="match" id="matchExact" value="exact">
完全 <span class="sub">从标题开头连续对上,不误伤别的剧</span>
</label>
</div>
<div class="toolbar">
<div class="swarm">
<div class="swarm-label">种子存活 — 点一段只看这类</div>
<div class="swarm-bar" id="swarmBar"></div>
</div>
<div class="controls">
<label class="mini">至少 <input id="minSeeds" type="number" min="0" value="0"> 种子</label>
<span class="count" id="count"></span>
</div>
</div>
<div class="progress" id="progress"></div>
<div class="tblwrap" id="tblwrap" hidden>
<table>
<thead id="thead"></thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div class="empty" id="empty">
<div class="big">还没有结果</div>
<div class="hint">输入一个名字开始,例如 <code>pawn stars s03</code>。标题须包含全部关键词。</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
'use strict';
const SITES = [
{ id: 'tpb', label: 'TPB' },
{ id: 'therarbg', label: 'therarbg' },
{ id: 'knaben', label: 'knaben' },
{ id: 'eztv', label: 'EZTV' },
{ id: 'rargb', label: 'rargb' },
];
// 请求输入点:运行中 / 已完成,用这些把控件状态和 DOM 同步起来
const state = {
running: false,
es: null,
rows: [], // 合并后的结果数组(原始顺序,展示时再排序/过滤)
order: [], // 排序后的 index 数组,对应 rows
selected: new Set(),// 选中的行 id(hash 或 title)
sortKey: 'seeds',
sortDir: -1,
aliveFilter: null, // null | 'alive' | 'weak' | 'dead'
minSeeds: 0,
clientFilter: '',
};
const $ = (s) => document.querySelector(s);
const fmtSize = (b) => {
if (!b) return '-';
const u = ['B','KB','MB','GB','TB']; let v = b, i = 0;
while (v >= 1024 && i < u.length-1) { v /= 1024; i++; }
return (v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)) + ' ' + u[i];
};
const fmtDate = (d) => (d || '').slice(0, 10);
const rowId = (it) => it.hash || ('t:' + (it.title || '').toLowerCase());
const aliveClass = (s) => (s >= 5 ? 's-alive' : s >= 1 ? 's-weak' : 's-dead');
const aliveBucket = (s) => (s >= 5 ? 'alive' : s >= 1 ? 'weak' : 'dead');
/* ---------- source chips ---------- */
function buildChips() {
const on = new Set(['tpb', 'therarbg', 'knaben', 'eztv']);
$('#chips').replaceChildren();
for (const s of SITES) {
const el = document.createElement('label');
el.className = 'chip';
el.dataset.id = s.id;
if (on.has(s.id)) el.dataset.on = '';
el.innerHTML = '<span class="dot"></span>' + s.label +
(s.id === 'rargb' ? '<span style="font-size:11px;opacity:.7">(慢)</span>' : '');
el.addEventListener('click', () => { el.toggleAttribute('data-on'); });
$('#chips').appendChild(el);
}
}
function selectedSites() {
return SITES.filter((s) => document.querySelector('.chip[data-id="' + s.id + '"]').hasAttribute('data-on'))
.map((s) => s.id);
}
/* ---------- swarm bar ---------- */
function buildSwarm(results) {
const bar = $('#swarmBar');
bar.replaceChildren();
const buckets = { alive: 0, weak: 0, dead: 0 };
for (const r of results) buckets[aliveBucket(r.seeders)]++;
const label = { alive: '活', weak: '弱', dead: '死' };
for (const key of ['alive', 'weak', 'dead']) {
if (!buckets[key]) continue;
const seg = document.createElement('button');
seg.className = 'seg';
seg.dataset.f = key;
seg.dataset.n = buckets[key];
if (state.aliveFilter === key) seg.classList.add('off');
seg.innerHTML = '<span class="seg-label">' + label[key] + '</span><span class="seg-count">' + buckets[key] + '</span>';
seg.style.flex = buckets[key] || 0.0001;
seg.title = label[key] + ':' + buckets[key] + ' 条,点一下只看这类';
seg.addEventListener('click', () => {
state.aliveFilter = state.aliveFilter === key ? null : key;
buildSwarm(results);
renderTable();
});
bar.appendChild(seg);
}
if (!results.length) bar.innerHTML = '<span class="note" style="color:var(--faint);font-size:12px">—</span>';
}
/* ---------- table headers ---------- */
const HEADS = [
{ key: 'cb', label: '' },
{ key: 'seeds', label: '种子', sortable: true, num: true },
{ key: 'leech', label: '下载', num: true },
{ key: 'size', label: '大小', sortable: true, num: true },
{ key: 'src', label: '来源' },
{ key: 'title', label: '标题', sortable: true },
{ key: 'date', label: '发布', sortable: true },
{ key: 'op', label: '' },
];
function buildHead() {
const tr = document.createElement('tr');
for (const h of HEADS) {
const th = document.createElement('th');
if (h.label) th.textContent = h.label;
if (h.num) th.classList.add('num');
if (h.sortable) {
th.classList.add('sortable');
th.dataset.key = h.key;
if (state.sortKey === h.key) {
const dir = document.createElement('span');
dir.className = 'dir';
dir.textContent = state.sortDir === -1 ? ' ↓' : ' ↑';
th.appendChild(dir);
}
th.addEventListener('click', (e) => {
if (e.target.className === 'dir') return;
if (state.sortKey === h.key) state.sortDir *= -1;
else { state.sortKey = h.key; state.sortDir = -1; }
buildHead();
renderTable();
});
}
tr.appendChild(th);
}
$('#thead').replaceChildren(tr);
}
/* ---------- filtering + sorting ---------- */
function applyView() {
const tokens = state.clientFilter.trim().toLowerCase().split(/\s+/).filter(Boolean);
let idx = [];
state.rows.forEach((r, i) => {
if (r.seeders < state.minSeeds) return;
if (state.aliveFilter && aliveBucket(r.seeders) !== state.aliveFilter) return;
if (tokens.length) {
const t = r.title.toLowerCase();
if (!tokens.every((k) => t.includes(k))) return;
}
idx.push(i);
});
const dir = state.sortDir;
const cmp = {
seeds: (a, b) => a.seeders - b.seeders || b.leechers - a.leechers,
size: (a, b) => (a.size || 0) - (b.size || 0),
title: (a, b) => a.title.localeCompare(b.title, 'zh'),
date: (a, b) => String(b.date || '').localeCompare(String(a.date || '')),
}[state.sortKey] || ((a, b) => b.seeders - a.seeders);
idx.sort((x, y) => dir * cmp(state.rows[x], state.rows[y]));
state.order = idx;
}
/* ---------- render ---------- */
function renderTable() {
applyView();
const tbody = $('#tbody');
tbody.replaceChildren();
for (const i of state.order) {
const r = state.rows[i];
const tr = document.createElement('tr');
const id = rowId(r);
if (state.selected.has(id)) tr.classList.add('sel');
// checkbox
const tdCb = document.createElement('td');
tdCb.className = 'cb';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = state.selected.has(id);
cb.addEventListener('change', () => {
cb.checked ? state.selected.add(id) : state.selected.delete(id);
tr.classList.toggle('sel', cb.checked);
updateCount();
});
tdCb.appendChild(cb);
tr.appendChild(tdCb);
// seeds
const tdSeeds = document.createElement('td');
tdSeeds.className = 'num';
const seedEl = document.createElement('span');
seedEl.className = 'seed-num ' + aliveClass(r.seeders);
seedEl.textContent = r.seeders;
tdSeeds.appendChild(seedEl);
tr.appendChild(tdSeeds);
// leechers
const tdLe = document.createElement('td');
tdLe.className = 'num';
tdLe.textContent = r.leechers || 0;
tr.appendChild(tdLe);
// size
const tdSize = document.createElement('td');
tdSize.className = 'num td-size';
tdSize.textContent = fmtSize(r.size);
tr.appendChild(tdSize);
// source
const tdSrc = document.createElement('td');
tdSrc.className = 'td-src';
tdSrc.textContent = (r.sources || []).join('+');
tdSrc.title = '当前种子数取自: ' + (r.seedFrom || '');
tr.appendChild(tdSrc);
// title (click = copy magnet)
const tdTitle = document.createElement('td');
tdTitle.className = 'title-cell';
const a = document.createElement('a');
a.textContent = r.title;
a.title = '点击复制磁力';
if (r.magnet) {
a.addEventListener('click', async () => {
await copyText(r.magnet);
toast('已复制磁力');
});
} else {
a.style.cursor = 'default';
a.title = '没有磁力链接';
}
tdTitle.appendChild(a);
if (r.hash) {
const h = document.createElement('span');
h.className = 'hash';
h.textContent = r.hash.slice(0, 12);
h.title = 'infohash: ' + r.hash;
tdTitle.appendChild(h);
}
tr.appendChild(tdTitle);
// date
const tdDate = document.createElement('td');
tdDate.className = 'td-date';
tdDate.textContent = fmtDate(r.date);
tr.appendChild(tdDate);
// op (details link)
const tdOp = document.createElement('td');
tdOp.className = 'op';
if (r.details) {
const d = document.createElement('a');
d.textContent = '↗';
d.title = '打开原页面';
d.href = r.details;
d.target = '_blank';
d.rel = 'noopener';
tdOp.appendChild(d);
}
tr.appendChild(tdOp);
tbody.appendChild(tr);
}
updateCount();
}
function updateCount() {
const sel = state.selected.size;
$('#count').innerHTML =
'显示 ' + state.order.length + ' / ' + state.rows.length +
' 条 · 已选 <b>' + sel + '</b>' +
'<button class="copybtn" style="opacity:1;margin-left:10px" id="copySel" ' + (sel ? '' : 'disabled') + '>复制选中磁力</button>' +
'<button class="copybtn" style="opacity:1" id="copyAll">复制全部磁力</button>';
const cs = $('#copySel'), ca = $('#copyAll');
if (cs) cs.addEventListener('click', () => {
const mags = state.rows.filter((r) => state.selected.has(rowId(r)) && r.magnet).map((r) => r.magnet);
copyText(mags.join('\n')).then(() => toast('已复制 ' + mags.length + ' 条磁力'));
});
if (ca) ca.addEventListener('click', () => {
const mags = state.rows.filter((r) => r.magnet).map((r) => r.magnet);
copyText(mags.join('\n')).then(() => toast('已复制 ' + mags.length + ' 条磁力'));
});
}
/* ---------- progress ---------- */
function showProgress(show) {
$('#progress').classList.toggle('show', show);
$('#go').classList.toggle('running', show);
$('#go').disabled = show;
}
function addProgressLine(text, kind) {
const p = $('#progress');
const line = document.createElement('div');
line.className = 'line';
const spinner = document.createElement('span');
spinner.className = 'spinner';
const msg = document.createElement('span');
msg.textContent = text;
if (kind === 'bad') msg.className = 'src-bad';
else if (kind === 'src') msg.className = 'src-ok';
else msg.className = 'note';
line.appendChild(spinner);
line.appendChild(msg);
p.appendChild(line);
p.scrollTop = p.scrollHeight;
}
/* ---------- search ---------- */
async function search() {
const q = $('#query').value.trim();
if (!q || state.running) return;
const sites = selectedSites();
if (!sites.length) { toast('至少选一个数据源'); return; }
state.es && state.es.close();
state.rows = [];
state.selected = new Set();
state.aliveFilter = null;
state.clientFilter = '';
state.minSeeds = parseInt($('#minSeeds').value || '0', 10) || 0;
$('#tbody').replaceChildren();
$('#tblwrap').hidden = true;
$('#empty').hidden = true;
showProgress(true);
$('#progress').replaceChildren();
addProgressLine('搜索 ' + q + ' · ' + sites.join(', ') + '…');
const params = new URLSearchParams({ query: q, sites: sites.join(',') });
if (state.minSeeds) params.set('minSeeds', state.minSeeds);
params.set('sort', state.sortKey);
// 智能(子串) / 完全(开头连续对)
const matchSel = document.querySelector('input[name="match"]:checked');
params.set('match', matchSel ? matchSel.value : 'smart');
state.running = true;
const es = new EventSource('/api/stream?' + params.toString());
state.es = es;
const onDone = () => {
es.close();
state.running = false;
showProgress(false);
};
es.onerror = () => { if (state.running) { addProgressLine('连接中断,请重试', 'bad'); onDone(); } };
es.addEventListener('source', (e) => {
const d = JSON.parse(e.data);
addProgressLine(d.message, d.ok === false ? 'bad' : 'src');
});
es.addEventListener('expand', (e) => {
addProgressLine(JSON.parse(e.data).message, 'note');
});
es.addEventListener('summary', (e) => {
addProgressLine(JSON.parse(e.data).message, 'note');
});
es.addEventListener('done', (e) => {
const d = JSON.parse(e.data);
onDone();
state.rows = d.results || [];
if (!state.rows.length) {
$('#empty').hidden = false;
$('#empty').querySelector('.big').textContent = '没有找到匹配的结果';
$('#empty').querySelector('.hint').textContent = '试试更短的关键词,或者把 rargb 也勾上(它慢)。';
} else {
$('#empty').hidden = true;
$('#tblwrap').hidden = false;
state.order = state.rows.map((_, i) => i);
buildHead();
buildSwarm(state.rows);
renderTable();
toast('搜索完成,共 ' + state.rows.length + ' 条');
}
});
es.addEventListener('error', (e) => {
const d = JSON.parse(e.data || '{}');
onDone();
addProgressLine('出错: ' + (d.message || '未知错误'), 'bad');
});
}
/* ---------- toast / copy ---------- */
let toastTimer = 0;
function toast(text) {
const t = $('#toast');
t.textContent = text;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 1800);
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
} catch (e) {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
}
/* ---------- init ---------- */
buildChips();
buildHead();
$('#searchForm').addEventListener('submit', (e) => { e.preventDefault(); search(); });
$('#minSeeds').addEventListener('change', () => {
state.minSeeds = parseInt($('#minSeeds').value || '0', 10) || 0;
renderTable();
});
</script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{
"name": "magnet-finder",
"version": "1.0.0",
"private": true,
"description": "多站点磁力搜索:聚合 TPB / therarbg / knaben / EZTV 的 API,按种子数排序去重。网页版 + 命令行,零依赖。",
"scripts": {
"test": "node --test tests/parse.test.js tests/cli.test.js tests/engine.test.js",
"start": "node cli/launch.js",
"serve": "node cli/serve.js",
"search": "node cli/search.js",
"launch": "node cli/launch.js"
},
"keywords": [
"torrent",
"magnet",
"search",
"bittorrent",
"seeders",
"cli"
],
"license": "MIT",
"author": "blueslmj",
"repository": {
"type": "git",
"url": "https://github.com/blueslmj/magnet-finder.git"
},
"homepage": "https://github.com/blueslmj/magnet-finder#readme",
"bugs": {
"url": "https://github.com/blueslmj/magnet-finder/issues"
},
"engines": {
"node": ">=18"
}
}
+166
View File
@@ -0,0 +1,166 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const M = require('../cli/match.js');
const F = require('../cli/format.js');
const S = require('../cli/sources.js');
test('关键词按子串匹配,分隔符无关', () => {
const tokens = M.tokenize('pawn stars s03');
assert.deepStrictEqual(tokens, ['pawn', 'stars', 's03']);
assert.ok(M.matchesTokens('Pawn.Stars.S03E07.HDTV.XviD-MOMENTUM', tokens));
assert.ok(M.matchesTokens('Pawn Stars S03E26 Wise Guys 480p x264', tokens));
assert.ok(!M.matchesTokens('Pawn.Stars.S04E01.720p', tokens));
assert.ok(!M.matchesTokens('Cajun.Stars.S03E01', tokens)); // 少了 pawn
});
test('放宽查询只留片名,季集号和规格词摘掉', () => {
assert.strictEqual(M.broadQuery('pawn stars s03 1080p x264'), 'pawn stars');
assert.strictEqual(M.broadQuery('Pawn.Stars.S03E07.HDTV'), 'pawn stars');
// 全是规格词时保留原样,不然就没得搜了
assert.strictEqual(M.broadQuery('s03'), 's03');
});
test('裸季号才展开逐集查询', () => {
assert.strictEqual(M.bareSeason(M.tokenize('pawn stars s03')), 's03');
assert.strictEqual(M.bareSeason(M.tokenize('pawn stars s03e01')), '');
assert.deepStrictEqual(M.episodeQueries('pawn stars s03', 's03', 3), [
'pawn stars s03e01',
'pawn stars s03e02',
'pawn stars s03e03',
]);
});
test('同一 infohash 跨站合并:种子数取最大,来源都记下', () => {
const merged = M.mergeResults([
{ title: 'Show S03E01 720p', hash: 'AABB', seeders: 3, leechers: 1, size: 100, source: 'TPB' },
{
title: 'Show S03E01 720p x264-GRP',
hash: 'aabb',
seeders: 46,
leechers: 29,
size: 100,
magnet: 'magnet:?xt=urn:btih:aabb',
source: 'EZTV',
},
]);
assert.strictEqual(merged.length, 1);
assert.strictEqual(merged[0].seeders, 46);
assert.strictEqual(merged[0].leechers, 29);
assert.strictEqual(merged[0].seedFrom, 'EZTV');
assert.deepStrictEqual(merged[0].sources, ['TPB', 'EZTV']);
assert.strictEqual(merged[0].magnet, 'magnet:?xt=urn:btih:aabb');
assert.strictEqual(merged[0].title, 'Show S03E01 720p x264-GRP'); // 取更长的标题
});
test('没有 infohash 时按归一化标题去重', () => {
const merged = M.mergeResults([
{ title: 'Show.S03E01.720p', seeders: 1, source: 'a' },
{ title: 'show s03e01 720p', seeders: 5, source: 'b' },
]);
assert.strictEqual(merged.length, 1);
assert.strictEqual(merged[0].seeders, 5);
});
test('默认按种子数从多到少排', () => {
const list = M.sortResults(
[
{ title: 'a', seeders: 1, leechers: 0, size: 1 },
{ title: 'b', seeders: 46, leechers: 0, size: 1 },
{ title: 'c', seeders: 0, leechers: 9, size: 1 },
],
'seeds'
);
assert.deepStrictEqual(list.map((x) => x.title), ['b', 'a', 'c']);
});
test('IMDb id 取出现最多的那个,tt 前缀去掉', () => {
assert.strictEqual(
M.guessImdb([
{ imdb: 'tt1492088' },
{ imdb: 'tt1492088' },
{ imdb: 'tt0000001' },
{ imdb: '' },
{ imdb: 'nope' },
]),
'1492088'
);
assert.strictEqual(M.guessImdb([{ imdb: '' }]), '');
});
test('裸 infohash 能拼出带 tracker 的磁力', () => {
const mag = M.magnetFrom('AABBCCDD', 'Some Show S03E01');
assert.ok(mag.startsWith('magnet:?xt=urn:btih:aabbccdd'));
assert.ok(mag.includes('&dn=Some%20Show%20S03E01'));
assert.ok(mag.includes('tracker.opentrackr.org'));
});
test('体积格式化与解析能对上', () => {
assert.strictEqual(F.humanSize(0), '-');
assert.strictEqual(F.humanSize(1024), '1.0 KB');
assert.strictEqual(F.humanSize(146 * 1024 * 1024), '146 MB');
assert.strictEqual(S.parseSize('188.4 MiB'), Math.round(188.4 * 1024 * 1024));
assert.strictEqual(S.parseSize('15.9 GB'), Math.round(15.9 * 1024 ** 3));
assert.strictEqual(S.parseSize('不是体积'), 0);
});
test('中文按两格宽算,表格才不会歪', () => {
assert.strictEqual(F.dispWidth('中文'), 4);
assert.strictEqual(F.dispWidth('abc'), 3);
assert.strictEqual(F.clip('abcdefgh', 5), 'abcd…');
});
test('表格输出含表头和数据', () => {
const out = F.renderTable([
{ seeders: 46, leechers: 29, size: 146 * 1024 * 1024, sources: ['therarbg', 'EZTV'], title: 'Pawn Stars S03E06' },
]);
const lines = out.split('\n');
assert.ok(lines[0].includes('种子'));
assert.ok(lines[2].includes('Pawn Stars S03E06'));
assert.ok(lines[2].includes('therarbg+EZTV'));
});
test('CSV 带 BOM 和全部字段;txt 可选带标题', () => {
const list = [
{ seeders: 5, leechers: 1, size: 1024, sources: ['TPB'], title: 'X S03E01', magnet: 'magnet:?xt=urn:btih:ab', hash: 'ab' },
];
const csv = F.toCsv(list);
assert.ok(csv.charCodeAt(0) === 0xfeff);
assert.ok(csv.includes('"种子"'));
assert.ok(csv.includes('"magnet:?xt=urn:btih:ab"'));
assert.strictEqual(F.toTxt(list, true).trim(), 'X S03E01\tmagnet:?xt=urn:btih:ab');
assert.strictEqual(F.toTxt(list, false).trim(), 'magnet:?xt=urn:btih:ab');
// 没有磁力的不导出
assert.strictEqual(F.toTxt([{ title: 'no magnet' }], true).trim(), '');
});
test('完全匹配:查询从标题开头连续对上,不误伤中间夹关键词的剧', () => {
const t = 'Your.Friends.and.Neighbors.S01E39.1080p.x265-ELiTE';
// 智能匹配会命中(friends 是子串),完全匹配必须排除
assert.ok(M.matchesTokens(t, M.tokenize("friends s01")));
assert.strictEqual(M.matchesExact(t, M.tokenize("friends s01")), false);
});
test('完全匹配:带 the 的剧名和裸季号都能对上', () => {
assert.ok(M.matchesExact('The.Big.Bang.Theory.S01E01.1080p', M.tokenize('big bang theory')));
assert.ok(M.matchesExact('The.Walking.Dead.S01E02', M.tokenize('walking dead s01')));
assert.ok(M.matchesExact('Pawn.Stars.S03E07.HDTV', M.tokenize('pawn stars')));
});
test('完全匹配:顺序错、少词、词不连贯都该排除', () => {
assert.strictEqual(M.matchesExact('Stars.Pawn.S03E07', M.tokenize('pawn stars')), false);
assert.strictEqual(M.matchesExact('Friends.S01', M.tokenize('friends s02')), false);
assert.strictEqual(M.matchesExact('The.Big.Bang.Theory', M.tokenize('big theory')), false);
});
test('matchesAny 三种模式:smart 子串 / exact 开头连续 / loose 只要首词', () => {
const title = 'Your.Friends.and.Neighbors.S01E39';
const tokens = M.tokenize("friends s01");
assert.ok(M.matchesAny(title, tokens, "smart"));
assert.strictEqual(M.matchesAny(title, tokens, "exact"), false);
assert.ok(M.matchesAny(title, tokens, "loose"));
assert.strictEqual(M.matchesAny('Nothing.To.Do.With.Friends', M.tokenize('nothing s01'), 'loose'), true);
assert.strictEqual(M.matchesAny('Nothing', M.tokenize("'friends"), "loose"), false);
});
+137
View File
@@ -0,0 +1,137 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const http = require('http');
const { createServer } = require('../cli/serve.js');
const { runSearch, normalizeOptions, DEFAULTS, ALL_SITES, DEFAULT_SITES, SORTS } = require('../cli/engine.js');
function start() {
return new Promise((resolve) => {
const server = createServer();
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
resolve({ server, base: 'http://127.0.0.1:' + port });
});
});
}
/** 读取一个 SSE 流,把事件解析成 [{event, data}] */
function collectStream(url) {
return new Promise((resolve, reject) => {
const out = [];
http
.get(url, (res) => {
res.setEncoding('utf8');
let buf = '';
let timer = 0;
const flush = () => {
const ev = /event: (\S+)\ndata: (.+?)\n\n/g;
let m;
while ((m = ev.exec(buf))) out.push({ event: m[1], data: JSON.parse(m[2]) });
buf = '';
};
res.on('data', (chunk) => {
buf += chunk;
flush();
// done / error 之后服务端会关闭连接
if (out.length && /^(done|error)$/.test(out[out.length - 1].event)) {
clearTimeout(timer);
res.destroy();
resolve(out);
}
});
res.on('end', () => resolve(out));
res.on('error', reject);
timer = setTimeout(() => reject(new Error('SSE 超时')), 60000);
})
.on('error', reject);
});
}
test('normalizeOptions:默认值与过滤', () => {
const o = normalizeOptions({ query: 'pawn stars s03' });
assert.strictEqual(o.query, 'pawn stars s03');
assert.deepStrictEqual(o.sites, DEFAULT_SITES);
assert.strictEqual(o.sort, 'seeds');
assert.ok(o.expand);
});
test('normalizeOptions:异常值被钳制,未知数据源报错', () => {
const o = normalizeOptions({ query: 'x', minSeeds: -5, pages: 999, limit: 10, sort: 'bogus' });
assert.strictEqual(o.minSeeds, 0);
assert.strictEqual(o.pages, 30);
assert.strictEqual(o.limit, 20);
assert.strictEqual(o.sort, 'seeds');
assert.throws(() => normalizeOptions({ sites: 'nope' }), /未知数据源/);
assert.throws(() => normalizeOptions({}), /请输入关键词/);
assert.throws(() => normalizeOptions({ query: ' ' }), /请输入关键词/);
});
test('runSearch:空关键词直接抛错,不发起任何请求', async () => {
await assert.rejects(runSearch({ query: ' ' }), /请输入关键词/);
await assert.rejects(runSearch({ query: 'x', sites: 'nope' }), /未知数据源/);
});
test('静态页面:GET / 返回 HTML,且受目录限制', async () => {
const { server, base } = await start();
try {
const get = (p) =>
new Promise((resolve, reject) => {
http.get(base + p, (res) => {
let s = '';
res.on('data', (d) => (s += d));
res.on('end', () => resolve({ status: res.statusCode, body: s }));
}).on('error', reject);
});
const ok = await get('/');
assert.strictEqual(ok.status, 200);
assert.ok(ok.body.includes('磁力搜索'));
// 目录穿越会被拦下:%2f 先被 URL 解析器解码并归一化点段,
// 落在 WEB_DIR 内就 404,越界就 403 —— 两种都不该读到文件
const bad = await get('/..%2f..%2f..%2fetc%2fpasswd');
assert.ok(bad.status === 403 || bad.status === 404);
} finally {
server.close();
}
});
test('SSE:错误数据源走 error 事件,不崩服务', async () => {
const { server, base } = await start();
try {
const events = await collectStream(base + '/api/stream?query=x&sites=nope');
const last = events[events.length - 1];
assert.strictEqual(last.event, 'error');
assert.ok(last.data.message.includes('未知数据源'));
} finally {
server.close();
}
});
test('SSE:合法搜索按 start→source→…→done 顺序,done 带结果', async () => {
const { server, base } = await start();
try {
const events = await collectStream(base + '/api/stream?query=big%20buck%20bunny&sites=tpb');
const kinds = events.map((e) => e.event);
assert.ok(kinds[0] === 'start');
assert.ok(kinds[kinds.length - 1] === 'done');
const done = events[kinds.length - 1];
assert.ok(Array.isArray(done.data.results));
assert.ok(done.data.results.length > 0);
assert.ok(done.data.results[0].title.includes('Big Buck Bunny') || done.data.results[0].title.includes('Big.Buck.Bunny'));
} finally {
server.close();
}
});
test('模块导出是完整的(供 CLI / 服务共用)', () => {
assert.ok(Array.isArray(ALL_SITES));
assert.ok(Array.isArray(DEFAULT_SITES));
assert.ok(Array.isArray(SORTS));
assert.strictEqual(typeof runSearch, 'function');
assert.strictEqual(typeof normalizeOptions, 'function');
assert.strictEqual(typeof DEFAULTS, 'object');
});
+337
View File
@@ -0,0 +1,337 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const P = require('../cli/parse.js');
// 经典 rarbg / rargb 镜像布局:服务端渲染,列表页没有磁力,详情页在 /torrent/<id>
const CLASSIC_URL = 'https://rargb.to/search/?search=pawn+stars';
const CLASSIC_HTML = `
<table class="lista2t">
<tr class="lista2">
<td class="lista"><a href="/torrents.php?category=41"><img src="/images/cat41.gif" alt=""></a></td>
<td class="lista">
<a href="/torrent/hy1cbpk" title="Pawn.Stars.S24.1080p.WEB.h264-EDITH"><img src="/covers/a.jpg"></a>
<a href="/torrent/hy1cbpk">Pawn.Stars.S24.1080p.WEB.h264-EDITH</a>
</td>
<td class="lista"><a href="/torrents.php?category=41">TV/HD</a></td>
<td class="lista">2026-07-17 04:17:41</td>
<td class="lista">15.9 GB</td>
<td class="lista"><font color="#ff0000">1</font></td>
<td class="lista">21</td>
<td class="lista"><a href="/torrents.php?author=FileLeaker">FileLeaker</a></td>
</tr>
<tr class="lista2">
<td class="lista"><a href="/torrents.php?category=44"><img src="/images/cat44.gif" alt=""></a></td>
<td class="lista"><a href="/torrent/9qk2m1z">Pawn Stars S22E01 1080p HEVC x265-MeGusta</a></td>
<td class="lista"><a href="/torrents.php?category=44">TV/HEVC/x265</a></td>
<td class="lista">2024-07-14 19:26:11</td>
<td class="lista">458 MB</td>
<td class="lista">9</td>
<td class="lista">6</td>
<td class="lista"><a href="/torrents.php?author=philo138">philo138</a></td>
</tr>
</table>
<div id="pager_links">Pages: 1
<a href="/search/?search=pawn+stars&amp;page=2">2</a>
<a href="/search/?search=pawn+stars&amp;page=3">3</a>
<a href="/search/?search=pawn+stars&amp;page=2">&gt;&gt;</a>
<a href="/search/?search=pawn+stars&amp;page=71">71</a>
</div>`;
// therarbg 类布局:详情页 /post/detail/...,行内直接带磁力
const MODERN_URL = 'https://therarbg.com/get-posts/keywords:pawn+stars/?page=3';
const MODERN_HTML = `
<tr class="list-entry">
<td><a href="/get-posts/category:TV/">TV</a></td>
<td><a href="/post/detail/1234567/pawn-stars-s24-1080p-web/">Pawn Stars S24 1080p WEB</a></td>
<td>458 MB</td>
<td><a href="magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567&amp;dn=Pawn+Stars+S24">磁力</a></td>
</tr>
<div class="pagination">
<a href="?page=1">1</a><a href="?page=2">2</a><a href="?page=25">25</a>
</div>`;
const DETAIL_HTML = `
<div class="download">
<a href="magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678&amp;dn=Pawn.Stars.S24&amp;tr=udp%3A%2F%2Ftracker.example%3A80">Magnet</a>
<a href="/download.php?id=hy1cbpk&amp;f=Pawn.Stars.S24.torrent">Torrent 文件</a>
</div>`;
test('经典布局:按表格行提取帖子,标题与大小配对正确', () => {
const posts = P.extractPosts(CLASSIC_HTML, CLASSIC_URL);
assert.strictEqual(posts.length, 2);
assert.strictEqual(posts[0].url, 'https://rargb.to/torrent/hy1cbpk');
assert.strictEqual(posts[0].title, 'Pawn.Stars.S24.1080p.WEB.h264-EDITH');
assert.strictEqual(posts[0].size, '15.9 GB');
assert.strictEqual(posts[0].magnet, ''); // 列表页无磁力,需要进详情页
assert.strictEqual(posts[1].url, 'https://rargb.to/torrent/9qk2m1z');
assert.strictEqual(posts[1].size, '458 MB');
});
test('分类/上传者等 torrents.php 链接不会被当成帖子', () => {
const posts = P.extractPosts(CLASSIC_HTML, CLASSIC_URL);
assert.ok(posts.every((p) => !p.url.includes('torrents.php')));
});
test('分页探测:拿到最大页码和可复用的页码模板', () => {
const { maxPage, template } = P.findPagination(CLASSIC_HTML, CLASSIC_URL, CLASSIC_URL);
assert.strictEqual(maxPage, 71);
assert.strictEqual(template, 'https://rargb.to/search/?search=pawn+stars&page={n}');
assert.strictEqual(
P.buildPageUrl(CLASSIC_URL, 5, template),
'https://rargb.to/search/?search=pawn+stars&page=5'
);
});
test('当前页 URL 自带 page 参数时,无分页链接也能拼出目标页', () => {
const url = 'https://rargb.to/search/?search=x&page=4';
assert.strictEqual(P.currentPageNo(url), 4);
assert.strictEqual(P.buildPageUrl(url, 9, ''), 'https://rargb.to/search/?search=x&page=9');
});
test('当前页 URL 没有 page 参数时,回退为追加 page 参数', () => {
assert.strictEqual(
P.buildPageUrl('https://rargb.to/search/?search=x', 2, ''),
'https://rargb.to/search/?search=x&page=2'
);
});
test('therarbg 布局:详情页链接 + 行内磁力都能取到', () => {
const posts = P.extractPosts(MODERN_HTML, MODERN_URL);
assert.strictEqual(posts.length, 1);
assert.strictEqual(posts[0].url, 'https://therarbg.com/post/detail/1234567/pawn-stars-s24-1080p-web/');
assert.strictEqual(posts[0].title, 'Pawn Stars S24 1080p WEB');
assert.strictEqual(P.infoHash(posts[0].magnet), '0123456789abcdef0123456789abcdef01234567');
assert.ok(posts[0].magnet.includes('&dn=Pawn+Stars+S24')); // &amp; 已解码
});
test('详情页:磁力和 .torrent 下载地址都能取到', () => {
const magnets = P.extractMagnets(DETAIL_HTML);
assert.strictEqual(magnets.length, 1);
assert.strictEqual(P.infoHash(magnets[0]), '1234567890abcdef1234567890abcdef12345678');
assert.ok(magnets[0].includes('&tr=udp%3A%2F%2Ftracker.example%3A80'));
const files = P.extractTorrentFileUrls(DETAIL_HTML, 'https://rargb.to/torrent/hy1cbpk');
assert.deepStrictEqual(files, [
'https://rargb.to/download.php?id=hy1cbpk&f=Pawn.Stars.S24.torrent',
]);
});
test('磁力按 infohash 去重', () => {
const html = `<a href="magnet:?xt=urn:btih:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&dn=a">a</a>
<a href="magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=b">b</a>`;
assert.strictEqual(P.extractMagnets(html).length, 1);
});
test('没有表格结构时退化为按详情链接去重', () => {
const html = `<div>
<a href="/torrent/aaa">One</a><a href="/torrent/aaa"><img></a>
<a href="/torrent/bbb">Two</a>
<a href="/torrents.php?category=1">TV</a>
</div>`;
const posts = P.extractPosts(html, 'https://rargb.to/search/?search=x');
assert.deepStrictEqual(posts.map((p) => p.title), ['One', 'Two']);
});
test('自定义详情页正则可以适配未知镜像', () => {
const html = '<div><a href="/detail.php?tid=42">Some Post</a></div>';
const base = 'https://example.org/list.php?q=x';
assert.strictEqual(P.extractPosts(html, base).length, 0);
const posts = P.extractPosts(html, base, { detailPattern: '^/detail\\.php\\?tid=' });
assert.strictEqual(posts.length, 1);
assert.strictEqual(posts[0].title, 'Some Post');
});
test('分页链接路径与当前页不同时也能用(/search/ 页分页指向 torrents.php)', () => {
const html = `<div><a href="/torrent/aaa">One</a>
<a href="/torrents.php?search=x&amp;page=2">2</a>
<a href="/torrents.php?search=x&amp;page=12">12</a></div>`;
const { maxPage, template } = P.findPagination(
html,
'https://rargb.to/search/?search=x',
'https://rargb.to/search/?search=x'
);
assert.strictEqual(maxPage, 12);
assert.strictEqual(template, 'https://rargb.to/torrents.php?search=x&page={n}');
});
test('同路径分页链接优先于异路径', () => {
const html = `<div>
<a href="/torrents.php?search=x&amp;page=99">99</a>
<a href="/search/?search=x&amp;page=7">7</a></div>`;
const { maxPage, template } = P.findPagination(
html,
'https://rargb.to/search/?search=x',
'https://rargb.to/search/?search=x'
);
assert.strictEqual(maxPage, 7);
assert.strictEqual(template, 'https://rargb.to/search/?search=x&page={n}');
});
test('磁力标题可从 dn 参数还原', () => {
assert.strictEqual(
P.titleFromMagnet('magnet:?xt=urn:btih:' + 'a'.repeat(40) + '&dn=Pawn+Stars+S24E01'),
'Pawn Stars S24E01'
);
});
// rargb.to 的真实形式:页码在路径里 —— https://rargb.to/search/1/?search=Pawn%20Stars
const PATH_URL = 'https://rargb.to/search/1/?search=Pawn%20Stars';
const PATH_PAGER =
'<div id="pager_links">Pages: 1 ' +
'<a href="/search/2/?search=Pawn%20Stars">2</a> ' +
'<a href="/search/71/?search=Pawn%20Stars">71</a></div>';
test('页码在路径里的分页(rargb.to /search/N/)能识别', () => {
assert.strictEqual(P.currentPageNo(PATH_URL), 1);
const { maxPage, template } = P.findPagination(PATH_PAGER, PATH_URL, PATH_URL);
assert.strictEqual(maxPage, 71);
assert.strictEqual(template, 'https://rargb.to/search/{n}/?search=Pawn%20Stars');
assert.strictEqual(
P.buildPageUrl(PATH_URL, 5, template),
'https://rargb.to/search/5/?search=Pawn%20Stars'
);
});
test('翻页优先用站点自己的分页链接,而不是拼 URL', () => {
assert.strictEqual(
P.findPageUrl(PATH_PAGER, PATH_URL, PATH_URL, 2),
'https://rargb.to/search/2/?search=Pawn%20Stars'
);
assert.strictEqual(P.findPageUrl(PATH_PAGER, PATH_URL, PATH_URL, 9), ''); // 该页没有这个链接
});
test('帖子详情链接里的数字不会被当成页码', () => {
const html = '<a href="/torrents/12345">Some Post</a><a href="/torrent/abc">B</a>';
assert.strictEqual(P.findPagination(html, PATH_URL, PATH_URL).maxPage, 1);
});
// 下面两个用例来自 rargb.to 真实页面暴露的坑
test('结果表里的推荐海报条整行丢掉,不会串成「A 的标题 + B 的链接」', () => {
const html = [
'<tr><td class="lista">',
'<a title="Enemy (2013)" href="/torrent/enemy-2013-6713723.html"><img src="/a.jpg"></a>',
'</td><td class="lista">',
'<a title="Gail Daughtry (2026)" href="/torrent/gail-daughtry-6713521.html"><img src="/b.jpg"></a>',
'</td></tr>',
'<tr class="lista2"><td class="lista">',
'<a href="/torrent/pawn-stars-s24-6685358.html">Pawn.Stars.S24.1080p.WEB.h264-EDITH</a>',
'</td><td class="lista">15.9 GB</td></tr>',
].join('');
const posts = P.extractPosts(html, 'https://rargb.to/search/1/?search=Pawn%20Stars');
assert.strictEqual(posts.length, 1);
assert.strictEqual(posts[0].title, 'Pawn.Stars.S24.1080p.WEB.h264-EDITH');
assert.strictEqual(posts[0].size, '15.9 GB');
});
test('表头排序链接(仍是第 1 页)不会把总页数压成 1', () => {
const html = [
'<a href="/search/1/?search=Pawn%20Stars&amp;order=data&amp;by=ASC">Added</a>',
'<a href="/search/2/?search=Pawn%20Stars">2</a>',
'<a href="/search/71/?search=Pawn%20Stars">71</a>',
].join('');
const { maxPage, template } = P.findPagination(html, PATH_URL, PATH_URL);
assert.strictEqual(maxPage, 71);
assert.strictEqual(template, 'https://rargb.to/search/{n}/?search=Pawn%20Stars');
});
test('反爬校验页能被识别出来(用于区分「被拦」和「JS 渲染」)', () => {
assert.ok(P.looksBlocked('<title>Just a moment...</title><div id="cf-wrapper">'));
assert.ok(P.looksBlocked('<h1>Attention Required! | Cloudflare</h1>'));
assert.strictEqual(P.looksBlocked(CLASSIC_HTML), false);
});
// knaben 的 TPB 代理:https://unblocked.knaben.info/thepiratebay/s/?q=..&page=0&orderby=99
const TPB_URL = 'https://unblocked.knaben.info/thepiratebay/s/?q=Pawn.Stars&video=on&page=0&orderby=99';
const TPB_PAGER = [
'<a href="/thepiratebay/s/?q=Pawn.Stars&amp;video=on&amp;page=1&amp;orderby=99">2</a>',
'<a href="/thepiratebay/s/?q=Pawn.Stars&amp;video=on&amp;page=2&amp;orderby=99">3</a>',
'<a href="/thepiratebay/s/?q=Pawn.Stars&amp;video=on&amp;page=0&amp;orderby=7">Size</a>',
'<a href="/browse/2001000/1">Movies</a>',
].join('');
test('TPB 代理:页码从 0 开始,排序和分类链接不会被当成分页', () => {
const r = P.findPagination(TPB_PAGER, TPB_URL, TPB_URL);
assert.strictEqual(r.pageBase, 0); // ?page=0 就是第一页
assert.strictEqual(r.current, 0);
assert.strictEqual(r.maxPage, 2); // 站点编号 0/1/2 共三页
assert.ok(r.template.includes('page={n}'), r.template);
assert.strictEqual(
P.findPageUrl(TPB_PAGER, TPB_URL, TPB_URL, 1),
'https://unblocked.knaben.info/thepiratebay/s/?q=Pawn.Stars&video=on&page=1&orderby=99'
);
});
test('详情链接文字是来源站名时,标题取磁力自带的名字(knaben 结果行)', () => {
const row = [
'<tr data-id="abc">',
'<td><a title="Pawn.Stars.S18E12.WEB.h264-BAE[TGx]" href="magnet:?xt=urn:btih:' +
'6'.repeat(40) +
'&amp;dn=Pawn.Stars.S18E12.WEB.h264-BAE%5BTGx%5D">Pawn.Stars.S18E12.WEB.h264-BAE[TGx]</a></td>',
'<td>567.7 MB</td>',
'<td><a href="https://knaben.xyz/thepiratebay/description.php?id=43339699">The Pirate Bay</a></td>',
'</tr>',
].join('');
const posts = P.extractPosts(row, 'https://knaben.org/search/pawn.stars/0/1/seeders');
assert.strictEqual(posts.length, 1);
assert.strictEqual(posts[0].title, 'Pawn.Stars.S18E12.WEB.h264-BAE[TGx]');
assert.strictEqual(posts[0].size, '567.7 MB');
assert.ok(posts[0].url.includes('description.php'));
});
test('knaben 的 go-away 挑战页能被识别成「被拦」', () => {
assert.ok(P.looksBlocked('<title>One moment...</title><p>Protected by go-away :: Request Id abc</p>'));
});
// EZTV:剧集页 /shows/<id>/<slug>/,行内既有磁力也有 .torrent
const EZTV_URL = 'https://eztvx.to/shows/1252/pawn-stars/';
const EZTV_ROW = [
'<tr name="hover" class="forum_header_border">',
'<td class="forum_thread_post"><a href="/shows/1252/pawn-stars/"><img src="/i.gif"></a></td>',
'<td class="forum_thread_post"><a href="/ep/1434578/pawn-stars-s17e17-480p-x264-msd/"',
' class="epinfo" title="Pawn Stars S17E17 480p x264-mSD [eztv]">Pawn Stars S17E17 480p x264-mSD</a></td>',
'<td class="forum_thread_post" align="center">',
'<a href="magnet:?xt=urn:btih:42A39EF0A704FA3CEB569D38DFC826015533EDBE&amp;dn=Pawn.Stars.S17E17.480p.x264-mSD%5Beztv%5D"',
' class="magnet" title="Magnet Link"><img src="/images/magnet.gif"></a>',
'<a href="https://zoink.ch/torrent/Pawn.Stars.S17E17.480p.x264-mSD.torrent"',
' class="download_1" title="Download Torrent"><img src="/images/download.png"></a>',
'</td>',
'<td class="forum_thread_post" align="center">188.4 MB</td>',
'<td class="forum_thread_post" align="center">12 Apr 2020</td>',
'</tr>',
].join('');
test('EZTV 剧集页:一行同时有磁力和 .torrent,两者都收下', () => {
const posts = P.extractPosts(EZTV_ROW, EZTV_URL);
assert.strictEqual(posts.length, 1);
assert.strictEqual(
posts[0].url,
'https://eztvx.to/ep/1434578/pawn-stars-s17e17-480p-x264-msd/'
);
// 标题取自磁力的 dn 参数:比锚文字长,而且和实际文件名一致
assert.strictEqual(posts[0].title, 'Pawn.Stars.S17E17.480p.x264-mSD[eztv]');
assert.strictEqual(posts[0].size, '188.4 MB');
assert.strictEqual(
P.infoHash(posts[0].magnet),
'42a39ef0a704fa3ceb569d38dfc826015533edbe'
);
assert.strictEqual(
posts[0].torrent,
'https://zoink.ch/torrent/Pawn.Stars.S17E17.480p.x264-mSD.torrent'
);
});
test('.torrent 文件地址不会被当成帖子详情页', () => {
assert.strictEqual(P.isDetailUrl('https://zoink.ch/torrent/Some.Show.S01E01.torrent'), false);
assert.strictEqual(P.isDetailUrl('https://eztvx.to/ep/123/some-show/'), true);
});
test('EZTV 的 /page_2 分页形式能识别', () => {
const html = '<a href="/page_1">1</a><a href="/page_2">2</a><a href="/page_9">9</a>';
const url = 'https://eztvx.to/page_0';
const r = P.findPagination(html, url, url);
assert.strictEqual(r.maxPage, 9);
assert.strictEqual(P.buildPageUrl(url, 3, r.template), 'https://eztvx.to/page_3');
});
+25
View File
@@ -0,0 +1,25 @@
@echo off
rem ---------------------------------------------------------------
rem ASCII ONLY.
rem cmd.exe parses .bat files as system ANSI (GBK here), NOT UTF-8,
rem so any non-ASCII byte here corrupts line parsing. All Chinese
rem messages are printed by node (UTF-8) after chcp 65001 below.
rem ---------------------------------------------------------------
chcp 65001 >nul
title Magnet Search
where node >nul 2>nul
if errorlevel 1 (
echo.
echo Node.js not found / Node.js not installed.
echo Install it first: https://nodejs.org/
echo.
pause
exit /b 1
)
cd /d "%~dp0"
node cli\launch.js
echo.
pause