Bilibili 直播流链接复制按钮

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

安装此脚本
作者推荐脚本

您可能也喜欢Bilibili 视频直链复制按钮

安装此脚本
// ==UserScript==
// @name                 Bilibili Live Master URL Copier
// @name:zh-CN           Bilibili 直播流链接复制按钮
// @namespace            https://github.com/TZFC
// @version              0.5
// @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-idle
// @grant                unsafeWindow
// @grant                GM_setClipboard
// @grant                GM_xmlhttpRequest
// @connect              api.live.bilibili.com
// ==/UserScript==

(function () {
    'use strict';

    const QN_LABEL = {
        30000: '杜比',
        25000: '默认',
        20000: '4K',
        10000: '原画',
        400:   '蓝光',
        250:   '超清',
        150:   '高清',
        80:    '流畅'
    };

    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 safely_get(obj, getter) {
        try { return getter(obj); } catch { return undefined; }
    }

    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 get_anchor_uid_from_page() {
        const neptune = unsafeWindow.__NEPTUNE_IS_MY_WAIFU__;
        const uid_from_anchor = safely_get(neptune, n => n.roomInitRes.data.anchor_info.base_info.uid);
        const uid_from_room   = safely_get(neptune, n => n.roomInitRes.data.room_info.uid);
        const uid = Number(uid_from_anchor || uid_from_room || 0);
        return uid || null;
    }

    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 build_master_gateway_url(cid_number, mid_number, qn_number) {
        const cid = `cid=${cid_number}`;
        const mid = `mid=${mid_number || 0}`;
        const qn  = `qn=${qn_number}`;
        const fixed = 'pt=web&p2p_type=-1&net=0&free_type=0&build=0&feature=2&drm_type=0&cam_id=0';
        return `https://api.live.bilibili.com/xlive/play-gateway/master/url?${cid}&${mid}&${qn}&${fixed}`;
    }

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

    function gm_fetch_raw(url_string) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url_string,
                responseType: 'text',
                onload: function (response) {
                    const content_type = (response.responseHeaders || '')
                        .split(/\r?\n/)
                        .find(h => /^content-type:/i.test(h)) || '';
                    const final_url = response.finalUrl || url_string;
                    resolve({
                        text: response.responseText || '',
                        contentType: content_type.split(':')[1]?.trim().toLowerCase() || '',
                        finalUrl: final_url,
                        status: response.status
                    });
                },
                onerror: reject
            });
        });
    }

    function depth_first_find_value_by_keys(any_object, keys_array) {
        const stack = [any_object];
        while (stack.length) {
            const current = stack.pop();
            if (!current || typeof current !== 'object') continue;
            for (const key of Object.keys(current)) {
                const value = current[key];
                if (keys_array.includes(key) && typeof value === 'string' && /^https?:\/\//.test(value)) {
                    return value;
                }
                if (value && typeof value === 'object') stack.push(value);
            }
        }
        return null;
    }

    function compose_from_codec(codec_obj) {
        const ui = Array.isArray(codec_obj.url_info) ? codec_obj.url_info[0] : null;
        const host = ui && ui.host;
        const base = codec_obj.base_url;
        const extra = ui && ui.extra;
        if (typeof host === 'string' && typeof base === 'string' && typeof extra === 'string') {
            return host + base + extra;
        }
        if (typeof codec_obj.url === 'string' && /^https?:\/\//.test(codec_obj.url)) {
            return codec_obj.url;
        }
        return null;
    }

    function extract_master_url_from_play_info_json(play_info_json) {
        const playurl = safely_get(play_info_json, j => j.data.playurl_info.playurl);
        if (!playurl) return null;
        const explicit = depth_first_find_value_by_keys(playurl, ['master_url', 'm3u8_master_url']);
        if (explicit) return explicit;
        const streams = Array.isArray(playurl.stream) ? playurl.stream : [];
        const sorted_streams = [...streams].sort((a, b) => {
            const ah = String(a.protocol_name || '').toLowerCase().includes('hls') ? -1 : 1;
            const bh = String(b.protocol_name || '').toLowerCase().includes('hls') ? -1 : 1;
            return ah - bh;
        });
        for (const s of sorted_streams) {
            const formats = Array.isArray(s.format) ? s.format : [];
            for (const f of formats) {
                const codecs = Array.isArray(f.codec) ? f.codec : [];
                for (const c of codecs) {
                    const u = compose_from_codec(c);
                    if (u) return u;
                }
            }
        }
        return null;
    }

    function extract_cid_from_play_info_json(play_info_json) {
        const cid = safely_get(play_info_json, j => j.data.playurl_info.playurl.cid)
                 || safely_get(play_info_json, j => j.data.playurl_info.playurl.video_project.cid);
        return Number(cid || 0) || null;
    }

    function compose_url_for_selected_qn_from_play_info(play_info_json, selected_qn) {
        const playurl = safely_get(play_info_json, j => j.data.playurl_info.playurl);
        if (!playurl) return null;
        const streams = Array.isArray(playurl.stream) ? playurl.stream : [];
        const sorted_streams = [...streams].sort((a, b) => {
            const ah = String(a.protocol_name || '').toLowerCase().includes('hls') ? -1 : 1;
            const bh = String(b.protocol_name || '').toLowerCase().includes('hls') ? -1 : 1;
            return ah - bh;
        });
        for (const s of sorted_streams) {
            const formats = Array.isArray(s.format) ? s.format : [];
            for (const f of formats) {
                const codecs = Array.isArray(f.codec) ? f.codec : [];
                const exact = codecs.find(c => Number(c.current_qn) === Number(selected_qn));
                if (exact) {
                    const u = compose_from_codec(exact);
                    if (u) return u;
                }
            }
        }
        for (const s of sorted_streams) {
            const formats = Array.isArray(s.format) ? s.format : [];
            for (const f of formats) {
                const codecs = Array.isArray(f.codec) ? f.codec : [];
                for (const c of codecs) {
                    const u = compose_from_codec(c);
                    if (u) return u;
                }
            }
        }
        return null;
    }

    function collect_available_qn_and_current(play_info_json) {
        const playurl = safely_get(play_info_json, j => j.data.playurl_info.playurl);
        const available_set = new Set();
        let current_max_qn = null;
        const g_desc = safely_get(playurl, p => p.g_qn_desc) || [];
        for (const item of g_desc) {
            const qn = Number(item.qn);
            if (!Number.isNaN(qn)) available_set.add(qn);
        }
        const streams = safely_get(playurl, p => p.stream) || [];
        for (const s of streams) {
            const formats = s && s.format || [];
            for (const f of formats) {
                const codecs = f && f.codec || [];
                for (const c of codecs) {
                    const cur = Number(c.current_qn);
                    if (!Number.isNaN(cur)) {
                        available_set.add(cur);
                        if (current_max_qn === null || cur > current_max_qn) current_max_qn = cur;
                    }
                    const accepts = c.accept_qn || c.acceptQn || [];
                    for (const a of accepts) {
                        const n = Number(a);
                        if (!Number.isNaN(n)) available_set.add(n);
                    }
                }
            }
        }
        if (current_max_qn === null) current_max_qn = 10000;
        const filtered_sorted = Array.from(available_set).filter(q => q <= current_max_qn).sort((a, b) => b - a);
        if (filtered_sorted.length === 0) filtered_sorted.push(current_max_qn);
        return { available_qn_sorted: filtered_sorted, current_max_qn };
    }

    function create_header_controls_node() {
        const wrapper = document.createElement('span');
        const button = document.createElement('button');
        const select = document.createElement('select');
        wrapper.style.display = 'inline-flex';
        wrapper.style.gap = '6px';
        wrapper.style.alignItems = 'center';
        button.id = 'copy-master-url-header-button';
        button.type = 'button';
        button.textContent = '复制直播流URL';
        button.classList.add('live-skin-normal-a-text');
        button.style.width = '8.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'; });
        select.id = 'quality-number-selector';
        select.style.height = '22px';
        select.style.border = '1.5px solid #999';
        select.style.borderRadius = '4px';
        select.style.background = 'transparent';
        select.style.color = '#999';
        select.style.filter = 'contrast(0.8)';
        select.classList.add('live-skin-normal-a-text');
        wrapper.appendChild(button);
        wrapper.appendChild(select);
        return { wrapper, button, select };
    }

    function fill_quality_selector(select_node, qn_list, default_qn) {
        select_node.innerHTML = '';
        const unique_sorted = Array.from(new Set(qn_list)).sort((a, b) => b - a);
        for (const qn of unique_sorted) {
            const opt = document.createElement('option');
            const label = QN_LABEL[qn] ? `${QN_LABEL[qn]} (${qn})` : `品质 ${qn}`;
            opt.value = String(qn);
            opt.textContent = label;
            select_node.appendChild(opt);
        }
        const default_index = unique_sorted.findIndex(q => q === default_qn);
        select_node.selectedIndex = default_index >= 0 ? default_index : 0;
    }

    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, select } = create_header_controls_node();
        right_container.appendChild(wrapper);

        const room_id_number = get_room_id_from_page();
        if (!room_id_number) {
            button.textContent = '未获取房间号';
            return;
        }

        let play_info_json = null;
        try {
            const url = build_play_info_url(room_id_number);
            play_info_json = await gm_get_json(url);
        } catch (e) {
            console.log('[bili-copy] 获取房间播放信息失败', e);
        }

        const { available_qn_sorted, current_max_qn } = collect_available_qn_and_current(play_info_json || {});
        fill_quality_selector(select, available_qn_sorted, current_max_qn);

        const cid = extract_cid_from_play_info_json(play_info_json || {});
        const mid = get_anchor_uid_from_page();

        button.addEventListener('click', async () => {
            const original_text = button.textContent;
            try {
                const selected_qn = Number(select.value || current_max_qn);
                if (cid) {
                    const master_url = build_master_gateway_url(cid, mid, selected_qn);
                    const gw = await gm_fetch_raw(master_url);
                    let result_url = null;
                    if (gw.text.trim().startsWith('{')) {
                        try {
                            const gateway_json = JSON.parse(gw.text);
                            result_url = depth_first_find_value_by_keys(gateway_json, ['master_url', 'm3u8_master_url']);
                            if (result_url) console.log('[bili-copy] 使用JSON返回的master url');
                        } catch (_) {}
                    }
                    if (!result_url) {
                        const looks_like_m3u8 = gw.text.startsWith('#EXTM3U') || /apple\.mpegurl|mpegurl|m3u8/.test(gw.contentType);
                        if (looks_like_m3u8) {
                            result_url = gw.finalUrl;
                            console.log('[bili-copy] 网关直接返回M3U8,使用finalUrl');
                        }
                    }
                    if (!result_url) {
                        result_url = compose_url_for_selected_qn_from_play_info(play_info_json || {}, selected_qn)
                                  || extract_master_url_from_play_info_json(play_info_json || {});
                        if (result_url) console.log('[bili-copy] 使用回退URL');
                    }
                    if (result_url) {
                        GM_setClipboard(result_url, { type: 'text', mimetype: 'text/plain' });
                        button.textContent = '已复制';
                        setTimeout(() => { button.textContent = original_text; }, 1000);
                        return;
                    }
                }
                const fallback_url = compose_url_for_selected_qn_from_play_info(play_info_json || {}, Number(select.value || current_max_qn))
                                  || extract_master_url_from_play_info_json(play_info_json || {});
                if (fallback_url) {
                    GM_setClipboard(fallback_url, { type: 'text', mimetype: 'text/plain' });
                    console.log('[bili-copy] 无cid/网关失败,使用备用URL');
                    button.textContent = '已复制(备用)';
                    setTimeout(() => { button.textContent = original_text; }, 1000);
                } else {
                    button.textContent = '未找到链接';
                    setTimeout(() => { button.textContent = original_text; }, 1200);
                }
            } catch (err) {
                console.log('[bili-copy] 复制失败', err);
                button.textContent = '出错';
                setTimeout(() => { button.textContent = original_text; }, 1200);
            }
        });
    }

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

QingJ © 2025

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