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

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

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

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

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

138 lines
4.8 KiB
JavaScript

'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');
});