Bilibili完整弹幕下载器(WBI认证版)

下载B站视频完整弹幕(携带WBI认证)

// ==UserScript==
// @name         Bilibili完整弹幕下载器(WBI认证版)
// @namespace    http://tampermonkey.net/
// @version      0.3
// @description  下载B站视频完整弹幕(携带WBI认证)
// @author       weiye
// @match        https://www.bilibili.com/video/*
// @grant        GM_xmlhttpRequest
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/protobuf.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js
// ==/UserScript==

(function() {
    'use strict';

    // WBI签名相关配置
    const mixinKeyEncTab = [
        46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
        33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
        61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
        36, 20, 34, 44, 52
    ];

    // 对 imgKey 和 subKey 进行字符顺序打乱编码
    const getMixinKey = (orig) => mixinKeyEncTab.map(n => orig[n]).join('').slice(0, 32);

    // 为请求参数进行 wbi 签名
    function encWbi(params, img_key, sub_key) {
        console.log('开始WBI签名, 参数:', params);
        const mixin_key = getMixinKey(img_key + sub_key);
        const curr_time = Math.round(Date.now() / 1000);
        const chr_filter = /[!'()*]/g;

        // 确保所有参数都是字符串
        const safeParams = {};
        for (const [key, value] of Object.entries(params)) {
            if (value !== undefined && value !== null) {
                // 先转换为字符串,再过滤特殊字符
                safeParams[key] = String(value).replace(chr_filter, '');
            }
        }
        
        // 添加 wts 字段
        safeParams.wts = curr_time;
        
        // 按照 key 重排参数
        const query = Object.keys(safeParams)
            .sort()
            .map(key => {
                return `${encodeURIComponent(key)}=${encodeURIComponent(safeParams[key])}`;
            })
            .join('&');

        // 使用 CryptoJS 计算 MD5
        const wbi_sign = CryptoJS.MD5(query + mixin_key).toString();
        console.log('WBI签名完成:', query + '&w_rid=' + wbi_sign);
        return query + '&w_rid=' + wbi_sign;
    }

    // 获取最新的 img_key 和 sub_key
    async function getWbiKeys() {
        console.log('开始获取WBI Keys...');
        const response = await fetch('https://api.bilibili.com/x/web-interface/nav', {
            headers: {
                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3',
                'Referer': 'https://www.bilibili.com/'
            }
        });
        
        const data = await response.json();
        if (!data.data?.wbi_img) {
            throw new Error('无法获取WBI Keys');
        }

        const { img_url, sub_url } = data.data.wbi_img;
        const img_key = img_url.slice(img_url.lastIndexOf('/') + 1, img_url.lastIndexOf('.'));
        const sub_key = sub_url.slice(sub_url.lastIndexOf('/') + 1, sub_url.lastIndexOf('.'));
        
        console.log('成功获取WBI Keys:', { img_key, sub_key });
        return { img_key, sub_key };
    }

    // 弹幕 protobuf 消息结构
    const danmakuProto = {
        nested: {
            DmSegMobileReply: {
                fields: {
                    elems: {
                        rule: "repeated",
                        type: "DanmakuElem",
                        id: 1
                    }
                }
            },
            DanmakuElem: {
                fields: {
                    id: { type: "int64", id: 1 },
                    progress: { type: "int32", id: 2 },
                    mode: { type: "int32", id: 3 },
                    fontsize: { type: "int32", id: 4 },
                    color: { type: "uint32", id: 5 },
                    midHash: { type: "string", id: 6 },
                    content: { type: "string", id: 7 },
                    ctime: { type: "int64", id: 8 },
                    weight: { type: "int32", id: 9 },
                    idStr: { type: "string", id: 10 },
                    attr: { type: "int32", id: 11 },
                    action: { type: "string", id: 12 }
                }
            }
        }
    };

    /**
     * 获取视频信息
     */
    async function getVideoInfo() {
        console.log('开始获取视频信息...');
        
        // 1. 从URL获取bvid
        const bvid = window.location.pathname.split('/')[2];
        if (!bvid) throw new Error('无法获取视频BV号');
        console.log('获取到BV号:', bvid);

        // 2. 从页面全局变量获取信息
        const initialState = window.__INITIAL_STATE__;
        if (initialState?.aid && initialState?.cid) {
            console.log('从页面变量获取视频信息:', {
                aid: initialState.aid,
                cid: initialState.cid,
                duration: initialState.duration
            });
            return {
                aid: initialState.aid,
                cid: initialState.cid,
                duration: initialState.duration || 0
            };
        }

        // 3. 如果页面变量获取失败,则通过API获取
        console.log('从API获取视频信息...');
        const response = await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`);
        const data = await response.json();
        
        if (data.code !== 0) throw new Error(`获取视频信息失败: ${data.message}`);
        if (!data.data?.aid || !data.data?.cid) throw new Error('无法获取视频信息');
        
        const result = {
            aid: data.data.aid,
            cid: data.data.cid,
            duration: data.data.duration
        };
        
        console.log('成功获取视频信息:', result);
        return result;
    }

    /**
     * 获取单个分段的弹幕
     */
    async function getSegmentDanmaku(cid, aid, segmentIndex, wbiKeys) {
        console.log(`获取第 ${segmentIndex} 段弹幕...`);
        
        // 准备参数
        const params = {
            type: 1,
            oid: cid,
            segment_index: segmentIndex,
            pid: aid,
            web_location: 1315873,
            wts: Math.round(Date.now() / 1000)
        };
        
        // WBI签名
        const query = encWbi(params, wbiKeys.img_key, wbiKeys.sub_key);
        const url = `https://api.bilibili.com/x/v2/dm/wbi/web/seg.so?${query}`;
        
        console.log('请求URL:', url);
        
        const response = await fetch(url);
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const buffer = await response.arrayBuffer();
        
        // 使用protobuf解析
        const root = protobuf.Root.fromJSON(danmakuProto);
        const DmSegMobileReply = root.lookupType("DmSegMobileReply");
        
        try {
            const message = DmSegMobileReply.decode(new Uint8Array(buffer));
            const danmakus = DmSegMobileReply.toObject(message, {
                longs: String,
                enums: String,
                bytes: String,
            }).elems || [];
            
            console.log(`第 ${segmentIndex} 段弹幕获取成功, 数量:`, danmakus.length);
            return danmakus;
        } catch (error) {
            console.error('Protobuf解析错误:', error);
            console.log('原始数据:', new Uint8Array(buffer));
            throw new Error('弹幕数据解析失败');
        }
    }

    /**
     * 下载弹幕
     */
    async function downloadDanmaku() {
        try {
            // 1. 获取WBI Keys
            const wbiKeys = await getWbiKeys();
            
            // 2. 获取视频信息
            const { cid, duration, aid } = await getVideoInfo();
            console.log('视频信息:', { cid, duration, aid });

            // 3. 计算分段数(每段6分钟)
            const segmentCount = Math.ceil(duration / 360);
            console.log(`需要获取 ${segmentCount} 个分段的弹幕`);

            // 4. 获取所有分段的弹幕
            const allDanmakus = new Set();
            for(let i = 1; i <= segmentCount; i++) {
                const danmakus = await getSegmentDanmaku(cid, aid, i, wbiKeys);
                danmakus.forEach(d => {
                    const time = (d.progress/1000).toFixed(2);
                    allDanmakus.add(`[${time}s] ${d.content}`);
                });
                if(i < segmentCount) {
                    await new Promise(resolve => setTimeout(resolve, 300));
                }
            }

            // 5. 按时间排序并下载
            const content = Array.from(allDanmakus)
                .sort((a, b) => {
                    const timeA = parseFloat(a.match(/\[(.*?)s\]/)[1]);
                    const timeB = parseFloat(b.match(/\[(.*?)s\]/)[1]);
                    return timeA - timeB;
                })
                .join('\n');

            const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = `danmaku_${cid}_full.txt`;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);

            console.log(`成功下载 ${allDanmakus.size} 条弹幕`);

        } catch (error) {
            console.error('获取弹幕失败:', error);
            alert('获取弹幕失败: ' + error.message);
        }
    }

    // 添加下载按钮
    const button = document.createElement('button');
    button.textContent = '下载完整弹幕(WBI)';
    button.style.cssText = 'position: fixed; right: 20px; top: 100px; z-index: 9999; padding: 8px 16px;';
    button.onclick = downloadDanmaku;
    document.body.appendChild(button);

})();

QingJ © 2025

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