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:
1 parent
559065b415
commit
0e85433a5d
18 files changed
+3400
-1
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -1 +1,180 @@
|
||||
# magnet-finder
|
||||
# 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
@@ -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
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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&page=2">2</a>
|
||||
<a href="/search/?search=pawn+stars&page=3">3</a>
|
||||
<a href="/search/?search=pawn+stars&page=2">>></a>
|
||||
<a href="/search/?search=pawn+stars&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&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&dn=Pawn.Stars.S24&tr=udp%3A%2F%2Ftracker.example%3A80">Magnet</a>
|
||||
<a href="/download.php?id=hy1cbpk&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')); // & 已解码
|
||||
});
|
||||
|
||||
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&page=2">2</a>
|
||||
<a href="/torrents.php?search=x&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&page=99">99</a>
|
||||
<a href="/search/?search=x&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&order=data&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&video=on&page=1&orderby=99">2</a>',
|
||||
'<a href="/thepiratebay/s/?q=Pawn.Stars&video=on&page=2&orderby=99">3</a>',
|
||||
'<a href="/thepiratebay/s/?q=Pawn.Stars&video=on&page=0&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) +
|
||||
'&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&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');
|
||||
});
|
||||
@@ -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
|
||||
Reference in new issue
Block a user