「搜到 ≠ 下得动」:给自建磁力索引器补一个中文搜索前端和死种预筛
我在一台常年闲置的内网小主机上跑了个 DHT 索引器。它工作得很勤奋:挂在 BT 的 DHT 网络上,谁发布了新种子它就记一笔,实测每秒能录进 0.7 条、一天六万条,元数据(名字、大小、文件列表)一应俱全。
但真用起来才发现,它解决的是”有什么“,完全没解决”能不能下“。
一、索引器的两个盲区
盲区一:搜到不等于下得动。
索引库里是有 n_seeders / n_leechers 这两个字段的——听起来正是”做种人数”。我满怀期待地查了一下,全库 285 条记录,两列全是 NULL。
这不是 bug,是 DHT 索引器的固有局限:它只是在 DHT 网络上”听见”了谁在宣告某个种子,至于现在还有没有人在做种,它从不验证。于是你搜到一个看着很香的资源,复制磁力链丢进下载器,然后卡在 0% 一动不动——每一条搜索结果都是一次盲盒。
盲区二(更隐蔽):它的中文搜索在架构上就是坏的。
索引器自带一个 Web 界面,用的是 SQLite 的 FTS5 全文索引。我搜了个中文关键词,返回 0 条。可我明明知道库里有这个名字。
挖下去才发现根因:FTS5 默认的分词器 unicode61 会把一整串连续的中文当成一个 token。《诸神的黄昏》在索引里就是一个不可分割的词。所以:
- 搜「诸神的黄昏」——能命中(整串相等)
- 搜「诸神」——命中不了(不相等,也不是前缀…实际上前缀查询能救一半)
- 搜「黄昏」——永远不可能命中(它在词的中间,而 FTS5 的 token 匹配不认”词中词”)
而现实是,我库里19% 的条目是中文名。也就是说,五分之一的内容对这个搜索框是不可见的。这不是”体验不好”,是”功能不存在”。
于是我给自己定了个目标:补上存活预筛和中文检索,做成一个只给自己用的搜索工具。
二、先解决”有没有”:选型
索引器我选了 magnetico——理由是它单个静态二进制 + 一个 SQLite 文件就能跑,不需要 Docker,不需要 PostgreSQL,对一台 4 核 3.7G 内存的闲置小机器非常友好。(功能更全的 bitmagnet 要 Postgres,成本和爆炸半径都不是一个量级。)
选型本身不复杂,倒是踩了两个坑:
坑一:SQLite 必须用 -cgo 变体。 release 里有个 magnetico-linux-amd64 看着最正常,跑起来直接报 go-sqlite3 requires cgo to work. This is a stub——它是 CGO_ENABLED=0 编译的,静态版只能配 PostgreSQL。得下载带 -cgo 后缀的那个。
坑二:默认的 bootstrap 节点在国内几乎不可用。 上游默认走作者自建的 dht.tgragnato.it(六个端口),这个域名有多个 A 记录轮询,实测时通时不通。换成公共的 dht.transmissionbt.com:6881 后稳定得多——多次测试都稳定应答 56 字节。
顺手记一个冷知识:magnetico 没有子命令,不是 magnetico crawl,而是直接跟选项(-d 只跑爬虫、-w 只跑 Web UI)。我一开始凭印象写了 crawl,好在写文档前跑了 --help 才没把错的命令写进 README。
三、判断种子死活:只发查询,不下载内容
核心思路很直接:对每个 info_hash,向 DHT 网络问一句”谁在这个种子上?”
技术上就是 BEP-5 的 get_peers 查询:从本地 DHT 路由表里挑出离目标 info_hash 最近的若干节点,发 KRPC 查询,收集返回的 peer 列表。有 peer 回来 → 活;明显有节点应答但一个 peer 都没有 → 死;一个应答都没有 → 不确定(网络抖动,稍后重试,不误判成死种)。
整个探测只发 UDP 查询包,不下载任何内容,所以带宽开销极小(几个 Mbps)。我用纯标准库实现了一个探测引擎:bencode 编解码、节点表 bootstrap / 扩展 / 持久化都是自己写的,零第三方依赖。
关键是怎么确认它判断得对。 我设计了真值对照:
- 正样本:Debian 官方 ISO 的种子——全世界镜像站都在做种,必然活着。实测返回 256 个 peer ✅
- 负样本:随机生成的 info_hash——不可能有 peer。实测 0 个 peer ✅
- 再用索引库里 8 个真实种子交叉验证:全部检出,peer 数 2~135 不等 ✅
我自己写的引擎,当然要拿已知答案的题去考它。
四、压测决定架构
接下来最重要的一步是压测——因为架构取决于一个比值。
我把探测引擎跑起来,测不同批大小下的吞吐。结果:批量 50 时约 6.9 个/秒(≈60 万/天)。
而索引器的入库速度是 0.7 条/秒。也就是说,检测能力是产出速度的十倍——全量预筛完全跟得上,不需要”抽样检测”或”只检热门”这类妥协。
于是架构就定了:一个常驻 worker,双优先级队列。
- 队列 A(搜索插队,小批量 16 条):用户正在看的结果优先探测,几秒内出状态;
- 队列 B(后台回填,批量 50 条):从最新入库往前扫,已探测且未过期的直接跳过。
配合 TTL 策略:活种 24 小时复查、死种 7 天复查、探测失败的 1 小时重试。这样既保证搜索结果新鲜,又不会把带宽浪费在反复确认同一批数据上。
五、四个只有跑起来才会暴露的 bug
这部分是我觉得最值得记录的。代码写完能跑,和代码真的能用,中间隔着的就是这些。
bug 1:中文搜索的架构缺陷
前面说了根因:unicode61 把整串中文当一个 token。
修的时候我先试了 FTS5 内置的另一个分词器 trigram(三元组,天生支持子串)——但它要求查询至少 3 个字符,而「诸神」只有 2 个字,直接失效。中文两字词太常见了,这条路走不通。
最后的解法是自己造索引:
- 建索引时,把每个 CJK 字符用空格拆开:
诸神的黄昏→诸 神 的 黄 昏(非 CJK 字符原样保留) - 查询时,把中文按二元词组拼接:
"诸 神" AND "神 的" AND "的 黄" AND "黄 昏",英文数字词转成前缀查询"1080p"*
这样 1 字、2 字、任意位置的中文子串都能命中,词组相邻约束又保证了准确率。修完实测:「诸神」「黄昏」「中文字幕」「电影」全部命中,耗时 1~6ms。
同一个索引顺手也解决了英文的子串问题(搜 nkkd 能命中 17.nkkd011)。
bug 2:一个非 UTF-8 的种子名,打挂了整个接口
上线后有个接口开始间歇性 500。日志里的异常很奇怪:
1 | OperationalError("Could not decode to UTF-8 column 'name' with text '2012.03.21_...rmvb'") |
原因是索引库里存在历史遗留的非 UTF-8 编码种子名(GBK、Shift-JIS 之类,早年 Windows 下的产物)。Python 的 sqlite3 默认 text_factory 会尝试严格解码 UTF-8,遇到这种名字直接抛异常——整个查询失败,整个接口 500。
修法是自定义 text_factory 做宽松解码(errors="replace"):坏字符变成替换符,但不至于把接口打挂。
bug 3:systemd 的依赖被我写反了
我在服务单元里写了 Requires=magnetico.service,想法是”我依赖它”。但 systemd 的语义是:被依赖的单元停了,依赖它的单元也会被停掉。结果索引器一重启(比如升级),搜索服务就跟着被停,而且不会自动恢复——测的时候正好撞上。
改成 Wants=(软依赖)就对了:索引器不在时搜索服务也能降级运行(库还在,只是不更新)。
bug 4:一个字典键名冲突,让我误判”探测全挂了”
我看了眼统计接口,发现”活种数”从 776 掉到 423,而”已探测”数却在涨。第一反应是探测出了问题。
查了半天数据库,发现库里的数据完全正常。真凶是一行偷懒的 Python:
1 | return {"indexed": ..., **health.counts(), **prober.snapshot()} |
health.counts() 给的是累计的活/死数量,而 prober.snapshot() 里有个本次进程会话的活/死计数——两个字典的 key 重名,后者把前者覆盖了。所以我看到的”活种数”其实是”本进程启动以来探测到的活种数”,跟全库毫无关系。数字自相矛盾的样子,让我怀疑错了对象。
教训:字典展开合并(**a, **b)时,重名的 key 是静默覆盖,不报错。给会话计数改名为 session_* 就没事了。
六、怎么证明它真的能用
我不太信任”能打开页面 = 做完了”这种验收。所以做了三层验证:
第一层:死种过滤的端到端证明。 从存活库里取一条已判定为死的种子,用它的名字去搜,然后断言:开启”隐藏死种”时它不出现、关闭时它出现。这样证明的是”过滤逻辑真的生效”,而不是”我觉得它生效”。
第二层:前端用 jsdom 真实执行。 前端最容易的验收方式是”我用浏览器打开看了一眼,没问题”——但这既不可重复,也漏细节。更好的做法是让页面在无头环境里真的跑起来:用 jsdom 加载页面、注入 fetch,然后真实触发搜索、点击按钮,断言渲染行数、中文检索、死种过滤、展开文件、空结果提示,以及全程零 JS 错误。
一次跑出 12 项结论,而且可重复。这套测试后来还抓到了我下面要说的安全问题。
第三层:安全测试。 服务是在内网暴露的,所以目录穿越、XSS 这些都得自己打一遍:
1 | curl --path-as-is "http://<host>:8090/static/../../etc/passwd" # 期望 404 |
前端那层则是在 jsdom 里往搜索框里塞一段 <img src=x onerror=...>,断言它不执行、且界面上显示的是转义后的原文。
七、一条二手结论,查完发现是反的
开发到一半,我看到一个说法:“应该用 bitmagnet,因为 magnetico 已经停止维护了。”
听起来很合理,而且是决定要不要推倒重来的问题。我没直接采信,而是去 GitHub API 拉了一手数据:
| 仓库 | Star | 最后提交 | 近 90 天提交 | 最近发布 |
|---|---|---|---|---|
boramalper/magnetico(原版) |
3131 | 2022-01-20,已归档 | 0 | v0.12.0(2020-11) |
tgragnato/magnetico(我在用的分支) |
147 | 2026-09-29 | 54 次 | v2.4.1(2026-09-28) |
bitmagnet-io/bitmagnet |
4387 | 2026-05-21 | 0 次 | v0.10.1-beta.1 |
结论是反的:
- “magnetico 停止维护”说的其实是原版仓库——它确实 2022 年就归档了,而搜索”magnetico”最先跳出来的就是它(Star 也全在它头上)。我在用的是社区的活跃分支,两天前还有提交。
- 反倒是被推荐的 bitmagnet,近 90 天 0 次提交,最后一次发布是 beta(稳定版停在 2025-03)。
我没换。这件事给我的启发不是”谁更好”,而是:别人转述的”某项目已死/更优”,值得花五分钟去拉一手数据核实——尤其当它要你推翻已有工作时。Star 数、归档状态、提交时间线都在 API 里,是免费的。
顺带一提,那段时间我还发现 bitmagnet 那边有个 issue 在请求”支持 SQLite 存储”(说明它至今必须依赖 Postgres),而 magnetico 这边有个 issue 在请求”增加 seeders/leechers 字段”——正好就是我做的这个死种预筛。上游还没做,我先给自己做了。
八、开源,以及发布前的隐私审计
代码写完我把它开源了:https://github.com/nova02640/bt-search(AGPL-3.0)。
关于许可证和”归属”,我做了个刻意的选择。这个项目没有修改上游一行源码——它只是以只读方式读上游的 SQLite 索引库,存活探测是我照 BEP-5 规范自己实现的。所以:
- 没有 fork,而是建了独立仓库。fork 一个自己没改过的仓库,会让”这里有我的改动”产生误导;
- README 最顶部写清二次开发声明(基于哪个分支、原版已归档、遵循同一许可证);
- 许可证沿用上游的 AGPL-3.0——这样无论从”衍生作品”还是”独立实现”哪个角度理解,都不会有歧义。
发布前我跑了一遍隐私审计,用正则扫了全部文件 + 全部 git 历史,检查项包括:凭证关键词、私钥块、邮箱、内网 IP、私有路径、主机用户名、真实磁力链。
结果里有一条值得单独说:扫描报出 dht_probe.py 命中了 "192.168."。我打开一看——那是探测引擎里的私网地址过滤白名单(用来避免把探测包发到内网保留地址)。它是个安全特性,不是泄露。如果我不看上下文直接”清理”,就会亲手删掉一个保护机制。扫描器只负责提示,判断还得人来做。
真正需要修的有四处,都是我自己写出来的:
| 问题 | 修法 |
|---|---|
| 500 错误把异常原文返回给客户端(泄露内部路径/SQL 细节) | 只返回 internal error,细节进本地日志 |
搜索词经 innerHTML 回显(自 XSS 面) |
做 HTML 转义,并对返回值做空值兜底 |
| 服务文件里写着主机用户名 | 改成 YOUR_USER 占位符 |
| 静态目录前缀校验没带路径分隔符(理论上可被同前缀目录绕过) | 补上分隔符比较 |
修完重新部署,把这四项跑了一遍验证:目录穿越的四种变体全部 404、正常静态文件仍 200、XSS 断言不执行、功能回归通过。安全修复不做验证,等于没修。
九、现在的样子
服务已经常驻运行,内网任何设备打开就能用:中文任意子串搜索、结果带 peer 数和检测时间、死种默认隐去、一键复制磁力链、可展开种子文件列表。
实测量级:
- 索引约 1800 条时,已探测 1758 条,其中死种 255 条(约 14.5%)——也就是说,如果没有这层过滤,每 7 条搜索结果里就有 1 条是死链;
- 搜索响应 1~15ms;服务自身内存约 17MB、CPU 约 3%;
- 探测吞吐 6.9 个/秒,全量预筛无压力。
当然也有要说清楚的局限:索引还年轻。它是几个小时前才上线的,一天只能积累六万条左右,所以冷门老资源今晚大概率搜不到——不是功能问题,是还没爬到。DHT 上 peer 少也不完全等于死(冷门种可能被误判)。还有最重要的一条:这套东西只判断”有没有人在做种”,不判断内容是否安全,版权和恶意文件的风险得自己评估。
十、几个收获
如果只留三条:
- “搜得到”和”用得上”之间往往隔着一层没人验证的假设。 索引器的
n_seeders字段一直存在、一直是 NULL,没人觉得有问题——直到你真的想下载。补上这层验证,工具的实用性会发生质变。 - 中文检索是个很容易被忽略的坑。 大多数全文索引方案的默认分词器对 CJK 都不友好,而且失效方式很隐蔽:不是报错,是”静默地搜不到”。但凡你的数据里有中文,都值得专门测一次。
- 写完能跑 ≠ 能用。 这篇文章里四个 bug,有三个是只有真跑一段时间、真被用户(我自己)用起来才会暴露的。验收时尽量把”我看了眼觉得没问题”换成”用脚本断言它确实成立”。
代码在这里,欢迎围观或自取:https://github.com/nova02640/bt-search





