PHP实现BT种子转磁力链接:bencode解析与info_hash计算
简介:这份资源面向PHP开发者与P2P文件分享场景的学习者,聚焦BT种子文件与磁力链接之间的转换问题,适合具备一定PHP基础、希望理解DHT与BitTorrent元数据机制的中级开发者参考。压缩包共5个文件,以4个PHP脚本和1份Markdown说明为主,整体仅约3KB,体量轻巧:PHP文件承担种子解析、Bencode编解码与转换入口等核心逻辑,Markdown文档则用于补充使用说明与实现思路。已有4101人学习下载,说明该主题在文件分享与分布式存储方向具有稳定的关注度。读者可从中获得一套可运行的转换脚本,理解如何解析种子文件info部分、计算SHA1得到Info Hash,并按magnet:?xt=urn:btih格式拼接文件名与Tracker参数,同时接触Bencode编解码这一BT协议基础环节,为后续扩展P2P下载工具或研究分布式文件定位提供可复用的代码起点。
1. 一个 PHP 脚本把种子文件转成磁力链接,到底值不值得拆
手里攒了一堆.torrent文件,想批量转成磁力链接丢进自己的资源索引库,结果发现网上能用的在线转换站要么限速、要么夹广告、要么转完的链接缺dn参数看着别扭。这个基于 PHP 实现的 BT 种子转磁力链接工具包,解决的正是这个具体问题:读种子文件、解析 bencode 结构、算出 info_hash、拼出标准磁力链接。它适合谁?适合手里有 PHP 环境、想在自己服务器上跑一个转换接口的从业者,也适合想搞明白磁力链接到底怎么从种子文件里「长」出来的开发者。整包是源码形式,不是编译好的二进制,意味着你能改、能嵌进自己的采集流程,而不是被一个黑匣子卡住。
2. 拆开 bencode:种子文件里到底藏了什么
2.1 为什么不能直接字符串截取
很多人第一次处理.torrent会想当然地去找announce后面的 URL,或者用正则抠name字段。这条路走不通,因为种子文件是 bencode 编码,一种前缀式、自描述的二进制格式。它的规则只有四条:整数写成i<数字>e,字符串写成<长度>:<内容>,列表写成l<元素>e,字典写成d<键值对>e,且字典的键必须按字节序升序排列。字符串长度前缀是字节数不是字符数,中文种子名一个汉字占三字节,长度前缀写错一位,后面整个解析全崩。
磁力链接的核心是xt=urn:btih:<info_hash>,这个 hash 不是整个文件的哈希,而是种子里info这个字典原始字节的 SHA1。注意是原始字节,不是解析后再序列化的结果。这是整个工具最关键的原理点:你必须保留info字典在文件中的原始切片,直接对它做 SHA1,任何「解析成数组再重新编码」的操作都可能因为键序、整数编码差异导致 hash 对不上。血泪经验是,重编码出来的种子看着结构一样,算出的 info_hash 却和主流客户端不一致,链接自然下不动。
2.2 解析器的骨架代码
下面这段是 bencode 解析的核心,思路是游标推进,遇到字典时记录info键对应值的起止偏移,供后面切片用。
<?php // 极简 bencode 解析器,返回解析结果并记录 info 字典的原始区间 function bdecode(string $data, int &$pos = 0, array &$infoRange = []) { $len = strlen($data); if ($pos >= $len) return null; $ch = $data[$pos]; if ($ch === 'i') { // 整数 i123e $end = strpos($data, 'e', $pos); $num = (int)substr($data, $pos + 1, $end - $pos - 1); $pos = $end + 1; return $num; } if ($ch === 'l') { // 列表 l...e $pos++; $list = []; while ($data[$pos] !== 'e') { $list[] = bdecode($data, $pos, $infoRange); } $pos++; // 跳过结尾 e return $list; } if ($ch === 'd') { // 字典 d...e $pos++; $dict = []; while ($data[$pos] !== 'e') { $key = bdecode($data, $pos, $infoRange); // 键一定是字符串 $valStart = $pos; // 记录值起始偏移 $val = bdecode($data, $pos, $infoRange); if ($key === 'info') { $infoRange = [$valStart, $pos - $valStart]; // [起点, 长度] } $dict[$key] = $val; } $pos++; return $dict; } // 字符串 <len>:<content> $colon = strpos($data, ':', $pos); $slen = (int)substr($data, $pos, $colon - $pos); $str = substr($data, $colon + 1, $slen); $pos = $colon + 1 + $slen; return $str; }逻辑说明:$pos是全局游标,递归调用共享同一个引用,保证解析连续推进。$infoRange用引用传出,记录info值在原始字符串里的起点和长度。参数上唯一要注意的是$pos必须按引用传,否则递归回来游标就乱了。这段代码不处理异常输入,生产环境要在每个strpos返回false时抛错,否则畸形种子会让脚本静默产出错误 hash。
2.3 从 info 切片到磁力链接
拿到$infoRange后,切片、SHA1、拼链接三步走。dn参数是显示名,取自info.name,要做 URL 编码;tr是 tracker,取自顶层announce和announce-list,可以有多个。
<?php $raw = file_get_contents($argv[1]); $pos = 0; $infoRange = []; $meta = bdecode($raw, $pos, $infoRange); // 关键:对 info 字典的原始字节做 SHA1 $infoBytes = substr($raw, $infoRange[0], $infoRange[1]); $infoHash = sha1($infoBytes); // 40 位十六进制 $name = $meta['info']['name'] ?? 'unknown'; $trackers = []; if (!empty($meta['announce'])) $trackers[] = $meta['announce']; if (!empty($meta['announce-list'])) { foreach ($meta['announce-list'] as $tier) { foreach ($tier as $tr) $trackers[] = $tr; } } $trackers = array_unique($trackers); $magnet = 'magnet:?xt=urn:btih:' . $infoHash . '&dn=' . rawurlencode($name); foreach ($trackers as $tr) { $magnet .= '&tr=' . rawurlencode($tr); } echo $magnet . PHP_EOL;参数说明:sha1()默认输出小写十六进制,磁力链接里大小写都合法,但部分老客户端只认大写,稳妥做法是统一转大写或统一小写并在文档里写清。rawurlencode比urlencode更适合 URL 参数,前者把空格编成%20而非+。tracker 去重后顺序保留,announce-list的分层结构在拼链接时被拍平,这是常见做法,因为磁力链接的tr参数本身不表达优先级。
3. 把脚本跑起来:命令行、批量与接口三种用法
3.1 命令行单文件转换
最直接的用法是 CLI。把上面两段合成一个torrent2magnet.php,接收文件路径参数。
php torrent2magnet.php ./demo.torrent # 输出示例 # magnet:?xt=urn:btih:ABCDEF...&dn=demo&tr=udp%3A%2F%2Ftracker.example%3A80跑之前确认 PHP 版本,这套代码只用到sha1、substr、strpos这些基础函数,PHP 7.0 以上都能跑,不需要额外扩展。如果输出为空,先看文件是不是真的 bencode——用xxd demo.torrent | head看头几个字节,正常种子以d8:announce或d4:info开头,如果开头是PK说明你拿到的是 zip 压缩包没解压。
3.2 批量目录处理
实际场景很少只转一个。批量版加个目录遍历,把结果写成一行一个的文本,方便后续导入。
<?php $dir = $argv[1]; $out = fopen('magnets.txt', 'w'); foreach (glob($dir . '/*.torrent') as $file) { $raw = file_get_contents($file); $pos = 0; $infoRange = []; $meta = bdecode($raw, $pos, $infoRange); if (empty($infoRange)) { // 没有 info 字典,跳过 fwrite(STDERR, "skip: $file\n"); continue; } $hash = strtoupper(sha1(substr($raw, $infoRange[0], $infoRange[1]))); $name = $meta['info']['name'] ?? basename($file, '.torrent'); fwrite($out, "magnet:?xt=urn:btih:$hash&dn=" . rawurlencode($name) . "\n"); } fclose($out);逻辑说明:glob只匹配当前层,子目录要换RecursiveDirectoryIterator。$infoRange为空说明这个文件不是合法种子或结构异常,直接跳过并打到 stderr,不污染结果文件。hash 统一转大写,避免下游系统大小写敏感导致去重失败。批量跑几千个文件时注意内存,file_get_contents一次读整个文件,单个种子通常几十 KB 到几 MB,问题不大,但超大种子(含大封面图)要留意memory_limit。
3.3 包成 HTTP 接口
想给前端或其他服务调用,就套一层最简单的路由。不引框架,原生 PHP 足够。
<?php // upload.php 接收 multipart 上传的种子,返回 JSON header('Content-Type: application/json'); if (empty($_FILES['torrent']['tmp_name'])) { http_response_code(400); echo json_encode(['error' => 'no file']); exit; } $raw = file_get_contents($_FILES['torrent']['tmp_name']); $pos = 0; $infoRange = []; $meta = bdecode($raw, $pos, $infoRange); if (empty($infoRange)) { http_response_code(422); echo json_encode(['error' => 'invalid torrent']); exit; } $hash = strtoupper(sha1(substr($raw, $infoRange[0], $infoRange[1]))); echo json_encode([ 'info_hash' => $hash, 'name' => $meta['info']['name'] ?? '', 'magnet' => 'magnet:?xt=urn:btih:' . $hash . '&dn=' . rawurlencode($meta['info']['name'] ?? ''), ]);参数与边界:上传大小受upload_max_filesize和post_max_size限制,默认 2M,种子一般够用。接口不要回显原始文件内容,只回 hash 和名字。生产环境要加文件类型校验和频率限制,否则就是个现成的滥用入口。这套接口适合内网或带鉴权的场景,裸奔在公网迟早被刷。
4. 避坑与排查:五个让链接「看着对却下不动」的坑
4.1 现象:磁力链接能生成,客户端却一直找元数据
原因几乎都是 info_hash 算错。最常见的是解析后重新bencode编码再哈希,键序或整数编码和原始字节不一致。解决:坚持对原始切片做 SHA1,不要重编码。验证方法是拿一个已知种子,用主流客户端「复制磁力链接」功能对比 hash,一致才算过。
4.2 现象:中文种子名在链接里变成乱码
原因是对name直接拼接没做 URL 编码,或者用了urlencode把空格编成+。解决:统一rawurlencode。另外 bencode 字符串是字节序列,PHP 里不要对它做mb_convert_encoding,原样编码即可,客户端自己会按 UTF-8 解析。
4.3 现象:批量转换中途脚本挂掉,结果文件半截
原因是某个畸形种子触发了解析器无限递归或越界,PHP 报 fatal 直接终止。解决:给解析器加深度上限和边界检查,批量循环里用try/catch包住单文件处理,单个失败不影响整体。更稳的做法是先把所有文件解析成 hash 列表,再统一写文件。
4.4 现象:同一批种子转出的链接去重后数量对不上
原因是 hash 大小写不统一,或者dn参数编码差异导致字符串比较失败。解决:入库前统一strtoupper处理 hash,去重只按 info_hash 比较,不要拿整条链接比。这是资源索引场景的高频翻车点。
4.5 现象:接口上传大种子返回 500
原因是post_max_size超限后$_FILES为空但$_POST也被清空,代码没判空直接访问数组键。解决:入口先判$_FILES和$_SERVER['CONTENT_LENGTH'],超限时返回明确的 413 而不是让脚本报错。后悔药是提前在php.ini里把限制调到合理值并在接口文档里写明。
5. 进阶:用 info_hash 做去重与校验的完整闭环
转换只是第一步,真正让这套工具产生价值的是把 info_hash 当成资源指纹用。同一个文件无论换多少个种子、换多少个 tracker,info_hash 不变,这是它比文件名可靠得多的原因。我一般会在转换后做两件事:一是把 hash 存进带唯一索引的表,导入时INSERT ... ON DUPLICATE KEY UPDATE天然去重;二是拿 hash 反查已有资源,避免重复采集。
CREATE TABLE magnets ( info_hash CHAR(40) NOT NULL, name VARCHAR(512), magnet TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (info_hash) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO magnets (info_hash, name, magnet) VALUES ('ABCDEF...', 'demo', 'magnet:?xt=urn:btih:ABCDEF...') ON DUPLICATE KEY UPDATE name = VALUES(name);表结构里info_hash用CHAR(40)定长,比VARCHAR索引更紧凑。magnet字段存完整链接方便直接取用,但去重逻辑只认主键。校验环节可以再加一步:对转换结果做一次「反向解析」,从磁力链接里抠出 hash 和原始种子算出的 hash 比对,不一致就报警。这个自检在批量导入时能拦住绝大多数解析器 bug。
| 校验项 | 方法 | 失败含义 |
|---|---|---|
| hash 一致性 | 原始切片 SHA1 vs 链接中 btih | 解析或编码有误 |
| 大小写 | 统一转大写后比较 | 下游去重失效 |
| name 编码 | rawurldecode后与原文比对 | URL 编码用错函数 |
| tracker 数量 | 去重后计数 | announce-list 拍平遗漏 |
最后说个习惯。从那以后我每次把这类转换脚本接进采集流程,都会先拿十个已知种子跑一遍回归,把 hash 和客户端结果逐个对齐,再放开批量。这套 PHP 实现不大,但 bencode 的边界和 info_hash 的原始字节语义,是那种「不看一眼就敢上线、上线就翻车」的地方。希望帮到你。
本文还有配套的精品资源,点击获取