// ==UserScript== // @name B站 m4s 合并工具 // @namespace https://github.com/bili-m4s-merge // @version 1.0.0 // @description 在浏览器内将 B站的 video.m4s + audio.m4s 合并为 MP4 并下载(基于 ffmpeg.wasm,无需安装任何软件)。支持本地文件合并,也可尝试直接抓取当前 B 站视频。 // @author WorkBuddy // @match https://www.bilibili.com/* // @match https://www.bilibili.tv/* // @match https://m.bilibili.com/* // @grant none // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; const CDN_LIST = [ 'https://cdn.jsdelivr.net/npm/@ffmpeg/ffmpeg@0.11.6/dist/ffmpeg.min.js', 'https://unpkg.com/@ffmpeg/ffmpeg@0.11.6/dist/ffmpeg.min.js', ]; let ffmpeg = null; let loading = false; // ---- 动态加载 ffmpeg.wasm ---- function loadScript(url) { return new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = url; s.onload = () => resolve(); s.onerror = () => reject(new Error('load ' + url)); document.head.appendChild(s); }); } async function ensureFFmpeg(log) { if (ffmpeg) return ffmpeg; if (loading) throw new Error('ffmpeg 正在加载中…'); loading = true; log('正在加载 ffmpeg.wasm …'); let loaded = false; for (const url of CDN_LIST) { try { await loadScript(url); loaded = true; break; } catch (e) { /* try next */ } } if (!loaded || typeof window.createFFmpeg === 'undefined') { loading = false; throw new Error('无法从 CDN 加载 ffmpeg,请检查网络或换用本地 EXE 版。'); } ffmpeg = window.createFFmpeg({ coreURL: 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.11.1/dist/ffmpeg-core.js', wasmURL: 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.11.1/dist/ffmpeg-core.wasm', logger: ({ message }) => log(message), }); await ffmpeg.load(); loading = false; log('ffmpeg 就绪 ✓'); return ffmpeg; } // ---- 合并:video.m4s + audio.m4s -> out.mp4 ---- async function mergeFiles(videoFile, audioFile, outputName, log, onProgress) { const ff = await ensureFFmpeg(log); log('写入输入文件…'); ff.FS('writeFile', 'video.m4s', await videoFile.arrayBuffer()); ff.FS('writeFile', 'audio.m4s', await audioFile.arrayBuffer()); const re = ff.run('-i', 'video.m4s', '-i', 'audio.m4s', '-c', 'copy', '-movflags', '+faststart', 'out.mp4'); await re; log('读取输出文件…'); const data = ff.FS('readFile', 'out.mp4'); const blob = new Blob([data.buffer], { type: 'video/mp4' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = outputName || 'bilibili_merged.mp4'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 5000); ff.FS('unlink', 'video.m4s'); ff.FS('unlink', 'audio.m4s'); ff.FS('unlink', 'out.mp4'); log('✅ 已触发下载: ' + (outputName || 'bilibili_merged.mp4')); } // ---- 尝试从当前 B 站页面抓取 dash 流(best-effort)---- async function grabCurrent(log) { const m = location.href.match(/BV\w+/); if (!m) { log('未在当前页面识别到 BV 号。'); return; } const bvid = m[0]; const api = `https://api.bilibili.com/x/player/playurl?bvid=${bvid}&qn=127&fnval=16&fourk=1`; log('请求 playurl: ' + bvid); const resp = await fetch(api, { credentials: 'include' }); const json = await resp.json(); const dash = json?.data?.dash; if (!dash) { log('未获取到 dash 流(可能需要登录或受限制)。'); return null; } const pick = (arr) => arr && arr.length ? arr.reduce((a, b) => (b.bandwidth > a.bandwidth ? b : a)) : null; const v = pick(dash.video), a = pick(dash.audio) || pick(dash.dolby?.audio); return { videoUrl: v?.baseUrl, audioUrl: a?.baseUrl }; } async function fetchAsFile(url, name) { const r = await fetch(url, { credentials: 'include', headers: { Referer: 'https://www.bilibili.com/' } }); const buf = await r.arrayBuffer(); return new File([buf], name); } // ---- UI ---- function buildUI() { if (document.getElementById('bili-m4s-panel')) return; const style = document.createElement('style'); style.textContent = `#bili-m4s-panel{position:fixed;right:16px;bottom:16px;width:320px;background:#fff;border:1px solid #fb7299;border-radius:12px;box-shadow:0 6px 24px rgba(0,0,0,.18);z-index:999999;font-family:-apple-system,"PingFang SC",Microsoft YaHei,sans-serif;font-size:13px;color:#222;overflow:hidden} #bili-m4s-panel .hd{background:#fb7299;color:#fff;padding:8px 12px;cursor:move;display:flex;justify-content:space-between;align-items:center;font-weight:600} #bili-m4s-panel .bd{padding:10px 12px;display:flex;flex-direction:column;gap:8px} #bili-m4s-panel input[type=file]{font-size:12px} #bili-m4s-panel button{background:#fb7299;color:#fff;border:none;border-radius:8px;padding:8px;cursor:pointer;font-size:13px} #bili-m4s-panel button:disabled{opacity:.5;cursor:not-allowed} #bili-m4s-panel button.ghost{background:#fff;color:#fb7299;border:1px solid #fb7299} #bili-m4s-panel .log{background:#f6f7f9;border-radius:8px;padding:6px;height:96px;overflow:auto;white-space:pre-wrap;font-family:Consolas,monospace;font-size:11px;color:#333} #bili-m4s-panel .row{display:flex;gap:6px} #bili-m4s-panel .row button{flex:1} #bili-m4s-panel .x{cursor:pointer;font-weight:400}`; document.head.appendChild(style); const panel = document.createElement('div'); panel.id = 'bili-m4s-panel'; panel.innerHTML = `