Bilibili Video MP4 Copier

Add a button inside the Bilibili player controls that copies the highest available progressive MP4 URL. Useful for VRChat, custom players, or direct download.

安裝腳本?
作者推薦腳本

您可能也會喜歡 Bilibili Live Master URL Copier

安裝腳本
// ==UserScript==
// @name                 Bilibili Video MP4 Copier
// @name:zh-CN           Bilibili 视频直链复制按钮
// @namespace            https://github.com/TZFC
// @version              0.1
// @description          Add a button inside the Bilibili player controls that copies the highest available progressive MP4 URL. Useful for VRChat, custom players, or direct download.
// @description:zh-CN    在Bilibili播放器工具栏内添加一个“复制MP4直链”按钮。复制的链接可用于VRChat、自定义播放器或直接下载。
// @author               TZFC
// @match                *://www.bilibili.com/video/*
// @icon                 https://www.bilibili.com/favicon.ico
// @license              GPL-3.0
// @run-at               document-idle
// @grant                GM_setClipboard
// @grant                GM_xmlhttpRequest
// @connect              api.bilibili.com
// ==/UserScript==

(function () {
  "use strict";

  // -------------------------------
  // Locale Detection And Messages
  // -------------------------------
  function determine_locale() {
    const navigator_languages = (navigator.languages && navigator.languages.length > 0) ? navigator.languages : [navigator.language || "en"];
    const primary_language = String(navigator_languages[0] || "en").toLowerCase();
    if (primary_language.startsWith("zh")) return "zh-CN";
    return "en";
  }

  const current_locale = determine_locale();

  const locale_messages = {
    "en": {
      button_idle: "Copy MP4",
      button_fetching: "Fetching…",
      button_copied: "Copied ✅",
      button_error: "Error ❌",
      button_title: "Copy highest MP4 URL for VRChat",
      error_extract_bvid: "Could not extract BV identifier.",
      error_bad_json: "Failed to parse JSON.",
      error_no_mp4: "No MP4 found.",
      error_no_mp4_candidates: "No MP4 candidates.",
    },
    "zh-CN": {
      button_idle: "复制 MP4",
      button_fetching: "获取中…",
      button_copied: "已复制 ✅",
      button_error: "出错 ❌",
      button_title: "复制最高画质 MP4 直链(适用于 VRChat)",
      error_extract_bvid: "无法提取 BV 号。",
      error_bad_json: "JSON 解析失败。",
      error_no_mp4: "未找到 MP4。",
      error_no_mp4_candidates: "没有可用的 MP4。",
    }
  };

  const L = locale_messages[current_locale] || locale_messages["en"];

  // -------------------------------
  // Create A Styled Player Button
  // -------------------------------
  function create_download_button() {
    const button_element = document.createElement("div");
    button_element.textContent = L.button_idle;
    button_element.style.cursor = "pointer";
    button_element.style.padding = "4px 8px";
    button_element.style.fontSize = "12px";
    button_element.style.border = "1px solid #ccc";
    button_element.style.borderRadius = "6px";
    button_element.style.background = "#fff";
    button_element.style.marginLeft = "8px";
    button_element.style.userSelect = "none";
    button_element.title = L.button_title;
    return button_element;
  }

  function set_button_state(button_element, label_text, is_disabled) {
    button_element.textContent = label_text;
    button_element.style.opacity = is_disabled ? "0.6" : "1";
  }

  function copy_text_to_clipboard(plain_text) {
    GM_setClipboard(plain_text, { type: "text", mimetype: "text/plain" });
  }

  // -------------------------------
  // Bilibili API Calls
  // -------------------------------
  function extract_bvid() {
    const match_result = location.pathname.match(/\/video\/(BV[0-9A-Za-z]+)/);
    if (!match_result) throw new Error(L.error_extract_bvid);
    return match_result[1];
  }

  function extract_page_number() {
    const url_object = new URL(location.href);
    return parseInt(url_object.searchParams.get("p") || "1", 10);
  }

  function http_get_json(url_string) {
    return new Promise((resolve_function, reject_function) => {
      GM_xmlhttpRequest({
        method: "GET",
        url: url_string,
        headers: { Referer: "https://www.bilibili.com/" },
        timeout: 30000,
        onload: (response_object) => {
          try {
            resolve_function(JSON.parse(response_object.responseText));
          } catch {
            reject_function(new Error(L.error_bad_json));
          }
        },
        onerror: () => reject_function(new Error("Network error: " + url_string)),
        ontimeout: () => reject_function(new Error("Network timeout: " + url_string)),
      });
    });
  }

  async function get_cid_for_page(bvid_value, page_number) {
    const json_result = await http_get_json(
      `https://api.bilibili.com/x/player/pagelist?bvid=${encodeURIComponent(bvid_value)}&jsonp=jsonp`
    );
    const page_item = json_result.data.find((item) => item.page === page_number) || json_result.data[0];
    return page_item.cid;
  }

  async function get_best_progressive_mp4(bvid_value, cid_value) {
    const params = new URLSearchParams({
      bvid: String(bvid_value),
      cid: String(cid_value),
      qn: "120",
      fourk: "1",
      fnver: "0",
      fnval: "0",
      otype: "json",
      platform: "html5",
    });
    const api_url = "https://api.bilibili.com/x/player/playurl?" + params.toString();
    const json_result = await http_get_json(api_url);

    if (!json_result.data?.durl) throw new Error(L.error_no_mp4);

    const candidate_urls = [];
    for (const entry of json_result.data.durl) {
      if (entry.url && entry.url.toLowerCase().includes(".mp4") && !entry.url.toLowerCase().includes(".m4s")) {
        candidate_urls.push({ url: entry.url, size: Number(entry.size || 0) });
      }
      if (Array.isArray(entry.backup_url)) {
        for (const backup of entry.backup_url) {
          if (backup && backup.toLowerCase().includes(".mp4") && !backup.toLowerCase().includes(".m4s")) {
            candidate_urls.push({ url: backup, size: Number(entry.size || 0) });
          }
        }
      }
    }
    if (candidate_urls.length === 0) throw new Error(L.error_no_mp4_candidates);

    candidate_urls.sort((a, b) => (b.size || 0) - (a.size || 0));
    return candidate_urls[0].url;
  }

  // -------------------------------
  // Attach Button To Player Controls
  // -------------------------------
  function add_download_button() {
    const target_area = document.querySelector(
      "#bilibili-player > div > div > div.bpx-player-primary-area > div.bpx-player-sending-area > div"
    );

    if (target_area && !target_area.querySelector(".download-btn")) {
      const download_button = create_download_button();
      download_button.classList.add("download-btn");
      target_area.appendChild(download_button);

      download_button.addEventListener("click", async () => {
        set_button_state(download_button, L.button_fetching, true);
        try {
          const bvid_value = extract_bvid();
          const page_number = extract_page_number();
          const cid_value = await get_cid_for_page(bvid_value, page_number);
          const mp4_url = await get_best_progressive_mp4(bvid_value, cid_value);
          copy_text_to_clipboard(mp4_url);
          set_button_state(download_button, L.button_copied, true);
        } catch (error_object) {
          console.error(error_object);
          set_button_state(download_button, L.button_error, true);
        }
        setTimeout(() => set_button_state(download_button, L.button_idle, false), 1500);
      });
    }
  }

  const mutation_observer = new MutationObserver(add_download_button);
  mutation_observer.observe(document.body, { childList: true, subtree: true });
  add_download_button();
})();