// ==UserScript==
// @name Bilibili Video Screenshot Helper
// @name:zh-TW Bilibili 影片截圖助手
// @name:zh-CN Bilibili 视频截图助手
// @namespace https://www.tampermonkey.net/
// @version 2.4
// @description Bilibili Video Screenshot Tool – supports screenshot button, hotkey capture, burst mode, customizable hotkeys and burst intervals, with menu language switch between Chinese and English.
// @description:zh-TW B站影片截圖工具,支援截圖按鈕、快捷鍵截圖、連拍功能,自定義快捷鍵、連拍間隔設定、中英菜單切換
// @description:zh-CN B站视频截图工具,支援截图按钮、快捷键截图、连拍功能,自定义快捷键、连拍间隔设定、中英菜单切换
// @author ChatGPT
// @match https://www.bilibili.com/*
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ====== 預設設定 ======
const DEFAULTS = {
key: 'S',
interval: 1000,
minInterval: 100,
lang: 'EN',
lockKey: 'bili_screenshot_prompt_lock'
};
// ====== 語言包 ======
const LANGS = {
EN: {
screenshot: 'Screenshot',
keySetting: key => `Set Screenshot Key (Current: ${key})`,
intervalSetting: val => `Set Burst Interval (Current: ${val}ms)`,
langSwitch: 'Switch to 中文',
keyPrompt: 'Enter new key (A-Z)',
intervalPrompt: 'Enter new interval in ms (>= 100)'
},
ZH: {
screenshot: '截圖',
keySetting: key => `設定截圖快捷鍵(目前:${key})`,
intervalSetting: val => `設定連拍間隔(目前:${val} 毫秒)`,
langSwitch: '切換到 English',
keyPrompt: '輸入新快捷鍵(A-Z)',
intervalPrompt: '輸入新的連拍間隔(最小 100ms)'
}
};
// ====== 狀態管理 ======
let lang = GM_getValue('lang', DEFAULTS.lang);
let hotkey = GM_getValue('hotkey', DEFAULTS.key);
let interval = GM_getValue('interval', DEFAULTS.interval);
function getLangPack() {
return LANGS[lang];
}
// ====== 設定選單註冊 ======
function safePrompt(action) {
if (window.top !== window.self) return;
if (sessionStorage.getItem(DEFAULTS.lockKey) === '1') return;
sessionStorage.setItem(DEFAULTS.lockKey, '1');
try {
action();
} finally {
sessionStorage.removeItem(DEFAULTS.lockKey);
}
}
GM_registerMenuCommand(getLangPack().keySetting(hotkey), () => {
safePrompt(() => {
const input = prompt(getLangPack().keyPrompt);
if (input && /^[a-zA-Z]$/.test(input)) {
const newKey = input.toUpperCase();
if (newKey !== hotkey) {
GM_setValue('hotkey', newKey);
location.reload();
}
}
});
});
GM_registerMenuCommand(getLangPack().intervalSetting(interval), () => {
safePrompt(() => {
const input = prompt(getLangPack().intervalPrompt);
const val = parseInt(input, 10);
if (!isNaN(val) && val >= DEFAULTS.minInterval && val !== interval) {
GM_setValue('interval', val);
location.reload();
}
});
});
GM_registerMenuCommand(getLangPack().langSwitch, () => {
safePrompt(() => {
const newLang = lang === 'EN' ? 'ZH' : 'EN';
GM_setValue('lang', newLang);
location.reload();
});
});
// ====== 取得影片標題 ======
function getVideoTitle() {
// 新播放器
let title = document.querySelector('h1[data-v-1c684a5a]')?.innerText
|| document.querySelector('h1.video-title')?.innerText
|| document.querySelector('h1')?.innerText;
// 備用:<title>
if (!title) {
title = document.title.replace(/_.*$/, '').trim();
}
// 過濾非法檔名字元
if (title) {
title = title.replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, '_');
} else {
title = 'UnknownTitle';
}
return title;
}
// ====== 截圖邏輯 ======
function takeScreenshot() {
const video = document.querySelector('video');
const match = location.pathname.match(/\/video\/(BV\w+)/);
if (!match || !video || video.paused) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
const pad = (n, len = 2) => n.toString().padStart(len, '0');
const padMs = n => pad(n, 3);
const bvId = match[1];
const t = video.currentTime;
const h = pad(Math.floor(t / 3600));
const m = pad(Math.floor((t % 3600) / 60));
const s = pad(Math.floor(t % 60));
const ms = padMs(Math.floor((t * 1000) % 1000));
const res = `${canvas.width}x${canvas.height}`;
const title = getVideoTitle();
// 修改命名規則:影片標題_小時_分鐘_秒_毫秒_BV號_解析度
const filename = `${title}_${h}_${m}_${s}_${ms}_${bvId}_${res}.png`;
canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 100);
}, 'image/png');
}
// ====== 插入截圖按鈕 ======
function insertScreenshotButton() {
const qualityBtn = document.querySelector('.bpx-player-ctrl-quality');
if (!qualityBtn || document.querySelector('.bili-screenshot-btn')) return;
const btn = document.createElement('div');
btn.className = 'bpx-player-ctrl-btn bili-screenshot-btn';
Object.assign(btn.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: '18px',
marginRight: '6px'
});
btn.title = getLangPack().screenshot;
btn.innerHTML = '📸';
btn.addEventListener('click', takeScreenshot);
qualityBtn.parentNode.insertBefore(btn, qualityBtn);
}
// ====== 監聽 DOM 插入按鈕(全程監聽,SPA跳轉也能偵測) ======
const observer = new MutationObserver(() => {
insertScreenshotButton();
// 動態更新 title
const btn = document.querySelector('.bili-screenshot-btn');
if (btn) btn.title = getLangPack().screenshot;
});
observer.observe(document.body, { childList: true, subtree: true });
// ====== 快捷鍵與連拍 ======
let holdTimer = null;
document.addEventListener('keydown', e => {
if (e.repeat) return;
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName) || e.target.isContentEditable) return;
hotkey = GM_getValue('hotkey', DEFAULTS.key);
interval = GM_getValue('interval', DEFAULTS.interval);
if (e.key.toUpperCase() === hotkey && !holdTimer) {
takeScreenshot();
holdTimer = setInterval(takeScreenshot, interval);
}
});
document.addEventListener('keyup', e => {
hotkey = GM_getValue('hotkey', DEFAULTS.key);
if (e.key.toUpperCase() === hotkey && holdTimer) {
clearInterval(holdTimer);
holdTimer = null;
}
});
// ====== SPA 路徑變化偵測(不 reload,只更新語言包) ======
let lastPath = location.pathname;
function onPathChange() {
if (location.pathname !== lastPath) {
lastPath = location.pathname;
// 只要是影片頁就更新按鈕 title
setTimeout(() => {
const btn = document.querySelector('.bili-screenshot-btn');
if (btn) btn.title = getLangPack().screenshot;
}, 500);
}
}
(function(history){
const pushState = history.pushState;
const replaceState = history.replaceState;
history.pushState = function() {
pushState.apply(this, arguments);
setTimeout(onPathChange, 100);
};
history.replaceState = function() {
replaceState.apply(this, arguments);
setTimeout(onPathChange, 100);
};
})(window.history);
window.addEventListener('popstate', () => setTimeout(onPathChange, 100));
// ====== 初始化 ======
insertScreenshotButton();
})();