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