
相信不少同学会遇到类似这样的一些场景或需求:
1. 观看一些学习视频时,经常会因为网络原因一卡一卡的,想要下载下来看但大部分网站没有提供下载方法
2. 想要将一些视频资源网站上开放的视频收集下载下来用于搭建自己的视频资源库。但由于这些网站基本都是通过实时推流的方式播放视频的,拿不到实际的视频地址,无从下载。
我也是经常遇到上述问题,因此,就决定想办法搞一个自动化爬虫工具,能够自动的将一些资源网站上的流视频批量下载下来。
m3u8格式的视频索引文件用于索引整个视频所有的片段,那么,我们是否能够通过解析m3u8文件,并转换成可本地播放的视频格式,如mp4下载到本地呢?答案当然是可以的。这需要用到或了解的一些技术或工具,我们下面细说。m3u8是苹果公司推出的视频播放标准,是m3u的一种,只是编码格式采用的是UTF-8。 m3u8准确来说是一种索引文件,使用m3u8文件实际上是通过它来解析对应的放在服务器上的视频网络地址,从而实现在线播放。使用m3u8格式文件主要因为可以实现多码率视频的适配,视频网站可以根据用户的网络带宽情况,自动为客户端匹配一个合适的码率文件进行播放,从而保证视频的流畅度。
ts是日本高清摄像机拍摄下进行的封装格式,全称为MPEG2-TS。ts即"Transport Stream"的缩写。MPEG2-TS格式的特点就是要求从视频流的任一片段开始都是可以独立解码的。 在m3u8索引文件中的视频片段就是采用的ts格式的片段。
FFmpeg是一套可以用来记录、转换数字音频、视频,并能将其转化为流的开源计算机程序。采用LGPL或GPL许可证。它提供了录制、转换以及流化音视频的完整解决方案。它包含了非常先进的音频/视频编解码库libavcodec,为了保证高可移植性和编解码质量,libavcodec里很多code都是从头开发的。
Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome。Puppeteer 默认以 headless 模式运行,但是可以通过修改配置文件运行“有头”模式。 我们通常使用这个工具来开发爬虫程序或自动化测试工具,能让我们更加灵活,更加贴近真实用户操作的执行程序
const puppeteer = require("puppeteer");const { resolve } = require("path");const { writeJSONSync, readJSONSync, existsSync } = require('fs-extra');const child_process = require('child_process');// 本地安装的ffmpeg目录const ffmpeg = "/Users/tangwenhui/kiner/software/ffmpeg2";// 视屏下载目录const outputPath = "/Users/tangwenhui/kiner/learning/xxxx";// 如果网站需要登录,你也可以将你在网站登录后的cookies复制下来,注入到无头浏览器中,这样就不需要再无头浏览器中额外再登录一次了const cookieArr = "name=testCookies".split(";");// 课程id(你需要爬取的目标课程的id)const courseId = "210945";// 有些网站发起接口请求时还需要额外携带token信息在请求头,根据实际需要是否配置const authorization = "Bearer xxxxx";// 将cookies处理成对象形式,方便后续注入const cookies = cookieArr.map(item => {const row = item.trim();const [name, value] = row.split("=");return {name,value}});// 工具方法,用于等待一定的时长async function wait(delay = 1000) {return new Promise(resolve => {setTimeout(() => {resolve();}, delay);});;}// 创建一个无头浏览器实例const browser = await puppeteer.launch({headless: true});// 在无头浏览器中发起接口请求async function getApiRes(url) {return new Promise(async resolve => {// 在无头浏览器中新建一个标签页const page = await browser.newPage();// 未新开的标签也设置cookie和通用请求头page.setCookie(...cookies);page.setExtraHTTPHeaders({authorization})// 监听页面上的接口返回事件,里面就包含了我们想要爬取的一些信息,如视频名称、m3u8page.on('response', async res => {// 响应urlconst curUrl = res.url();// 如果响应的url就是我们目标接口的url,就说明接收到了目标接口的返回了if (curUrl === url) {// 获取返回结果const data = await res.json();// 关闭当前标签页await page.close();// 回传接口响应resolve(data);}});// 访问目标接口await page.goto(url);});}/*** 根据id获取单个视频的m3u8文件并执行下载任务* @param {*} id* @returns*/async function openVideoPage(id) {return new Promise(async resolve => {// 获取token,用户获取视频详情时传的tokenconst { data: { access_token } } = await getApiRes("https://xxxx.com/access_token")// 获取课程基础内容,包括视频id和视频名称const { data: { content, content_title } } = await getApiRes(`https://xxx.com?content_id=${id}`);// 删除视频名称中的空格符const name = content_title.replace(/\s*/g, '');// 目标视频idconst videoId = content[0].boot_params.media_id;// 更具视频id和token获取当前视频的m3u8所以文件urlconst { data: { mediaMetaInfo: { videoGroup } } } = await getApiRes(`https://xxxx.com?mediaId=${videoId}&accessToken=${access_token}`);const playUrl = videoGroup[0].playURL;const info = {url: playUrl,name: name};// 执行下载任务downloadMp4ByM3U8(playUrl, name);resolve(info);})}/*** 执行批量下载任务* @param {*} arr* @param {*} m3u8Urls* @param {*} total* @returns*/async function openVideoPages(arr, m3u8Urls = [], total) {if (arr.length === 0) {console.log("所有视频下载完毕");return m3u8Urls;}const curId = arr.shift();if (curId) {console.log(`当前进度:${total - arr.length}/${total}`);const info = await openVideoPage(curId);m3u8Urls.push(info);return openVideoPages(arr, m3u8Urls, total);}}// 根据m3u8执行下载任务function downloadMp4ByM3U8(url, name) {name = name.replace(/\s*/g, '');console.log(`[${name}]开始下载--->`, url);// ffmpeg -i "m3u8索引文件url" -c copy "视屏.mp4"child_process.exec(`${ffmpeg} -i "${url}" -c copy ${resolve(outputPath, name)}.mp4`);}// 执行下载主函数async function doStart() {// 获取目标课程的所有视频分组信息const { data: { chapter_list } } = await getApiRes(`https://xxxx?course_id=${courseId}&__timestamp=${Date.now()}`);// 遍历所有分组获取所有视频详情idchapter_list.forEach(async item => {const groupId = item.chapter_id;// 根据课程id和分组id获取视频的详情id列表const { data: { section_list } } = await getApiRes(`https://xxxx?course_id=${courseId}&chapter_id=${groupId}&__timestamp=${Date.now()}`);const contentList = section_list.map(item => item.group_list.map(item => item.content_list.map(item => item.content).reduce((a, b) => [...a, ...b], [])).reduce((a, b) => [...a, ...b], [])).reduce((a, b) => [...a, ...b], []).filter(item => !!item.content_id);// 根据视频详情id列表开始批量下载任务await openVideoPages(contentList.map(item => item.content_id), [], contentList.length);});}doStart();
ffmpeg,我们要将一个m3u8转换成可本地播放的mp4文件就非常简单了,直接执行一下命令即可:# 可传入本地m3u8文件路径或远程urlffmpeg -i "m3u8索引文件url" -c copy "视屏.mp4"
1. 确定目标网站视频格式(如果是可直接播放的格式,如:mp4、rmvb等,就无需转换了,如果是m3u8,那么我们就借助ffmpeg进行转换)
2. 分析网页数据格式,使用`puppeteer`按照规则爬取页面内容(需要注意网站的权限校验或token等,可以在浏览器中登录后复制cookie或token到无头浏览器中使用)
3. 爬取了目标的视频链接或m3u8链接之后,执行下载任务将目标视频下载下来即可。

「作者简介」
汤文辉(KinerTang)
网易有道资深前端开发工程师,大前端与前端智能化道路上的探索者。
开源项目“AntDesign/ProComponent”的贡献者之一。目前负责有道素质教育板块相关工作。

文章评论