首页 / 资讯中心 / 文章详情

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

基于DHT与infohash索引的磁力链接聚合搜索实践 ★ FEATURED ARTICLE
简介一套面向前端开发者与课程设计学习者的磁力链接聚合搜索项目源码围绕多源磁力检索场景组织页面与交互可用于理解搜索类 Web 应用的组件拆分、数据请求与界面呈现也适合作为二次开发或项目实训参考。压缩包共 103 个文件约 3.09MB包含 42 个 JavaScript 业务脚本、26 个 Vue 组件、若干 XML/YML 配置、SCSS 样式、JSON 数据及图标字体和静态资源工程结构相对完整便于按模块查阅与复用。目前已有 5265 人学习。读者可从中获得工程骨架、页面组件组织方式、样式与构建配置文件并参考搜索入口、结果展示和资源加载等实现思路用于补齐前端项目经验、完成课程作业或搭建自己的聚合搜索原型。1. 磁力链接聚合搜索不是网页爬虫是 infohash 索引工程先澄清一个常见误解磁力链接聚合搜索不是在网页上抓链接并保存到数据库它更像一个持续运行的 P2P 网络监测系统。后端从 DHT 网络获取 infohash再获取 torrent 元数据把名称、文件列表、大小等字段写入全文检索引擎前端只负责把查询词转换成一次带权重的检索。整套系统的重活在索引层前端只是入口。它适合两类人有 Python 异步经验、想弄清搜索系统从采集到检索完整链路的开发者以及需要为内部资源库做垂直检索的团队。需要提前说清楚索引对象只应是公开、合法分发的内容抓取侧必须维护黑名单合规决定这套系统能否长期运行。2. 把 magnet 拆开infohash、元数据与索引字段的三层数据模型磁力链接看起来只是一串magnet:?xturn: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高主键统一转小写 hexdn显示名低仅作参考列不参与去重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 b4: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 (bd, bl): depth 1 elif ch be: 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, concurrency64, node_cap100_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%8Bpage1size20sortrelevance返回结构建议固定成{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 BOOTSTRAPdht-bootstrap.example:6881 \ -e CONCURRENCY64 \ -e NODE_CAP100000 \ -e BLACKLIST_PATH/etc/magnet/blacklist.txt \ -v /data/magnet:/data \ magnet/crawler:latest--network host不是偷懒。DHT 走 UDPbridge 网络下大量短连接会把 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: fnow-{days}d}}}, {term: {peer_count: 0}}, ]}} } }, } r requests.post(f{es_url}/{index}/_search, jsonbody, timeout10).json() total r[hits][total][value] stale r[aggregations][stale][doc_count] return stale / total if total else 0.0days默认 30资源更新快的库可以调到 14冷门资源为主的库调到 60 更合理。track_total_hits: True是为了拿到精确总数数据量特别大时可以关掉改成估算但阈值判断会变粗。filter聚合不参与打分开销远小于同条件的query所以这个脚本在百万级索引上也能秒级返回。除了陈旧率我还会看两个数元数据取回率searchable: true占比和名称字段覆盖率name非空的占比。三个数一起看基本能定位问题出在爬虫、元数据交换还是入库管道。有一次取回率突然掉了 20 个点最后查出来是某个引导节点返回的全是失效地址换了一批引导节点就恢复了光看总量根本发现不了。从那以后我每次动索引或者爬虫参数都强制先跑一遍这个巡检脚本把三个数记在变更记录里对比前后再决定是否继续。这套习惯救过我好几次尤其是那种“改动很小、影响很隐蔽”的配置调整。希望帮到你。需要复现的话把资源包里的 crawler、index、api 三个目录按上面的顺序跑一遍先小规模验证再放量。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站