likes
comments
collection
share

NodeJS视频爬虫实战(M3U8)

作者站长头像
站长
· 阅读数 5

NodeJS视频爬虫实战(M3U8)

背景

相信不少同学会遇到类似这样的一些场景或需求:

  1. 观看一些学习视频时,经常会因为网络原因一卡一卡的,想要下载下来看但大部分网站没有提供下载方法

  2. 想要将一些视频资源网站上开放的视频收集下载下来用于搭建自己的视频资源库。但由于这些网站基本都是通过实时推流的方式播放视频的,拿不到实际的视频地址,无从下载。

我也是经常遇到上述问题,因此,就决定想办法搞一个自动化爬虫工具,能够自动的将一些资源网站上的流视频批量下载下来。

资料收集

        既然要开发自动爬虫工具,那么,我们首先得了解你要爬取的目标网站或资源。我们当前的目标是想要自动化的爬取一些学习网站或视频资源网站上的视频流,并下载下来。那么我们就得清楚,这些网站上的视频流都是怎样的格式。

        经过观察,发现诸如「爱奇艺」「优酷」等视频网站,都是采用m3u8格式的视频索引文件用于索引整个视频所有的片段,那么,我们是否能够通过解析m3u8文件,并转换成可本地播放的视频格式,如mp4下载到本地呢?答案当然是可以的。这需要用到或了解的一些技术或工具,我们下面细说。

相关专业名词示意

  • M3U8:

            m3u8是苹果公司推出的视频播放标准,是m3u的一种,只是编码格式采用的是UTF-8。

      m3u8准确来说是一种索引文件,使用m3u8文件实际上是通过它来解析对应的放在服务器上的视频网络地址,从而实现在线播放。使用m3u8格式文件主要因为可以实现多码率视频的适配,视频网站可以根据用户的网络带宽情况,自动为客户端匹配一个合适的码率文件进行播放,从而保证视频的流畅度。

  • TS:

    ts是日本高清摄像机拍摄下进行的封装格式,全称为MPEG2-TS。ts即"Transport Stream"的缩写。MPEG2-TS格式的特点就是要求从视频流的任一片段开始都是可以独立解码的。

    在m3u8索引文件中的视频片段就是采用的ts格式的片段

  • FFMPEG:

    FFmpeg是一套可以用来记录、转换数字音频、视频,并能将其转化为流的开源计算机程序。采用LGPL或GPL许可证。它提供了录制、转换以及流化音视频的完整解决方案。它包含了非常先进的音频/视频编解码库libavcodec,为了保证高可移植性和编解码质量,libavcodec里很多code都是从头开发的

  • puppeteer:

    Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome。Puppeteer 默认以 headless 模式运行,但是可以通过修改配置文件运行“有头”模式。

    我们通常使用这个工具来开发爬虫程序或自动化测试工具,能让我们更加灵活,更加贴近真实用户操作的执行程序

实现思路

爬取目标M3U8文件

既然我们已经知道了很多在线视频网站和视频学习网站的视频好多是采用m3u8格式的索引文件来索引视频片段的,那么我们首要的目标就是先从目标网站将m3u8的链接或者文本内容爬取下来。

由于大部分网站都是需要登录的,为了方便爬取,我们借助: puppeteer进行访问,没有登录权限时只需要在其中选择用户名和密码输入框,在无头浏览器中登录即可获取网站的访问权限(这类流程这里不多说了,网上很多教程,可自行搜索)。

有了登录权限之后,我们就可以观察视频播放页面是如何返回m3u8索引文件的,有一些是直接在详情接口返回m3u8文件url,有一些则是直接返回一个文本,安全度更高的,会将返回的m3u8文本进行加密(加密的这种由于涉及不同的加密算法,解密方式不一,但一定可以解密,不然页面也无法播放,因此可以根据不同网站观察其源码分析解密算法)。

当我们得到了一个m3u8文件url后,就可以借助这个url下载视屏了。如果分析出来的是m3u8是文本,则可以通过爬虫工具爬取并保存在本地,之后用于下载使用。

以下为本人爬取某个视屏学习网站的部分代码,代码仅供参考:

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
        })
        // 监听页面上的接口返回事件,里面就包含了我们想要爬取的一些信息,如视频名称、m3u8
        page.on('response', async res => {
            // 响应url
            const 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,用户获取视频详情时传的token
        const { 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, '');
        // 目标视频id
        const videoId = content[0].boot_params.media_id;
        // 更具视频id和token获取当前视频的m3u8所以文件url
        const { 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()}`);
    // 遍历所有分组获取所有视频详情id
    chapter_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();

生成MP4文件

借助于ffmpeg,我们要将一个m3u8转换成可本地播放的mp4文件就非常简单了,直接执行一下命令即可(ffmpeg安装详见:ffmpeg安装教程):

# 可传入本地m3u8文件路径或远程url
ffmpeg -i "m3u8索引文件url" -c copy "视屏.mp4"

结语

至此,我们就已经将目标网站上的视频下载到本地了。当然不同的网站可能有不同的规则,实际还需要大家根据要爬取视频的不同网站稍微修改爬取的逻辑,但大体流程总结一下是:

  1. 确定目标网站视频格式(如果是可直接播放的格式,如:mp4、rmvb等,就无需转换了,如果是m3u8,那么我们就借助ffmpeg进行转换)

  2. 分析网页数据格式,使用puppeteer按照规则爬取页面内容(需要注意网站的权限校验或token等,可以在浏览器中登录后复制cookie或token到无头浏览器中使用)

  3. 爬取了目标的视频链接或m3u8链接之后,执行下载任务将目标视频下载下来即可。