简介这是一套基于Web端运行的m3u8视频在线提取下载工具源码面向普通用户与前端开发者用于解决HLS流媒体视频无法直接保存、需逐段下载合并的痛点尤其适合直播回放、在线课程与临时视频收藏等场景。无需安装任何本地软件通过浏览器打开页面、填入m3u8链接即可完成提取支持加密流解密处理并可将输出转码封装为MP4格式。压缩包共20个文件大小约1.16MB以JavaScript核心脚本、HTML操作页面、图片素材及说明文档为主核心功能覆盖视频分片请求、AES解密、流式保存以及MP4转封装等关键环节同时包含项目配置文件、功能预览截图与基础使用说明便于快速部署与二次开发。已有8030人学习/下载源码结构清晰既可直接部署提供在线服务也适合开发者研究HLS协议处理、前端文件流操作及浏览器端视频合成的实现思路。1. 在线拆 m3u8 之前先把 HLS 下载这件事讲透“在线 m3u8 视频提取下载工具支持转 MP4 格式无需安装”——这个标题拆开看本质是三件事拿到 m3u8 播放列表、把里面的分片全部拉下来、再合并转封装成一个 MP4 文件。先给一个反直觉结论m3u8 本身不是视频它只是一份文本播放清单视频内容全部散落在几百个 .ts 分片里所谓“在线下载工具”做的大部分工作也不是下载而是把这些分片按顺序拼回去。真正难住开发者的是鉴权参数时效、分片合并的乱序、TS 到 MP4 的封装转换以及把整个过程嵌进一个“无需安装”的网页工作流。这篇文章按一线实践路线从抓包定位 m3u8 开始一路写到后端实现、并发调度、常见踩坑和输出校验适合想自己搭一个可靠下载工具的开发者也适合被“在线工具下载出来打不开”折磨过的使用者。2. 拿到 m3u8 地址才是第一步抓包定位与格式解析很多人以为做这类工具最难的是下载和转 MP4动手之后才发现光是找出页面上真正播放的那个 m3u8 地址就能卡住半天。视频站点为了防盗链和统计会把播放列表藏进 JS 变量、接口响应甚至 blob URL 里网络面板里一眼望过去全是分片请求反而看不到总清单。这一章先把“怎么拿地址”和“拿到后怎么解析”两个前置问题解决掉。2.1 从播放器网络面板里定位真实地址常见做法是打开浏览器开发者工具切到 Network 面板刷新页面让视频重新加载然后按资源类型过滤。绝大多数 HLS 播放器的 m3u8 请求会出现在 Fetch/XHR 或 Media 分类下文件名通常带 .m3u8 或 index.m3u8 字样。如果站点做了自定义后缀或者 URL 带了签名参数直接肉眼扫列表就不靠谱了我一般会在控制台里跑一段脚本把页面生命周期内所有包含 m3u8 的资源一次性列出来。// 列出页面加载期间所有含 m3u8 的资源地址 performance.getEntriesByType(resource) .map(entry entry.name) .filter(url url.includes(.m3u8) || url.includes(m3u8?) || url.includes(m3u8))这段代码的核心是 performance API。getEntriesByType(resource)返回页面从开始加载到当前时刻发起的全部资源请求包括 XHR、fetch、视频流、图片和脚本map取出每个请求的完整 URL再用filter把含 m3u8 的地址筛出来。控制台执行后能看到一串候选地址通常带?token或者?sign之类的鉴权参数这些参数在后续步骤里不能丢。注意如果页面是 SPA播放器是在路由切换后才创建的脚本需要在视频真正开始播放之后再跑否则资源列表里是空的。有些情况下资源列表里完全没有 m3u8但视频确实在播。常见原因是播放器先把 m3u8 拉回来后转成了 blob URL 再喂给 video 标签此时网络面板里只能看到 blob: 开头的媒体请求。遇到这种情况去 Sources 面板对页面 JS 做全局搜索关键词换成m3u8、playlist、manifest、key或index在源码里定位播放器初始化时读取的配置对象再顺着这个对象的赋值链路找到真实接口。还有一类站点是后端接口返回 JSON里面同时塞了视频标题和播放地址这种情况切到 Fetch/XHR 过滤翻响应体里带.m3u8的那条记录即可。2.2 读懂 m3u8 里的标签与分片规则拿到地址只是第一步真正决定下载器怎么写的是 m3u8 文本内部的标签。一个典型的点播型 m3u8 长这样#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:6 #EXT-X-KEY:METHODAES-128,URIkey.key #EXTINF:6.000, 00000.ts #EXTINF:6.000, 00001.ts #EXTINF:4.500, 00002.ts #EXT-X-ENDLISTEXTM3U是文件头EXTINF后面紧跟的数字是当前分片的时长单位秒下一行就是分片的相对或绝对 URL。EXT-X-KEY表示这个流做了 AES-128 加密URI指向密钥文件。EXT-X-ENDLIST是点播流的结束标记有它说明分片列表是完整的可以一次抓完没有它说明是直播流列表会持续增长不能按普通下载器处理。标签作用下载器怎么用EXT-X-TARGETDURATION单个分片的最大时长估算分片数量做进度条EXTINF单个分片时长累计总时长校验下载结果EXT-X-KEY加密方法与密钥地址决定分片是否需要解密EXT-X-ENDLIST点播流结束标记判断是否可以一次性下载EXT-X-STREAM-INF多码率主列表声明需要递归进入子列表EXT-X-STREAM-INF是一个经常被忽略的坑很多站点返回的顶层 m3u8 其实是多码率清单里面没有分片只有几个子列表地址分别对应流畅、高清、超清。写解析器时不能默认拿到手就是分片列表要先检测文本里是否存在#EXT-X-STREAM-INF存在就继续往子列表递归直到拿到真正含分片的层级。解析代码我用 Node 实现了一个最小版本逻辑是逐行扫描遇到EXTINF记下时长遇到非注释行就当作分片地址同时对EXT-X-KEY做单独处理。function resolveUrl(url, baseUrl) { // 分片地址可能是相对路径需要拼上 m3u8 所在的目录 return /^https?:\/\//i.test(url) ? url : new URL(url, baseUrl).href; } function parseM3u8(text, baseUrl) { const lines text.split(/\r?\n/).map(line line.trim()); const segments []; let keyInfo null; let lastDuration 0; for (const line of lines) { if (line.startsWith(#EXTINF:)) { const match line.match(/#EXTINF:\s*([\d.])/); lastDuration match ? parseFloat(match[1]) : 0; } else if (line.startsWith(#EXT-X-KEY)) { const uri line.match(/URI([^])/)?.[1]; const method line.match(/METHOD([^,\s])/)?.[1]; keyInfo { method, uri: uri ? resolveUrl(uri, baseUrl) : null }; } else if (line !line.startsWith(#)) { // 这一行是分片地址记录它对应的时长 segments.push({ url: resolveUrl(line, baseUrl), duration: lastDuration }); } } return { segments, keyInfo, hasEndList: text.includes(#EXT-X-ENDLIST) }; }resolveUrl处理三类情况绝对 URL 直接返回不带协议的//xx.com/xxx.ts用new URL补上协议纯相对路径则相对 m3u8 所在目录解析。很多下载工具翻车的点就在这——m3u8 里写的是segment/00000.ts这种相对路径如果直接拿它去请求会落到站点根目录导致 404。parseM3u8返回的对象里segments是带顺序的分片数组keyInfo是加密信息hasEndList用来区分点播和直播这三个字段正好是后续下载器需要的全部输入。3. 把分片拼成 MP4无需安装的下载与转封装实现“无需安装”这四个字值得先拆清楚对最终用户来说打开网页就能用不需要在自己电脑上装播放器、下载器或者转换软件但对实现者来说服务端还是需要一个转封装引擎。市面上的在线工具绝大多数是后端架构——浏览器负责提交任务和展示进度服务器负责下载、合并和转码。这里要先解决一个认识问题为什么分片都下完了还不能直接叫 MP4。3.1 为什么拼接 .ts 不等于转出 MP4TS 分片是 MPEG-TS 容器每个分片自带 PAT、PMT 和 PES 头所以理论上把几百个 .ts 按顺序拼接成一个文件播放器可以正常播这也是很多简单工具的做法。但 MP4 是不同的容器格式它的结构要求有 ftyp 头、moov box 和 mdatmoov 里还得记录每个样本的时间戳和偏移量。直接把 .ts 内容塞进一个文件改名为 .mp4是典型的“假 MP4”很多播放器会因找不到 moov box 而报“文件已损坏”或者只认出一个未知编码流。正确的做法是转封装而不是转码。转封装的意思是视频和音频的编码数据原样保留只换掉外面的容器壳子这个过程用 FFmpeg 的-c copy参数完成速度接近磁盘拷贝几乎不损失画质。唯一的代价是 FFmpeg 需要重新整理时间戳、计算关键帧位置所以当原始分片本身时间戳不连续时转封装后的 MP4 在进度条拖动上会暴露出问题这个坑放到下一章细说。3.2 一个能跑的下载与转换实现Node 后端加 FFmpeg下面这个实现假设场景是用户提交一个 m3u8 地址服务端下载全部 TS 分片合并后转封装成 MP4 返回下载。语言选 Node代码直接依赖 Node 18 自带的全局 fetch不需要额外安装 HTTP 库FFmpeg 负责合并和转封装。分片下载采用简单的并发控制文件写入临时目录最后通过 FFmpeg 的 concat demuxer 读取分片列表完成合并。const fs require(fs); const path require(path); const { execFile } require(child_process); const { promisify } require(util); const execFileAsync promisify(execFile); // 统一的下载函数带上目标站点要求的请求头 async function download(url, headers {}) { const res await fetch(url, { headers }); if (!res.ok) throw new Error(HTTP ${res.status} for ${url}); return Buffer.from(await res.arrayBuffer()); } // 并发控制只开 concurrency 个 worker每个 worker 循环取分片 async function downloadAll(segments, concurrency, headers) { const tmpDir fs.mkdtempSync(m3u8_dl_); let index 0; async function worker() { while (index segments.length) { const i index; const file path.join(tmpDir, String(i).padStart(6, 0) .ts); try { const buf await download(segments[i].url, headers); fs.writeFileSync(file, buf); } catch (e) { // 单分片失败后重试一次仍失败就抛出让整个任务失败 const buf await download(segments[i].url, headers); fs.writeFileSync(file, buf); } } } await Promise.all(Array.from({ length: concurrency }, () worker())); return tmpDir; } // 合并与转封装用 concat demuxer避免把所有分片读进内存 async function mergeToMp4(tmpDir, outputName) { const files fs.readdirSync(tmpDir).sort(); const listFile path.join(tmpDir, concat.txt); const list files .map(name file ${path.join(tmpDir, name).replace(/\\/g, /)}) .join(\n); fs.writeFileSync(listFile, list); await execFileAsync(ffmpeg, [ -f, concat, -safe, 0, -i, listFile, -c, copy, -movflags, faststart, outputName ]); } async function main(m3u8Url, headers) { const text await download(m3u8Url, headers).then(buf buf.toString()); const { segments } parseM3u8(text, m3u8Url); // 复用上一章的解析函数 const tmpDir await downloadAll(segments, 4, headers); await mergeToMp4(tmpDir, output.mp4); console.log(done, segments:, segments.length); }downloadAll里用mkdtempSync创建临时目录文件名统一用 6 位零填充编号这是为了避免readdirSync的字符串排序把100.ts排到20.ts前面。合并阶段没有自己拼字节而是把分片路径写进 concat.txt再由 FFmpeg 去读。这样做的两个原因一是几百个分片加起来可能超过 1GB用Buffer.concat一次性读进内存工具会直接崩二是 FFmpeg 对 TS 流的拼接做了容错遇到某个分片尾部有残缺包时会自动截断对齐比自己硬拼更稳。-movflags faststart把 moov box 挪到文件头部MP4 才能在浏览器里边下边播否则要等文件全部下载完才能开始播放。请求头headers里通常要带User-Agent和RefererReferer 指向 m3u8 所在页面这个值在很多站点直接决定分片请求是返回 200 还是 403。m3u8 地址常常带时效签名整体下载耗时越长签名过期的风险越大所以并发数一般不建议太小。4 到 6 是比较稳的区间并发再高容易触发 CDN 的限流而且单分片失败后的重试会让网络连接堆积。3.3 纯前端方案什么时候可用不搭后端、完全在浏览器里把分片抓下来合并成 MP4技术上走的是另一条路用 fetch 拉取所有分片到内存Blob 合并后触发浏览器下载。这个方案有两个硬边界。第一是 CORS分片所在域名必须允许跨域请求光这一条就挡掉了大多数实际站点。第二是内存一个 30 分钟的 1080P 视频TS 分片累计很容易超过 1GB浏览器 tab 直接崩溃。所以纯前端方案只适合分片数量少、时长短、且站点放开跨域的场景比如内部系统的预览视频、临时测试流。真正的在线工具要把下载和转封装放在服务端浏览器只负责任务提交和进度展示“无需安装”是给使用者的承诺不是给实现者的省事借口。4. 在线 m3u8 下载工具最容易翻车的 5 个坑现象、原因与排查写一个 m3u8 下载器最让人抓狂的不是逻辑写不出来而是下载和转封装“看起来成功”交付的文件在播放器里却各种不对劲。这一章把实践里踩过的高频坑按阶段拆开每条按现象、原因、解决思路来写。这些坑基本是行业通病提前知道能省下大量排查时间。4.1 下载与重试阶段的坑排序、鉴权与直播流坑一所有分片都显示下载成功合并后视频却花屏、跳帧、甚至在某个时间点直接卡死。现象表现是进度条能拖但画面出现马赛克或重复帧。原因是分片合并顺序错了常见于直接用fs.readdirSync返回的文件名字符串排序下2.ts会排在10.ts前面或者某些分片下载时 TCP 连接被重置写进盘里的文件只有正常大小的一半FFmpeg 按正常顺序读到一个截断的 TS 包就开始花屏。解决方式分两处文件名写入时用padStart(6, 0)做零填充保证字典序就是播放顺序下载函数里对每个分片做字节数检查和重试失败一次后延迟 500ms 再试重试两次仍失败就终止整个任务并提示用户不要产出残次品。坑二下载到一半突然集体 403前面的分片正常后面的全部失效。原因是 m3u8 和分片 URL 携带的鉴权签名有时效签名过期后 CDN 拒绝继续提供服务。排查时可以在一批 403 返回里看响应头是否带X-Cache或Via字段确认是否由 CDN 拦截。解决思路有三个方向适当提高并发数缩短整体下载时长让整个下载在签名有效期内完成下载前从页面请求头里把 Cookie 和 Referer 完整带上有些鉴权参数是绑定会话的如果站点支持重新请求 m3u8 地址获取新签名就在检测到 403 时重新拉取一次 m3u8用新地址续传剩余分片。坑三分片列表越拉越长下载永远完不成。现象是任务跑了很久进度从 80% 跳到 85% 又跳回 82%总量在增长。原因是这个 m3u8 根本没有EXT-X-ENDLIST它是直播流或类直播流播放列表实时追加新分片。在下载器里要先判断hasEndList没有结束标记的要走直播处理逻辑只下载当前已存在的分片到列表末尾就停止不要继续轮询否则任务永远跑不完。如果业务上确实要录完整段直播那需要边拉边存并设置最大分片数或最长时长上限超出后强制结束任务。4.2 封装与加密阶段的坑假 MP4、黑屏与资源耗尽坑四工具提示“转换完成”下载回来的文件扩展名是 .mp4但播放器打不开。打开方式不对的典型判断是同样的文件改成 .ts 扩展名后能正常播放。这说明工具做的是假转换——它只是把 TS 分片拼起来改了后缀内容根本没进 MP4 容器。在实现里必须让 FFmpeg 参与最后的封装用-c copy转封装而不是拼接后改名。如果你用的是纯浏览器 Blob 方案不存在真正的 MP4 转封装能力除非引入 ffmpeg.wasm 这类重型方案但它的包体积和内存占用在小工具里通常不可接受。坑五下载的流带 AES-128 加密分片全部下完合并后播放黑屏或只有音轨没有画面。原因是对EXT-X-KEY的处理只存了地址没有真正解密。AES-128 加密的 key 文件固定 16 字节解密算法是 aes-128-cbcIV 的取值规则是如果EXT-X-KEY标签里写了IV0x...就用它没有写则以分片在列表中的序号从 0 开始转成 16 字节十六进制作为 IV。很多自研工具栽在 IV 上——没有传 IV 或传成了全零导致第一个分片解出来是乱的但后面几个分片看起来正常这个错位会让排查过程极其痛苦。解密后的分片再参与合并合并逻辑和未加密流完全一致。坑六视频下到一半服务器内存飙升进程被系统杀掉。现象是任务数量和分片数量都正常但内存曲线一路上涨。原因是实现里把所有分片先存在内存数组里最后一次性写入磁盘或一次性Buffer.concat。三百个分片平均每个 2MB同时存在内存里就是 600MB。解决方式就是本章代码里写的那种做法每个分片下载完立即writeFileSync落盘合并阶段交给 FFmpeg 从磁盘读取服务端内存占用稳定在几十 MB 级别。如果临时目录磁盘占用需要控制可以在任务结束后统一清理但不要为了省存储把数据留在内存里那是用自己的内存换磁盘不划算。5. 从脚本到在线工具任务队列、并发控制与进度回传一个命令行脚本能跑通离一个“在线工具”还差不少工程活。在线工具的本质是用户打开网页提交 m3u8 地址服务器在后台执行下载任务前端实时看到进度完成后拿到文件。这里最核心的三个设计点是任务状态模型、并发调度和进度回传。本章给出一个可以直接照搬的轻量工程方案。5.1 定义任务模型一个下载任务最少存什么任务模型是整个在线工具的地基状态字段设计得不好后面接前端、接数据库都会别扭。我一般用一个任务对象来贯穿全流程字段按下面的表来设计。字段类型说明taskIdstring任务唯一 ID前端轮询进度用m3u8Urlstring用户提交的播放列表地址statusstringpending / downloading / merging / done / failedtotalSegmentsnumber解析出的总分段数finishedSegmentsnumber已成功下载的分片数outputNamestring转封装后的文件名errorMessagestring失败时的原因返回给前端展示状态流转是pending - downloading - merging - done任何一步出错都落到failed并把errorMessage写清楚。为什么要把totalSegments和finishedSegments拆开存因为进度条的粒度需要精确到分片下载阶段每完成一个分片就更新一次finishedSegments前端能算出百分比合并阶段再把 status 切到 merging此时进度条显示为“正在合并”而不是卡死在 99%。5.2 用信号量控制并发别把分片队列一把梭分片下载的并发控制是新手最容易写错的地方。直观写法是Promise.all(segments.map(download))几百个分片一次性全部发起请求CDN 会把来源 IP 限流大量连接直接超时而且失败后完全不知道哪些分片没成功重试逻辑几乎没法写。正确的做法是用一个信号量把并发数钳制住固定几个 worker 循环从任务队列里取分片。class Semaphore { constructor(max) { this.max max; this.active 0; this.waiting []; } async take() { if (this.active this.max) { this.active 1; return; } // 超过并发上限时挂起等 release 唤醒 await new Promise(resolve this.waiting.push(resolve)); this.active 1; } release() { this.active - 1; const next this.waiting.shift(); if (next) next(); } } // 用法每个分片下载前 take下载完 release const sem new Semaphore(4); async function downloadWithLimit(segment) { await sem.take(); try { const buf await download(segment.url, headers); fs.writeFileSync(segmentFile(segment.index), buf); } finally { sem.release(); // 用 finally 保证失败也释放锁 } }take方法在并发数未满时直接计数并返回超过上限则把当前调用挂到一个等待队列里release在任务完成后把队列头部的一个等待者唤醒。这个模式的优点是把并发数硬编码成了上限无论分片总数是多少同时在途的 HTTP 请求不会超过 4 个。下载失败的单个分片先重试两次每次间隔 500ms 做指数退避连续失败的分片数超过总分片数的 5% 时终止整个任务因为这说明目标站点在拦截工具继续重试只会浪费时间。5.3 进度回传的两种做法轮询与推送在线工具的进度条实现方式就两类轮询和推送。轮询是小工具最省事的方案——后端提供一个查询接口前端每隔一秒拉一次任务状态推送更实时但需要额外维护 SSE 或 WebSocket 连接。对绝大多数下载工具来说轮询已经完全够用因为分片下载的粒度本身就是秒级的一秒钟的进度刷新足够平滑。async function pollTask(taskId) { while (true) { const res await fetch(/api/task/${taskId}); const data await res.json(); if (data.status done || data.status failed) { return data; // 任务结束跳出循环 } updateProgressBar(data.finishedSegments, data.totalSegments); await new Promise(resolve setTimeout(resolve, 1000)); } }轮询接口的响应体直接复用任务对象的全部字段前端拿到finishedSegments / totalSegments计算百分比。这里有一个小技巧updateProgressBar接收两个数字在 downloading 阶段用分片进度状态切到 merging 后进度条显示为不定态动画避免用户看到 99% 卡几分钟。SSE 推送适合用来做多用户任务列表的全局通知但需要考虑断线重连和服务端连接的释放小工具里收益不高我通常不推荐在第一个版本引入。6. 加密流与输出校验让工具再往前一步6.1 读 EXT-X-KEY 做 AES-128 解密很多实际场景里的 m3u8 并不只是防盗链那么简单EXT-X-KEY标签意味着分片内容是 AES-128 加密的。处理顺序是先从 key 地址下载 16 字节密钥文件再对每个分片解密最后把解密后的数据写入临时文件参与合并。解密用 Node 内置的 crypto 模块就够了关键点是 IV 的取值规则EXT-X-KEY标签带IV0x...就用标签值不带则以分片序号从 0 开始转成十六进制补足 16 字节。const crypto require(crypto); function decryptSegment(encryptedBuf, keyBuf, sequenceNumber) { // IV 按 HLS 规范取分片序号并转成 16 字节十六进制 const iv Buffer.alloc(16); iv.writeUInt32BE(sequenceNumber, 12); const decipher crypto.createDecipheriv(aes-128-cbc, keyBuf, iv); return Buffer.concat([decipher.update(encryptedBuf), decipher.final()]); }这里最隐蔽的坑是序号对齐。分片在 m3u8 列表里的顺序号是从 0 开始递增的解密时必须拿这个序号去生成 IV而不是拿文件在本地磁盘上的写入顺序。如果下载时为了并发把分片编号打乱了解密阶段也要用原始序号否则从第一个分片开始解出来的就是乱码。这类流在合并前最好先抽查三个分片解密后的数据如果能通过 FFmpeg 识别再做全量解密避免几百个分片全部解完才发现 IV 规则用错了。6.2 下载完别急着交付先做输出校验工具跑完不代表就可以把文件交给用户了。我现在的习惯是合并完成后先用 ffprobe 检查一遍输出文件再决定要不要走下载流程。校验要做三件事时长是否与 m3u8 里所有 EXTINF 之和基本一致误差超过 2% 说明有分片没下全是否存在视频流和音频流关键帧是否能正常定位。# 检查输出文件的时长和流信息 ffprobe -v error -show_entries formatduration -show_entries streamcodec_type,codec_name output.mp4看到 output 文件时长比预期少了十几秒第一反应不应该是怀疑转封装而是回去数一下临时目录里的分片数量通常能发现确有某几个分片虽然返回了 200但文件字节数明显小于正常值。这个教训是我在某个模拟项目 X 里用血泪换来的当时只顾着把分片下完、合并命令执行成功没做校验就把文件交付了对方反馈播到一半黑屏排查了大半天发现是第 17 号分片只写入了半截数据。从那以后输出校验成了我所有下载工具的标准动作宁可多等一次 ffprobe也不把残次品交出去。希望这个习惯能帮到你。本文还有配套的精品资源点击获取