Greasy Fork is available in English.

B站直播随看随录

无需打开弹幕姬,必要时直接录制的快速切片工具

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