您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
无需打开弹幕姬,必要时直接录制的快速切片工具
// ==UserScript== // @name B站直播随看随录 // @namespace http://tampermonkey.net/ // @version 0.9 // @description 无需打开弹幕姬,必要时直接录制的快速切片工具 // @author Eric Lam // @license MIT // @include /https?:\/\/live\.bilibili\.com\/(blanc\/)?\d+\??.*/ // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js // @grant none // ==/UserScript== class StreamUrlGetter { constructor() { if (this.constructor == StreamUrlGetter){ throw new Error('cannot initialize abstract class') } } async getUrl(roomid){ } } let enableIndexedDB = false; let limit1gb = false; (async function() { 'use strict'; const uidRegex = /\/\/space\.bilibili\.com\/(?<id>\d+)\//g const roomLink = $('.room-owner-username').attr('href') const uid = uidRegex.exec(roomLink)?.groups?.id const roomReg = /^\/(blanc\/)?(?<id>\d+)/ let roomId = parseInt(roomReg.exec(location.pathname)?.groups?.id) let res = await fetcher('https://api.live.bilibili.com/room/v1/Room/room_init?id='+roomId) roomId = res.data.room_id console.log('正在测试获取B站直播流') if (res.data.live_status != 1){ console.log('此房间目前没有直播') return } // ========= indexdb 操作 ========================= const key = `stream_record.${roomId}` if (window.indexedDB){ try { await connect(key) enableIndexedDB = true }catch(err){ console.error(err) alert(`連接資料庫時出現錯誤: ${err.message}, 没办法使用 IndexedDB。(尝试刷新?)`) closeDatabase() } }else{ alert('你的瀏覽器不支援IndexedDB。') } if (!enableIndexedDB) { limit1gb = confirm('由于 IndexedDB 无法被使用,是否应该限制每次最多录制 1gb 视频以防止浏览器崩溃?') } // ======== 更改方式实作 , 如无法寻找可以更改别的 class ===== const urlGetter = new RoomPlayInfo() // =================================================== const rows = $('.rows-ctnr') rows.append(`<button id="record">开始录制</button>`) //刷新一次可用线路 //await findSuitableURL(stream_urls) $('#record').on('click', async () => { try { if (stop_record){ const startDate = new Date().toString().substring(0, 24).replaceAll(' ', '-').replaceAll(':', '-') startRecord(urlGetter, roomId).then(data => download_flv(data, `${roomId}-${startDate}.flv`)).catch(err => { throw new Error(err) }) }else{ stopRecord() } }catch(err){ alert(`啟用录制时出现错误: ${err?.message ?? err}`) console.error(err) } }) })().catch(console.warn); async function findSuitableURL(stream_urls){ for (const stream_url of stream_urls){ try { await testUrlValid(stream_url) console.log(`找到可用线路: ${stream_url}`) return stream_url }catch(err){ console.warn(`测试线路 ${stream_url} 时出现错误: ${err}, 寻找下一个节点`) } } return undefined } async function fetcher(url) { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), 5000); // 五秒timeout const res = await fetch(url, { signal: controller.signal }) clearTimeout(id) if (!res.ok){ throw new Error(res.statusText) } const data = await res.json() console.debug(data) if (data.code != 0){ throw new Error(`B站API请求错误: ${data.message}`) } return data } let stop_record = true let timer_interval = -1 async function testUrlValid(url){ const res = await fetch(url, { credentials: 'same-origin' }) if (!res.ok){ throw new Error(res.statusText) } } function toTimer(secs){ let min = 0; let hr = 0; while(secs >= 60){ secs -= 60 min++ } while (min >= 60){ min -= 60 hr++ } const mu = min > 9 ? `${min}`: `0${min}` const ms = secs > 9 ? `${secs}` : `0${secs}` return `${hr}:${mu}:${ms}` } function isFlvHeader(buf) { if (!buf || buf.length < 4) { return false; } return buf[0] === 0x46 && buf[1] === 0x4c && buf[2] === 0x56 && buf[3] === 0x01; } let symbol = '🔴' function startTimer(){ let seconds = 0 timer_interval = setInterval(() => { seconds += 1 symbol = seconds % 2 == 0 ? '🔴' : '⚪' }, 1000) } function stopTimer() { clearInterval(timer_interval) $('#record')[0].innerText = '开始录制' } function round(float){ return Math.round(float * 10) / 10 } function formatSize(size) { const mb = round(size/1024/1024) if (mb > 1000){ return `${round(mb / 1000).toFixed(1)}GB` }else{ return `${mb.toFixed(1)}MB` } } const banned_urls = new Set(); async function startRecord(urlGetter, roomId) { await clearRecords() // 清空之前的记录 $('#record').attr('disabled', '') $('#record')[0].innerText = '寻找线路中' const urls = await urlGetter.getUrl(roomId) if (urls.length == 0){ throw new Error('没有可用线路,稍后再尝试?') } let res = undefined for (const url of urls) { try { console.log('正在测试目前线路...') if (banned_urls.has(url)) { console.warn('该线路在黑名单内,已略过') continue } const controller = new AbortController(); const id = setTimeout(() => controller.abort(), 1000); res = await fetch(url, { credentials: 'same-origin', signal: controller.signal }) clearTimeout(id) if (res.ok && !res.bodyUsed) break }catch(err){ console.warn(`使用线路 ${url} 时出现错误: ${err}, 使用下一个节点`) } } if (!res) { throw new Error('没有可用线路,稍后再尝试?') } console.log('线路请求成功, 正在开始录制') startTimer() const reader = res.body.getReader(); stop_record = false const chunks = [] // 不支援 indexeddb 时采用 let size = 0 console.log('录制已经开始...') $('#record').removeAttr('disabled') while (!stop_record){ const {done, value } = await reader.read() // 下播 if (done){ if (size == 0) { banned_urls.add(res.url) throw new Error('此线路不可用,请再尝试一次。') } stop_record = true break } size += value.length $('#record')[0].innerText = `${symbol}录制中(${formatSize(size)})` // hover 显示目前录制视频大小 const blob = new Blob([value], { type: 'application/octet-stream'}) if (enableIndexedDB){ await pushRecord(blob) }else{ chunks.push(blob) if (limit1gb && round(size/1024/1024) > 1000){ // 采用非 indexeddb 且启用了限制 1gb 大小录制 stop_record = true break } } } stopTimer() console.log('录制已中止。') if (enableIndexedDB){ return await pollRecords() }else{ return chunks } } async function stopRecord(){ stop_record = true } function download_flv(chunks, file = 'test.flv'){ if (!chunks || chunks.length == 0){ console.warn('没有可以下载的资料') alert('没有可以下载的资料') return } const blob = new Blob(chunks, { type: 'video/x-flv' }, file) const url = window.URL.createObjectURL(blob) const a = document.createElement('a'); a.style.display = "none"; a.setAttribute("href", url); a.setAttribute("download", file); document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); a.remove(); } class RoomPlayUrl extends StreamUrlGetter { async getUrl(roomid){ const stream_urls = [] const res = await fetcher(`http://api.live.bilibili.com/room/v1/Room/playUrl?cid=${roomid}&qn=10000`) const durls = res.data.durl if (durls.length == 0){ console.warn('没有可用的直播视频流') return stream_urls } for (const durl of durls){ stream_urls.push(durl.url) } return stream_urls } } class RoomPlayInfo extends StreamUrlGetter { async getUrl(roomid){ const stream_urls = [] const url = `https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo?room_id=${roomid}&protocol=0,1&format=0,2&codec=0,1&qn=10000&platform=web&ptype=16` const res = await fetcher(url) if (res.data.is_hidden){ console.warn('此直播間被隱藏') return stream_urls } if (res.data.is_locked){ console.warn('此直播間已被封鎖') return stream_urls } if (res.data.encrypted && !res.data.pwd_verified){ console.warn('此直播間已被上鎖') return stream_urls } const streams = res?.data?.playurl_info?.playurl?.stream ?? [] if (streams.length == 0){ console.warn('没有可用的直播视频流') return stream_urls } for (const st of streams){ for (const format of st.format){ if (format.format_name !== 'flv'){ console.warn(`线路 ${index} 格式 ${f_index} 并不是 flv, 已经略过`) continue } for (const codec of format.codec.sort((a,b) => b.current_qn - a.current_qn)){ const base_url = codec.base_url for (const url_info of codec.url_info){ const real_url = url_info.host + base_url + url_info.extra stream_urls.push(real_url) } } return stream_urls } } } } // ========== indexdb ========== function log(msg){ console.log(`[IndexedDB] ${msg}`) } let db = undefined const storeName = 'stream_record' async function connect(key){ return new Promise((res, rej) => { const open = window.indexedDB.open(key, 1) log('connecting to indexedDB') open.onerror = function(event){ log('connection error: '+event.target.error.message) rej(event.target.error) } open.onsuccess = function(event){ db = open.result log('connection success') createObjectStoreIfNotExist(db, rej) res(event) } open.onupgradeneeded = function(event) { db = event.target.result; log('connection success on upgrade needed') createObjectStoreIfNotExist(db, rej) res(event.target.error) } }) } function closeDatabase(){ db?.close() } async function drop(key){ return new Promise((res, rej) => { const req = window.indexedDB.deleteDatabase(key); req.onsuccess = function () { log("Deleted database successfully"); res() }; req.onerror = function () { log("Couldn't delete database"); rej(req.error) }; req.onblocked = function () { log("Couldn't delete database due to the operation being blocked"); rej(req.error) }; }) } function createObjectStoreIfNotExist(db, rej){ if(!db) return try{ if (!db.objectStoreNames.contains(storeName)) { log(`objectStore ${storeName} does not exist, creating new one.`) db.createObjectStore(storeName, { autoIncrement: true }) log('successfully created.') } }catch(err){ log('error while creating object store: '+err.message) rej(err) } db.onerror = function(event) { log("Database error: " + event.target.error.message); } db.onclose = () => { console.log('Database connection closed'); } } async function pushRecord(object){ return new Promise((res, rej)=>{ if (!db){ log('db not defined, so skipped') rej(new Error('db is not defined')) } try{ const tran = db.transaction([storeName], 'readwrite') handleTrans(rej, tran) const s = tran.objectStore(storeName).add(object) s.onsuccess = (e) => { //log('pushing successful') res(e) } s.onerror = () => { log('error while adding byte: '+s.error.message) rej(s.error) } }catch(err){ rej(err) } }) } function handleTrans(rej, tran){ tran.oncomplete = function(){ //log('transaction completed') } tran.onerror = function(){ log('transaction error: '+tran.error.message) rej(tran.error) } } async function pollRecords(){ const buffer = await listRecords() await clearRecords() return buffer } async function listRecords(){ return new Promise((res, rej) => { if (!db){ log('db not defined, so skipped') rej(new Error('db is not defined')) } try{ const tran = db.transaction([storeName], 'readwrite') handleTrans(rej, tran) const cursors = tran.objectStore(storeName).openCursor() const records = [] cursors.onsuccess = function(event){ let cursor = event.target.result; if (cursor) { records.push(cursor.value) cursor.continue(); } else { log("total bytes: "+records.length); res(records) } } cursors.onerror = function(){ log('error while fetching data: '+cursors.error.message) rej(cursors.error) } }catch(err){ rej(err) } }) } async function clearRecords(){ return new Promise((res, rej) => { if (!db){ log('db not defined, so skipped') rej(new Error('db is not defined')) } try{ const tran = db.transaction([storeName], 'readwrite') handleTrans(rej, tran) const req = tran.objectStore(storeName).clear() req.onsuccess = (e) => { log('clear success') res(e) } req.onerror = () =>{ log('error while clearing data: '+req.error.message) rej(req.error) } }catch(err){ rej(err) } }) }
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址