B站直播随看随录

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

  1. // ==UserScript==
  2. // @name B站直播随看随录
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.9
  5. // @description 无需打开弹幕姬,必要时直接录制的快速切片工具
  6. // @author Eric Lam
  7. // @license MIT
  8. // @include /https?:\/\/live\.bilibili\.com\/(blanc\/)?\d+\??.*/
  9. // @require https://cdn.jsdelivr.net/npm/jquery@3.5.1/dist/jquery.min.js
  10. // @grant none
  11. // ==/UserScript==
  12.  
  13.  
  14. class StreamUrlGetter {
  15.  
  16. constructor() {
  17. if (this.constructor == StreamUrlGetter){
  18. throw new Error('cannot initialize abstract class')
  19. }
  20. }
  21.  
  22. async getUrl(roomid){
  23. }
  24.  
  25. }
  26.  
  27. let enableIndexedDB = false;
  28. let limit1gb = false;
  29.  
  30. (async function() {
  31. 'use strict';
  32. const uidRegex = /\/\/space\.bilibili\.com\/(?<id>\d+)\//g
  33. const roomLink = $('.room-owner-username').attr('href')
  34. const uid = uidRegex.exec(roomLink)?.groups?.id
  35.  
  36. const roomReg = /^\/(blanc\/)?(?<id>\d+)/
  37. let roomId = parseInt(roomReg.exec(location.pathname)?.groups?.id)
  38.  
  39. let res = await fetcher('https://api.live.bilibili.com/room/v1/Room/room_init?id='+roomId)
  40. roomId = res.data.room_id
  41.  
  42. console.log('正在测试获取B站直播流')
  43.  
  44. if (res.data.live_status != 1){
  45. console.log('此房间目前没有直播')
  46. return
  47. }
  48.  
  49. // ========= indexdb 操作 =========================
  50. const key = `stream_record.${roomId}`
  51.  
  52. if (window.indexedDB){
  53. try {
  54. await connect(key)
  55. enableIndexedDB = true
  56. }catch(err){
  57. console.error(err)
  58. alert(`連接資料庫時出現錯誤: ${err.message}, 没办法使用 IndexedDB。(尝试刷新?)`)
  59. closeDatabase()
  60. }
  61. }else{
  62. alert('你的瀏覽器不支援IndexedDB。')
  63. }
  64.  
  65. if (!enableIndexedDB) {
  66. limit1gb = confirm('由于 IndexedDB 无法被使用,是否应该限制每次最多录制 1gb 视频以防止浏览器崩溃?')
  67. }
  68.  
  69. // ======== 更改方式实作 , 如无法寻找可以更改别的 class =====
  70. const urlGetter = new RoomPlayInfo()
  71. // ===================================================
  72.  
  73.  
  74.  
  75. const rows = $('.rows-ctnr')
  76. rows.append(`<button id="record">开始录制</button>`)
  77.  
  78. //刷新一次可用线路
  79. //await findSuitableURL(stream_urls)
  80.  
  81. $('#record').on('click', async () => {
  82. try {
  83. if (stop_record){
  84. const startDate = new Date().toString().substring(0, 24).replaceAll(' ', '-').replaceAll(':', '-')
  85. startRecord(urlGetter, roomId).then(data => download_flv(data, `${roomId}-${startDate}.flv`)).catch(err => { throw new Error(err) })
  86. }else{
  87. stopRecord()
  88. }
  89. }catch(err){
  90. alert(`啟用录制时出现错误: ${err?.message ?? err}`)
  91. console.error(err)
  92. }
  93. })
  94.  
  95. })().catch(console.warn);
  96.  
  97. async function findSuitableURL(stream_urls){
  98. for (const stream_url of stream_urls){
  99. try {
  100. await testUrlValid(stream_url)
  101. console.log(`找到可用线路: ${stream_url}`)
  102. return stream_url
  103. }catch(err){
  104. console.warn(`测试线路 ${stream_url} 时出现错误: ${err}, 寻找下一个节点`)
  105. }
  106. }
  107. return undefined
  108. }
  109.  
  110. async function fetcher(url) {
  111. const controller = new AbortController();
  112. const id = setTimeout(() => controller.abort(), 5000); // 五秒timeout
  113. const res = await fetch(url, { signal: controller.signal })
  114. clearTimeout(id)
  115. if (!res.ok){
  116. throw new Error(res.statusText)
  117. }
  118.  
  119. const data = await res.json()
  120. console.debug(data)
  121. if (data.code != 0){
  122. throw new Error(`BAPI请求错误: ${data.message}`)
  123. }
  124. return data
  125. }
  126.  
  127.  
  128. let stop_record = true
  129. let timer_interval = -1
  130.  
  131. async function testUrlValid(url){
  132. const res = await fetch(url, { credentials: 'same-origin' })
  133. if (!res.ok){
  134. throw new Error(res.statusText)
  135. }
  136. }
  137.  
  138.  
  139. function toTimer(secs){
  140. let min = 0;
  141. let hr = 0;
  142. while(secs >= 60){
  143. secs -= 60
  144. min++
  145. }
  146. while (min >= 60){
  147. min -= 60
  148. hr++
  149. }
  150. const mu = min > 9 ? `${min}`: `0${min}`
  151. const ms = secs > 9 ? `${secs}` : `0${secs}`
  152. return `${hr}:${mu}:${ms}`
  153. }
  154.  
  155. function isFlvHeader(buf) {
  156. if (!buf || buf.length < 4) {
  157. return false;
  158. }
  159. return buf[0] === 0x46 && buf[1] === 0x4c && buf[2] === 0x56 && buf[3] === 0x01;
  160. }
  161.  
  162.  
  163. let symbol = '🔴'
  164. function startTimer(){
  165. let seconds = 0
  166. timer_interval = setInterval(() => {
  167. seconds += 1
  168. symbol = seconds % 2 == 0 ? '🔴' : '⚪'
  169. }, 1000)
  170. }
  171.  
  172. function stopTimer() {
  173. clearInterval(timer_interval)
  174. $('#record')[0].innerText = '开始录制'
  175. }
  176.  
  177. function round(float){
  178. return Math.round(float * 10) / 10
  179. }
  180.  
  181. function formatSize(size) {
  182. const mb = round(size/1024/1024)
  183. if (mb > 1000){
  184. return `${round(mb / 1000).toFixed(1)}GB`
  185. }else{
  186. return `${mb.toFixed(1)}MB`
  187. }
  188. }
  189.  
  190.  
  191. const banned_urls = new Set();
  192.  
  193. async function startRecord(urlGetter, roomId) {
  194. await clearRecords() // 清空之前的记录
  195.  
  196. $('#record').attr('disabled', '')
  197. $('#record')[0].innerText = '寻找线路中'
  198.  
  199. const urls = await urlGetter.getUrl(roomId)
  200.  
  201. if (urls.length == 0){
  202. throw new Error('没有可用线路,稍后再尝试?')
  203. }
  204.  
  205. let res = undefined
  206. for (const url of urls) {
  207. try {
  208. console.log('正在测试目前线路...')
  209. if (banned_urls.has(url)) {
  210. console.warn('该线路在黑名单内,已略过')
  211. continue
  212. }
  213. const controller = new AbortController();
  214. const id = setTimeout(() => controller.abort(), 1000);
  215. res = await fetch(url, { credentials: 'same-origin', signal: controller.signal })
  216. clearTimeout(id)
  217. if (res.ok && !res.bodyUsed) break
  218. }catch(err){
  219. console.warn(`使用线路 ${url} 时出现错误: ${err}, 使用下一个节点`)
  220. }
  221. }
  222. if (!res) {
  223. throw new Error('没有可用线路,稍后再尝试?')
  224. }
  225. console.log('线路请求成功, 正在开始录制')
  226. startTimer()
  227. const reader = res.body.getReader();
  228. stop_record = false
  229. const chunks = [] // 不支援 indexeddb 时采用
  230. let size = 0
  231. console.log('录制已经开始...')
  232. $('#record').removeAttr('disabled')
  233. while (!stop_record){
  234. const {done, value } = await reader.read()
  235. // 下播
  236. if (done){
  237. if (size == 0) {
  238. banned_urls.add(res.url)
  239. throw new Error('此线路不可用,请再尝试一次。')
  240. }
  241. stop_record = true
  242. break
  243. }
  244. size += value.length
  245. $('#record')[0].innerText = `${symbol}录制中(${formatSize(size)})` // hover 显示目前录制视频大小
  246. const blob = new Blob([value], { type: 'application/octet-stream'})
  247. if (enableIndexedDB){
  248. await pushRecord(blob)
  249. }else{
  250. chunks.push(blob)
  251. if (limit1gb && round(size/1024/1024) > 1000){ // 采用非 indexeddb 且启用了限制 1gb 大小录制
  252. stop_record = true
  253. break
  254. }
  255. }
  256. }
  257. stopTimer()
  258. console.log('录制已中止。')
  259. if (enableIndexedDB){
  260. return await pollRecords()
  261. }else{
  262. return chunks
  263. }
  264. }
  265.  
  266.  
  267. async function stopRecord(){
  268. stop_record = true
  269. }
  270.  
  271.  
  272. function download_flv(chunks, file = 'test.flv'){
  273. if (!chunks || chunks.length == 0){
  274. console.warn('没有可以下载的资料')
  275. alert('没有可以下载的资料')
  276. return
  277. }
  278. const blob = new Blob(chunks, { type: 'video/x-flv' }, file)
  279. const url = window.URL.createObjectURL(blob)
  280. const a = document.createElement('a');
  281. a.style.display = "none";
  282. a.setAttribute("href", url);
  283. a.setAttribute("download", file);
  284. document.body.appendChild(a);
  285. a.click();
  286. window.URL.revokeObjectURL(url);
  287. a.remove();
  288. }
  289.  
  290.  
  291. class RoomPlayUrl extends StreamUrlGetter {
  292.  
  293. async getUrl(roomid){
  294. const stream_urls = []
  295. const res = await fetcher(`http://api.live.bilibili.com/room/v1/Room/playUrl?cid=${roomid}&qn=10000`)
  296.  
  297. const durls = res.data.durl
  298. if (durls.length == 0){
  299. console.warn('没有可用的直播视频流')
  300. return stream_urls
  301. }
  302.  
  303. for (const durl of durls){
  304. stream_urls.push(durl.url)
  305. }
  306.  
  307. return stream_urls
  308. }
  309. }
  310.  
  311.  
  312. class RoomPlayInfo extends StreamUrlGetter {
  313.  
  314. async getUrl(roomid){
  315. const stream_urls = []
  316. 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`
  317. const res = await fetcher(url)
  318.  
  319. if (res.data.is_hidden){
  320. console.warn('此直播間被隱藏')
  321. return stream_urls
  322. }
  323.  
  324. if (res.data.is_locked){
  325. console.warn('此直播間已被封鎖')
  326. return stream_urls
  327. }
  328.  
  329. if (res.data.encrypted && !res.data.pwd_verified){
  330. console.warn('此直播間已被上鎖')
  331. return stream_urls
  332. }
  333.  
  334. const streams = res?.data?.playurl_info?.playurl?.stream ?? []
  335. if (streams.length == 0){
  336. console.warn('没有可用的直播视频流')
  337. return stream_urls
  338. }
  339.  
  340. for (const st of streams){
  341. for (const format of st.format){
  342. if (format.format_name !== 'flv'){
  343. console.warn(`线路 ${index} 格式 ${f_index} 并不是 flv, 已经略过`)
  344. continue
  345. }
  346.  
  347. for (const codec of format.codec.sort((a,b) => b.current_qn - a.current_qn)){
  348. const base_url = codec.base_url
  349. for (const url_info of codec.url_info){
  350. const real_url = url_info.host + base_url + url_info.extra
  351. stream_urls.push(real_url)
  352. }
  353. }
  354.  
  355. return stream_urls
  356. }
  357.  
  358.  
  359. }
  360. }
  361.  
  362. }
  363.  
  364. // ========== indexdb ==========
  365.  
  366. function log(msg){
  367. console.log(`[IndexedDB] ${msg}`)
  368. }
  369.  
  370. let db = undefined
  371. const storeName = 'stream_record'
  372.  
  373. async function connect(key){
  374. return new Promise((res, rej) => {
  375. const open = window.indexedDB.open(key, 1)
  376. log('connecting to indexedDB')
  377. open.onerror = function(event){
  378. log('connection error: '+event.target.error.message)
  379. rej(event.target.error)
  380. }
  381. open.onsuccess = function(event){
  382. db = open.result
  383. log('connection success')
  384. createObjectStoreIfNotExist(db, rej)
  385. res(event)
  386. }
  387. open.onupgradeneeded = function(event) {
  388. db = event.target.result;
  389. log('connection success on upgrade needed')
  390. createObjectStoreIfNotExist(db, rej)
  391. res(event.target.error)
  392. }
  393. })
  394.  
  395. }
  396.  
  397. function closeDatabase(){
  398. db?.close()
  399. }
  400.  
  401. async function drop(key){
  402. return new Promise((res, rej) => {
  403. const req = window.indexedDB.deleteDatabase(key);
  404. req.onsuccess = function () {
  405. log("Deleted database successfully");
  406. res()
  407. };
  408. req.onerror = function () {
  409. log("Couldn't delete database");
  410. rej(req.error)
  411. };
  412. req.onblocked = function () {
  413. log("Couldn't delete database due to the operation being blocked");
  414. rej(req.error)
  415. };
  416. })
  417. }
  418.  
  419. function createObjectStoreIfNotExist(db, rej){
  420. if(!db) return
  421. try{
  422. if (!db.objectStoreNames.contains(storeName)) {
  423. log(`objectStore ${storeName} does not exist, creating new one.`)
  424. db.createObjectStore(storeName, { autoIncrement: true })
  425. log('successfully created.')
  426. }
  427. }catch(err){
  428. log('error while creating object store: '+err.message)
  429. rej(err)
  430. }
  431. db.onerror = function(event) {
  432. log("Database error: " + event.target.error.message);
  433. }
  434. db.onclose = () => {
  435. console.log('Database connection closed');
  436. }
  437. }
  438.  
  439.  
  440. async function pushRecord(object){
  441. return new Promise((res, rej)=>{
  442. if (!db){
  443. log('db not defined, so skipped')
  444. rej(new Error('db is not defined'))
  445. }
  446. try{
  447. const tran = db.transaction([storeName], 'readwrite')
  448. handleTrans(rej, tran)
  449. const s = tran.objectStore(storeName).add(object)
  450. s.onsuccess = (e) => {
  451. //log('pushing successful')
  452. res(e)
  453. }
  454. s.onerror = () => {
  455. log('error while adding byte: '+s.error.message)
  456. rej(s.error)
  457. }
  458. }catch(err){
  459. rej(err)
  460. }
  461. })
  462. }
  463.  
  464. function handleTrans(rej, tran){
  465. tran.oncomplete = function(){
  466. //log('transaction completed')
  467. }
  468. tran.onerror = function(){
  469. log('transaction error: '+tran.error.message)
  470. rej(tran.error)
  471. }
  472. }
  473.  
  474. async function pollRecords(){
  475. const buffer = await listRecords()
  476. await clearRecords()
  477. return buffer
  478. }
  479.  
  480. async function listRecords(){
  481. return new Promise((res, rej) => {
  482. if (!db){
  483. log('db not defined, so skipped')
  484. rej(new Error('db is not defined'))
  485. }
  486. try{
  487. const tran = db.transaction([storeName], 'readwrite')
  488. handleTrans(rej, tran)
  489. const cursors = tran.objectStore(storeName).openCursor()
  490. const records = []
  491. cursors.onsuccess = function(event){
  492. let cursor = event.target.result;
  493. if (cursor) {
  494. records.push(cursor.value)
  495. cursor.continue();
  496. }
  497. else {
  498. log("total bytes: "+records.length);
  499. res(records)
  500. }
  501. }
  502. cursors.onerror = function(){
  503. log('error while fetching data: '+cursors.error.message)
  504. rej(cursors.error)
  505. }
  506. }catch(err){
  507. rej(err)
  508. }
  509. })
  510. }
  511.  
  512. async function clearRecords(){
  513. return new Promise((res, rej) => {
  514. if (!db){
  515. log('db not defined, so skipped')
  516. rej(new Error('db is not defined'))
  517. }
  518. try{
  519. const tran = db.transaction([storeName], 'readwrite')
  520. handleTrans(rej, tran)
  521. const req = tran.objectStore(storeName).clear()
  522. req.onsuccess = (e) => {
  523. log('clear success')
  524. res(e)
  525. }
  526. req.onerror = () =>{
  527. log('error while clearing data: '+req.error.message)
  528. rej(req.error)
  529. }
  530. }catch(err){
  531. rej(err)
  532. }
  533. })
  534. }

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址