// ==UserScript==
// @name Bilibili Video Downloader
// @name:zh-CN 哔哩哔哩视频下载器
// @description Download videos from Bilibili (No Bangumi)
// @description:zh-CN 下载哔哩哔哩视频(不支持番剧)
// @author jc3213
// @namespace https://github.com/jc3213/userscript
// @supportURL https://github.com/jc3213/userscript/issues
// @homepageURL https://github.com/jc3213/userscript
// @license MIT
// @match https://www.bilibili.com/video/*
// @match https://www.bilibili.com/v/*
// @icon https://i0.hdslb.com/bfs/static/jinkela/long/images/512.png
// @grant GM_download
// @run-at document-idle
// @compatible chrome
// @compatible firefox
// @compatible edge
// @compatible opera
// @compatible safari
// @compatible kiwi
// @compatible qq
// @compatible via
// @compatible brave
// @version 2025.6.2.1
// ==/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 = `
<div id="bili_video_menu">
<div id="bili_video_optbtn" class="bili_video_button">设置</div>
<div id="bili_video_anabtn" class="bili_video_button">解析</div>
</div>
<div id="bili_video_options" class="bili_video_pane bili_video_hidden">
<h4>自动宽屏</h4>
<select name="autowide">
<option value="0">关闭</option>
<option value="1">启用</option>
</select>
<h4>编码格式</h4>
<select name="videocodec">
<option value="0">H.264</option>
<option value="1">HEVC</option>
<option value="2">AV-1</option>
</select>
</div>
<div id="bili_video_analyse" class="bili_video_pane bili_video_result bili_video_hidden"></div>
`
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]/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)
})
}