Bilibili 直播流链接复制器

下拉列出 current_qn + accept_qn。默认选中最低 fMP4 清晰度。点击复制时按所选清晰度请求网关返回正确 URL/主机。偏好 HLS fMP4。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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();
})();