B 站浏览助手

可在当前页面查看B站的字幕和封面,支持字幕下载

// ==UserScript==
// @name         B 站浏览助手
// @namespace    Rhttps://www.runningcheese.com/userscripts
// @description  可在当前页面查看B站的字幕和封面,支持字幕下载
// @author       RunningCheese
// @version      1.0
// @match        http*://www.bilibili.com/video/*
// @icon         https://t1.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&size=32&url=https://www.bilibili.com
// @license      MIT
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // 简化的元素创建工具
    const elements = {
        createAs(nodeType, config, appendTo) {
            const element = document.createElement(nodeType);
            if (config) {
                Object.entries(config).forEach(([key, value]) => {
                    element[key] = value;
                });
            }
            if (appendTo) appendTo.appendChild(element);
            return element;
        },
        getAs(selector) {
            return document.body.querySelector(selector);
        }
    };

    // 简化的fetch函数
    function fetch(url, option = {}) {
        return new Promise((resolve, reject) => {
            const req = new XMLHttpRequest();
            req.onreadystatechange = () => {
                if (req.readyState === 4) {
                    resolve({
                        ok: req.status >= 200 && req.status <= 299,
                        status: req.status,
                        statusText: req.statusText,
                        json: () => Promise.resolve(JSON.parse(req.responseText)),
                        text: () => Promise.resolve(req.responseText)
                    });
                }
            };
            if (option.credentials == 'include') req.withCredentials = true;
            req.onerror = reject;
            req.open('GET', url);
            req.send();
        });
    }

    // 创建预览图片元素
    const preview = elements.createAs("img", {
        id: "preview",
        style: `
            position: absolute;
            z-index: 2000;
            max-width: 60vw;
            max-height: 60vh;
            border: 1px solid #fb7299;
            border-radius: 4px;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
            display: none;
        `
    }, document.body);

        // 创建字幕显示面板
    const subtitlePanel = elements.createAs("div", {
        id: "subtitle-panel",
        style: `
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 320px;
            max-width: 800px;
            max-height: 80vh;
            background-color: white;
            border-radius: 8px;
            box-shadow:0 4px 12px rgba(0,0,0,0.25);
            z-index: 10000;
            display: none;
            flex-direction: column;
            overflow: hidden;
        `
    }, document.body);

    // 创建字幕面板标题栏
    const subtitleHeader = elements.createAs("div", {
        style: `
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 5px 10px;
            background-color: #F07C99;
            color: white;
            font-weight: bold;
            border-top-left-radius: 8px;
            border-top-right-radius: 8px;
            cursor: move; /* 添加移动光标样式 */
        `
    }, subtitlePanel);

    // 添加拖动功能
    let isDragging = false;
    let offsetX, offsetY;

    // 鼠标按下事件
    subtitleHeader.onmousedown = function(e) {
        isDragging = true;

        // 计算鼠标在面板内的相对位置
        const rect = subtitlePanel.getBoundingClientRect();
        offsetX = e.clientX - rect.left;
        offsetY = e.clientY - rect.top;

        // 移除transform属性,使定位更直接
        subtitlePanel.style.transform = 'none';

        // 更新面板位置为当前位置
        subtitlePanel.style.left = rect.left + 'px';
        subtitlePanel.style.top = rect.top + 'px';

        // 防止选中文本
        e.preventDefault();
    };

    // 鼠标移动事件
    document.addEventListener('mousemove', function(e) {
        if (!isDragging) return;

        // 计算新位置
        let newLeft = e.clientX - offsetX;
        let newTop = e.clientY - offsetY;

        // 获取面板尺寸
        const rect = subtitlePanel.getBoundingClientRect();

        // 防止面板移出视口
        newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - rect.width));
        newTop = Math.max(0, Math.min(newTop, window.innerHeight - rect.height));

        // 更新位置
        subtitlePanel.style.left = newLeft + 'px';
        subtitlePanel.style.top = newTop + 'px';
    });

    // 鼠标释放事件
    document.addEventListener('mouseup', function() {
        isDragging = false;
    });

    // 鼠标离开窗口事件
    document.addEventListener('mouseleave', function() {
        isDragging = false;
    });

    // 创建字幕标题
    elements.createAs("div", {
        id: "subtitle-title",
        textContent: "视频字幕",
        style: `
            font-size: 14px;
        `
    }, subtitleHeader);

    // ... 其余代码保持不变 ...

    // 创建按钮容器
    const buttonContainer = elements.createAs("div", {
        style: `
            display: flex;
            gap: 10px;
        `
    }, subtitleHeader);

    // 创建下载按钮
    const downloadBtn = elements.createAs("button", {
        textContent: "下载",
        style: `
            background-color: #fb7299;
            color: white;
            border: none;
            border-radius: 4px;
            padding: 5px 10px;
            cursor: pointer;
            font-size: 14px;
        `,
        onclick: function() {
            const subtitleContent = document.getElementById('subtitle-content').textContent;
            const blob = new Blob([subtitleContent], {type: 'text/plain;charset=utf-8'});
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = `bilibili_subtitle_${new Date().getTime()}.txt`;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
        }
    }, buttonContainer);

    // 创建关闭按钮
    const closeBtn = elements.createAs("button", {
        textContent: "关闭",
        style: `
            background-color: #fb7299;
            color: white;
            border: none;
            border-radius: 4px;
            padding: 5px 10px;
            cursor: pointer;
            font-size: 14px;
        `,
        onclick: function() {
            subtitlePanel.style.display = 'none';
        }
    }, buttonContainer);

    // 创建字幕内容区域
    const subtitleContent = elements.createAs("div", {
        id: "subtitle-content",
        style: `
            padding: 15px;
            overflow-y: auto;
            max-height: calc(80vh - 50px);
            line-height: 1.6;
            white-space: pre-wrap;
            font-size: 14px;
        `
    }, subtitlePanel);

    // 添加CSS样式
    const style = elements.createAs('style', {
        textContent: `
            .bili-icon-btn {
                display: inline-flex;
                align-items: center;
                justify-content: center;
                width: 16px;
                height: 16px;
                border-radius: 4px;
                cursor: pointer;
                margin-left: 10px;
                transition: background-color 0.3s;
            }

            .bili-icon-btn svg {
                width: 12px;
                height: 12px;
                fill: currentColor;
            }

            .bili-subtitle-btn {
                color: white;
                background-color: #00a1d6;
            }

            .bili-subtitle-btn:hover {
                background-color: #00b5e5;
                color: white;
            }

            .bili-cover-btn {
                color: white;
                background-color: #fb7299;
            }

            .bili-cover-btn:hover {
                background-color: #fc8bab;
                color: white;
            }

            #subtitle-panel button:hover {
                opacity: 0.9;
            }
        `
    }, document.head);

    // B站字幕和封面查看器主体
    const bilibiliViewer = {
        window: "undefined" == typeof(unsafeWindow) ? window : unsafeWindow,
        cid: undefined,
        subtitle: undefined,
        pcid: undefined,
        buttonAdded: false,
        buttonCheckInterval: null,

        toast(msg, error) {
            if (error) console.error(msg, error);
            if (!this.toastDiv) {
                this.toastDiv = document.createElement('div');
                this.toastDiv.className = 'bilibili-player-video-toast-item';
            }
            const panel = elements.getAs('.bilibili-player-video-toast-top');
            if (!panel) return;
            clearTimeout(this.removeTimmer);
            this.toastDiv.innerText = msg + (error ? `:${error}` : '');
            panel.appendChild(this.toastDiv);
            this.removeTimmer = setTimeout(() => {
                panel.contains(this.toastDiv) && panel.removeChild(this.toastDiv);
            }, 3000);
        },

        getSubtitle(lan, name) {
            const item = this.getSubtitleInfo(lan, name);
            if (!item) throw('找不到所选语言字幕' + lan);

            return fetch(item.subtitle_url)
                .then(res => res.json());
        },

        getSubtitleInfo(lan, name) {
            return this.subtitle.subtitles.find(item => item.lan == lan || item.lan_doc == name);
        },

        getInfo(name) {
            return this.window[name]
            || this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__[name]
            || this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__.epInfo && this.window.__INITIAL_STATE__.epInfo[name]
            || this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__.videoData && this.window.__INITIAL_STATE__.videoData[name];
        },

        getEpid() {
            return this.getInfo('id')
            || /ep(\d+)/.test(location.pathname) && +RegExp.$1
            || /ss\d+/.test(location.pathname);
        },

        getEpInfo() {
            const bvid = this.getInfo('bvid'),
                  epid = this.getEpid(),
                  cidMap = this.getInfo('cidMap'),
                  page = this?.window?.__INITIAL_STATE__?.p;
            let ep = cidMap?.[bvid];
            if (ep) {
                this.aid = ep.aid;
                this.bvid = ep.bvid;
                this.cid = ep.cids[page];
                return this.cid;
            }
            ep = this.window.__NEXT_DATA__?.props?.pageProps?.dehydratedState?.queries
            ?.find(query => query?.queryKey?.[0] == "pgc/view/web/season")
            ?.state?.data;
            ep = (ep?.seasonInfo ?? ep)?.mediaInfo?.episodes
            ?.find(ep => epid == true || ep.ep_id == epid);
            if (ep) {
                this.epid = ep.ep_id;
                this.cid = ep.cid;
                this.aid = ep.aid;
                this.bvid = ep.bvid;
                return this.cid;
            }
            ep = this.window.__INITIAL_STATE__?.epInfo;
            if (ep) {
                this.epid = ep.id;
                this.cid = ep.cid;
                this.aid = ep.aid;
                this.bvid = ep.bvid;
                return this.cid;
            }
            ep = this.window.playerRaw?.getManifest();
            if (ep) {
                this.epid = ep.episodeId;
                this.cid = ep.cid;
                this.aid = ep.aid;
                this.bvid = ep.bvid;
                return this.cid;
            }
        },

        async setupData() {
            if (this.subtitle && (this.pcid == this.getEpInfo())) return this.subtitle;

            if (location.pathname == '/blackboard/html5player.html') {
                let match = location.search.match(/cid=(\d+)/i);
                if (!match) return;
                this.window.cid = match[1];
                match = location.search.match(/aid=(\d+)/i);
                if (match) this.window.aid = match[1];
                match = location.search.match(/bvid=(\d+)/i);
                if (match) this.window.bvid = match[1];
            }

            this.pcid = this.getEpInfo();
            if ((!this.cid && !this.epid) || (!this.aid && !this.bvid)) return;

            this.player = this.window.player;
            this.subtitle = {count: 0, subtitles: []};

            return fetch(`https://api.bilibili.com/x/player${this.cid ? '/wbi' : ''}/v2?${this.cid ? `cid=${this.cid}` : `&ep_id=${this.epid}`}${this.aid ? `&aid=${this.aid}` : `&bvid=${this.bvid}`}`, {credentials: 'include'}).then(res => {
                if (res.status == 200) {
                    return res.json().then(ret => {
                        if (ret.code == -404) {
                            return fetch(`//api.bilibili.com/x/v2/dm/view?${this.aid ? `aid=${this.aid}` : `bvid=${this.bvid}`}&oid=${this.cid}&type=1`, {credentials: 'include'}).then(res => {
                                return res.json();
                            }).then(ret => {
                                if (ret.code != 0) throw('无法读取本视频APP字幕配置' + ret.message);
                                this.subtitle = ret.data && ret.data.subtitle || {subtitles: []};
                                this.subtitle.count = this.subtitle.subtitles.length;
                                this.subtitle.subtitles.forEach(item => (item.subtitle_url = item.subtitle_url.replace(/https?:\/\//, '//')));
                                return this.subtitle;
                            });
                        }
                        if (ret.code != 0 || !ret.data || !ret.data.subtitle) throw('读取视频字幕配置错误:' + ret.code + ret.message);
                        this.subtitle = ret.data.subtitle;
                        this.subtitle.count = this.subtitle.subtitles.length;
                        return this.subtitle;
                    });
                } else {
                    throw('请求字幕配置失败:' + res.statusText);
                }
            });
        },

        // 获取B站视频封面URL
        getBiliCoverUrl() {
            try {
                // 尝试从meta标签获取封面
                const metaImage = document.querySelector('meta[itemprop=image]');
                if (metaImage) {
                    return metaImage.content.replace(/@100w_100h_1c.png/g, '');
                }

                // 尝试其他方法获取封面
                const ogImage = document.querySelector('meta[property="og:image"]');
                if (ogImage) {
                    return ogImage.content.replace(/@100w_100h_1c.png/g, '');
                }

                // 尝试从视频页面获取封面
                const videoInfo = this.window.__INITIAL_STATE__?.videoData;
                if (videoInfo && videoInfo.pic) {
                    return videoInfo.pic;
                }

                return null;
            } catch (error) {
                console.error('获取B站封面出错:', error);
                return null;
            }
        },

        // 添加字幕和封面按钮到视频标题后面
        addButtons() {
            // 如果按钮已添加,则不重复添加
            if (elements.getAs('#subtitle-viewer-btn') && elements.getAs('#cover-viewer-btn')) {
                return;
            }

            // 查找视频标题元素
            const titleElement = elements.getAs('.video-title') || // 普通视频页面
                               elements.getAs('.media-title') || // 番剧页面
                               elements.getAs('.tit') || // 其他可能的标题类
                               elements.getAs('.bpx-player-video-title'); // 新版播放器标题

            if (!titleElement) {
                console.log('找不到视频标题元素');
                return;
            }

            // 创建封面按钮(放在前面)
            if (!elements.getAs('#cover-viewer-btn')) {
                const coverBtn = elements.createAs('a', {
                    id: 'cover-viewer-btn',
                    className: 'bili-icon-btn bili-cover-btn',
                    title: '查看视频封面',
                    innerHTML: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/><path d="M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z"/></svg>',
                    onmouseenter: (e) => this.showCoverPreview(e),
                    onmouseleave: () => this.hideCoverPreview(),
                    onclick: () => this.openCoverInNewTab()
                }, titleElement);
            }

            // 创建字幕按钮(放在后面)
            if (!elements.getAs('#subtitle-viewer-btn')) {
                const subtitleBtn = elements.createAs('a', {
                    id: 'subtitle-viewer-btn',
                    className: 'bili-icon-btn bili-subtitle-btn',
                    title: '获取视频字幕',
                    innerHTML: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.5a1 1 0 0 0-.8.4l-1.9 2.533a1 1 0 0 1-1.6 0L5.3 12.4a1 1 0 0 0-.8-.4H2a2 2 0 0 1-2-2V2zm7.194 2.766a1.688 1.688 0 0 0-.227-.272 1.467 1.467 0 0 0-.469-.324l-.008-.004A1.785 1.785 0 0 0 5.734 4C4.776 4 4 4.746 4 5.667c0 .92.776 1.666 1.734 1.666.343 0 .662-.095.931-.26-.137.389-.39.804-.81 1.22a.405.405 0 0 0 .011.59c.173.16.447.155.614-.01 1.334-1.329 1.37-2.758.941-3.706a2.461 2.461 0 0 0-.227-.4zM11 7.073c-.136.389-.39.804-.81 1.22a.405.405 0 0 0 .012.59c.172.16.446.155.613-.01 1.334-1.329 1.37-2.758.942-3.706a2.466 2.466 0 0 0-.228-.4 1.686 1.686 0 0 0-.227-.273 1.466 1.466 0 0 0-.469-.324l-.008-.004A1.785 1.785 0 0 0 10.07 4c-.957 0-1.734.746-1.734 1.667 0 .92.777 1.666 1.734 1.666.343 0 .662-.095.931-.26z"/></svg>',
                    onclick: () => this.showSubtitleInPanel()
                }, titleElement);
            }

            this.buttonAdded = true;
            console.log('B站字幕和封面查看按钮已添加到标题后面');
        },

       // 在面板中显示字幕
        showSubtitleInPanel() {
            if (!this.subtitle || this.subtitle.count === 0) {
                this.toast('当前视频没有可用字幕');
                return;
            }

            // 获取第一个可用字幕
            const firstSubtitle = this.subtitle.subtitles[0];
            if (!firstSubtitle) {
                this.toast('无法获取字幕信息');
                return;
            }

            // 更新标题显示字幕语言
            document.getElementById('subtitle-title').textContent = `视频字幕 (${firstSubtitle.lan_doc || firstSubtitle.lan})`;

            // 显示加载中
            subtitleContent.textContent = '正在加载字幕...';
            subtitlePanel.style.display = 'flex';

            this.getSubtitle(firstSubtitle.lan)
                .then(data => {
                    if (!data || !(data.body instanceof Array)) {
                        throw '数据错误';
                    }

                    // 只提取字幕内容,不包含时间戳
                    const formattedSubtitle = data.body.map(item => item.content).join('\r\n');

                    // 显示字幕内容
                    subtitleContent.textContent = formattedSubtitle;
                })
                .catch(e => {
                    subtitleContent.textContent = `获取字幕失败: ${e}`;
                    this.toast('获取字幕失败', e);
                });
        },

        // 格式化时间为 mm:ss.ms 格式
        formatTime(seconds) {
            const min = Math.floor(seconds / 60);
            const sec = Math.floor(seconds % 60);
            const ms = Math.floor((seconds % 1) * 100);
            return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`;
        },

        // 显示封面预览
        showCoverPreview(event) {
            const coverUrl = this.getBiliCoverUrl();
            if (coverUrl) {
                preview.src = coverUrl;

                // 获取按钮位置
                const rect = event.currentTarget.getBoundingClientRect();

                // 设置预览图片位置在按钮右下角
                preview.style.left = (rect.right + 10) + 'px';
                preview.style.top = rect.top + 'px';

                // 重置任何可能的宽高限制,让图片先以原始大小加载
                preview.style.width = 'auto';
                preview.style.height = 'auto';

                // 图片加载完成后检查大小
                preview.onload = () => {
                    const screenWidth = window.innerWidth * 0.6;
                    const screenHeight = window.innerHeight * 0.6;

                    // 如果图片尺寸超过屏幕60%,则按比例缩小
                    if (preview.naturalWidth > screenWidth || preview.naturalHeight > screenHeight) {
                        const widthRatio = screenWidth / preview.naturalWidth;
                        const heightRatio = screenHeight / preview.naturalHeight;
                        const ratio = Math.min(widthRatio, heightRatio);

                        preview.style.width = (preview.naturalWidth * ratio) + 'px';
                        preview.style.height = (preview.naturalHeight * ratio) + 'px';
                    } else {
                        // 使用原始大小
                        preview.style.width = preview.naturalWidth + 'px';
                        preview.style.height = preview.naturalHeight + 'px';
                    }

                    // 确保预览图片不超出视口
                    const previewRect = preview.getBoundingClientRect();

                    // 检查右边界
                    if (previewRect.right > window.innerWidth) {
                        preview.style.left = (rect.left - previewRect.width - 10) + 'px';
                    }

                    // 检查下边界
                    if (previewRect.bottom > window.innerHeight) {
                        preview.style.top = (window.innerHeight - previewRect.height - 10) + 'px';
                    }

                    preview.style.display = 'block';
                };
            } else {
                console.log('未找到封面图片');
            }
        },

        // 隐藏封面预览
        hideCoverPreview() {
            preview.style.display = 'none';
        },

        // 在新标签页打开封面
        openCoverInNewTab() {
            const coverUrl = this.getBiliCoverUrl();
            if (coverUrl) {
                window.open(coverUrl, '_blank');
            } else {
                this.toast('无法获取视频封面');
            }
        },

        // 重置状态,用于页面切换时
        reset() {
            this.buttonAdded = false;
            this.subtitle = null;
            this.pcid = null;

            // 清除定时检查
            if (this.buttonCheckInterval) {
                clearInterval(this.buttonCheckInterval);
                this.buttonCheckInterval = null;
            }
        },

        // 启动定时检查按钮是否存在
        startButtonCheck() {
            // 清除可能存在的旧定时器
            if (this.buttonCheckInterval) {
                clearInterval(this.buttonCheckInterval);
            }

            // 每2秒检查一次按钮是否存在
            this.buttonCheckInterval = setInterval(() => {
                if (!elements.getAs('#subtitle-viewer-btn') || !elements.getAs('#cover-viewer-btn')) {
                    console.log('按钮已消失,重新添加');
                    this.buttonAdded = false;
                    this.addButtons();
                }
            }, 2000);
        },

        init() {
            this.setupData().then(subtitle => {
                if (!subtitle) return;
                this.addButtons();
                this.startButtonCheck(); // 启动按钮检查
                console.log('B站字幕和封面查看器初始化成功');
            }).catch(e => {
                console.error('B站字幕和封面查看器初始化失败', e);
            });

            // 监听页面变化,处理SPA页面跳转
            let lastUrl = location.href;
            new MutationObserver((mutations, observer) => {
                // 检测URL变化,如果变化则重置状态
                if (lastUrl !== location.href) {
                    lastUrl = location.href;
                    this.reset();

                    // 在URL变化后重新初始化
                    setTimeout(() => {
                        this.setupData().then(subtitle => {
                            if (!subtitle) return;
                            this.addButtons();
                            this.startButtonCheck();
                        }).catch(e => {
                            console.error('B站字幕和封面查看器重新初始化失败', e);
                        });
                    }, 1000); // 延迟1秒,等待页面加载
                }

                // 监听DOM变化,在关键元素变化时重新添加按钮
                for (const mutation of mutations) {
                    if (!mutation.target) continue;
                    if (mutation.target.getAttribute('stage') == 1 ||
                        mutation.target.classList.contains('bpx-player-subtitle-wrap') ||
                        mutation.target.classList.contains('tit') ||
                        mutation.target.classList.contains('bpx-player-ctrl-subtitle-bilingual') ||
                        mutation.target.classList.contains('squirtle-quality-wrap') ||
                        mutation.target.classList.contains('video-title') ||
                        mutation.target.classList.contains('media-title')) {

                        // 如果按钮已添加,则不重复初始化
                        if (!elements.getAs('#subtitle-viewer-btn') || !elements.getAs('#cover-viewer-btn')) {
                            this.setupData().then(subtitle => {
                                if (!subtitle) return;
                                this.addButtons();
                            });
                        }
                        break;
                    }
                }
            }).observe(document.body, {
                childList: true,
                subtree: true,
            });
        }
    };

    // 初始化
    bilibiliViewer.init();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址