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