Files
magnet-finder/cli/serve.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

133 lines
4.2 KiB
JavaScript

#!/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 };