├── .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 | 
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 | */
--------------------------------------------------------------------------------