From 0e85433a5d03cf53c8de91877794dd76da065ebc Mon Sep 17 00:00:00 2001 From: blueslmj Date: Thu, 10 Sep 2026 22:39:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=9A=E7=AB=99=E7=82=B9=E7=A3=81?= =?UTF-8?q?=E5=8A=9B=E6=90=9C=E7=B4=A2=E5=B7=A5=E5=85=B7=EF=BC=88=E7=BD=91?= =?UTF-8?q?=E9=A1=B5=E7=89=88=20+=20=E5=91=BD=E4=BB=A4=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 聚合 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) --- .gitattributes | 14 + .gitignore | 21 ++ LICENSE | 21 ++ README.md | 181 +++++++++++- cli/engine.js | 247 ++++++++++++++++ cli/format.js | 132 +++++++++ cli/launch.js | 73 +++++ cli/match.js | 215 ++++++++++++++ cli/parse.js | 590 +++++++++++++++++++++++++++++++++++++ cli/search.js | 160 +++++++++++ cli/serve.js | 132 +++++++++ cli/sources.js | 245 ++++++++++++++++ cli/web/index.html | 671 +++++++++++++++++++++++++++++++++++++++++++ package.json | 34 +++ tests/cli.test.js | 166 +++++++++++ tests/engine.test.js | 137 +++++++++ tests/parse.test.js | 337 ++++++++++++++++++++++ 启动搜索.bat | 25 ++ 18 files changed, 3400 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 cli/engine.js create mode 100644 cli/format.js create mode 100644 cli/launch.js create mode 100644 cli/match.js create mode 100644 cli/parse.js create mode 100644 cli/search.js create mode 100644 cli/serve.js create mode 100644 cli/sources.js create mode 100644 cli/web/index.html create mode 100644 package.json create mode 100644 tests/cli.test.js create mode 100644 tests/engine.test.js create mode 100644 tests/parse.test.js create mode 100644 启动搜索.bat diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7d91702 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dae336d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4c99a4a --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 2052ecb..ec49b75 100644 --- a/README.md +++ b/README.md @@ -1 +1,180 @@ -# magnet-finder \ No newline at end of file +# 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) diff --git a/cli/engine.js b/cli/engine.js new file mode 100644 index 0000000..b77169d --- /dev/null +++ b/cli/engine.js @@ -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 }; diff --git a/cli/format.js b/cli/format.js new file mode 100644 index 0000000..a98f858 --- /dev/null +++ b/cli/format.js @@ -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 }; diff --git a/cli/launch.js b/cli/launch.js new file mode 100644 index 0000000..e7f8485 --- /dev/null +++ b/cli/launch.js @@ -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); +}); diff --git a/cli/match.js b/cli/match.js new file mode 100644 index 0000000..d08763f --- /dev/null +++ b/cli/match.js @@ -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, +}; diff --git a/cli/parse.js b/cli/parse.js new file mode 100644 index 0000000..7e19513 --- /dev/null +++ b/cli/parse.js @@ -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] || ''; + } + + /** 抽出所有 ,返回 { href, text } */ + function extractAnchors(html) { + const out = []; + const re = /]*)>([\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; + } + + /** 把一行切成各个 的纯文本 */ + function rowCells(rowHtml) { + const out = []; + const re = /]*>([\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 切成 块,一行一个帖子 */ + function extractRows(html) { + const out = []; + const re = /]*>([\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 的链接 —— 否则会被站点塞进结果表的推荐海报条 + * (一个 里好几部不相干的电影)串成「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 抽出所有帖子。 + * 优先按 分组(能把标题/大小/内联磁力配对准确), + * 没有表格结构时退化为「按详情链接去重」。 + */ + 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); diff --git a/cli/search.js b/cli/search.js new file mode 100644 index 0000000..629ed6b --- /dev/null +++ b/cli/search.js @@ -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 文件 导出「标题磁力」', + ' --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); +}); diff --git a/cli/serve.js b/cli/serve.js new file mode 100644 index 0000000..72d21ef --- /dev/null +++ b/cli/serve.js @@ -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 }; diff --git a/cli/sources.js b/cli/sources.js new file mode 100644 index 0000000..fdddc62 --- /dev/null +++ b/cli/sources.js @@ -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 }; diff --git a/cli/web/index.html b/cli/web/index.html new file mode 100644 index 0000000..4ce8131 --- /dev/null +++ b/cli/web/index.html @@ -0,0 +1,671 @@ + + + + + +磁力搜索 · 多站点聚合 + + + + + +
+
+

磁力搜索

+ 一次问多个站点 · 去重后按最活跃的来源计种子数 +
+ + + +
+
+ + +
+ +
+
+
种子存活 — 点一段只看这类
+
+
+
+ + +
+
+ +
+ + + +
+
还没有结果
+
输入一个名字开始,例如 pawn stars s03。标题须包含全部关键词。
+
+
+ +
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..d81a3a3 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/tests/cli.test.js b/tests/cli.test.js new file mode 100644 index 0000000..8c016b2 --- /dev/null +++ b/tests/cli.test.js @@ -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); +}); \ No newline at end of file diff --git a/tests/engine.test.js b/tests/engine.test.js new file mode 100644 index 0000000..0e7eeca --- /dev/null +++ b/tests/engine.test.js @@ -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'); +}); diff --git a/tests/parse.test.js b/tests/parse.test.js new file mode 100644 index 0000000..e793134 --- /dev/null +++ b/tests/parse.test.js @@ -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/ +const CLASSIC_URL = 'https://rargb.to/search/?search=pawn+stars'; +const CLASSIC_HTML = ` + + + + + + + + + + + + + + + + + + + + + +
+ + Pawn.Stars.S24.1080p.WEB.h264-EDITH + TV/HD2026-07-17 04:17:4115.9 GB121FileLeaker
Pawn Stars S22E01 1080p HEVC x265-MeGustaTV/HEVC/x2652024-07-14 19:26:11458 MB96philo138
+
`; + +// therarbg 类布局:详情页 /post/detail/...,行内直接带磁力 +const MODERN_URL = 'https://therarbg.com/get-posts/keywords:pawn+stars/?page=3'; +const MODERN_HTML = ` + + TV + Pawn Stars S24 1080p WEB + 458 MB + 磁力 + +`; + +const DETAIL_HTML = ` +`; + +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 + b`; + assert.strictEqual(P.extractMagnets(html).length, 1); +}); + +test('没有表格结构时退化为按详情链接去重', () => { + const html = `
+ One + Two + TV +
`; + const posts = P.extractPosts(html, 'https://rargb.to/search/?search=x'); + assert.deepStrictEqual(posts.map((p) => p.title), ['One', 'Two']); +}); + +test('自定义详情页正则可以适配未知镜像', () => { + const html = '
Some Post
'; + 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 = `
One + 2 + 12
`; + 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 = `
+ 99 + 7
`; + 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 = + ''; + +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 = 'Some PostB'; + assert.strictEqual(P.findPagination(html, PATH_URL, PATH_URL).maxPage, 1); +}); + +// 下面两个用例来自 rargb.to 真实页面暴露的坑 + +test('结果表里的推荐海报条整行丢掉,不会串成「A 的标题 + B 的链接」', () => { + const html = [ + '', + '', + '', + '', + '', + '', + 'Pawn.Stars.S24.1080p.WEB.h264-EDITH', + '15.9 GB', + ].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 = [ + 'Added', + '2', + '71', + ].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('Just a moment...
')); + assert.ok(P.looksBlocked('

Attention Required! | Cloudflare

')); + 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 = [ + '2', + '3', + 'Size', + 'Movies', +].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 = [ + '', + 'Pawn.Stars.S18E12.WEB.h264-BAE[TGx]', + '567.7 MB', + 'The Pirate Bay', + '', + ].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('One moment...

Protected by go-away :: Request Id abc

')); +}); + +// EZTV:剧集页 /shows///,行内既有磁力也有 .torrent +const EZTV_URL = 'https://eztvx.to/shows/1252/pawn-stars/'; +const EZTV_ROW = [ + '', + '', + 'Pawn Stars S17E17 480p x264-mSD', + '', + '', + '', + '', + '188.4 MB', + '12 Apr 2020', + '', +].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 = '129'; + 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'); +}); diff --git a/启动搜索.bat b/启动搜索.bat new file mode 100644 index 0000000..7dba941 --- /dev/null +++ b/启动搜索.bat @@ -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