// ==UserScript==
// @name 哔哩视频下载
// @namespace http://tampermonkey.net/
// @version 2026.4.25.211634
// @description B站视频流下载工具(DASH 视频+音频分流)
// @author yinzhenyu
// @homepage https://github.com/yinzhenyu-su/skills
// @match https://www.bilibili.com/video/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=bilibili.com
// @grant GM_download
// @grant GM_xmlhttpRequest
// @grant GM_setClipboard
// @grant unsafeWindow
// @run-at document-idle
// @license MIT
// @downloadURL https://update.greasyfork.icu/scripts/575356/%E5%93%94%E5%93%A9%E8%A7%86%E9%A2%91%E4%B8%8B%E8%BD%BD.user.js
// @updateURL https://update.greasyfork.icu/scripts/575356/%E5%93%94%E5%93%A9%E8%A7%86%E9%A2%91%E4%B8%8B%E8%BD%BD.meta.js
// ==/UserScript==
(function () {
'use strict';
const pageWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
let host = null;
let titleObserver = null;
let latestPlayInfo = null;
let latestPlayInfoMeta = null;
let activePlayinfoSignature = null;
function cleanup() {
if (titleObserver) { titleObserver.disconnect(); titleObserver = null; }
if (host) { host.remove(); host = null; }
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function getInitialHostPosition() {
return {
left: Math.max(window.innerWidth - 156, 16),
top: 16,
};
}
function mountHost() {
if (!host || host.isConnected)
return;
document.body.appendChild(host);
}
function init() {
const playInfo = getMatchedPlayInfo();
if (!playInfo) {
console.warn('[bili-dl] No matched playinfo found for current page');
return;
}
cleanup();
activePlayinfoSignature = getPlayinfoSignature(playInfo);
run(playInfo);
}
function getVideoTitle() {
const raw = document.title.replace(/_哔哩哔哩_.*$/, '').replace(/\s*[-|]\s*哔哩哔哩.*$/, '').trim() || 'video';
return raw.replace(/[/\\:*?"<>|]/g, '_');
}
function dlFile(url, filename) {
console.log('[bili-dl] download:', filename, url);
GM_xmlhttpRequest({
method: 'GET',
url,
headers: {
Referer: 'https://www.bilibili.com',
Origin: 'https://www.bilibili.com',
},
responseType: 'blob',
onload: (res) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(res.response);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 1000);
},
onerror: (err) => {
console.error('[bili-dl] download error', err);
window.open(url, '_blank');
},
});
}
function getCurrentPageInfo() {
const state = pageWindow.__INITIAL_STATE__ || {};
const pathnameMatch = location.pathname.match(/\/video\/([^/?]+)/);
const search = new URLSearchParams(location.search);
const pageNo = Number.parseInt(search.get('p') || '1', 10) || 1;
const pages = state.videoData && Array.isArray(state.videoData.pages) ? state.videoData.pages : [];
const matchedPage = pages.find((item) => item.page === pageNo);
return {
bvid: pathnameMatch ? pathnameMatch[1] : (state.bvid || ''),
cid: matchedPage ? String(matchedPage.cid) : (state.cid ? String(state.cid) : ''),
pageNo,
};
}
function extractPlayinfoMeta(playInfo, requestUrl = '') {
const data = playInfo && playInfo.data;
const url = new URL(requestUrl || location.href, location.origin);
return {
bvid: url.searchParams.get('bvid') || '',
cid: data && data.cid ? String(data.cid) : (url.searchParams.get('cid') || ''),
};
}
function isPlayinfoPayload(payload) {
return !!(payload && payload.code === 0 && payload.data && (payload.data.dash || payload.data.durl));
}
function getMatchedPlayInfo() {
const current = getCurrentPageInfo();
if (latestPlayInfo && isPlayInfoForCurrentPage(latestPlayInfoMeta, current))
return latestPlayInfo;
const pagePlayInfo = pageWindow.__playinfo__;
if (!isPlayinfoPayload(pagePlayInfo))
return null;
if (activePlayinfoSignature === null)
return pagePlayInfo;
const meta = extractPlayinfoMeta(pagePlayInfo);
return isPlayInfoForCurrentPage(meta, current) ? pagePlayInfo : null;
}
function isPlayInfoForCurrentPage(meta, current = getCurrentPageInfo()) {
if (!meta)
return false;
if (current.cid && meta.cid)
return current.cid === meta.cid;
if (current.bvid && meta.bvid)
return current.bvid === meta.bvid;
return false;
}
function storePlayInfo(playInfo, requestUrl = '') {
if (!isPlayinfoPayload(playInfo))
return;
latestPlayInfo = playInfo;
latestPlayInfoMeta = extractPlayinfoMeta(playInfo, requestUrl);
if (isPlayInfoForCurrentPage(latestPlayInfoMeta)) {
const signature = getPlayinfoSignature(playInfo);
if (signature && signature !== activePlayinfoSignature) {
console.log('[bili-dl] playurl updated, refreshing panel...');
init();
}
}
}
function getPlayinfoSignature(playInfo) {
if (!playInfo || !playInfo.data)
return null;
const data = playInfo.data;
const dashVideo = data.dash && data.dash.video && data.dash.video[0];
const durlVideo = data.durl && data.durl[0];
const videoUrl = (dashVideo && (dashVideo.baseUrl || dashVideo.base_url)) || (durlVideo && durlVideo.url) || '';
const acceptQuality = data.accept_quality ? data.accept_quality.join(',') : '';
return [data.cid || '', data.quality || '', acceptQuality, videoUrl].join('|');
}
function waitForPlayinfo(cb, timeout = 10000, previousSignature = null) {
// 页面内切换后优先等待新的 playurl 响应,其次回退到首屏内联的 __playinfo__
const start = Date.now();
const timer = setInterval(() => {
if (Date.now() - start > timeout) {
clearInterval(timer);
console.error('[bili-dl] playinfo not found after timeout');
return;
}
const playInfo = getMatchedPlayInfo();
const currentSignature = getPlayinfoSignature(playInfo);
if (currentSignature && currentSignature !== previousSignature) {
clearInterval(timer);
cb();
}
}, 200);
}
function hookPlayurlResponses() {
const PLAYURL_RE = /\/x\/player\/(wbi\/)?playurl/i;
const originalFetch = window.fetch;
if (typeof originalFetch === 'function') {
window.fetch = async function (...args) {
const response = await originalFetch.apply(this, args);
try {
const requestUrl = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
if (PLAYURL_RE.test(requestUrl)) {
response.clone().json().then((payload) => {
storePlayInfo(payload, requestUrl);
}).catch(() => { });
}
}
catch (error) {
console.warn('[bili-dl] fetch hook failed', error);
}
return response;
};
}
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this.__biliDlUrl = url;
return originalOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (...args) {
this.addEventListener('load', () => {
try {
const requestUrl = this.__biliDlUrl || '';
if (!PLAYURL_RE.test(requestUrl) || typeof this.responseText !== 'string')
return;
storePlayInfo(JSON.parse(this.responseText), requestUrl);
}
catch (error) {
console.warn('[bili-dl] xhr hook failed', error);
}
});
return originalSend.apply(this, args);
};
}
storePlayInfo(pageWindow.__playinfo__);
hookPlayurlResponses();
waitForPlayinfo(init);
// 监听 B 站 SPA 路由跳转
let currentUrl = location.href;
const onUrlChange = () => {
if (location.href === currentUrl)
return;
const previousSignature = activePlayinfoSignature;
currentUrl = location.href;
if (/\/video\//.test(location.pathname)) {
console.log('[bili-dl] URL changed, reinitializing...');
waitForPlayinfo(init, 10000, previousSignature);
}
else {
cleanup();
}
};
// 拦截 pushState / replaceState
['pushState', 'replaceState'].forEach((method) => {
const orig = history[method];
history[method] = function (...args) {
const ret = orig.apply(this, args);
onUrlChange();
return ret;
};
});
window.addEventListener('popstate', onUrlChange);
function run(playInfo) {
const title = getVideoTitle();
const data = playInfo.data || {};
const videoStreams = (data.dash && data.dash.video) || data.durl || [];
const audioStreams = (data.dash && data.dash.audio) || [];
const durlStreams = data.durl || [];
if (!videoStreams.length && !durlStreams.length) {
console.warn('[bili-dl] No video streams found');
return;
}
const QUALITY_MAP = {
127: '超高清 8K',
126: '杜比视界',
125: 'HDR 真彩',
120: '超清 4K',
116: '高清 1080P60',
112: '高清 1080P+',
80: '高清 1080P',
74: '高清 720P60',
64: '高清 720P',
32: '清晰 480P',
16: '流畅 360P',
};
const CODEC_MAP = { 7: 'AVC', 12: 'HEVC', 13: 'AV1' };
const AUDIO_QUALITY_MAP = {
30280: '高品质 (320kbps)',
30232: '中品质 (128kbps)',
30216: '低品质 (64kbps)',
};
// 按画质去重,保留带宽最高的
const videoGroups = {};
videoStreams.forEach((s) => {
if (!videoGroups[s.id] || s.bandwidth > videoGroups[s.id].bandwidth) {
videoGroups[s.id] = s;
}
});
const bestVideoStreams = Object.values(videoGroups).sort((a, b) => b.id - a.id);
const bestAudio = audioStreams.length
? audioStreams.reduce((a, b) => a.bandwidth > b.bandwidth ? a : b)
: null;
/* ---- Shadow DOM(隔离 B 站样式) ---- */
host = document.createElement('div');
host.className = 'bili-dl-entry';
const initialPosition = getInitialHostPosition();
// 用 setAttribute 写 style 以最高优先级覆盖 B 站可能的全局 CSS
host.setAttribute('style', [
'display:flex !important',
'align-items:center !important',
'flex-shrink:0 !important',
'position:fixed !important',
`left:${initialPosition.left}px !important`,
`top:${initialPosition.top}px !important`,
'overflow:visible !important',
'z-index:2147483647 !important',
'height:auto !important',
'margin:0 !important',
'line-height:normal !important',
'font-family:sans-serif !important',
'font-size:13px !important',
'pointer-events:auto !important',
'visibility:visible !important',
'opacity:1 !important',
].join(';'));
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
:host { all: initial; }
.wrapper { position: relative; display: flex; align-items: center; height: 28px; overflow: visible; }
.main-btn {
display: inline-flex; align-items: center; gap: 4px;
height: 28px; padding: 0 10px; cursor: pointer;
background: #f6f7f8; color: #18191c;
border: 1px solid #e3e5e7; border-radius: 14px;
font-size: 13px; font-family: sans-serif; font-weight: 500;
white-space: nowrap;
box-sizing: border-box;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.main-btn:hover {
background: #e8f3ff;
border-color: #91caff;
color: #1677ff;
}
.panel {
position: absolute; right: 0; top: calc(100% + 8px);
width: 400px; max-width: min(400px, calc(100vw - 24px));
min-width: 270px; max-height: 420px; overflow-y: auto; overflow-x: hidden;
background: #fff; border: 1px solid #e2e2e2; border-radius: 6px;
box-shadow: 0 10px 30px rgba(0,0,0,.18); padding: 8px;
box-sizing: border-box;
z-index: 2147483647;
opacity: 0; transform: translateY(-6px);
visibility: hidden; pointer-events: none;
transition: opacity 0.3s ease, transform 0.3s ease, visibility 0.3s;
}
.panel.open {
opacity: 1; transform: translateY(0);
visibility: visible; pointer-events: auto;
}
.section-title {
font-size: 11px; color: #999; padding: 4px 4px 2px;
border-bottom: 1px solid #f0f0f0; margin-bottom: 4px;
font-family: sans-serif;
}
.panel-item {
display: flex; align-items: center; justify-content: space-between;
width: 100%; box-sizing: border-box;
padding: 7px 10px; margin: 2px 0;
background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px;
font-size: 12px; font-family: sans-serif; color: #333; cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.panel-item:hover { background: #e8f4ff; border-color: #b3d7ff; color: #1677ff; }
.panel-item .label { flex: 1; text-align: left; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.panel-item .badge {
font-size: 10px; background: #e6f0ff; color: #1677ff;
border-radius: 3px; padding: 1px 5px; margin-left: 6px; flex-shrink: 0;
}
.audio-bar {
margin-top: 6px; padding: 6px 8px;
background: #f6ffed; border: 1px solid #b7eb8f; border-radius: 4px;
font-size: 11px; font-family: sans-serif; color: #389e0d;
display: flex; align-items: center; gap: 6px;
}
.audio-bar a {
color: #389e0d; text-decoration: underline; cursor: pointer;
}
.hint-bar {
margin-top: 6px; padding: 6px 8px;
background: #fffbe6; border: 1px solid #ffe58f; border-radius: 4px;
font-size: 11px; font-family: sans-serif; color: #874d00; line-height: 1.5;
}
.hint-bar code {
display: block; margin-top: 4px; background: #f5f5f5;
padding: 3px 6px; border-radius: 3px;
font-family: monospace; font-size: 10px;
white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
cursor: pointer;
}
.hint-bar code:hover { background: #e8f4ff; }
.video-title {
padding: 6px 4px 8px;
font-size: 12px; font-family: sans-serif; color: #333; font-weight: bold;
border-bottom: 1px solid #f0f0f0; margin-bottom: 6px;
white-space: normal; overflow-wrap: anywhere; word-break: break-word;
line-height: 1.5;
max-width: 100%; box-sizing: border-box;
}
`;
shadow.appendChild(style);
const wrapper = document.createElement('div');
wrapper.className = 'wrapper';
const mainBtn = document.createElement('button');
mainBtn.className = 'main-btn';
mainBtn.innerHTML = '⬇ 下载视频';
const panel = document.createElement('div');
panel.className = 'panel';
/* ---- 视频标题 ---- */
const titleEl = document.createElement('div');
titleEl.className = 'video-title';
titleEl.title = title;
titleEl.textContent = title;
panel.appendChild(titleEl);
// 监听 document.title 变化,实时更新面板标题
titleObserver = new MutationObserver(() => {
const updated = getVideoTitle();
titleEl.title = updated;
titleEl.textContent = updated;
});
titleObserver.observe(document.querySelector('title'), { childList: true });
/* ---- 视频流列表 ---- */
if (bestVideoStreams.length) {
const sectionTitle = document.createElement('div');
sectionTitle.className = 'section-title';
sectionTitle.textContent = '视频流(需配合音频合并)';
panel.appendChild(sectionTitle);
bestVideoStreams.forEach((stream) => {
const qualityName = QUALITY_MAP[stream.id] || `质量 ID ${stream.id}`;
const codec = CODEC_MAP[stream.codecid] || `codec ${stream.codecid}`;
const res = stream.width ? `${stream.width}×${stream.height}` : '';
const url = stream.baseUrl || stream.base_url || '';
const item = document.createElement('div');
item.className = 'panel-item';
item.innerHTML = `
${qualityName}${res ? ` · ${res}` : ''}
${codec}
`;
item.title = bestAudio ? '点击下载并复制合并命令' : '点击下载';
item.addEventListener('click', () => {
if (url) {
dlFile(url, `${title}_video.m4s`);
if (bestAudio) {
const cmd = `ffmpeg -i "${title}_video.m4s" -i "${title}_audio.m4s" -c copy "${title}.mp4"`;
GM_setClipboard(cmd);
const labelEl = item.querySelector('.label');
const orig = labelEl.textContent;
labelEl.textContent = '✓ 已复制合并命令';
setTimeout(() => { labelEl.textContent = orig; }, 2000);
}
}
else { alert('无法获取该流的有效 URL'); }
});
panel.appendChild(item);
});
}
/* ---- durl 格式(非 DASH,视频音频合并) ---- */
durlStreams.forEach((stream, i) => {
const item = document.createElement('div');
item.className = 'panel-item';
const mb = stream.size ? `${Math.round(stream.size / 1024 / 1024)}MB` : '';
item.innerHTML = `流 ${i + 1}(含音频)
${mb ? `${mb}` : ''}`;
item.addEventListener('click', () => {
if (stream.url)
dlFile(stream.url, `${title}.mp4`);
});
panel.appendChild(item);
});
/* ---- 音频流 ---- */
if (audioStreams.length) {
const sectionTitle = document.createElement('div');
sectionTitle.className = 'section-title';
sectionTitle.textContent = '音频流';
panel.appendChild(sectionTitle);
const sortedAudio = [...audioStreams].sort((a, b) => b.id - a.id);
sortedAudio.forEach((audio) => {
const audioUrl = audio.baseUrl || audio.base_url || '';
const audioQuality = AUDIO_QUALITY_MAP[audio.id] || `音频 ID ${audio.id}`;
const item = document.createElement('div');
item.className = 'panel-item';
item.innerHTML = `🎵 ${audioQuality}`;
item.title = '点击下载';
item.addEventListener('click', () => {
if (audioUrl)
dlFile(audioUrl, `${title}_audio.m4s`);
});
panel.appendChild(item);
});
}
/* ---- FFmpeg 合并提示 ---- */
if (videoStreams.length && bestAudio) {
const cmd = `ffmpeg -i "${title}_video.m4s" -i "${title}_audio.m4s" -c copy "${title}.mp4"`;
const hint = document.createElement('div');
hint.className = 'hint-bar';
hint.innerHTML = `⚠ DASH 格式需分别下载视频和音频,再用 FFmpeg 合并:
${cmd}`;
const codeEl = hint.querySelector('code');
codeEl.__biliDlCmd = cmd;
codeEl.addEventListener('click', () => {
GM_setClipboard(cmd);
codeEl.textContent = '✓ 已复制!';
clearTimeout(codeEl.__biliDlRestoreTimer);
codeEl.__biliDlRestoreTimer = setTimeout(() => {
codeEl.textContent = codeEl.__biliDlCmd;
}, 2000);
});
panel.appendChild(hint);
}
let hideTimer = null;
let dragState = null;
let suppressClick = false;
mainBtn.addEventListener('click', (event) => {
if (suppressClick) {
suppressClick = false;
event.preventDefault();
event.stopPropagation();
return;
}
panel.classList.toggle('open');
});
mainBtn.addEventListener('pointerdown', (event) => {
if (event.button !== 0)
return;
const rect = host.getBoundingClientRect();
dragState = {
startX: event.clientX,
startY: event.clientY,
originLeft: rect.left,
originTop: rect.top,
dragged: false,
pointerId: event.pointerId,
};
mainBtn.setPointerCapture(event.pointerId);
});
mainBtn.addEventListener('pointermove', (event) => {
if (!dragState || event.pointerId !== dragState.pointerId)
return;
const deltaX = event.clientX - dragState.startX;
const deltaY = event.clientY - dragState.startY;
if (!dragState.dragged && Math.hypot(deltaX, deltaY) > 4)
dragState.dragged = true;
if (!dragState.dragged)
return;
event.preventDefault();
panel.classList.remove('open');
const nextLeft = clamp(dragState.originLeft + deltaX, 8, Math.max(window.innerWidth - host.offsetWidth - 8, 8));
const nextTop = clamp(dragState.originTop + deltaY, 8, Math.max(window.innerHeight - host.offsetHeight - 8, 8));
host.style.left = `${nextLeft}px`;
host.style.top = `${nextTop}px`;
});
const stopDragging = (event) => {
if (!dragState || event.pointerId !== dragState.pointerId)
return;
if (mainBtn.hasPointerCapture(event.pointerId))
mainBtn.releasePointerCapture(event.pointerId);
suppressClick = dragState.dragged;
dragState = null;
};
mainBtn.addEventListener('pointerup', stopDragging);
mainBtn.addEventListener('pointercancel', stopDragging);
wrapper.addEventListener('mouseleave', () => {
hideTimer = setTimeout(() => panel.classList.remove('open'), 1000);
});
wrapper.addEventListener('mouseenter', () => {
clearTimeout(hideTimer);
});
wrapper.appendChild(mainBtn);
wrapper.appendChild(panel);
shadow.appendChild(wrapper);
mountHost();
console.log('[bili-dl] injected, videos:', bestVideoStreams.length, 'audio:', !!bestAudio);
} // end run()
})();