// ==UserScript== // @name 获取哔哩哔哩视频音频 // @description 下载音频(支持分P) // @license The Unlicense // @version 1.1.0 // @author girl-dream // @namespace https://github.com/girl-dream/ // @match https://www.bilibili.com/video/* // @icon https://www.bilibili.com/favicon.ico // @run-at document-end // ==/UserScript== (async () => { 'use strict' const dashCache = new Map() let data = __playinfo__?.data?.dash let dataCid = null if (!data) { throw new Error("未获取音频信息") } // ==================== 拦截切换分P/合集时返回的数据 ==================== const xhrOpen = XMLHttpRequest.prototype.open XMLHttpRequest.prototype.open = function () { const xhr = this const url = arguments[1] // 此处拦截切换合集返回的数据 if (typeof url === 'string' && url.includes('wbi/playurl')) { // playurl 的参数放在 query 上,顺手记下这次请求的是哪个分P let cid = null try { cid = Number(new URL(url, location.origin).searchParams.get('cid')) || null } catch { /* 解析失败就不缓存 */ } const responseTextGetter = Object.getOwnPropertyDescriptor( XMLHttpRequest.prototype, 'responseText' ) // 拦截responseText Object.defineProperty(xhr, 'responseText', { get: function () { const result = JSON.parse(responseTextGetter.get.call(this)) const dash = result?.data?.dash if (dash) { data = dash dataCid = cid if (cid) dashCache.set(cid, dash) } return JSON.stringify(result) } }) } return xhrOpen.apply(this, arguments) } const apiGet = async (url) => { const res = await fetch(url, { credentials: 'include' }) if (!res.ok) throw new Error('请求失败 HTTP ' + res.status) const json = await res.json() if (json.code !== 0) throw new Error(json.message || ('接口返回 code ' + json.code)) return json.data } const getBvid = () => location.pathname.match(/\/video\/(BV[\w]+)/)?.[1] // 视频信息(含分P列表),pages: [{ cid, page, part, duration }] const getVideoInfo = async () => { const bvid = getBvid() if (!bvid) throw new Error('未找到 bvid') const d = await apiGet('https://api.bilibili.com/x/web-interface/view?bvid=' + bvid) return { aid: d.aid, title: d.title, pages: d.pages ?? [] } } // 当前是第几个分P const getCurrentPage = () => Number(new URLSearchParams(location.search).get('p')) || 1 // 某个分P的 dash,优先复用已有数据,避免重复请求 const getDash = async (aid, cid) => { if (dashCache.has(cid)) return dashCache.get(cid) if (dataCid !== null && cid === dataCid) return data const query = new URLSearchParams({ avid: aid, cid: cid, qn: 127, fnver: 0, fnval: 4048, fourk: 1 }) const d = await apiGet('https://api.bilibili.com/x/player/playurl?' + query) dashCache.set(cid, d.dash) return d.dash } // ==================== 音频流 ==================== const AUDIO_NAMES = { 30216: '64K', 30232: '132K', 30280: '192K' } // 优先取备用地址里不带端口的,带端口的镜像在部分网络环境下不可用 const pickUrl = (item) => { if (!item) return null const list = (item.backupUrl ?? []).concat(item.baseUrl ?? []) return list.find(u => { try { return new URL(u).port === '' } catch { return false } }) ?? item.baseUrl ?? null } // dash -> [{ key, name, url }] const listAudios = (dash) => { if (!dash) return [] const audios = (dash.audio ?? []).map(a => ({ key: String(a.id), name: AUDIO_NAMES[a.id] ?? String(a.id), url: pickUrl(a) })) if (dash.dolby?.audio?.length) { audios.push({ key: 'dolby', name: '杜比音效', url: pickUrl(dash.dolby.audio[0]) }) } if (dash.flac?.audio) { audios.push({ key: 'flac', name: 'Hi-Res', url: pickUrl(dash.flac.audio) }) } return audios.filter(a => a.url) } // 取指定音质,该分P没有这个音质时退化成它自己的最高音质 const pickAudio = (dash, key) => { const audios = listAudios(dash) if (!audios.length) return null return audios.find(a => a.key === key) ?? audios[0] } const safeName = (name) => (name ?? '') .trim() .replaceAll(/[\\/:*?"<>|]/g, '_') .replaceAll(/\s+/g, ' ') .slice(0, 60) const formatProgress = (progress) => progress.total ? (progress.loaded / progress.total * 100).toFixed(2) + '%' : (progress.loaded / 1048576).toFixed(2) + 'MB' const download = (url, filename, onProgress) => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.open('GET', url, true) xhr.responseType = 'blob' xhr.onload = function () { if (xhr.status !== 200) { reject(new Error('下载失败,状态码:' + xhr.status)) return } const blob = new Blob([xhr.response]) const objectUrl = URL.createObjectURL(blob) const a = document.createElement('a') a.href = objectUrl a.download = filename a.click() URL.revokeObjectURL(objectUrl) resolve() } xhr.onerror = function () { reject(new Error('请求失败')) } xhr.onprogress = onProgress xhr.send() }) const isElementLoaded = async (selector, root = document) => { const getElement = () => root.querySelector(selector) return new Promise((resolve) => { const element = getElement() if (element) return resolve(element) const observer = new MutationObserver(() => { const element2 = getElement() if (!element2) return resolve(element2) observer.disconnect() }); observer.observe(root === document ? root.documentElement : root, { childList: true, subtree: true }) }) } // ==================== 弹窗 ==================== const pop = document.createElement('div') pop.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; z-index: 999999;` const content = document.createElement('div') content.style.cssText = ` background: var(--bg1_float); padding: 20px; border-radius: 8px; width: 340px; max-width: 90%; max-height: 80vh; overflow-y: auto; color:var(--text1);` content.innerHTML = `
下载音频
选择音频类型
` const select = content.querySelector('#quality') const partsBox = content.querySelector('#parts-box') const partsList = content.querySelector('#parts-list') const partsTip = content.querySelector('#parts-tip') // 当前视频信息,取不到分P列表时为 null,退化成只下载当前分P let videoInfo = null // videoInfo 对应的 bvid,同个视频重复打开弹窗时不重复请求 let videoBvid = null const renderAudios = (dash) => { select.replaceChildren() listAudios(dash).forEach(audio => { const op = document.createElement('option') op.value = audio.key op.textContent = audio.name select.append(op) }) } const renderParts = () => { const current = getCurrentPage() partsList.replaceChildren() videoInfo.pages.forEach(page => { const row = document.createElement('label') row.style.cssText = ` display: flex; align-items: center; gap: 6px; padding: 4px 8px; cursor: pointer; font-size: 13px; color: var(--text1);` const box = document.createElement('input') box.type = 'checkbox' box.value = page.cid box.checked = page.page === current box.style.cssText = 'flex: none; margin: 0;' const text = document.createElement('span') text.textContent = `P${page.page} ${page.part}` text.title = page.part text.style.cssText = 'overflow: hidden; text-overflow: ellipsis; white-space: nowrap;' row.append(box, text) partsList.append(row) }) partsTip.textContent = `共 ${videoInfo.pages.length} 个分P(可多选,默认当前P)` partsBox.style.display = '' } const openPop = async () => { pop.remove() pop.append(content) document.body.append(pop) renderAudios(data) partsBox.style.display = 'none' partsList.replaceChildren() partsTip.textContent = '正在获取分P列表...' // 同一个视频直接复用已经取到的分P列表,不再重复请求 if (videoInfo && videoBvid === getBvid()) { renderParts() return } try { videoInfo = await getVideoInfo() videoBvid = getBvid() // 页面内联的播放数据对应的就是 url 上的当前分P,首次进来时确定它的归属 if (dataCid === null) dataCid = videoInfo.pages[getCurrentPage() - 1]?.cid ?? null renderParts() } catch (e) { // 拿不到分P列表就退化成只下载当前分P,不影响原有使用 console.error('获取分P列表失败:', e) videoInfo = null videoBvid = null } } content.querySelector('#cancel-btn').onclick = () => { pop.remove() } content.querySelector('#parts-toggle').onclick = () => { const boxes = [...partsList.querySelectorAll('input[type=checkbox]')] const allChecked = boxes.every(b => b.checked) boxes.forEach(b => { b.checked = !allChecked }) } content.querySelector('#confirm-btn').onclick = async () => { const qualityKey = select.value // 选中的分P,拿不到分P列表时退化成当前分P const targets = videoInfo?.pages?.length ? videoInfo.pages.filter(p => [...partsList.querySelectorAll('input[type=checkbox]:checked')] .some(b => Number(b.value) === p.cid)) : [null] if (!targets.length) { alert('请至少选择一个分P') return } const title = videoInfo?.title || document.querySelector('.video-title')?.textContent.trim() || 'audio' const multi = (videoInfo?.pages?.length ?? 0) > 1 const total = targets.length let failed = 0 pop.remove() for (let i = 0; i < total; i++) { const page = targets[i] const name = `P${page?.page ?? getCurrentPage()}` const prefix = total > 1 ? `(${i + 1}/${total}) ` : '' progressDiv.textContent = `${prefix}正在获取 ${name} 的音频信息...` let dash try { dash = page ? await getDash(videoInfo.aid, page.cid) : data } catch (e) { console.error(`${name} 获取音频信息失败:`, e) progressDiv.textContent = `${prefix}${name} 获取音频信息失败` failed++ continue } const audio = pickAudio(dash, qualityKey) if (!audio) { console.error(`${name} 没有可用的音频流`) progressDiv.textContent = `${prefix}${name} 没有可用的音频流` failed++ continue } const filename = multi ? `${safeName(title)}_P${page.page}_${safeName(page.part)}.m4s` : `${safeName(title)}.m4s` // 该分P没有所选音质时会自动降级,在进度里标出来 const degrade = audio.key === qualityKey ? '' : `(${audio.name})` try { await download(audio.url, filename, (progress) => { progressDiv.textContent = `${prefix}${name} ${degrade}${formatProgress(progress)}` }) } catch (e) { console.error(`${name} 下载失败:`, e) progressDiv.textContent = `${prefix}${name} 下载失败` failed++ continue } // 多个文件连续下载时留点间隔,避免被浏览器拦截 if (i < total - 1) await new Promise(r => setTimeout(r, 300)) } progressDiv.textContent = failed ? `完成 ${total - failed}/${total} 个音频,${failed} 个失败(详见控制台)` : (total > 1 ? `已下载 ${total} 个音频` : null) } // ==================== 工具栏按钮 ==================== const toolbarLeft = document.querySelector('.video-toolbar-left-main') const progressDiv = document.createElement('div') progressDiv.style.color = 'var(--text2)' progressDiv.style.fontSize = '12px' const div = document.createElement('div') div.className = 'video-toolbar-right-item' div.title = '下载音频' div.innerHTML = '' div.style.cssText = ` width: 35px; height: 35px; margin-right: 18px;` div.addEventListener('click', () => { openPop() }) await Promise.all([isElementLoaded('.nav-search-input[maxlength]'), isElementLoaded('.view-icon[width]')]) toolbarLeft.append(div, progressDiv) })();