ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于DHT与infohash索引的磁力链接聚合搜索实践

基于DHT与infohash索引的磁力链接聚合搜索实践

简介:一套面向前端开发者与课程设计学习者的磁力链接聚合搜索项目源码,围绕多源磁力检索场景组织页面与交互,可用于理解搜索类 Web 应用的组件拆分、数据请求与界面呈现,也适合作为二次开发或项目实训参考。压缩包共 103 个文件,约 3.09MB,包含 42 个 JavaScript 业务脚本、26 个 Vue 组件、若干 XML/YML 配置、SCSS 样式、JSON 数据及图标字体和静态资源,工程结构相对完整,便于按模块查阅与复用。目前已有 5265 人学习。读者可从中获得工程骨架、页面组件组织方式、样式与构建配置文件,并参考搜索入口、结果展示和资源加载等实现思路,用于补齐前端项目经验、完成课程作业或搭建自己的聚合搜索原型。

1. 磁力链接聚合搜索:不是网页爬虫,是 infohash 索引工程

先澄清一个常见误解:磁力链接聚合搜索不是在网页上抓链接并保存到数据库,它更像一个持续运行的 P2P 网络监测系统。后端从 DHT 网络获取 infohash,再获取 torrent 元数据,把名称、文件列表、大小等字段写入全文检索引擎,前端只负责把查询词转换成一次带权重的检索。整套系统的重活在索引层,前端只是入口。它适合两类人:有 Python 异步经验、想弄清搜索系统从采集到检索完整链路的开发者;以及需要为内部资源库做垂直检索的团队。需要提前说清楚:索引对象只应是公开、合法分发的内容,抓取侧必须维护黑名单,合规决定这套系统能否长期运行。

2. 把 magnet 拆开:infohash、元数据与索引字段的三层数据模型

磁力链接看起来只是一串magnet:?xt=urn:btih:...,拆开后是三层结构,每一层对应系统里的一块存储。先把这三层讲清楚,后面爬虫和索引的字段设计才不会返工。我见过太多项目一上来就写爬虫,字段随手定,跑到几十万条才发现主键不统一、名称字段既当展示又当检索,最后只能停机重建索引。

这三层分别是:第一层,infohash 是内容寻址的唯一键,只做标识,不做展示;第二层,torrent 元数据是快照,包含名称、文件树、分片长度,随时可能被上游改写;第三层,索引字段是为检索服务的派生数据,包括分词后的名称、文件路径、热度权重。三层职责分清,后面加字段、换分词器、做冷热分层才有余地。

2.1 magnet URI 的字段与 infohash 的两种编码

xt是精确主题,值形如urn:btih:<infohash>。infohash 有两种写法:40 位十六进制,或 32 位 Base32。新手最容易在这里出错:把 Base32 当十六进制直接查库,查不到就开始怀疑索引没写进去,白排查半天。常见做法是入库前统一转成小写十六进制,Base32 前缀单独识别后解码,保证主键只有一种形态。

其余字段里,dn是显示名,tr是 tracker 地址,xl是精确字节长度,so用于指定文件索引。这里也有一个容易出错的地方:dn完全不可信,任何人都能改,不能拿它当主键,也不能拿它做去重依据。我一般只把dn存成“首次抓取时的建议名”,真正的名称以元数据里的name字段为准。

字段含义是否可信入库建议
xt精确主题,含 infohash高主键,统一转小写 hex
dn显示名低仅作参考列,不参与去重
trtracker 列表中存数组,用于可用性探测
xl精确长度中与元数据 size 交叉校验
so文件索引高单文件资源才用

2.2 infohash 是从 torrent 里算出来的,不是猜的

infohash 的定义很干脆:对 torrent 文件里info字典的 bencode 原始字节做 SHA-1。注意是原始字节,不是解析成对象后再序列化。重新序列化会改变键顺序和整数编码,算出来的哈希和网络里的对不上,这个错误在自研解析器上出现频率极高。

import hashlib def infohash_from_torrent(raw: bytes) -> str: """raw 为 .torrent 完整字节,返回 40 位小写十六进制 infohash""" key = b"4:infod" # info 字典的 bencode 前缀 start = raw.index(key) + len(key) - 1 # 回退到 'd' depth, i, end = 0, start, None while i < len(raw): ch = raw[i:i + 1] if ch in (b"d", b"l"): depth += 1 elif ch == b"e": depth -= 1 if depth == 0: end = i + 1 break i += 1 if end is None: raise ValueError("torrent 结构不完整") return hashlib.sha1(raw[start:end]).hexdigest()

这段代码做了一件很朴素的事:在原始字节里定位info字典的起止位置,截出字节区间直接哈希。参数只有一个raw,就是文件完整内容,不要先解码成字符串再传进来,二进制里会出现非法 UTF-8 序列,解码这一步就可能损坏数据。

这段朴素扫描有一个已知缺陷:字典值里的字符串如果包含d或e字符,深度计数会被干扰,截取区间偏移。我在早期版本上遇到过,表现为同一批 torrent 算出的 infohash 有一小部分和客户端不一致,而且只在文件名带特定字符时触发。生产环境请直接用成熟的 bencode 库按解析器位置截取,或者拿 libtorrent 的info_hash()做交叉校验。

2.3 为什么索引层只存 infohash 与元数据快照

磁力链接指向内容寻址:同一个 infohash 永远指向同一份内容,但描述这份内容的名字可以被不同发布者改成完全不同的样子。索引层如果把名称当唯一标识,就会出现同一资源多条记录、搜索时重复刷屏。我一般把 infohash 设成唯一键,名称、文件树、大小作为“快照字段”存下来,同时记录first_seen和last_seen两个时间戳。

这两个时间戳的用途比想象中大。first_seen用于时间衰减排序,老资源默认降权;last_seen用于清理判定,超过一定天数没再被网络里观测到、且peer_count归零的记录,可以进冷存储而不是直接删。直接删的后果是用户搜历史资源时结果突然消失,体验断崖式下跌,这个坑后面还会提到。

3. DHT 爬虫落地:从 find_node 到 announce_peer 的完整抓取链路

DHT 爬虫是这套系统里最容易被低估的部分。很多人以为写个 UDP 循环发get_peers就完事,实际跑起来会发现节点表迅速膨胀、无效 infohash 占了大半、元数据取回率低得可怜。这一章按链路顺序拆:先建路由表,再发查询,再取元数据,最后做并发和过滤。每一步都有参数需要调,调错的表现也不一样。

DHT 基于 Kademlia。每个节点有 160 位 ID,节点之间按 ID 异或距离组织成桶。爬虫要做的事可以概括成一句话:不断向“离随机目标 ID 最近的节点”发查询,从响应里收集新节点和新 infohash。理解这一点,后面所有参数都有了解释——为什么目标 ID 要随机、为什么路由表要定期刷新、为什么并发不能无限加。

3.1 路由表与节点发现的基本节奏

路由表按前缀分桶,每个桶保存若干个活跃节点。爬虫启动时先用一批引导节点做ping,拿到初始路由表,之后每次find_node都会带回一批新节点。这里的关键参数是桶容量和刷新间隔:桶容量太小,路由表很快见底,抓取效率下降;太大,内存和无效节点比例一起上涨。我一般把单桶容量设在 8 到 16,全局节点上限设在 5 万到 10 万之间。

刷新节奏上,别等节点全部失效再补。常见做法是维护一个“待验证队列”,每轮抓取按比例从队列里取节点做ping,成功的回填路由表,失败的丢弃并计数。连续失败三次的地址进黑名单,避免在同一批失效节点上反复消耗配额。这套机制听起来琐碎,但它是爬虫能连续跑几周不掉速的前提。

3.2 get_peers 与 announce_peer 的抓取流程

get_peers是主力查询:给一个 infohash,对方返回持有该资源的 peer 列表,或者返回更接近目标的一批节点。爬虫这里其实不关心 peer,只关心“对方响应里带的节点”和“对方是否认识这个 infohash”。announce_peer则是节点主动宣告自己持有某资源,抓取侧监听这类报文,能拿到大量新鲜 infohash。

import asyncio, random async def crawl_loop(bootstrap, out_queue, concurrency=64, node_cap=100_000): """bootstrap: [(host, port)];out_queue: 收集 infohash 的异步队列""" nodes = set(bootstrap) sem = asyncio.Semaphore(concurrency) while True: if len(nodes) < 200: # 路由表见底,重新注入引导节点 nodes.update(bootstrap) host, port = random.choice(tuple(nodes)) async with sem: try: new_nodes, infohashes = await query_get_peers(host, port) except Exception: nodes.discard((host, port)) # 失效节点直接剔除 continue if len(nodes) < node_cap: nodes.update(new_nodes) for ih in infohashes: await out_queue.put(ih)

concurrency控制同时在途的 UDP 查询数,默认 64 是个保守值,家用带宽和普通云主机都能扛住;调到 256 以上要先确认出口 NAT 的会话表容量,否则丢包率会先于吞吐量上升。node_cap是节点集合的硬上限,不设的话跑一夜能涨到几百万,内存直接吃满,这是我在一台 4G 内存的机器上实打实踩过的坑。

query_get_peers内部要做三件事:构造 bencode 查询报文、发 UDP、按事务 ID 匹配响应并设超时。超时建议 3 秒,重试一次即可,重试太多会让无效节点拖慢整轮节奏。响应解析失败要单独计数,这个指标能提前暴露协议实现上的兼容问题。

3.3 元数据扩展协议与取回率

拿到 infohash 只是第一步,没有元数据就没有可检索的名称和文件树。BEP 9 定义了元数据交换:先发扩展握手,声明支持ut_metadata,再按分片请求对方把 info 字典传过来。BEP 10 是扩展协议的承载层,两者通常一起实现。

这一步的实际取回率通常在 30% 到 60% 之间,取决于网络状况和资源新旧。取回失败的原因分几类:对方已经离线、对方不支持扩展协议、分片请求超时、元数据校验不过。我的处理方式是分级:第一次失败进重试队列,间隔拉长;连续三次失败标记为“元数据缺失”,但仍然保留 infohash 记录,只是不参与名称检索。直接丢弃会让索引量看着漂亮,实际覆盖度很差。

3.4 并发、限速与抓取侧过滤

并发上来之后,两件事必须同时做:限速和过滤。限速不只是为了保护自己出口,也是为了不给对端节点造成压力,UDP 洪水会让一些路由器直接把你的 IP 丢进丢弃列表,抓取效率反而下降。常见做法是按目标网段做令牌桶,单网段每秒查询数设上限。

过滤则是合规和质量的共同要求。抓取侧要维护一份黑名单,把明显违规的 infohash 或名称特征挡在入库之前;同时做基础质量过滤,比如元数据里文件数为 0、总大小为 0、名称全是空白字符的记录直接丢弃。这些规则不复杂,但必须写在入库管道里,而不是等索引建完再回头清洗,后者的成本是前者的好几倍。

4. 索引与检索:mapping 设计、分词权重与搜索 API 聚合

索引层决定搜索体验的上限。爬虫再勤快,字段设计错了,用户搜出来的结果也是乱的。这一章从 mapping 讲到查询改写,再落到服务端的 API 和缓存。核心思路只有一条:把“能搜到”和“排得对”分开处理,前者靠分词和字段覆盖,后者靠权重和排序函数。

4.1 mapping 设计与字段类型选择

mapping 一旦写错,改起来就是重建索引。最常见的错误是把名称字段设成text之后又想按精确值聚合,或者反过来设成keyword导致中文搜不到。正确做法是一个字段两种用途:主字段用text配中文分词器负责检索,子字段用keyword负责精确匹配和聚合。

{ "mappings": { "properties": { "infohash": { "type": "keyword" }, "name": { "type": "text", "analyzer": "ik_max_word", "fields": { "raw": { "type": "keyword", "ignore_above": 256 } } }, "files": { "type": "nested", "properties": { "path": { "type": "text", "analyzer": "ik_max_word" }, "size": { "type": "long" } } }, "total_size": { "type": "long" }, "peer_count": { "type": "integer" }, "first_seen": { "type": "date" }, "last_seen": { "type": "date" } } } }

infohash用keyword是硬性要求,它是主键,不能被分词。name用ik_max_word做细粒度切分,覆盖更多查询词组合;子字段raw用于“按名称精确去重”和聚合。files用nested是因为文件树是一对多关系,用普通对象数组会丢失“哪个路径对应哪个大小”的对应关系,搜索时容易出现跨文件误匹配。ignore_above: 256是防止超长名称把 keyword 索引撑爆,超过长度的值不进入该子字段,属于常见的保护性配置。

4.2 中文分词与查询改写

中文资源名有个特点:中英混排、带分辨率、带编码格式、带组名。用户查询也五花八门,有人搜“某关键词 1080p”,有人只搜关键词。分词器负责切,查询改写负责补。常见做法是在查询前做一次轻量归一化:全角转半角、大小写统一、把2160p/4k这类同义写法映射到同一个词项。

查询 DSL 上用bool的should组合名称匹配和文件路径匹配,给名称更高 boost。文件路径匹配要走nested,并且score_mode设成max,避免一个资源里文件多就天然得分高。

{ "query": { "bool": { "should": [ { "match": { "name": { "query": "示例关键词", "boost": 3 } } }, { "nested": { "path": "files", "score_mode": "max", "query": { "match": { "files.path": { "query": "示例关键词", "boost": 1 } } } } } ], "minimum_should_match": 1 } }, "sort": [ { "_score": "desc" }, { "peer_count": "desc" } ], "size": 20 }

boost: 3表示名称命中的权重是路径命中的三倍,这个比例可以根据实际搜索日志调。minimum_should_match: 1保证至少命中一个子句,否则空查询会召回全库。排序先按相关性再按peer_count,是“能搜到”和“排得对”分离的一个具体落点。

4.3 搜索 API 的入参、出参与缓存

API 层不要直接把 ES 的 DSL 暴露给前端。收口成固定参数:q查询词、page页码、size每页条数、sort排序方式,其余一律服务端拼装。这样后期换检索引擎或者加过滤条件,前端不用改。

curl -s "http://127.0.0.1:8080/api/search?q=%E7%A4%BA%E4%BE%8B&page=1&size=20&sort=relevance"

返回结构建议固定成{code, total, page, items[]},items里只放展示需要的字段:infohash、名称、大小、文件数、最后观测时间。不要把完整文件树塞进列表接口,单条响应轻松上百 KB,翻页时带宽和渲染都会崩。文件树单独开一个详情接口按需拉。

缓存层解决的是热门词重复查询。热门词的查询结果在短时间内几乎不变,缓存 30 到 60 秒足够。要注意缓存键必须包含全部查询参数,包括分页和排序,漏掉一个就会出现“翻到第二页还是第一页内容”的经典 bug。空结果也要缓存,但 TTL 要短,否则新入库的资源搜不到,用户会以为系统坏了。

4.4 部署与资源配比

三个组件分开部署:爬虫、索引、API。爬虫吃 CPU 和网络,索引吃内存和磁盘,API 吃并发连接,混在一台机器上会互相抢资源。小规模场景下,爬虫 2 核 4G、索引 4 核 8G 起、API 2 核 4G,是比较稳的起步配置。

docker run -d --name magnet-crawler \ --network host \ -e BOOTSTRAP="dht-bootstrap.example:6881" \ -e CONCURRENCY=64 \ -e NODE_CAP=100000 \ -e BLACKLIST_PATH=/etc/magnet/blacklist.txt \ -v /data/magnet:/data \ magnet/crawler:latest

--network host不是偷懒。DHT 走 UDP,bridge 网络下大量短连接会把 NAT 会话表打满,表现为抓取速率周期性归零,排查起来非常费劲。CONCURRENCY和NODE_CAP就是前面那两个参数,通过环境变量注入,方便按机器规格调整。BLACKLIST_PATH挂只读文件,更新黑名单不用重建镜像。

5. 上线前排查:五条血泪记录的现象、原因与修复

这一章记录的是真实跑挂过的五类问题,按组件分组。每一条都写成“现象 → 原因 → 解决”,方便对照排查。这些问题有个共同点:单看日志都不像大问题,叠在一起就会让系统看起来“能跑但不好用”。

5.1 爬虫侧:无效 infohash 与取回率虚高

现象一:入库 infohash 几十万,能搜到的名称不到两成。原因不是索引没写,而是把announce_peer拿到的声明当成了“资源可用”。节点宣告自己持有某资源,不代表这个资源还在网络上,也不代表元数据能取回来。解决是把“入库”和“可检索”拆成两个状态:infohash 先入库,元数据取回成功后才把searchable置为 true,检索时只查searchable: true。这个改动让搜索结果的有效率从两成出头拉到了七成以上。

现象二:节点集合内存占用每小时翻倍。原因是路由表只增不减,失效节点没有被及时剔除。解决是前面提到的三件套:单桶容量上限、全局节点上限、连续失败三次进黑名单。另外把节点集合换成带 TTL 的结构,超过一定时间没被任何响应带回的节点自动过期,内存曲线立刻变平。

5.2 索引侧:mapping 冲突与重建代价

现象三:想给名称加一个精确匹配子字段,重建索引时直接报 mapping 冲突。原因是旧索引里name已经是text,新 mapping 想改成别的结构,ES 不允许原地改类型。血泪经验是第一次建索引就用别名:真实索引叫magnet-v1,对外别名magnet,重建时新建magnet-v2灌数据,灌完把别名切过去,出问题切回来只要一条命令。别名这一步花五分钟,能省掉后面几小时的停机。

现象四:搜索“1080p”结果一大堆,但排在前面的全是好几年前的老资源。原因是纯_score排序下,文件名匹配度主导一切,新资源没有加权。解决是用function_score叠加时间衰减和peer_count因子,让近期观测到的、还有活跃 peer 的记录往上浮。调整之后同一批查询的首屏点击率提升很明显,具体数字因库而异,但方向是确定的。

5.3 服务侧:缓存击穿与慢查询

现象五:某个热门词缓存过期的一瞬间,API 延迟从 30ms 飙到 2s,索引节点 CPU 打满。典型缓存击穿。解决是三层:单飞锁保证同一个 key 只有一个请求回源,其余等待复用结果;TTL 加随机抖动,避免大批 key 同时过期;对确认无结果的查询写一个短 TTL 的空值标记,挡住重复穿透。慢查询则要开慢日志,把took超过 200ms 的查询单独收集,多数是nested查询没加过滤条件导致全量扫描。

6. 验证索引质量:抽样自检与一个可复用的巡检脚本

索引跑起来之后,最危险的状态是“看起来正常”。总量在涨、搜索有结果,但有效率和新鲜度可能在悄悄下降。我的习惯是每次改 mapping、调爬虫并发、或者换分词器之后,先跑一遍质量巡检,用数字确认没退化,再决定要不要全量重建。

巡检的核心指标是“陈旧记录占比”:last_seen超过 N 天、且peer_count为 0 的记录,在总量里占多少。这个比例超过三成就说明抓取侧覆盖在萎缩,要么引导节点失效,要么出口被限速,要么黑名单误伤。用一次 ES 聚合就能算出来,不需要全量扫描。

import requests def stale_ratio(es_url: str, index: str = "magnet", days: int = 30) -> float: """返回 last_seen 超过 days 天且 peer_count 为 0 的记录占比""" body = { "size": 0, "track_total_hits": True, "query": {"match_all": {}}, "aggs": { "stale": { "filter": {"bool": {"must": [ {"range": {"last_seen": {"lt": f"now-{days}d"}}}, {"term": {"peer_count": 0}}, ]}} } }, } r = requests.post(f"{es_url}/{index}/_search", json=body, timeout=10).json() total = r["hits"]["total"]["value"] stale = r["aggregations"]["stale"]["doc_count"] return stale / total if total else 0.0

days默认 30,资源更新快的库可以调到 14,冷门资源为主的库调到 60 更合理。track_total_hits: True是为了拿到精确总数,数据量特别大时可以关掉改成估算,但阈值判断会变粗。filter聚合不参与打分,开销远小于同条件的query,所以这个脚本在百万级索引上也能秒级返回。

除了陈旧率,我还会看两个数:元数据取回率(searchable: true占比)和名称字段覆盖率(name非空的占比)。三个数一起看,基本能定位问题出在爬虫、元数据交换还是入库管道。有一次取回率突然掉了 20 个点,最后查出来是某个引导节点返回的全是失效地址,换了一批引导节点就恢复了,光看总量根本发现不了。

从那以后我每次动索引或者爬虫参数,都强制先跑一遍这个巡检脚本,把三个数记在变更记录里,对比前后再决定是否继续。这套习惯救过我好几次,尤其是那种“改动很小、影响很隐蔽”的配置调整。希望帮到你。需要复现的话,把资源包里的 crawler、index、api 三个目录按上面的顺序跑一遍,先小规模验证再放量。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进