您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在哔哩哔哩视频工具栏中添加带清晰度选择的复制 MP4 按钮,支持深色模式。
// ==UserScript== // @name Bilibili Video MP4 Copy // @name:zh-CN Bilibili 视频直链复制器 // @namespace https://github.com/TZFC // @version 1.0 // @description Button + dropdown inside #arc_toolbar_report .video-toolbar-right; dark-mode readable; fetches all progressive MP4 qualities. // @description:zh-CN 在哔哩哔哩视频工具栏中添加带清晰度选择的复制 MP4 按钮,支持深色模式。 // @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"; const locale = (() => { const langs = (navigator.languages && navigator.languages.length) ? navigator.languages : [navigator.language || "en"]; return String(langs[0] || "en").toLowerCase().startsWith("zh") ? "zh-CN" : "en"; })(); const L = { "en": { button_idle:"Copy MP4", button_fetching:"Fetching…", button_copied:"Copied ✅", button_error:"Error ❌", button_title:"Copy selected MP4 URL", dropdown_title:"Choose MP4 stream (lowest preselected)", placeholder:"Select stream…", size_unknown:"unknown", label_unknown:"Unknown", 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 直链", dropdown_title:"选择 MP4 流(默认最低画质)", placeholder:"选择流…", size_unknown:"未知", label_unknown:"未知", error_extract_bvid:"无法提取 BV 号。", error_bad_json:"JSON 解析失败。", error_no_mp4:"未找到 MP4。", error_no_mp4_candidates:"没有可用的 MP4。" } }[locale]; const style = document.createElement("style"); style.textContent = ` #arc_toolbar_report .video-toolbar-right [data-bili-mp4] { display:inline-flex; align-items:center; gap:8px; margin-left:8px; color-scheme: light dark; } #arc_toolbar_report .video-toolbar-right .bili_mp4_select { font-size:12px; min-width:200px; padding:4px 8px; appearance:auto; -webkit-appearance:auto; -moz-appearance:auto; } #arc_toolbar_report .video-toolbar-right .bili_mp4_select option:disabled { opacity:0.7; } @media (prefers-color-scheme: dark) { #arc_toolbar_report .video-toolbar-right .bili_mp4_select { color:#e8e8e8 !important; background-color:#16181b !important; border:1px solid rgba(255,255,255,0.18) !important; -webkit-text-fill-color:#e8e8e8 !important; } #arc_toolbar_report .video-toolbar-right .bili_mp4_select option { color:#e8e8e8 !important; background-color:#16181b !important; } #arc_toolbar_report .video-toolbar-right .bili_mp4_select option:disabled { color:rgba(232,232,232,0.75) !important; } } #arc_toolbar_report .video-toolbar-right .bili_mp4_button { cursor:pointer; padding:6px 12px; font-size:12px; line-height:1; border:none; border-radius:8px; background:linear-gradient(135deg,#ff7ac3 0%,#7aa8ff 100%); color:#101010; font-weight:700; } #arc_toolbar_report .video-toolbar-right .bili_mp4_button[disabled]{ opacity:.6; cursor:not-allowed; } `; document.head.appendChild(style); const getBV = () => { const m = location.pathname.match(/\/video\/(BV[0-9A-Za-z]+)/); if (!m) throw new Error(L.error_extract_bvid); return m[1]; }; const getPage = () => { const u = new URL(location.href); return parseInt(u.searchParams.get("p") || "1", 10); }; const clip = (t) => GM_setClipboard(t, { type: "text", mimetype: "text/plain" }); const fmtSize = (bytes) => { if (!Number.isFinite(bytes) || bytes <= 0) return L.size_unknown; const units = ["B","KB","MB","GB"]; let i=0, v=bytes; while (v>=1024 && i<units.length-1){ v/=1024; i++; } return `${v.toFixed(v>=100?0:v>=10?1:2)} ${units[i]}`; }; const httpGetJson = (url) => new Promise((res, rej) => { GM_xmlhttpRequest({ method:"GET", url, headers:{ Referer:"https://www.bilibili.com/" }, timeout:30000, onload:r=>{ try{ res(JSON.parse(r.responseText)); } catch{ rej(new Error(L.error_bad_json)); } }, onerror:()=>rej(new Error("Network error: "+url)), ontimeout:()=>rej(new Error("Network timeout: "+url)) }); }); const pagelistCache = new Map(); const playurlCache = new Map(); const getCidForPage = async (bvid, page) => { if (!pagelistCache.has(bvid)) { const js = await httpGetJson(`https://api.bilibili.com/x/player/pagelist?bvid=${encodeURIComponent(bvid)}&jsonp=jsonp`); pagelistCache.set(bvid, Array.isArray(js?.data) ? js.data : []); } const arr = pagelistCache.get(bvid); const item = arr.find(x=>x.page===page) || arr[0]; return item && item.cid; }; const fetchPlayurlForQn = async (bvid, cid, qn) => { const key = `${bvid}:${cid}:qn=${qn}`; if (playurlCache.has(key)) return playurlCache.get(key); const params = new URLSearchParams({ bvid:String(bvid), cid:String(cid), qn:String(qn), fourk:"1", fnver:"0", fnval:"0", otype:"json", platform:"html5" }); const js = await httpGetJson(`https://api.bilibili.com/x/player/playurl?${params.toString()}`); playurlCache.set(key, js); return js; }; const getAllMp4Options = async (bvid, cid) => { const baseParams = new URLSearchParams({ bvid:String(bvid), cid:String(cid), qn:"120", fourk:"1", fnver:"0", fnval:"0", otype:"json", platform:"html5" }); const first = await httpGetJson(`https://api.bilibili.com/x/player/playurl?${baseParams.toString()}`); const support = Array.isArray(first?.data?.support_formats) ? first.data.support_formats : []; const acceptQ = Array.isArray(first?.data?.accept_quality) ? first.data.accept_quality : []; let qualities = support.length ? support.map(s => ({ qn: s.quality, label: s.new_description || s.display_desc || String(s.quality) })) : acceptQ.map(qn => ({ qn, label: String(qn) })); const seen = new Set(); qualities = qualities.filter(q=>!seen.has(String(q.qn)) && seen.add(String(q.qn))).sort((a,b)=>a.qn-b.qn); const results = []; for (const q of qualities) { try { const js = await fetchPlayurlForQn(bvid, cid, q.qn); const durl = js?.data?.durl; if (!Array.isArray(durl) || durl.length===0) continue; let picked = null; for (const e of durl) { if (e?.url && e.url.toLowerCase().includes(".mp4") && !e.url.toLowerCase().includes(".m4s")) { picked = { url:e.url, size:Number(e.size||0) }; break; } if (Array.isArray(e?.backup_url)) { const b = e.backup_url.find(u => u.toLowerCase().includes(".mp4") && !u.toLowerCase().includes(".m4s")); if (b) { picked = { url:b, size:Number(e.size||0) }; break; } } } if (picked) results.push({ qn:q.qn, label:q.label, url:picked.url, size:picked.size }); } catch {} } if (results.length===0) { const durl = first?.data?.durl; if (Array.isArray(durl)) { for (const e of durl) { if (e?.url && e.url.toLowerCase().includes(".mp4") && !e.url.toLowerCase().includes(".m4s")) { results.push({ qn:first?.data?.quality ?? 0, label:L.label_unknown, url:e.url, size:Number(e.size||0) }); } } } } const haveSize = results.every(r => Number.isFinite(r.size) && r.size>0); results.sort((a,b)=> haveSize ? (a.size-b.size) : (a.qn-b.qn)); return results; }; const createControls = ()=>{ const wrap = document.createElement("span"); wrap.setAttribute("data-bili-mp4","1"); const sel = document.createElement("select"); sel.className = "bili_mp4_select"; sel.title = L.dropdown_title; const ph = document.createElement("option"); ph.value=""; ph.disabled=true; ph.selected=true; ph.textContent=L.placeholder; sel.appendChild(ph); const btn = document.createElement("button"); btn.className="bili_mp4_button"; btn.title=L.button_title; btn.textContent=L.button_idle; wrap.appendChild(sel); wrap.appendChild(btn); return { wrap, sel, btn }; }; const setBtn = (btn, label, dis)=>{ btn.textContent = label; btn.disabled = !!dis; }; const populate = (sel, list)=>{ sel.length = 1; for (const it of list) { const o = document.createElement("option"); o.value = it.url; o.textContent = `${it.label} • ${fmtSize(it.size)} (qn=${it.qn})`; sel.appendChild(o); } if (sel.options.length > 1) sel.selectedIndex = 1; }; const controls = createControls(); let loaded = false; const loadOnce = async ()=>{ if (loaded) return; loaded = true; setBtn(controls.btn, L.button_fetching, true); try { const bvid = getBV(); const page = getPage(); const cid = await getCidForPage(bvid, page); const list = await getAllMp4Options(bvid, cid); if (!list || list.length===0) throw new Error(L.error_no_mp4_candidates); populate(controls.sel, list); setBtn(controls.btn, L.button_idle, false); } catch (e) { console.error(e); setBtn(controls.btn, L.button_error, true); } }; controls.sel.addEventListener("mousedown", loadOnce, { passive:true }); controls.btn.addEventListener("mousedown", loadOnce, { passive:true }); controls.btn.addEventListener("click", async ()=>{ if (!loaded) await loadOnce(); if (!controls.sel.value) { setBtn(controls.btn, L.button_error, true); setTimeout(()=>setBtn(controls.btn, L.button_idle, false), 1200); return; } try { clip(controls.sel.value); setBtn(controls.btn, L.button_copied, true); } catch(e){ console.error(e); setBtn(controls.btn, L.button_error, true); } setTimeout(()=>setBtn(controls.btn, L.button_idle, false), 1200); }, { passive:true }); const getToolbarRight = () => document.querySelector("#arc_toolbar_report .video-toolbar-right"); const getArcContainer = () => document.getElementById("arc_toolbar_report"); let rafQueued = false, mounting = false, mo = null; const mount = () => { if (mounting) return; mounting = true; try { const t = getToolbarRight(); if (!t) return; if (controls.wrap.parentElement !== t) { t.appendChild(controls.wrap); } } finally { mounting = false; } }; const scheduleMount = () => { if (rafQueued) return; rafQueued = true; requestAnimationFrame(() => { rafQueued = false; mount(); }); }; const ensureObserver = () => { if (mo) return; const arc = getArcContainer(); if (!arc) return; mo = new MutationObserver(() => { scheduleMount(); }); mo.observe(arc, { childList:true, subtree:true }); }; scheduleMount(); ensureObserver(); window.addEventListener("popstate", scheduleMount); })();