Files
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

248 lines
9.8 KiB
JavaScript
Raw Permalink 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.
/**
* 搜索流程本体。命令行(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 };