Greasy Fork

Greasy Fork is available in English.

哔哩哔哩视频下载器

下载哔哩哔哩视频(不支持番剧)

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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)
    })
}