Bilibili 直播流链接复制按钮

在Bilibili直播间上方添加一个“复制直播流链接”按钮。直播流链接可用在任意播放器,下载,或用于VRChat直播播放器。

当前为 2025-09-01 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name                 Bilibili Live Master URL Copier
// @name:zh-CN           Bilibili 直播流链接复制按钮
// @namespace            https://github.com/TZFC
// @version              0.1
// @description          Create a button above Bilibili Livestream that copies the stream url to clipboard. Can be used in custom player, download, or in VRChat stream video player.
// @description:zh-CN    在Bilibili直播间上方添加一个“复制直播流链接”按钮。直播流链接可用在任意播放器,下载,或用于VRChat直播播放器。
// @author               tianzifangchen
// @match                *://live.bilibili.com/*
// @icon                 https://www.bilibili.com/favicon.ico
// @license              GPL 3.0
// @run-at               document-start
// @grant                GM_setClipboard
// @grant                GM_xmlhttpRequest
// @connect              api.live.bilibili.com
// ==/UserScript==

(function () {
    'use strict';

    function wait_for_element_by_query(query_selector, timeout_milliseconds) {
        const start_time = Date.now();
        return new Promise((resolve) => {
            const interval_id = setInterval(() => {
                const node = document.querySelector(query_selector);
                if (node) {
                    clearInterval(interval_id);
                    resolve(node);
                } else if (Date.now() - start_time > timeout_milliseconds) {
                    clearInterval(interval_id);
                    resolve(null);
                }
            }, 150);
        });
    }

    function get_room_id_from_page() {
        const neptune = unsafeWindow.__NEPTUNE_IS_MY_WAIFU__;
        const possible_id = neptune && neptune.roomInitRes && neptune.roomInitRes.data && neptune.roomInitRes.data.room_id;
        if (possible_id) return Number(possible_id);
        const match = location.pathname.match(/\/(\d+)/);
        return Number(match && match[1]);
    }

    function build_play_info_url(room_id_number) {
        return `https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo?room_id=${room_id_number}&protocol=0,1&format=0,1,2&codec=0,1&qn=10000&platform=web&dolby=5&panorama=1`;
    }

    function gm_get_json(url_string) {
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url_string,
                headers: { 'Accept': 'application/json' },
                onload: function (response) {
                    resolve(JSON.parse(response.responseText));
                }
            });
        });
    }

    function depth_first_find_master_url(any_object) {
        const stack = [any_object];
        while (stack.length) {
            const current = stack.pop();
            if (!current) continue;
            if (typeof current === 'object') {
                for (const key of Object.keys(current)) {
                    const value = current[key];
                    if (key === 'master_url' && typeof value === 'string' && value.startsWith('http')) {
                        return value;
                    }
                    if (value && typeof value === 'object') stack.push(value);
                }
            }
        }
        return null;
    }

    function try_compose_hls_master_from_parts(playurl_object) {
        const streams = playurl_object && playurl_object.stream;
        if (!Array.isArray(streams)) return null;

        const hls_stream = streams.find(s => String(s.protocol_name).toLowerCase().includes('hls')) || streams[0];
        if (!hls_stream) return null;

        const first_format = Array.isArray(hls_stream.format) ? hls_stream.format[0] : null;
        const first_codec = first_format && Array.isArray(first_format.codec) ? first_format.codec[0] : null;
        if (!first_codec) return null;

        const url_info = Array.isArray(first_codec.url_info) ? first_codec.url_info[0] : null;
        const host = url_info && url_info.host;
        const base_url = first_codec.base_url;
        const extra = url_info && url_info.extra;

        if (typeof host === 'string' && typeof base_url === 'string' && typeof extra === 'string') {
            return `${host}${base_url}${extra}`;
        }
        return null;
    }

    function extract_master_url_from_play_info_json(play_info_json) {
        const playurl_object =
            play_info_json &&
            play_info_json.data &&
            play_info_json.data.playurl_info &&
            play_info_json.data.playurl_info.playurl;

        if (!playurl_object) return null;

        const by_key = depth_first_find_master_url(playurl_object);
        if (by_key) return by_key;

        return try_compose_hls_master_from_parts(playurl_object);
    }

    function create_header_button_node() {
        const wrapper = document.createElement('span');
        const button = document.createElement('button');

        wrapper.style.display = 'inline-block';
        button.id = 'copy-master-url-header-button';
        button.type = 'button';
        button.textContent = '复制 Master URL';
        button.classList.add('live-skin-normal-a-text');

        button.style.width = '7.5em';
        button.style.padding = '1px';
        button.style.background = 'transparent';
        button.style.border = '1.5px solid #999';
        button.style.borderRadius = '4px';
        button.style.color = '#999';
        button.style.filter = 'contrast(0.6)';
        button.style.cursor = 'pointer';
        button.addEventListener('mouseenter', () => { button.style.filter = 'none'; });
        button.addEventListener('mouseleave', () => { button.style.filter = 'contrast(0.6)'; });
        button.addEventListener('mousedown', () => { button.style.transform = 'translate(0.3px, 0.3px)'; });
        button.addEventListener('mouseup', () => { button.style.transform = 'none'; });

        wrapper.appendChild(button);
        return { wrapper, button };
    }

    async function mount_button_in_header_area() {
        const right_container =
            await wait_for_element_by_query('#head-info-vm .lower-row .right-ctnr', 180000) ||
            await wait_for_element_by_query('#head-info-vm .lower-row', 10000);

        if (!right_container) return;

        const { wrapper, button } = create_header_button_node();
        right_container.appendChild(wrapper);

        button.addEventListener('click', async () => {
            const room_id_number = get_room_id_from_page();
            const api_url_string = build_play_info_url(room_id_number);
            const play_info_json = await gm_get_json(api_url_string);
            const master_url_string = extract_master_url_from_play_info_json(play_info_json);

            GM_setClipboard(master_url_string, { type: 'text', mimetype: 'text/plain' });

            const original_text = button.textContent;
            button.textContent = '已复制';
            setTimeout(() => { button.textContent = original_text; }, 1000);
        });
    }

    if (/https:\/\/live\.bilibili\.com\/(blanc\/)?\d+/.test(location.href)) {
        mount_button_in_header_area();
    }
})();