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

161 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});