// ==UserScript==
// @name Bilibili Video Downloader
// @name:zh 哔哩哔哩视频下载器
// @namespace https://github.com/jc3213/userscript
// @version 1.8.2
// @description Download videos from Bilibili (No Bangumi)
// @description:zh 下载哔哩哔哩视频(不支持番剧)
// @author jc3213
// @match *://www.bilibili.com/video/*
// @match *://www.bilibili.com/v/*
// @grant GM_download
// @run-at document-idle
// @downloadURL none
// ==/UserScript==
let {autowide = '0', videocodec = '0'} = localStorage;
let bvWatch = location.pathname;
let bvTitle;
let bvPlayer;
let bvArchive;
let bvKey;
let bvOffset;
let bvMenu;
let bvNow;
let wideBtn;
let wideStat;
let bvOpen = true;
let history = {};
let archive;
let format = {
'30280': {text: '音频 高码率', ext: '.192k.m4a'},
'30232': {text: '音频 中码率', ext: '.128k.m4a'},
'30216': {text: '音频 低码率', ext: '.64k.m4a'},
'127': {text: '8K 超高清', ext: '.8k.mp4'},
'125': {text: '4K 超清+', ext: '.4k+.mp4'},
'120': {text: '4K 超清', ext: '.4k.mp4'},
'116': {text: '1080P 60帧', ext: '.1080f60.mp4'},
'112': {text: '1080P 高码率', ext: '.1080+.mp4'},
'80': {text: '1080P 高清', ext: '.1080.mp4'},
'74': {text: '720P 60帧', ext: '.720f60.mp4'},
'64': {text: '720P 高清', ext: '.720.mp4'},
'32': {text: '480P 清晰', ext: '.480.mp4'},
'16': {text: '360P 流畅', ext: '.360.mp4'},
'15': {text: '360P 流畅', ext: '.360-.mp4'},
'avc1': {title: '视频编码: H.264', alt: 'h264', type: 'video'},
'hvc1': {title: '视频编码: HEVC 增强', alt: 'h265', type: 'video'},
'hev1': {title: '视频编码: HEVC', alt: 'h265', type: 'video'},
'av01': {title: '视频编码:AV1', alt: 'av1', type: 'video'},
'mp4a': {title: '音频编码: AAC', alt: 'aac', type: 'audio'}
};
let bvHandler = bvWatch.match(/^\/(v(?:ideo)?)\//)?.[1];
switch (bvHandler) {
case 'video':
bvPlayer = true;
bvKey = 'data';
bvOffset = 'left: -300px;';
bvMenu = 'div.video-toolbar-left';
wideBtn = 'div.bpx-player-ctrl-wide';
wideStat = 'bpx-state-entered';
bvNow = 'li.bpx-state-multi-active-item';
break;
case 'v':
bvArchive = true;
bvKey = 'data';
bvOffset = 'left: -300px;';
bvMenu = 'div.select-type > ul.type';
wideBtn = 'div.bilibili-player-video-btn-widescreen';
wideStat = 'closed';
bvNow = 'div.select-type > ul.type > li.active';
break;
default:
bvKey = 'result';
bvOffset = 'left: -400px; top: -6px;';
bvMenu = 'div.toolbar > div.toolbar-left';
wideBtn = 'div.bpx-player-ctrl-wide';
wideStat = 'bpx-state-entered';
bvNow = '[class*="numberListItem_select"]';
};
window.addEventListener('play', async function biliVideoToolbar() {
let wide = await PromiseSelector(wideBtn);
let menu = await PromiseSelector(bvMenu);
if (!wide.classList.contains(wideStat) && localStorage.autowide === '1' ) {
wide.click();
}
menu.after(mainPane, cssPane);
window.removeEventListener('play', biliVideoToolbar);
}, true);
let menuItem = document.createElement('div');
menuItem.className = 'bili_video_button';
let mainPane = document.createElement('div');
mainPane.id = 'bili_video_main';
mainPane.innerHTML = `
自动宽屏
编码格式
`;
let [menuPane, optionsPane, analysePane] = mainPane.children;
let codecHandlers = {
'0': 'bili_video_l264',
'1': 'bili_video_l265',
'2': 'bili_video_lav1'
}
function biliVideoTitle(name) {
let multi = document.querySelector(bvNow)?.textContent?.trim();
name = multi ? `${name}-${multi}` : name;
bvTitle = name.trim().replace(/[\/\\:*?"<>|\s\r\n]/g, '_');
}
function biliVideoThumb(url) {
let thumb = menuItem.cloneNode(true);
thumb.classList.add('bili_video_thumb');
thumb.textContent = '视频封面';
thumb.url = url.replace(/^(https?:)?\/\//, 'https://');
thumb.file = bvTitle + url.slice(url.lastIndexOf('.'));
analysePane.appendChild(thumb);
}
async function biliVideoExtractor(vid, playurl) {
if (history[vid]) {
analysePane.innerHTML = '';
analysePane.append(...history[vid]);
} else {
let response = await fetch('https://api.bilibili.com/' + playurl + '&fnval=4050', {credentials: 'include'});
let json = await response.json();
let items = [];
let {video, audio} = json[bvKey]?.dash ?? {video: [], audio: []};
[...video, ...audio].forEach((a) => {
let {id, codecs, baseUrl} = a;
let codec = codecs.slice(0, codecs.indexOf('.'));
console.log(codec, id, a);
let {text, ext} = format[id];
let {title, alt, type} = format[codec];
let menu = menuItem.cloneNode(true);
menu.classList.add('bili_video_' + type, 'bili_video_' + alt);
menu.textContent = text;
menu.title = title;
menu.url = baseUrl;
menu.file = bvTitle + ext;
items.push(menu);
analysePane.appendChild(menu);
});
history[vid] = items;
}
analysePane.className = analysePane.className.replace(/\s?bili_video_l\w+/, '') + ' ' + codecHandlers[videocodec];
}
function biliVideoOptions() {
optionsPane.classList.toggle('bili_video_hidden');
analysePane.classList.add('bili_video_hidden');
}
function biliVideoAnalyze() {
optionsPane.classList.add('bili_video_hidden');
analysePane.classList.toggle('bili_video_hidden');
if (bvOpen || videocodec !== localStorage.videocodec) {
bvOpen = false;
videocodec = localStorage.videocodec;
analysePane.innerHTML = '';
if (bvPlayer) {
let {title, pic, aid, cid} = document.defaultView.__INITIAL_STATE__.videoData;
biliVideoTitle(title);
biliVideoThumb(pic);
biliVideoExtractor(cid, 'x/player/playurl?avid=' + aid + '&cid=' + cid);
}
else if (bvArchive) {
let {aid, cid} = document.defaultView;
biliVideoTitle(document.querySelector('div.match-info-title').textContent);
biliVideoExtractor(cid, 'x/player/playurl?avid=' + aid + '&cid=' + cid);
}
else {
let {name, thumbnailUrl} = JSON.parse(document.head.querySelector('script[type]').textContent).itemListElement[0];
let id = document.defaultView.__playinfo__.result.play_view_business_info.episode_info.ep_id;
biliVideoTitle(name);
biliVideoThumb(thumbnailUrl[0]);
biliVideoExtractor(id, `pgc/player/web/playurl?ep_id=${id}`);
}
}
}
menuPane.addEventListener('click', (event) => {
let {id} = event.target;
if (!id) {
return;
}
switch (id) {
case 'bili_video_optbtn':
biliVideoOptions();
break;
case 'bili_video_anabtn':
biliVideoAnalyze();
break;
}
});
optionsPane.addEventListener('change', (event) => {
localStorage[event.target.name] = event.target.value;
});
analysePane.addEventListener('click', (event) => {
let {altKey, target: {url, file}} = event;
if (url && file) {
if (altKey) {
var urls = [{url, options: {out: file, referer: location.href} }];
window.postMessage({aria2c: 'aria2c_jsonrpc_call', params: urls});
}
else {
GM_download({url, responseType: 'blob', headers: {referer: location.href}, name: file});
}
}
});
let [, optionWide,, optionCodec] = optionsPane.children;
optionWide.value = autowide;
optionCodec.value = videocodec;
let cssPane = document.createElement('style');
cssPane.textContent = `
#bili_video_main {font-size: 16px; position: relative; text-align: center; padding-right: 5px; line-height: 28px; z-index: 9999999; ${bvOffset}}
#bili_video_menu {display: flex; gap: 5px;}
.bili_video_button {border: outset 1px #000; padding: 3px; background-color: #c26; color: #fff; cursor: pointer; width: 100px;}
.bili_video_button:hover {filter: contrast(80%);}
.bili_video_button:active {filter: contrast(60%); border-style: inset;}
.bili_video_pane {position: absolute; top: 0px; left: 100%; background-color: #fff; border: solid 1px #000; padding: 5px;}
.bili_video_pane > h4, .bili_video_pane > select {width: 110px !important; padding: 5px; text-align: center;}
.bili_video_pane > h4 {color: #c26; font-weight: bold; margin: auto;}
.bili_video_result {display: grid; grid-template-columns: 1fr 1fr 1fr; grid-auto-flow: dense; gap: 5px;}
.bili_video_thumb {grid-column: 1;}
.bili_video_video {grid-column: 2;}
.bili_video_audio {grid-column: 3;}
.bili_video_hidden {display: none;}
.bili_video_l264 > .bili_video_video:not(.bili_video_h264), .bili_video_l265 > .bili_video_video:not(.bili_video_h265), .bili_video_lav1 > .bili_video_video:not(.bili_video_av1) {display: none;}
`;
new MutationObserver(mutations => {
if (bvWatch !== location.pathname) {
bvWatch = location.pathname;
bvOpen = true;
optionsPane.classList.add('bili_video_hidden');
analysePane.classList.add('bili_video_hidden');
}
}).observe(document.head, {childList: true});
function PromiseSelector(text) {
return new Promise((resolve, reject) => {
let time = 15;
let t = setInterval(() => {
let node = document.querySelector(text);
if (node) {
clearInterval(t);
resolve(node);
} else if (--time === 0) {
clearInterval(t);
reject();
}
}, 200);
});
}