Bilibili Video Downloader

Download videos from Bilibili (No Bangumi)

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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)
    })
}