您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
下拉列出 current_qn + accept_qn。默认选中最低 fMP4 清晰度。点击复制时按所选清晰度请求网关返回正确 URL/主机。偏好 HLS fMP4。
// ==UserScript== // @name Bilibili Live URL Copy // @name:zh-CN Bilibili 直播流链接复制器 // @namespace https://github.com/TZFC // @version 1.1 // @description Dropdown lists QNs from current_qn + accept_qn. Default selection = lowest fMP4 QN. On copy, query Bilibili gateway with that QN to get the correct URL/host. Prefers HLS fMP4. // @description:zh-CN 下拉列出 current_qn + accept_qn。默认选中最低 fMP4 清晰度。点击复制时按所选清晰度请求网关返回正确 URL/主机。偏好 HLS fMP4。 // @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_map = { 30000: '杜比', 25000: '默认', 20000: '4K', 15000: '2K', 10000: '原画', 400: '蓝光', 250: '超清', 150: '高清', 80: '流畅' }; // Prefer HLS fmp4 → HLS ts → FLV (fallback order only) const format_priority = { fmp4: 1, ts: 2, flv: 3 }; function wait_for_element(query_selector, timeout_ms) { const start_time = Date.now(); return new Promise((resolve) => { const timer = setInterval(() => { const node = document.querySelector(query_selector); if (node) { clearInterval(timer); resolve(node); return; } if (Date.now() - start_time > timeout_ms) { clearInterval(timer); resolve(null); } }, 150); }); } function safe_get(getter) { try { return getter(); } catch { return undefined; } } function get_room_id() { const neptune = unsafeWindow.__NEPTUNE_IS_MY_WAIFU__; const by_neptune = safe_get(() => neptune.roomInitRes.data.room_id); if (by_neptune) return Number(by_neptune); const m = location.pathname.match(/\/(\d+)/); return m ? Number(m[1]) : null; } function get_anchor_uid_from_page() { const neptune = unsafeWindow.__NEPTUNE_IS_MY_WAIFU__; const a = safe_get(() => neptune.roomInitRes.data.anchor_info.base_info.uid); const r = safe_get(() => neptune.roomInitRes.data.room_info.uid); const uid = Number(a || r || 0); return uid || 0; } 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_gateway_url(cid, mid, qn) { const cid_s = `cid=${cid}`; const mid_s = `mid=${mid || 0}`; const qn_s = `qn=${qn}`; 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_s}&${mid_s}&${qn_s}&${fixed}`; } function gm_get_json(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers: { 'Accept': 'application/json' }, onload: (res) => { try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(e); } }, onerror: reject }); }); } function gm_fetch_raw(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'text', onload: (r) => { const ct = (r.responseHeaders || '') .split(/\r?\n/).find(h => /^content-type:/i.test(h)) || ''; resolve({ text: r.responseText || '', contentType: ct.split(':')[1]?.trim().toLowerCase() || '', finalUrl: r.finalUrl || url, status: r.status }); }, onerror: reject }); }); } function depth_find_url(obj, keys) { const stack = [obj]; while (stack.length) { const cur = stack.pop(); if (!cur || typeof cur !== 'object') continue; for (const k of Object.keys(cur)) { const v = cur[k]; if (keys.includes(k) && typeof v === 'string' && /^https?:\/\//.test(v)) return v; if (v && typeof v === 'object') stack.push(v); } } return null; } function extract_cid(play_info_json) { const cid = safe_get(() => play_info_json.data.playurl_info.playurl.cid) || safe_get(() => play_info_json.data.playurl_info.playurl.video_project.cid); return Number(cid || 0) || null; } function choose_direct_url_for_current_qn(play_info_json, target_qn) { const playurl = safe_get(() => play_info_json.data.playurl_info.playurl) || {}; const streams = Array.isArray(playurl.stream) ? playurl.stream : []; let best = null, best_score = 1e9; for (const s of streams) { const formats = Array.isArray(s.format) ? s.format : []; for (const f of formats) { const fmt = String(f.format_name || '').toLowerCase(); // ts | fmp4 | flv const codecs = Array.isArray(f.codec) ? f.codec : []; for (const c of codecs) { if (Number(c.current_qn) !== Number(target_qn)) continue; const info = Array.isArray(c.url_info) ? c.url_info[0] : null; const host = info && info.host; const base = c.base_url; const extra = info && info.extra; if (host && base && extra) { const score = format_priority[fmt] ?? 99; if (score < best_score) { best_score = score; best = host + base + extra; } } } } } return best; } /** * Collect all QNs (current + accept). * Also collect QNs that are available specifically under HLS fMP4 formats, * using both current_qn and accept_qn advertised by those fMP4 codec entries. */ function collect_qns_with_fmp4_bias(play_info_json) { const playurl = safe_get(() => play_info_json.data.playurl_info.playurl) || {}; const streams = Array.isArray(playurl.stream) ? playurl.stream : []; const all_qn_set = new Set(); const fmp4_qn_set = new Set(); for (const s of streams) { const formats = Array.isArray(s.format) ? s.format : []; for (const f of formats) { const fmt = String(f.format_name || '').toLowerCase(); // ts | fmp4 | flv const codecs = Array.isArray(f.codec) ? f.codec : []; for (const c of codecs) { const cur = Number(c.current_qn); if (Number.isFinite(cur)) { all_qn_set.add(cur); if (fmt === 'fmp4') fmp4_qn_set.add(cur); } const acc = Array.isArray(c.accept_qn || c.acceptQn) ? (c.accept_qn || c.acceptQn) : []; for (const q of acc) { const n = Number(q); if (Number.isFinite(n)) { all_qn_set.add(n); if (fmt === 'fmp4') fmp4_qn_set.add(n); } } } } } const all_qns_sorted = Array.from(all_qn_set).sort((a, b) => a - b); const fmp4_qns_sorted = Array.from(fmp4_qn_set).sort((a, b) => a - b); return { all_qns_sorted, fmp4_qns_sorted }; } function inject_styles() { const style = document.createElement('style'); style.textContent = ` .blmuc_wrap { display:inline-flex; gap:8px; align-items:center; } .blmuc_btn { padding: 4px 10px; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; background-image: linear-gradient(135deg, #ff7ac6 0%, #8aa8ff 100%); color: #111; box-shadow: 0 2px 8px rgba(0,0,0,.15); transition: transform .08s ease, filter .15s ease; } .blmuc_btn:hover { filter: brightness(1.05); } .blmuc_btn:active { transform: translateY(1px); } .blmuc_sel { height: 26px; border-radius: 6px; padding: 0 8px; border: 1px solid var(--blmuc-border, #bbb); background: var(--blmuc-bg, #fff); color: var(--blmuc-fg, #222); } @media (prefers-color-scheme: dark) { .blmuc_btn { color: #000; } .blmuc_sel { --blmuc-bg: #1f1f1f; --blmuc-fg: #eaeaea; --blmuc-border: #444; } } `; document.head.appendChild(style); } function create_controls() { const wrap = document.createElement('span'); wrap.className = 'blmuc_wrap'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'blmuc_btn'; btn.textContent = '复制直播流 URL'; const sel = document.createElement('select'); sel.className = 'blmuc_sel'; sel.id = 'blmuc_quality_select'; wrap.appendChild(btn); wrap.appendChild(sel); return { wrap, btn, sel }; } function fill_quality_select(select_node, quality_numbers_asc) { select_node.innerHTML = ''; for (const qn of quality_numbers_asc) { const opt = document.createElement('option'); const label = qn_label_map[qn] ? `${qn_label_map[qn]} (${qn})` : `品质 ${qn}`; opt.value = String(qn); opt.textContent = label; select_node.appendChild(opt); } } function set_select_default_by_qn(select_node, target_qn) { const idx = Array.from(select_node.options).findIndex(o => Number(o.value) === Number(target_qn)); select_node.selectedIndex = idx >= 0 ? idx : 0; } async function main() { if (!/https:\/\/live\.bilibili\.com\/(blanc\/)?\d+/.test(location.href)) return; inject_styles(); const container = await wait_for_element('#head-info-vm .lower-row .right-ctnr', 180000) || await wait_for_element('#head-info-vm .lower-row', 10000); if (!container) return; const { wrap, btn, sel } = create_controls(); container.appendChild(wrap); const room_id = get_room_id(); if (!room_id) { btn.textContent = '未获取房间号'; return; } let play_info; try { play_info = await gm_get_json(build_play_info_url(room_id)); } catch { btn.textContent = '加载失败'; return; } // Collect all QNs and fMP4-capable QNs const { all_qns_sorted, fmp4_qns_sorted } = collect_qns_with_fmp4_bias(play_info); if (all_qns_sorted.length === 0) { btn.textContent = '无可用清晰度'; return; } // Fill dropdown with ALL available QNs fill_quality_select(sel, all_qns_sorted); // DEFAULT: lowest fMP4 if present; else lowest overall const default_qn = (fmp4_qns_sorted.length > 0 ? fmp4_qns_sorted[0] : all_qns_sorted[0]); set_select_default_by_qn(sel, default_qn); const cid = extract_cid(play_info); const mid = get_anchor_uid_from_page(); btn.addEventListener('click', async () => { const original = btn.textContent; try { const qn = Number(sel.value || default_qn); // Primary: gateway resolves correct host/URL for the target QN if (cid) { const gw_url = build_gateway_url(cid, mid, qn); const gw_res = await gm_fetch_raw(gw_url); let final_url = null; if (gw_res.text.trim().startsWith('{')) { try { const js = JSON.parse(gw_res.text); final_url = depth_find_url(js, ['master_url', 'm3u8_master_url']); } catch {} } if (!final_url) { const looks_m3u8 = gw_res.text.startsWith('#EXTM3U') || /apple\.mpegurl|mpegurl|m3u8/.test(gw_res.contentType); if (looks_m3u8) final_url = gw_res.finalUrl; } if (final_url) { GM_setClipboard(final_url, { type: 'text', mimetype: 'text/plain' }); btn.textContent = '已复制'; setTimeout(() => btn.textContent = original, 1000); return; } } // Fallback: only if target QN exists as current_qn in payload const direct = choose_direct_url_for_current_qn(play_info, qn); if (direct) { GM_setClipboard(direct, { type: 'text', mimetype: 'text/plain' }); btn.textContent = '已复制(直链)'; setTimeout(() => btn.textContent = original, 1000); return; } btn.textContent = '未找到链接'; setTimeout(() => btn.textContent = original, 1200); } catch (e) { console.log('[blmuc] 复制失败', e); btn.textContent = '出错'; setTimeout(() => btn.textContent = original, 1200); } }); } main(); })();