敞开生长之旅!这是我参与「日新方案 12 月更文挑战」的第1天,点击查看活动详情

原因

最近有个重视的虚拟主播宣布要中止以虚拟主播身份活动而回归声优本业了,并且在之后会连续删除自己虚拟主播相关的视频,所以我想着把她的视频都缓存下来留作纪念。

尽管后来发现已经来不及了,她删得太快了!!!

原理

视频下载

具体也没什么难的其实,首要便是调用了b站的3个API

  • 经过mid获取视频列表:api.bilibili.com/x/space/arc…
  • 经过bv号或av号来获取cid:api.bilibili.com/x/player/pl…
  • 经过cid来获取视频下载地址:api.bilibili.com/x/web-inter…

咱们知道每个b站视频对应一个仅有av号和bv号,可是分P的视频几个视频也是同一个avbv号,因此实际上每个视频文件对应的还有一个cid,所以获取到cid就可以定位到具体要下载的每个视频的地址。

在获取cid时,假如有登录状况则可以缓存1080P视频,假如登录状况是大会员可以选择1080P+视频。因此假如要缓存1080P和1080P+(其实也便是高帧率的视频)时,需求传入一个cookie(1080P+的特别要求大会员账号的cookie)

读取控制台输入

因为需求接收控制台输入mid以及cookie,我封装了一个获取控制台输入的办法:

import { stdin } from "process";
export default function readLineSync(): Promise<string> {
    return new Promise<string>((resolve, reject) => {
        stdin.resume()
        stdin.setEncoding("utf8");
        stdin.on("data", function (chunk) {
          stdin.pause();
          resolve(chunk.toString().trim());// 把回车去掉
        });
    });
}

原理便是运用nodejs的stdin目标的resume()办法敞开监听,然后监听到data事件的时分中止监听,特别注意的是要运用trim()办法将换行去掉,不然之后判别时会出现不匹配的情况。

值得一提的是,假如运用vscode的调试功用进行调试的时分,是无法进入控制台的,因此也就无法输入,可是在调试控制台里运用stdin.push()办法可以模仿控制台输入:

运用nodejs缓存某个up主全部视频

实现

首先在终端中获取用户的输入以拿到要下载up主的mid:

console.log("请输入要下载的up的mid:")
let correctMid = false;
let mid: string;
while (!correctMid) {
    mid = await readLineSync()
    if (isNaN(Number(mid))) {
        console.error("mid格式不合法,请从头输入(mid为纯数字)");
    }
    else {
        correctMid = true;
    }
}

获取mid成功后进行该up主下视频列表的获取,假如可以获取到列表,则继续从控制台获取用户从控制台输入的cookie:

const videoList = await getVideoList(Number(mid))
if (videoList.length) {
    console.log(`找到${videoList.length}条视频`)
    console.warn("输入cookie可以缓存1080P视频(输入空则缓存1080P下最高画质):")
    const cookie:string = await readLineSync();
    for (let i = 0; i < videoList.length; i++) {
        await download(videoList[i], cookie)
    }
    process.exit();
}
else {
    console.log(`未找到该账号下任何视频`)
    process.exit()
}

之后对每个视频调用download办法履行下载,首先获取cid,并判别cid的数量(多P视频有多个),之后经过cid来获取到实在的视频地址:

let cid: Array<VideoInfo> = await getCid(vid);
if (cid.length === 1) {
    const url = await getVideoLink(vid, cid[0].cid, cookie);
    try {
        await downloadVideo(url, vid, cid[0].name);
        resolve(null);
    }
    catch (e) {
        logError(e)
        reject(e);
    }
}
else {
    console.log(`mutiple videos, there are ${cid.length} videos`)
    const errorList: Array<Error> = []
    for (let i = 0; i < cid.length; i++) {
        const url: string = await getVideoLink(vid, cid[i].cid, cookie);
        try {
            await downloadVideo(url, vid, cid[i].name, i + 1);
        }
        catch (e) {
            errorList.push(e);
        }
    }
    if (errorList.length) {
        logError(errorList)
        reject(errorList)
    }
    else {
        resolve(null);
    }
}
function getCid(vid: string): Promise<Array<VideoInfo>> {
    return new Promise((resolve, reject) => {
        axios
        .get(`${urlKey.getCid}?` + (isBv(vid) ? `bvid=${vid}` : `aid=${vid}`))
        .then(res => {
            if (!res.data.code) {
                resolve(res.data.data.pages.map((p, index) => new VideoInfo(p.cid, res.data.data.title + (index ? `-p${index}` : ''))));
            }
            else {
                reject(`get cid error, please check the validation of bvid or aid noriginal error message: ${res.data.message}`);
            }
        })
    })
}

获取视频实在地址的时分,记住将之前拿到的cookie传进去,之后在成果中获取第一个视频地址,假如cookie正确就可以下载到1080P的视频(假如存在的话):

function getVideoLink(vid: string, cid: string, cookie?: string): Promise<string> {
    return new Promise((resolve, reject) => {
        axios
        .get(`${urlKey.getVideoLink}?` + (isBv(vid) ? `bvid=${vid}` : `aid=${vid}`) + `&cid=${cid}`, cookie ? {
            headers: {
                Cookie: cookie
            }
        } : {})
        .then(res => {
            if (!res.data.code) {
                resolve(res.data.data.durl[0].url);
            }
            else {
                logError(res.data.message);
                reject(res.data.message);
            }
        })
    })
}

运用视频实在地址履行下载,下载前做了一次已存在文件的判别:

if (!alwayCoverExists) {
    const fileExist = fs.existsSync(`${downloadPath}${name}.mp4`)
    if (fileExist) {
        if (alwaySkipExists) {
            resolve(null);
            return;
        }
        let alreadyChoose = false;
        while (!alreadyChoose) {
            alreadyChoose = true;
            console.warn(`文件${name}.mp4已存在,是否覆盖?(N)否 (Y)是 (AN)一直否 (AY)一直是`);
            const coverable: string = await readLineSync();
            switch (coverable) {
                case 'N': case 'n':
                resolve(null);
                return;
                case 'Y': case 'y':
                break;
                case 'AN': case 'an':
                alwaySkipExists = true;
                resolve(null);
                return;
                case 'AY': case 'ay':
                alwayCoverExists = true;
                break;
                default:
                console.warn("命令无效,请从头输入")
                alreadyChoose = false;
                break;
            }
        }
    }
}

取得回来成果后,运用fscreateWriteStream目标进行文件写入,运用response.data目标的pipe办法。

【注意】这儿的接口验证了请求头的Referer,因此需求修改请求头

axios.get(url, {
    responseType: 'stream',
    headers: { 'Referer': "https://www.bilibili.com/video/" + (isBv(vid) ? `bvid=${vid}` : `aid=${vid}` + (index ? `&p=${index + 1}` : '')) }
}).then(res => {
    let writer = fs.createWriteStream(`${downloadPath}${name.replace(/[\/:*?"<>|]/,"")}.mp4`);
    writer.on('finish', () => {
        console.log(`downloadVideo name=${name} finished`)
        writer.close()
        resolve(null);
    });
    res.data.pipe(writer)
})

项目开源地址:github.com/zhzhch335/d…