Bilibili Live Master URL Copier

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.

安裝腳本?
作者推薦腳本

您可能也會喜歡 Bilibili Video MP4 Copier

安裝腳本
// ==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或关注我们的公众号极客氢云获取最新地址