// ==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 = `