├── .gitignore ├── README.md └── danmu.js /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jsbilibiliDanmu 2 | 基于油猴的的在浏览器上直接运行的直播弹幕获取脚本,并选中特定弹幕显示屏幕(没错,就是把vtb直播同传man的烤的肉显示在屏幕) 3 | 4 | ### [另一个基于go的弹幕获取客户端传送门](https://github.com/sirodeneko/gobilibiliDanmu) 5 | 6 | 7 | ## 开始使用 8 | 1. 方法一油猴直接安装 9 | [传送门](https://greasyfork.org/zh-CN/scripts/400941-bilibili%E7%9B%B4%E6%92%AD%E7%83%A4%E8%82%89man%E5%AD%97%E5%B9%95%E6%98%BE%E7%A4%BA) 10 | 2. 方法二(不推荐,因为无法拥有后继更新)复制danmu.js内容 右键油猴图标创建新得脚本删除原来的,粘贴代码,保存。 11 | 12 | 3. 使用方法 13 | 可通过屏幕左上角按钮修改相关属性 14 | 15 | 4. 自定义修改 16 | ``` 17 | //代码第32行 18 | var zimuBottom="28px";//修改此数值改变字幕距底部的高度 19 | var zimuColor="red";//修改此处改变字幕颜色 20 | var zimuFontSize="25px";//修改此处改变字体大小 21 | 22 | var IsSikiName=0;// 1为启动同传man过滤 0为不启动,默认不启动 23 | //如果要启动同传man过滤,启动后需要修改SikiName里括号里的内容 24 | //如SikiName=["斋藤飞鳥Offcial","小明1","小明2"],则只会显示名字为,斋藤飞鳥Offcial,小明1,小明2的同传 25 | //此变量为字符串数字,元素为字符串变量,元素内容由 , 分隔(不是中文下的 ,) 26 | var SikiName=[""]; 27 | ``` 28 | 29 | 30 | ![image.png](https://i.loli.net/2020/04/15/I1OQEcVUHjbMreT.png) 31 | 32 | 33 | ### 谷歌浏览器已经上传了,可直接下载(应该可以使用吧) 34 | ``` 35 | 链接: https://pan.baidu.com/s/1vI2vXq3mkYe4hO7YCKMGCQ 提取码: s5ih 36 | ``` 37 | ### 这是油猴的安装包(国内搬运) 38 | [传送门](https://www.xmpojie.com/697.html) 39 | -------------------------------------------------------------------------------- /danmu.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name bilibili vtb直播同传man字幕显示 3 | // @version 202210431 4 | // @description !!! 5 | // @author siro 6 | // @match http://live.bilibili.com/* 7 | // @match https://live.bilibili.com/* 8 | // @require https://cdn.staticfile.org/jquery/1.12.4/jquery.min.js 9 | // @require https://cdnjs.cloudflare.com/ajax/libs/pako/1.0.10/pako.min.js 10 | // @namespace http://www.xiaosiro.cn 11 | // @grant unsafeWindow 12 | // @run-at document-idle 13 | // ==/UserScript== 14 | 15 | //脚本多次加载这可能是因为目标页面正在加载帧或iframe。 16 | // 17 | //将这下行添加到脚本代码部分的顶部: 18 | if (window.top != window.self) //-- Don't run on frames or iframes 19 | return; 20 | 21 | var room_id = 22129083;//默认房间号 22 | var uid = 0; 23 | var url; 24 | var mytoken; 25 | var port; 26 | var rawHeaderLen = 16; 27 | var packetOffset = 0; 28 | var headerOffset = 4; 29 | var verOffset = 6; 30 | var opOffset = 8; 31 | var seqOffset = 12; 32 | var socket; 33 | var utf8decoder = new TextDecoder(); 34 | var f = 0; //不知道为什么会建立两次连接,用这个标记一下。 35 | var zimuBottom = 40;//修改此数值改变字幕距底部的高度 36 | var zimuColor = "#FFFFFF";//修改此处改变字幕颜色 37 | var zimuFontSize = 25;//修改此处改变字体大小 38 | var zimuShadow = 1;//启动弹幕阴影 39 | var zimuShadowColor = "#66CCFF"// 弹幕阴影颜色 40 | var deltime = 3000;//字幕存在时间 41 | var IsSikiName = 0;// 1为启动同传man过滤 0为不启动,默认不启动 42 | //如果要启动同传man过滤,启动后需要修改SikiName里括号里的内容 43 | //如SikiName=["斋藤飞鳥Offcial","小明1","小明2"],则只会显示名字为,斋藤飞鳥Offcial,小明1,小明2的同传 44 | //此变量为字符串数字,元素为字符串变量,元素内容由 , 分隔(不是中文下的 ,) 45 | var SikiName = ["白峰さやか"]; 46 | var isSpecialRoom = false; 47 | var isTop = false;// 默认生成在底部; 48 | if (!document.getElementById("live-player-ctnr")) { 49 | console.log('特殊主题直播间,20s后执行脚本'); 50 | isSpecialRoom = true; 51 | zimuBottom = zimuBottom + 150; 52 | setTimeout(() => myCode(), 20000); 53 | } else { 54 | myCode(); 55 | } 56 | 57 | function myCode() { 58 | console.log("开始执行脚本"); 59 | // 创建页面字幕元素 60 | var danmudiv = $('
'); 61 | danmudiv.attr('id', 'danmu'); 62 | var danmudivwidth; 63 | if ($("#live-player-ctnr")) { 64 | danmudivwidth = $("#live-player-ctnr").width(); 65 | } else { 66 | danmudivwidth = "900px"; 67 | } 68 | console.log(danmudivwidth); 69 | danmudiv.css({ 70 | "min-width": "100px", 71 | "width": "100%", 72 | "magin": "0 auto", 73 | "position": "absolute", 74 | "left": "0px", 75 | "bottom": zimuBottom + "px", 76 | "z-index": "14", 77 | "color": zimuColor, 78 | "font-size": zimuFontSize + "px", 79 | "text-align": "center", 80 | "font-weight": "bold", 81 | "pointer-events": "none", 82 | "text-shadow": "0 0 0.2em #F87, 0 0 0.2em #F87", 83 | }); 84 | 85 | if (isTop) { 86 | danmudiv.css("bottom", ""); 87 | danmudiv.css("top", zimuBottom + "px"); 88 | } 89 | 90 | if (!document.getElementById("live-player-ctnr")) { 91 | console.log('主页面无此元素,尝试注入父div...');//player-ctnr 92 | 93 | //$("iframe:eq(1)").attr('id','danmulive') 94 | console.log(); 95 | danmudiv.appendTo($("#player-ctnr")); 96 | } else { 97 | danmudiv.appendTo($("#live-player-ctnr")); 98 | } 99 | 100 | 101 | // 创建控制面板 102 | var danmuControldiv = $('
字幕设置
'); 103 | danmuControldiv.attr('id', 'danmuControldiv'); 104 | danmuControldiv.css({ 105 | "height": "60px", 106 | "top": "100px", 107 | "left": "0", 108 | "width": "16px", 109 | "z-index": "999998", 110 | "display": "flex", 111 | "flex-direction": "column", 112 | "justify-content": "center", 113 | "align-items": "center", 114 | "position": "fixed", 115 | "transform": "translateY(-50%)", 116 | "background": "#FFF", 117 | "border-radius": "2px", 118 | }); 119 | danmuControldiv.appendTo($("body")); 120 | var danmuControlBody = $(``); 132 | function upDanmudiv() { 133 | danmudiv.css({ 134 | "bottom": zimuBottom + "px", 135 | "color": zimuColor, 136 | "font-size": zimuFontSize + "px", 137 | "z-index": "999999", 138 | }); 139 | if (zimuShadow == 1) { 140 | danmudiv.css({ 141 | "text-shadow": "0 0 0.2em " + zimuShadowColor + ", 0 0 0.2em " + zimuShadowColor, 142 | }); 143 | } else { 144 | danmudiv.css({ 145 | "text-shadow": "0 0 0", 146 | }); 147 | } 148 | 149 | if (isTop) { 150 | danmudiv.css("bottom", ""); 151 | danmudiv.css("top", zimuBottom + "px"); 152 | } else { 153 | danmudiv.css("bottom", zimuBottom + "px"); 154 | } 155 | } 156 | function bindDanmuDate() { 157 | var inputs = $("#danmuControlBody").children("input"); 158 | inputs[0].value = zimuFontSize; 159 | inputs[1].value = zimuColor; 160 | if (isSpecialRoom) { 161 | inputs[2].value = zimuBottom - 150; 162 | } else { 163 | inputs[2].value = zimuBottom; 164 | } 165 | inputs[3].checked = (zimuShadow == 0 ? false : true); 166 | inputs[4].value = zimuShadowColor; 167 | inputs[5].value = (isTop == 0 ? false : true); 168 | } 169 | function saveDanmuDate() { 170 | var inputs = $("#danmuControlBody").children("input"); 171 | zimuFontSize = inputs[0].value; 172 | zimuColor = inputs[1].value; 173 | if (isSpecialRoom) { 174 | zimuBottom = inputs[2].value; 175 | zimuBottom += 150; 176 | } else { 177 | zimuBottom = inputs[2].value; 178 | } 179 | zimuShadow = (inputs[3].checked ? 1 : 0); 180 | zimuShadowColor = inputs[4].value; 181 | isTop = (inputs[5].checked ? 1 : 0); 182 | upDanmudiv(); 183 | } 184 | danmuControlBody.appendTo($("body")); 185 | $("#danmuControldiv").on('click', function () { 186 | $("#danmuControlBody").css("display", "flex"); 187 | bindDanmuDate(); 188 | } 189 | ); 190 | $("#closeDiv").on('click', function () { 191 | $("#danmuControlBody").css("display", "none"); 192 | } 193 | ); 194 | $("#danmuControlOK").on('click', function () { 195 | saveDanmuDate(); 196 | } 197 | ); 198 | $("#danmuControlOld").on('click', function () { 199 | zimuBottom = 40;//修改此数值改变字幕距底部的高度 200 | zimuColor = "#FF0000";//修改此处改变字幕颜色 201 | zimuFontSize = 25;//修改此处改变字体大小 202 | zimuShadow = 1;//启动弹幕阴影 203 | zimuShadowColor = "#000F87"// 弹幕阴影颜色 204 | upDanmudiv(); 205 | } 206 | ); 207 | 208 | //获取当前房间编号 209 | var UR = document.location.toString(); 210 | var arrUrl = UR.split("//"); 211 | var start = arrUrl[1].indexOf("/"); 212 | var relUrl = arrUrl[1].substring(start + 1);//stop省略,截取从start开始到结尾的所有字符 213 | if (relUrl.indexOf("?") != -1) { 214 | relUrl = relUrl.split("?")[0]; 215 | } 216 | room_id = parseInt(relUrl); 217 | 218 | //获取你的uid 219 | $.ajax({ 220 | url: 'https://api.live.bilibili.com/xlive/web-ucenter/user/get_user_info', 221 | type: 'GET', 222 | dataType: 'json', 223 | success: function (data) { 224 | //console.log(data.data); 225 | uid = data.data.uid; 226 | //console.log(uid); 227 | }, 228 | xhrFields: { 229 | withCredentials: true // 这里设置了withCredentials 230 | }, 231 | }); 232 | //获取真实房间号 233 | $.ajax({ 234 | url: '//api.live.bilibili.com/room/v1/Room/room_init?id=' + room_id, 235 | type: 'GET', 236 | dataType: 'json', 237 | success: function (data) { 238 | room_id = data.data.room_id; 239 | 240 | } 241 | }); 242 | //获取弹幕连接和token 243 | $.ajax({ 244 | url: '//api.live.bilibili.com/room/v1/Danmu/getConf?room_id=' + room_id + '&platform=pc&player=web', 245 | type: 'GET', 246 | dataType: 'json', 247 | success: function (data) { 248 | url = data.data.host_server_list[1].host; 249 | port = data.data.host_server_list[1].wss_port; 250 | mytoken = data.data.token; 251 | DanmuSocket(); 252 | }, 253 | xhrFields: { withCredentials: true } 254 | }) 255 | // 蜜汁字符转换 256 | function txtEncoder(str) { 257 | var buf = new ArrayBuffer(str.length); 258 | var bufView = new Uint8Array(buf); 259 | for (var i = 0, strlen = str.length; i < strlen; i++) { 260 | bufView[i] = str.charCodeAt(i); 261 | } 262 | return bufView; 263 | } 264 | // 合并 265 | function mergeArrayBuffer(ab1, ab2) { 266 | var u81 = new Uint8Array(ab1), 267 | u82 = new Uint8Array(ab2), 268 | res = new Uint8Array(ab1.byteLength + ab2.byteLength); 269 | res.set(u81, 0); 270 | res.set(u82, ab1.byteLength); 271 | return res.buffer; 272 | } 273 | 274 | //发送心跳包 275 | function heartBeat() { 276 | var headerBuf = new ArrayBuffer(rawHeaderLen); 277 | var headerView = new DataView(headerBuf, 0); 278 | var ob = "[object Object]"; 279 | var bodyBuf = txtEncoder(ob); 280 | headerView.setInt32(packetOffset, rawHeaderLen + bodyBuf.byteLength); 281 | headerView.setInt16(headerOffset, rawHeaderLen); 282 | headerView.setInt16(verOffset, 1); 283 | headerView.setInt32(opOffset, 2); 284 | headerView.setInt32(seqOffset, 1); 285 | //console.log('发送信条'); 286 | socket.send(mergeArrayBuffer(headerBuf, bodyBuf)); 287 | }; 288 | // 导入css 289 | 290 | var style = document.createElement("style"); 291 | style.type = "text/css"; 292 | var text = document.createTextNode(`#danmu .message { 293 | transition: height 0.2s ease-in-out, margin 0.2s ease-in-out; 294 | } 295 | 296 | #danmu .message .text { 297 | text-align:center; 298 | font-weight: bold; 299 | pointer-events:none; 300 | } 301 | 302 | @keyframes message-move-in { 303 | 0% { 304 | opacity: 0; 305 | transform: translateY(100%); 306 | } 307 | 100% { 308 | opacity: 1; 309 | transform: translateY(0); 310 | } 311 | } 312 | 313 | #danmu .message.move-in { 314 | animation: message-move-in 0.3s ease-in-out; 315 | } 316 | 317 | 318 | @keyframes message-move-out { 319 | 0% { 320 | opacity: 1; 321 | transform: translateY(0); 322 | } 323 | 100% { 324 | opacity: 0; 325 | transform: translateY(-100%); 326 | } 327 | } 328 | #danmu .message.move-out { 329 | animation: message-move-out 0.3s ease-in-out; 330 | animation-fill-mode: forwards; 331 | }` 332 | ); 333 | style.appendChild(text); 334 | var head = document.getElementsByTagName("head")[0]; 335 | head.appendChild(style); 336 | 337 | // 消息渲染器 338 | class Message { 339 | //构造函数 340 | constructor() { 341 | const containerId = 'danmu'; 342 | this.containerEl = document.getElementById(containerId); 343 | } 344 | 345 | show({ text = '', duration = 2000 }) { 346 | // 创建一个Element对象 347 | let messageEl = document.createElement('div'); 348 | // 设置消息class,这里加上move-in可以直接看到弹出效果 349 | messageEl.className = 'message move-in'; 350 | // 消息内部html字符串 351 | messageEl.innerHTML = ` 352 |
${text}
353 | `; 354 | // 追加到message-container末尾 355 | // this.containerEl属性是我们在构造函数中创建的message-container容器 356 | this.containerEl.appendChild(messageEl); 357 | 358 | // 用setTimeout来做一个定时器 359 | setTimeout(() => { 360 | // 首先把move-in这个弹出动画类给移除掉,要不然会有问题,可以自己测试下 361 | messageEl.className = messageEl.className.replace('move-in', ''); 362 | // 增加一个move-out类 363 | messageEl.className += 'move-out'; 364 | 365 | // move-out动画结束后把元素的高度和边距都设置为0 366 | // 由于我们在css中设置了transition属性,所以会有一个过渡动画 367 | messageEl.addEventListener('animationend', () => { 368 | messageEl.setAttribute('style', 'height: 0; margin: 0'); 369 | }); 370 | 371 | // 这个地方是监听动画结束事件,在动画结束后把消息从dom树中移除。 372 | // 如果你是在增加move-out后直接调用messageEl.remove,那么你不会看到任何动画效果 373 | //messageEl.addEventListener('transitionend', () => { 374 | // // Element对象内部有一个remove方法,调用之后可以将该元素从dom树种移除! 375 | // messageEl.remove(); 376 | //}); 377 | // 以上方法似乎无效,所以用一个定时器来完成 378 | setTimeout(() => { 379 | messageEl.remove(); 380 | }, duration + 10000); 381 | }, duration); 382 | } 383 | 384 | } 385 | 386 | const message = new Message(); 387 | 388 | 389 | //数据包解析 感谢https://github.com/lovelyyoshino/Bilibili-Live-API/blob/master/API.WebSocket.md 390 | const textEncoder = new TextEncoder('utf-8'); 391 | const textDecoder = new TextDecoder('utf-8'); 392 | 393 | const readInt = function (buffer, start, len) { 394 | let result = 0 395 | for (let i = len - 1; i >= 0; i--) { 396 | result += Math.pow(256, len - i - 1) * buffer[start + i] 397 | } 398 | return result 399 | } 400 | 401 | const writeInt = function (buffer, start, len, value) { 402 | let i = 0 403 | while (i < len) { 404 | buffer[start + i] = value / Math.pow(256, len - i - 1) 405 | i++ 406 | } 407 | } 408 | 409 | function encode(str, op) { 410 | let data = textEncoder.encode(str); 411 | let packetLen = 16 + data.byteLength; 412 | let header = [0, 0, 0, 0, 0, 16, 0, 1, 0, 0, 0, op, 0, 0, 0, 1] 413 | writeInt(header, 0, 4, packetLen) 414 | return (new Uint8Array(header.concat(...data))).buffer 415 | } 416 | function decode(blob) { 417 | let buffer = new Uint8Array(blob) 418 | let result = {} 419 | result.packetLen = readInt(buffer, 0, 4) 420 | result.headerLen = readInt(buffer, 4, 2) 421 | result.ver = readInt(buffer, 6, 2) 422 | result.op = readInt(buffer, 8, 4) 423 | result.seq = readInt(buffer, 12, 4) 424 | if (result.op === 5) { 425 | result.body = [] 426 | let offset = 0; 427 | while (offset < buffer.length) { 428 | let packetLen = readInt(buffer, offset + 0, 4) 429 | let headerLen = 16// readInt(buffer,offset + 4,4) 430 | if (result.ver == 2) { 431 | let data = buffer.slice(offset + headerLen, offset + packetLen); 432 | let newBuffer = pako.inflate(new Uint8Array(data)); 433 | const obj = decode(newBuffer); 434 | const body = obj.body; 435 | result.body = result.body.concat(body); 436 | } else { 437 | let data = buffer.slice(offset + headerLen, offset + packetLen); 438 | let body = textDecoder.decode(data); 439 | if (body) { 440 | result.body.push(JSON.parse(body)); 441 | } 442 | } 443 | offset += packetLen; 444 | } 445 | } else if (result.op === 3) { 446 | result.body = { 447 | count: readInt(buffer, 16, 4) 448 | }; 449 | } 450 | return result; 451 | } 452 | 453 | // socket连接 454 | function DanmuSocket() { 455 | var ws = 'wss'; 456 | if (f) { 457 | return; 458 | } 459 | socket = new WebSocket(ws + '://' + url + ':' + port + '/sub'); 460 | f = 1; 461 | socket.binaryType = 'arraybuffer'; 462 | 463 | // Connection opened 464 | socket.addEventListener('open', function (event) { 465 | console.log('Danmu WebSocket Server Connected.'); 466 | console.log('Handshaking...'); 467 | var token = JSON.stringify({ 468 | 'uid': uid, 469 | 'roomid': room_id, 470 | 'key': mytoken, 471 | 'protover': 1, 472 | }); 473 | var headerBuf = new ArrayBuffer(rawHeaderLen); 474 | var headerView = new DataView(headerBuf, 0); 475 | var bodyBuf = txtEncoder(token); 476 | headerView.setInt32(packetOffset, rawHeaderLen + bodyBuf.byteLength); 477 | headerView.setInt16(headerOffset, rawHeaderLen); 478 | headerView.setInt16(verOffset, 1); 479 | headerView.setInt32(opOffset, 7); 480 | headerView.setInt32(seqOffset, 1); 481 | socket.send(mergeArrayBuffer(headerBuf, bodyBuf)); 482 | // heartBeat(); 483 | var Id = setInterval(function () { 484 | heartBeat(); 485 | }, 30 * 1000); 486 | }); 487 | 488 | socket.addEventListener('error', function (event) { 489 | console.log('WebSocket 错误: ', event); 490 | socket.close(); 491 | f = 0; 492 | console.log('WebSocket 重连 '); 493 | DanmuSocket(); 494 | }); 495 | 496 | socket.addEventListener('close', function (event) { 497 | console.log('WebSocket 关闭 '); 498 | f = 0; 499 | sleep(5000); 500 | console.log('WebSocket 重连 '); 501 | DanmuSocket(); 502 | }); 503 | 504 | // Listen for messages 505 | socket.addEventListener('message', function (msgEvent) { 506 | const packet = decode(msgEvent.data); 507 | switch (packet.op) { 508 | case 8: 509 | //console.log('加入房间'); 510 | break; 511 | case 3: 512 | //console.log(`人气`); 513 | break; 514 | case 5: 515 | packet.body.forEach((body) => { 516 | switch (body.cmd) { 517 | case 'DANMU_MSG': 518 | var tongchuan = body.info[1]; 519 | var manName = body.info[2][1]; 520 | //message.show({ 521 | // text: tongchuan, 522 | // duration: deltime, 523 | // }); 524 | if (tongchuan.indexOf("【") != -1) { 525 | tongchuan = tongchuan.replace("【", " "); 526 | tongchuan = tongchuan.replace("】", ""); 527 | if (!IsSikiName) { 528 | //console.log("显示字幕"); 529 | message.show({ 530 | text: tongchuan, 531 | duration: deltime, 532 | }); 533 | } else if ((SikiName.indexOf(manName) > -1)) { 534 | message.show({ 535 | text: tongchuan, 536 | duration: deltime, 537 | }); 538 | } 539 | 540 | } 541 | //console.log(`${body.info[2][1]}: ${body.info[1]}`); 542 | break; 543 | case 'SEND_GIFT': 544 | //console.log(`${body.data.uname} ${body.data.action} ${body.data.num} 个 ${body.data.giftName}`); 545 | break; 546 | case 'WELCOME': 547 | //console.log(`欢迎 ${body.data.uname}`); 548 | break; 549 | // 此处省略很多其他通知类型 550 | default: 551 | //console.log(body); 552 | } 553 | }) 554 | break; 555 | } 556 | }); 557 | } 558 | 559 | }; 560 | 561 | // 延迟执行 562 | 563 | 564 | /* 弹幕json示例 565 | { 566 | "info": [ 567 | [ 568 | 0, 569 | 1, 570 | 25, 571 | 16777215, 572 | 1526267394, 573 | -1189421307, 574 | 0, 575 | "46bc1d5e", 576 | 0 577 | ], 578 | "空投!", 579 | [ 580 | 10078392, 581 | "白の驹", 582 | 0, 583 | 0, 584 | 0, 585 | 10000, 586 | 1, 587 | "" 588 | ], 589 | [ 590 | 11, 591 | "狗雨", 592 | "宫本狗雨", 593 | 102, 594 | 10512625, 595 | "" 596 | ], 597 | [ 598 | 23, 599 | 0, 600 | 5805790, 601 | ">50000" 602 | ], 603 | [ 604 | "title-111-1", 605 | "title-111-1" 606 | ], 607 | 0, 608 | 0, 609 | { 610 | "uname_color": "" 611 | } 612 | ], 613 | "cmd": "DANMU_MSG" 614 | } 615 | */ --------------------------------------------------------------------------------