您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在Bilibili直播间上方添加一个“复制直播流链接”按钮。直播流链接可用在任意播放器,下载,或用于VRChat直播播放器。
// ==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或关注我们的公众号极客氢云获取最新地址