likes
comments
collection
share

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

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

起因

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

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

原理

视频下载

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

我们知道每个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…

转载自:https://juejin.cn/post/7176906830776369212
评论
请登录