// ==UserScript== // @name B站视频片段下载器 // @namespace bilibili-clip-downloader // @version 1.0 // @description 在B站视频页面选择任意时间段,录制并下载指定片段到自定义文件夹 // @author WorkBuddy // @match https://www.bilibili.com/video/* // @match https://www.bilibili.com/watchlater/* // @match https://www.bilibili.com/bangumi/play/* // @grant none // @license MIT // ==/UserScript== (function() { 'use strict'; // ==================== 工具函数 ==================== /** 解析时间字符串为秒数,支持 "SS" "MM:SS" "HH:MM:SS" */ function parseTime(str) { if (!str) return null; var parts = str.trim().split(':'); for (var i = 0; i < parts.length; i++) { if (isNaN(parseInt(parts[i], 10))) return null; } var nums = parts.map(function(n) { return parseInt(n, 10); }); if (nums.length === 1) return nums[0]; if (nums.length === 2) return nums[0] * 60 + nums[1]; if (nums.length === 3) return nums[0] * 3600 + nums[1] * 60 + nums[2]; return null; } /** 秒数格式化为 "MM:SS" 或 "H:MM:SS" */ function formatTime(seconds) { if (isNaN(seconds) || seconds < 0) return '00:00'; var h = Math.floor(seconds / 3600); var m = Math.floor((seconds % 3600) / 60); var s = Math.floor(seconds % 60); if (h > 0) { return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); } return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); } /** 秒数格式化为文件名安全格式 "0m10s" 或 "1h05m30s" */ function formatTimeForFilename(seconds) { var h = Math.floor(seconds / 3600); var m = Math.floor((seconds % 3600) / 60); var s = Math.floor(seconds % 60); if (h > 0) { return h + 'h' + String(m).padStart(2, '0') + 'm' + String(s).padStart(2, '0') + 's'; } return m + 'm' + String(s).padStart(2, '0') + 's'; } /** 清理文件名中的非法字符 */ function sanitizeFilename(name) { return name.replace(/[<>:"/\\|?*]/g, '_').trim().substring(0, 60) || 'bilibili_clip'; } /** 获取当前视频标题 */ function getVideoTitle() { var selectors = [ 'h1.video-title .tit', 'h1.video-title', '.video-title .tit', '.video-title', 'h1' ]; for (var i = 0; i < selectors.length; i++) { var el = document.querySelector(selectors[i]); if (el && el.textContent.trim()) { return sanitizeFilename(el.textContent.trim()); } } var match = window.location.href.match(/\/video\/(BV\w+)/); return match ? match[1] : 'bilibili_clip'; } /** 获取浏览器支持的最佳录制格式 */ function getSupportedMimeType() { var types = [ 'video/mp4;codecs=h264,aac', 'video/mp4;codecs=avc1', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm' ]; for (var i = 0; i < types.length; i++) { if (MediaRecorder.isTypeSupported(types[i])) return types[i]; } return ''; } /** 根据MIME类型获取文件扩展名 */ function getExtension(mimeType) { if (!mimeType) return 'webm'; if (mimeType.indexOf('mp4') !== -1) return 'mp4'; if (mimeType.indexOf('webm') !== -1) return 'webm'; return 'webm'; } /** 在页面中查找视频元素 */ function findVideoElement() { var selectors = [ '#bilibili-player video', '.bpx-player-video-wrap video', '.bilibili-player video', 'video' ]; for (var i = 0; i < selectors.length; i++) { var video = document.querySelector(selectors[i]); if (video && video.readyState > 0 && !isNaN(video.duration) && video.duration > 0) { return video; } } return null; } // ==================== 录制状态 ==================== var isRecording = false; var recordingCancelled = false; // ==================== 录制核心逻辑 ==================== /** * 录制视频指定时间段 * @param {HTMLVideoElement} video - 视频元素 * @param {number} startTime - 开始时间(秒) * @param {number} endTime - 结束时间(秒) * @param {function} onProgress - 进度回调 (progress, elapsed, duration) * @returns {Promise} 录制的视频Blob */ async function recordClip(video, startTime, endTime, onProgress) { var duration = endTime - startTime; recordingCancelled = false; // 保存当前播放状态,录制后恢复 var savedTime = video.currentTime; var wasPaused = video.paused; var savedVolume = video.volume; var savedMuted = video.muted; var savedRate = video.playbackRate; // 暂停并跳转到开始时间 video.pause(); video.currentTime = startTime; // 等待 seek 完成 await new Promise(function(resolve) { var resolved = false; var onSeeked = function() { if (resolved) return; resolved = true; video.removeEventListener('seeked', onSeeked); resolve(); }; video.addEventListener('seeked', onSeeked); // 超时保护 setTimeout(function() { if (!resolved) { resolved = true; video.removeEventListener('seeked', onSeeked); resolve(); } }, 5000); }); // 捕获视频流(包含画面和声音) var stream; if (video.captureStream) { stream = video.captureStream(); } else if (video.mozCaptureStream) { stream = video.mozCaptureStream(); } else { throw new Error('浏览器不支持视频流捕获'); } if (!stream) { throw new Error('无法创建视频流'); } // 检查视频轨道 var videoTracks = stream.getVideoTracks(); if (videoTracks.length === 0) { throw new Error('无法捕获视频画面,可能是DRM保护内容'); } // 设置录制器 var mimeType = getSupportedMimeType(); var recorderOptions = mimeType ? { mimeType: mimeType } : {}; var recorder = new MediaRecorder(stream, recorderOptions); var chunks = []; recorder.ondataavailable = function(e) { if (e.data && e.data.size > 0) { chunks.push(e.data); } }; // 开始录制(每100ms收集一次数据) recorder.start(100); // 播放视频开始录制 try { await video.play(); } catch (e) { if (recorder.state !== 'inactive') recorder.stop(); throw new Error('无法播放视频: ' + (e.message || e)); } // 监控录制进度,到达结束时间时停止 return new Promise(function(resolve, reject) { var stopped = false; function stop() { if (stopped) return; stopped = true; video.pause(); if (recorder.state !== 'inactive') { recorder.stop(); } } recorder.onstop = function() { var blob = new Blob(chunks, { type: mimeType || 'video/webm' }); // 恢复视频原始状态 video.currentTime = savedTime; video.volume = savedVolume; video.muted = savedMuted; video.playbackRate = savedRate; if (wasPaused) { video.pause(); } else { video.play().catch(function() {}); } resolve(blob); }; recorder.onerror = function(e) { stop(); reject(e.error || new Error('录制出错')); }; function checkEnd() { if (stopped) return; // 检查取消 if (recordingCancelled) { stop(); return; } // 更新进度 var elapsed = video.currentTime - startTime; var progress = Math.min(elapsed / duration, 1); onProgress(progress, elapsed, duration); // 检查是否到达结束时间 if (video.currentTime >= endTime || video.ended) { stop(); } else { requestAnimationFrame(checkEnd); } } requestAnimationFrame(checkEnd); }); } // ==================== 文件保存 ==================== /** * 保存Blob到文件 * 优先使用 File System Access API 让用户选择文件夹 * 不支持时回退到普通下载 */ async function saveBlob(blob, filename) { // 优先使用 File System Access API if (window.showDirectoryPicker) { try { var dirHandle = await window.showDirectoryPicker({ mode: 'readwrite', id: 'bilibili-clips', startIn: 'downloads' }); var fileHandle = await dirHandle.getFileHandle(filename, { create: true }); var writable = await fileHandle.createWritable(); await writable.write(blob); await writable.close(); return { method: 'folder', dirName: dirHandle.name }; } catch (e) { if (e.name === 'AbortError') return null; // 用户取消 // 其他错误,回退到普通下载 console.warn('File System Access API failed, falling back', e); } } // 后备方案:触发浏览器下载 var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(function() { URL.revokeObjectURL(url); }, 10000); return { method: 'download' }; } // ==================== UI 样式 ==================== function createStyles() { var style = document.createElement('style'); style.id = 'bili-clip-styles'; style.textContent = [ '#bili-clip-panel {', ' position: fixed; top: 80px; right: 20px; z-index: 99999;', ' width: 290px; background: #fff; border-radius: 12px;', ' box-shadow: 0 4px 20px rgba(0,0,0,0.15);', ' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;', ' font-size: 13px; overflow: hidden;', '}', '#bili-clip-panel .clip-header {', ' display: flex; justify-content: space-between; align-items: center;', ' padding: 10px 14px; background: #fb7299; color: #fff;', ' font-weight: bold; font-size: 14px; cursor: move; user-select: none;', '}', '#bili-clip-panel .clip-header span { pointer-events: none; }', '#bili-clip-panel .clip-toggle {', ' background: none; border: none; color: #fff; font-size: 18px;', ' cursor: pointer; padding: 0 4px; line-height: 1;', '}', '#bili-clip-panel .clip-toggle:hover { opacity: 0.8; }', '#bili-clip-panel .clip-body { padding: 12px 14px; }', '#bili-clip-panel .clip-body.collapsed { display: none; }', '#bili-clip-panel .clip-row {', ' display: flex; align-items: center; gap: 6px; margin-bottom: 8px;', '}', '#bili-clip-panel .clip-row label {', ' width: 34px; color: #666; flex-shrink: 0;', '}', '#bili-clip-panel .clip-row input {', ' flex: 1; padding: 6px 8px; border: 1px solid #ddd;', ' border-radius: 6px; font-size: 13px; outline: none;', ' transition: border-color 0.2s; min-width: 0;', '}', '#bili-clip-panel .clip-row input:focus { border-color: #fb7299; }', '#bili-clip-panel .clip-now-btn {', ' background: #f0f0f0; border: none; border-radius: 6px;', ' padding: 6px 8px; cursor: pointer; font-size: 14px;', ' flex-shrink: 0; transition: background 0.2s;', '}', '#bili-clip-panel .clip-now-btn:hover { background: #e0e0e0; }', '#bili-clip-panel .clip-info {', ' font-size: 12px; color: #999; margin: 8px 0; min-height: 16px;', '}', '#bili-clip-panel .clip-progress-wrap { margin: 8px 0; display: none; }', '#bili-clip-panel .clip-progress-wrap.active { display: block; }', '#bili-clip-panel .clip-progress-bar-bg {', ' height: 6px; background: #eee; border-radius: 3px; overflow: hidden;', '}', '#bili-clip-panel .clip-progress-bar {', ' height: 100%; background: #fb7299; width: 0%;', ' border-radius: 3px; transition: width 0.2s;', '}', '#bili-clip-panel .clip-progress-text {', ' font-size: 12px; color: #666; margin-top: 4px;', '}', '#bili-clip-panel .clip-download-btn {', ' width: 100%; padding: 10px; background: #fb7299; color: #fff;', ' border: none; border-radius: 8px; font-size: 14px; font-weight: bold;', ' cursor: pointer; transition: background 0.2s;', '}', '#bili-clip-panel .clip-download-btn:hover:not(:disabled) { background: #f0567f; }', '#bili-clip-panel .clip-download-btn:disabled { background: #ccc; cursor: not-allowed; }', '#bili-clip-panel .clip-cancel-btn {', ' width: 100%; padding: 8px; background: #fff; color: #ff5757;', ' border: 1px solid #ff5757; border-radius: 8px; font-size: 13px;', ' cursor: pointer; margin-top: 6px; display: none;', '}', '#bili-clip-panel .clip-cancel-btn.active { display: block; }', '#bili-clip-panel .clip-cancel-btn:hover { background: #fff5f5; }', '#bili-clip-panel .clip-tip {', ' font-size: 11px; color: #bbb; margin-top: 8px; line-height: 1.4;', '}' ].join('\n'); return style; } // ==================== UI 面板 ==================== function createPanel() { var panel = document.createElement('div'); panel.id = 'bili-clip-panel'; panel.innerHTML = [ '
', ' 🎬 视频片段下载器', ' ', '
', '
', '
', ' ', ' ', ' ', '
', '
', ' ', ' ', ' ', '
', '
', '
', '
', '
', '
', '
', '
', ' ', ' ', '
提示:录制时视频将实时播放,请先将播放器画质调至所需清晰度
', '
' ].join(''); return panel; } // ==================== 拖拽功能 ==================== function makeDraggable(panel) { var header = panel.querySelector('.clip-header'); var isDragging = false; var startX, startY, startLeft, startTop; header.addEventListener('mousedown', function(e) { if (e.target.classList.contains('clip-toggle')) return; isDragging = true; var rect = panel.getBoundingClientRect(); startX = e.clientX; startY = e.clientY; startLeft = rect.left; startTop = rect.top; panel.style.transition = 'none'; e.preventDefault(); }); document.addEventListener('mousemove', function(e) { if (!isDragging) return; var dx = e.clientX - startX; var dy = e.clientY - startY; panel.style.left = (startLeft + dx) + 'px'; panel.style.top = (startTop + dy) + 'px'; panel.style.right = 'auto'; }); document.addEventListener('mouseup', function() { if (isDragging) { isDragging = false; panel.style.transition = ''; } }); } // ==================== 事件处理 ==================== function getCurrentVideoTime() { var video = findVideoElement(); return video ? video.currentTime : 0; } function updateInfo() { var video = findVideoElement(); var info = document.getElementById('bili-clip-info'); if (!info) return; var startInput = document.getElementById('bili-clip-start'); var endInput = document.getElementById('bili-clip-end'); if (!startInput || !endInput) return; var startTime = parseTime(startInput.value); var endTime = parseTime(endInput.value); if (startTime === null || endTime === null) { info.textContent = '⚠️ 时间格式错误,请输入 MM:SS 或 HH:MM:SS'; info.style.color = '#ff5757'; return; } if (startTime >= endTime) { info.textContent = '⚠️ 开始时间必须小于结束时间'; info.style.color = '#ff5757'; return; } var clipDuration = endTime - startTime; var videoDuration = video ? video.duration : 0; var infoText = '片段时长: ' + formatTime(clipDuration); if (videoDuration > 0 && !isNaN(videoDuration) && isFinite(videoDuration)) { infoText += ' | 视频: ' + formatTime(videoDuration); if (endTime > videoDuration) { infoText += ' ⚠️ 超出视频长度'; info.style.color = '#ff5757'; } else { info.style.color = '#999'; } } else { info.style.color = '#999'; } info.textContent = infoText; } function setProgress(active, percent, text) { var wrap = document.getElementById('bili-clip-progress-wrap'); var bar = document.getElementById('bili-clip-progress-bar'); var textEl = document.getElementById('bili-clip-progress-text'); var cancelBtn = document.getElementById('bili-clip-cancel'); if (!wrap || !bar || !textEl || !cancelBtn) return; if (active) { wrap.classList.add('active'); cancelBtn.classList.add('active'); } else { wrap.classList.remove('active'); cancelBtn.classList.remove('active'); } if (percent !== undefined) { bar.style.width = Math.round(percent * 100) + '%'; } if (text !== undefined) { textEl.textContent = text; } } function showInfoMessage(message, color) { var info = document.getElementById('bili-clip-info'); if (!info) return; var originalText = info.textContent; var originalColor = info.style.color; info.textContent = message; info.style.color = color || '#4caf50'; setTimeout(function() { info.textContent = originalText; info.style.color = originalColor; }, 5000); } async function downloadClip() { if (isRecording) return; var downloadBtn = document.getElementById('bili-clip-download'); var startInput = document.getElementById('bili-clip-start'); var endInput = document.getElementById('bili-clip-end'); var startTime = parseTime(startInput.value); var endTime = parseTime(endInput.value); // 验证输入 if (startTime === null || endTime === null) { alert('时间格式错误!\n请输入 MM:SS 或 HH:MM:SS 格式\n例如:00:10 或 1:23:45'); return; } if (startTime >= endTime) { alert('开始时间必须小于结束时间!'); return; } // 查找视频元素 var video = findVideoElement(); if (!video) { alert('未找到视频元素!\n请确保视频已加载并开始播放过'); return; } // 检查浏览器API支持 if (!video.captureStream && !video.mozCaptureStream) { alert('当前浏览器不支持视频流捕获!\n请使用 Chrome 86+ 或 Edge 浏览器'); return; } if (typeof MediaRecorder === 'undefined') { alert('当前浏览器不支持 MediaRecorder API!\n请使用 Chrome 或 Edge 浏览器'); return; } // 限制结束时间不超过视频时长 var actualEndTime = endTime; if (video.duration && isFinite(video.duration) && endTime > video.duration) { actualEndTime = video.duration; } // 如果未选择文件夹,先让用户选择 var dirHandle = null; if (window.showDirectoryPicker) { try { dirHandle = await window.showDirectoryPicker({ mode: 'readwrite', id: 'bilibili-clips', startIn: 'downloads' }); } catch (e) { if (e.name === 'AbortError') { return; // 用户取消选择文件夹 } // 忽略错误,稍后回退到普通下载 } } // 禁用按钮,开始录制 isRecording = true; downloadBtn.disabled = true; downloadBtn.textContent = '⏳ 录制中...'; setProgress(true, 0, '准备中...'); try { // 生成文件名 var title = getVideoTitle(); var mimeType = getSupportedMimeType(); var ext = getExtension(mimeType); var filename = title + '_' + formatTimeForFilename(startTime) + '-' + formatTimeForFilename(actualEndTime) + '.' + ext; // 录制片段 var blob = await recordClip(video, startTime, actualEndTime, function(progress, elapsed, duration) { setProgress(true, progress, '录制中 ' + formatTime(elapsed) + ' / ' + formatTime(duration)); }); setProgress(true, 1, '录制完成,正在保存...'); // 保存文件 var sizeMB = (blob.size / 1024 / 1024).toFixed(1); var saveResult; if (dirHandle) { // 使用已选择的文件夹 try { var fileHandle = await dirHandle.getFileHandle(filename, { create: true }); var writable = await fileHandle.createWritable(); await writable.write(blob); await writable.close(); saveResult = { method: 'folder', dirName: dirHandle.name }; } catch (e) { // 文件夹保存失败,回退到普通下载 saveResult = await saveBlob(blob, filename); } } else { // 没有文件夹API,使用普通下载 saveResult = await saveBlob(blob, filename); } // 恢复UI状态 setProgress(false, 0, ''); downloadBtn.disabled = false; downloadBtn.textContent = '🎬 下载片段'; isRecording = false; if (saveResult === null) { showInfoMessage('已取消', '#999'); return; } if (saveResult.method === 'folder') { showInfoMessage('✅ 已保存到「' + saveResult.dirName + '」(' + sizeMB + 'MB, ' + ext.toUpperCase() + ')'); } else { showInfoMessage('✅ 已下载到下载文件夹 (' + sizeMB + 'MB, ' + ext.toUpperCase() + ')'); } } catch (e) { console.error('录制失败:', e); setProgress(false, 0, ''); downloadBtn.disabled = false; downloadBtn.textContent = '🎬 下载片段'; isRecording = false; alert('录制失败: ' + (e.message || e)); } } // ==================== 初始化 ==================== function init() { if (document.getElementById('bili-clip-panel')) return; if (!document.querySelector('video')) return; // 添加样式 if (!document.getElementById('bili-clip-styles')) { document.head.appendChild(createStyles()); } // 创建面板 var panel = createPanel(); document.body.appendChild(panel); // 拖拽 makeDraggable(panel); // 折叠/展开 var toggle = panel.querySelector('.clip-toggle'); var body = panel.querySelector('.clip-body'); toggle.addEventListener('click', function() { body.classList.toggle('collapsed'); toggle.textContent = body.classList.contains('collapsed') ? '+' : '−'; }); // "使用当前时间"按钮 var nowBtns = panel.querySelectorAll('.clip-now-btn'); for (var i = 0; i < nowBtns.length; i++) { nowBtns[i].addEventListener('click', function() { var target = this.dataset.target; var input = document.getElementById('bili-clip-' + target); var currentTime = getCurrentVideoTime(); input.value = formatTime(currentTime); updateInfo(); }); } // 输入变化时更新信息 document.getElementById('bili-clip-start').addEventListener('input', updateInfo); document.getElementById('bili-clip-end').addEventListener('input', updateInfo); // 下载按钮 document.getElementById('bili-clip-download').addEventListener('click', downloadClip); // 取消录制按钮 document.getElementById('bili-clip-cancel').addEventListener('click', function() { recordingCancelled = true; }); // 延迟更新信息(等待视频加载) setTimeout(updateInfo, 500); // 定期更新信息(应对视频加载延迟) var infoInterval = setInterval(updateInfo, 2000); panel.dataset.infoInterval = infoInterval; } // ==================== 启动 ==================== setInterval(function() { if (!document.getElementById('bili-clip-panel') && document.querySelector('video')) { init(); } }, 1000); })();