b站直播聊天室弹幕发送增强

原理是分开发送。接管了发送框,会提示屏蔽词

  1. // ==UserScript==
  2. // @name b站直播聊天室弹幕发送增强
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.4.2
  5. // @description 原理是分开发送。接管了发送框,会提示屏蔽词
  6. // @author Pronax
  7. // @include /https:\/\/live\.bilibili\.com\/(blanc\/)?\d+/
  8. // @icon http://bilibili.com/favicon.ico
  9. // @grant GM_addStyle
  10. // @run-at document-end
  11. // @require https://greasyfork.org/scripts/439903-blive-room-info-api/code/blive_room_info_api.js?version=1037039
  12. // ==/UserScript==
  13.  
  14. // todo 分段指针
  15. // todo 接管全屏输入栏
  16. // todo 自动判断屏蔽词 23237777
  17.  
  18. ; (async function () {
  19. 'use strict';
  20.  
  21. if (!document.cookie.match(/bili_jct=(\w*); /)) { return; }
  22.  
  23. // proxyFetch(); // 魔改fetch
  24.  
  25. let jct = document.cookie.match(/bili_jct=(\w*); /)[1];
  26. let roomId = await ROOM_INFO_API.getRid();
  27. let toastCount = 0;
  28. let isProcessing = false;
  29. let formData = new FormData();
  30. formData.set("bubble", 0);
  31. formData.set("color", 16777215);
  32. formData.set("mode", 1);
  33. formData.set("fontsize", 25);
  34. formData.set("roomid", roomId);
  35.  
  36. const LIMIT = await ROOM_INFO_API.getDanmuLength(roomId);
  37.  
  38. const riverCrabs = { "168": "f", "母鸡": "f", "神仙水": "f", "小赤佬": "f", "速器": "fire", "商丘": "fire", "慎判": "fire", "代练": "f", "违规直播": "f", "低俗": "f", "系统": "f", "渣女": "f", "肥": "fire", "墙了": "f", "变质": "f", "小熊": "f", "疫情": "f", "感染": "f", "分钟": "f", "爽死": "f", "黑历史": "f", "超度": "f", "渣男": "f", "和谐": "f", "河蟹": "f", "敏感": "f", "你妈": "f", "代孕": "f", "硬了": "f", "抖音": "f", "保卫": "f", "被gan": "f", "寄吧": "f", "郭楠": "f", "里番": "f", "小幸运": "f", "试看": "f", "加QQ": "f", "警察": "f", "营养": "f", "资料": "f", "家宝": "f", "饿死": "f", "不认字": "f", "横幅": "f", "hentai": "f", "诱惑": "f", "垃圾": "f", "福报": "f", "拉屎": "f", "顶不住": "f", "一口气": "f", "苏联": "f", "哪个平": "f", "老鼠台": "f", "顶得住": "f", "gay": "f", "黑幕": "f", "蜀黍我啊": "f", "梯子": "f", "美国": "f", "米国": "f", "未成年": "f", "爪巴": "f", "包子": "fire", "党": "fire", "89": "fire", "戏精": "fire", "八九": "fire", "八十九": "fire", "你画我猜": "fire", "叔叔我啊": "fire", "爬": "fire" };
  39. let wordTree = {};
  40. initTree();
  41.  
  42. // 弹出框CSS
  43. GM_addStyle(".link-toast.error{left:40px;right:40px;white-space:normal;margin:auto;text-align:center;box-shadow:0 .2em .1em .1em rgb(255 100 100/20%)}");
  44. // 原始粉丝牌
  45. // GM_addStyle("#control-panel-ctnr-box .medal-section{padding-left:0}");
  46. // 发送按钮 CSS
  47. GM_addStyle(".chat-input-focus button.right-action-btn{background-color:var(--brand_pink);}.chat-input-ctnr-new button.right-action-btn{min-width: 50px;}.chat-input-ctnr-new button.right-action-btn:hover{background-color:var(--brand_pink);}.chat-input-ctnr-new div.right-actions{margin-right:5px;}");
  48. // 输入框及母盒子
  49. GM_addStyle("#control-panel-ctnr-box{padding:10px 8px 8px}#control-panel-ctnr-box>.chat-input-ctnr-new{margin-top:10px;align-items:center;height:fit-content !important;border-radius:10px;min-height:52px;}");
  50. // 插件输入框及背景框的公共CSS
  51. GM_addStyle("div.chat-input-ctnr-new>.chat-input-new.default-height{height:fit-content !important;}.chat-input-new>.textarea-panel.input-area{scrollbar-width:thin;overflow-x:hidden;}.chat-input-new>.textarea-panel.input-area::-webkit-scrollbar{width:4px;}#liveDanmuInputBackground,#liveDanmuInputArea.focus{line-height:19px;height:87px;margin-left:10px;}");
  52. // @别人的提示标志
  53. GM_addStyle("#liveDanmuAtLabel,.at #liveDanmuInputBackground::before{content:'@'attr(data-at);border-radius:2px;background-color:var(--Pi4_u);box-shadow:0 0 0 1px var(--Pi4_u);padding:0 3px;margin:0 5px 0 3px;color:var(--text_white)}");
  54.  
  55. // 用于回复@别人
  56. let at = {
  57. username: undefined,
  58. uid: undefined,
  59. calDom: undefined,
  60. set user(username) {
  61. this.username = username;
  62. if (!this.username) {
  63. this.uid = undefined;
  64. let inputParentBox = document.querySelector(".chat-input-new");
  65. requestAnimationFrame(() => {
  66. inputParentBox.classList.remove("at");
  67. });
  68. inputArea.dom.style.textIndent = '';
  69. return;
  70. }
  71. // 计算字符长度赋值给text-indent
  72. at.calDom.innerText = "@" + this.username;
  73. // 查找UID
  74. let danmuDom = document.querySelector(`.chat-item[data-uname="${this.username}"]`);
  75. if (danmuDom && danmuDom.dataset.uid) {
  76. this.uid = danmuDom.dataset.uid;
  77. let inputParentBox = document.querySelector(".chat-input-new");
  78. requestAnimationFrame(() => {
  79. inputParentBox.classList.add("at");
  80. });
  81. inputArea.dom.dataset.at = this.username;
  82. inputArea.bgDom.dataset.at = this.username;
  83. let width = parseInt(getComputedStyle(at.calDom).width) + 15; // 我也不知道为什么加这个数
  84. inputArea.dom.style.textIndent = width + 'px';
  85. } else {
  86. console.warn(`初始化@数据失败:无法找到${this.username}的弹幕`);
  87. }
  88. }
  89. }
  90. let inputArea = {
  91. dom: undefined,
  92. bgDom: undefined,
  93. limitHintDom: undefined,
  94. _textValue: '',
  95. _htmlValue: '',
  96. _internalTimeout: undefined,
  97. set textValue(val) {
  98. this._textValue = val;
  99. this.bgDom.innerText = this._textValue;
  100. this.bgDom.scrollTop = this.dom.scrollTop;
  101. // 屏蔽词过滤
  102. clearTimeout(this._internalTimeout);
  103. this._internalTimeout = setTimeout(() => {
  104. inputArea.htmlValue = filter(inputArea.textValue);
  105. }, 170);
  106. // 指示器内容更新
  107. if (this.textValue.length > LIMIT) {
  108. this.limitHintDom.classList.add("over");
  109. } else {
  110. this.limitHintDom.classList.remove("over");
  111. }
  112. this.limitHintDom.innerText = `${this.textValue.length}/${LIMIT}`;
  113. },
  114. get textValue() { return this._textValue; },
  115. set htmlValue(val) {
  116. this._htmlValue = val;
  117. this.bgDom.innerHTML = this._htmlValue;
  118. },
  119. get htmlValue() { return this._htmlValue; },
  120. }
  121.  
  122. let deadLine = Date.now() + 7000;
  123. let itv = setInterval(() => {
  124. if (Date.now() > deadLine) {
  125. clearInterval(itv);
  126. console.log("弹幕发送增强-无法找到加载点");
  127. }
  128. let textarea = document.querySelector(".chat-input-new>.textarea-panel");
  129. if (textarea) {
  130. console.log("弹幕发送增强-加载完毕");
  131. clearInterval(itv);
  132.  
  133. // 原版打标签
  134. textarea.id = "originInputArea";
  135.  
  136. // SC的大按钮改小按钮
  137. // GM_addStyle(".icon-left-part-new .super-chat{width: 65px !important;}");
  138. // let scText = document.querySelector(".super-chat-text");
  139. // scText && (scText.innerText = "SC");
  140.  
  141. // 长度提示
  142. GM_addStyle(".input-limit-hint{display:none}.chat-input-focus .text-limit-hint{opacity:1}.text-limit-hint{opacity:0;z-index:2;font-size:12px;line-height:19px;color:var(--Ga3);bottom:0;right:12px}.text-limit-hint.over{color:var(--brand_blue)}");
  143. inputArea.limitHintDom = document.createElement("span");
  144. inputArea.limitHintDom.className = "text-limit-hint none-select p-absolute";
  145. inputArea.limitHintDom.innerText = "0/" + LIMIT;
  146. textarea.parentNode.after(inputArea.limitHintDom);
  147.  
  148. // 背景
  149. GM_addStyle("#liveDanmuInputBackground{opacity:0;left:0;top:50%;transform:translateY(-50%);color:transparent;overflow-y:auto;}.chat-input-focus #liveDanmuInputBackground{opacity:1}");
  150. inputArea.bgDom = document.createElement("div");
  151. inputArea.bgDom.id = "liveDanmuInputBackground";
  152. inputArea.bgDom.className = "textarea-panel p-absolute dp-i-block input-area";
  153. for (let item of Object.keys(textarea.dataset)) {
  154. inputArea.bgDom.dataset[item] = textarea.dataset[item];
  155. }
  156. textarea.after(inputArea.bgDom);
  157.  
  158. // 输入框
  159. GM_addStyle("#liveDanmuInputArea{padding:5px 0;z-index:1;position:relative;background-color:transparent;resize:none;}");
  160. GM_addStyle(".f-word{background-color:var(--Ly4)}.fire-word{background-color:var(--Or5)}");
  161. GM_addStyle("#liveDanmuInputArea.default{text-indent:.5rem;width: 97%;}");
  162. // 用input配合一个div背景。如果用contentEditable来搞,容易搞坏光标定位和编辑栈,已放弃
  163. inputArea.dom = textarea.cloneNode();
  164. inputArea.dom.id = "liveDanmuInputArea";
  165. inputArea.dom.classList.add("input-area");
  166. inputArea.dom.classList.add("dp-block");
  167. inputArea.dom.classList.remove("default-height");
  168.  
  169. inputArea.dom.addEventListener("scroll", (e) => {
  170. inputArea.bgDom.scrollTop = e.target.scrollTop;
  171. });
  172. inputArea.dom.addEventListener("focus", (e) => {
  173. e.target.classList.add("focus");
  174. e.target.classList.remove("default");
  175. textarea.dispatchEvent(new Event('focus'));
  176. // document.querySelector(".chat-input-ctnr-new").classList.add("chat-input-focus");
  177.  
  178. });
  179. inputArea.dom.addEventListener("blur", (e) => {
  180. e.target.classList.add("default");
  181. e.target.classList.remove("focus");
  182. textarea.dispatchEvent(new Event('blur'));
  183. // document.querySelector(".chat-input-ctnr-new").classList.remove("chat-input-focus");
  184. });
  185. inputArea.dom.addEventListener("input", (e) => {
  186. inputArea.textValue = e.target.value;
  187. });
  188. inputArea.dom.addEventListener("keydown", (e) => {
  189. if (e.key == 'Enter' || e.keyCode == 13) {
  190. // 回车在输入框里没有任何作用,发出去会变成一个空格,且好像会造成奇怪的bug,这里直接拦截掉
  191. e.preventDefault();
  192. e.stopPropagation();
  193. // 回车发送
  194. if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) {
  195. } else {
  196. dealDanmu(e.target);
  197. }
  198. } else if (e.key == 'Backspace' || e.keyCode == 8) {
  199. const startOffset = inputArea.dom.selectionStart; // 光标开始位置
  200. const endOffset = inputArea.dom.selectionEnd; // 光标结束位置(如果有选中的内容)
  201. // 在首位删除时,如果存在@标识,那就抹掉
  202. if (startOffset === 0 && endOffset === 0 && document.querySelector(".chat-input-new").classList.contains("at")) {
  203. at.user = undefined;
  204. }
  205. }
  206. });
  207. let sendBtn = document.querySelector(".chat-input-ctnr-new .right-action-btn");
  208. if (sendBtn) {
  209. sendBtn.addEventListener("click", (e) => {
  210. dealDanmu(inputArea.dom);
  211. });
  212. }
  213. textarea.after(inputArea.dom);
  214.  
  215. // 适配新版小表情
  216. GM_addStyle(".emotion-recent-wrap{display:none !important;}"); // 最近使用 懒,一刀切了吧
  217. let emojiBtn = document.querySelector(".emoticons-panel");
  218. emojiBtn.addEventListener("click", () => {
  219. let deadline = Date.now() + 1000;
  220. (function init() {
  221. let emojiPanel = document.querySelector(".emoji-wrap");
  222. if (emojiPanel) {
  223. emojiPanel.onclick = e => {
  224. let target = e.target;
  225. if (e.target.tagName == "IMG") {
  226. target = e.target.parentNode;
  227. }
  228. inputArea.dom.value = inputArea.dom.value + target.title;
  229. inputArea.dom.dispatchEvent(new Event('input'));
  230. cleanOriginInput();
  231. }
  232. } else if (Date.now() < deadline) {
  233. requestIdleCallback(init, { timeout: 1000 });
  234. }
  235. })();
  236. });
  237.  
  238. // @别人
  239. setTimeout(() => { // 页面刚加载完毕时,只有一个菜单,没有@按钮,所以注册一个监听器等首次打开菜单时才注册事件到@按钮上
  240. let observer = new MutationObserver(function (mutations) {
  241. let danmuMenuAt = document.querySelector(".danmaku-menu .at-this-guy");
  242. if (danmuMenuAt) {
  243. danmuMenuAt.addEventListener("click", async (e) => {
  244. let menu = e.target.closest(".danmaku-menu");
  245. let username = menu.querySelector(".username").innerText;
  246. at.user = username;
  247.  
  248. inputArea.dom.classList.add("focus");
  249. inputArea.dom.classList.remove("default");
  250.  
  251. cleanOriginInput(textarea);
  252. });
  253. // 首次触发后@按钮就一直在,不需要监听DOM了
  254. observer.disconnect();
  255. observer = null;
  256. }
  257. });
  258. observer.observe(document.querySelector(".danmaku-menu"), {
  259. childList: true,
  260. subtree: true
  261. });
  262. }, 3000);
  263. // @别人 用来测量字符长度的小框
  264. GM_addStyle("#liveDanmuAtLabel{opacity:0;z-index:-1;width:auto;left:0;top: 50%;transform: translateY(-50%);}.chat-input-ctnr-new:not(.chat-input-focus) .at #liveDanmuAtLabel{opacity:1;z-index:0;}");
  265. at.calDom = document.createElement("span");
  266. at.calDom.id = "liveDanmuAtLabel";
  267. at.calDom.removeAttribute("placeholder");
  268. at.calDom.className = "p-absolute none-select at-label";
  269. for (let item of Object.keys(textarea.dataset)) {
  270. at.calDom.dataset[item] = textarea.dataset[item];
  271. }
  272. textarea.before(at.calDom);
  273.  
  274. textarea.style.display = "none";
  275. }
  276. }, 100);
  277.  
  278. async function dealDanmu(textarea) {
  279. let msg = textarea.value;
  280. if (!msg) { toast("请输入内容", 1500, "info"); return; }
  281. if (isProcessing) { toast("弹幕正在发送中", 1500, "info"); return; }
  282. isProcessing = true;
  283. let page = 1;
  284. let segment = LIMIT;
  285. if (msg.length > segment) {
  286. // 自动平均每条弹幕的长度
  287. while (msg.length / segment % 1 < 0.7 && msg.length / segment % 1 != 0) {
  288. segment--;
  289. }
  290. page = Math.ceil(msg.length / segment);
  291. console.log(`长度:${msg.length} 间隔:${segment} 分页:${page}`);
  292. }
  293. let count = 0;
  294. do {
  295. let str = msg.substr(0, segment);
  296. // console.log("发送", str); await sleep(count++ ? 500 + Math.random() * 1000 >> 1 : 0);
  297. let result = await sendMsg(str, count++ ? 500 + Math.random() * 1000 >> 1 : 0);
  298. if (at.uid) { at.user = undefined; } // 首条@过后就不@了
  299. msg = msg.substr(segment);
  300. textarea.value = msg;
  301. textarea.dispatchEvent(new Event('input'));
  302. } while (msg.length > 0);
  303. isProcessing = false;
  304. };
  305.  
  306. function filter(str) {
  307. let result = testStr(str);
  308. for (let word of result) {
  309. str = str.replaceAll(word.w, `<span class="${word.t}-word">${word.w}</span>`);
  310. }
  311. str = str.replaceAll(/\n/g, "<br>"); // 替换换行
  312. // str = str.replaceAll(/\s/g, "&nbsp;"); // 替换空格,不替换的话多空格在HTML会缩成1个
  313. return str;
  314. }
  315.  
  316. // 清理原版的输入框内容 如果有内容会卡在输入状态,粉丝牌就不见了
  317. function cleanOriginInput(textarea) {
  318. if (!textarea) {
  319. textarea = document.querySelector("#originInputArea");
  320. }
  321. if (textarea) {
  322. textarea.value = '';
  323. textarea.dispatchEvent(new Event('input'));
  324. }
  325. }
  326.  
  327. async function sendMsg(msg, timer = 500) {
  328. return new Promise((resolve, reject) => {
  329. setTimeout(() => {
  330. jct = document.cookie.match(/bili_jct=(\w*); /)[1];
  331. formData.set("csrf", jct);
  332. formData.set("csrf_token", jct);
  333. formData.set("msg", msg);
  334. formData.set("rnd", Math.floor(new Date() / 1000));
  335. if (at.uid) { // 补充@别人的参数
  336. formData.set("reply_mid", at.uid);
  337. } else {
  338. formData.delete("reply_mid");
  339. }
  340. fetch("//api.live.bilibili.com/msg/send", {
  341. credentials: 'include',
  342. method: 'POST',
  343. body: formData
  344. })
  345. .then(response => response.json())
  346. .then(result => {
  347. if (result.code != 0 || result.msg != "") {
  348. switch (result.msg) {
  349. case "f":
  350. result.msg = "含有敏感词";
  351. break;
  352. case "fire":
  353. result.msg = "弹幕含有违禁词汇";
  354. break;
  355. case "k":
  356. result.msg = "内容含有房间屏蔽词";
  357. break;
  358. default:
  359. result.msg = result.message;
  360. }
  361. if (result.code == -111) {
  362. jct = document.cookie.match(/bili_jct=(\w*); /)[1];
  363. formData.set("csrf", jct);
  364. formData.set("csrf_token", jct);
  365. }
  366. toast(result.msg);
  367. isProcessing = false;
  368. reject(result);
  369. } else {
  370. resolve(true);
  371. }
  372. })
  373. .catch(err => {
  374. console.log("发送弹幕出错:", err);
  375. toast(err.msg || err.message);
  376. isProcessing = false;
  377. reject(err);
  378. });
  379. }, timer);
  380. });
  381. }
  382.  
  383. function testStr(str) {
  384. let result = [];
  385. for (let index = 0; index < str.length; index++) {
  386. let r = check(wordTree, str, index);
  387. if (r.i >= 0) {
  388. result.push({
  389. s: index,
  390. e: r.i,
  391. w: r.w,
  392. t: r.t
  393. });
  394. index = r.i - 1; // 减一用于抵消++
  395. }
  396. }
  397. return result;
  398.  
  399. function check(obj, str, index, word = "") {
  400. let letter = str[index];
  401. if (str.length > index && obj[letter]) {
  402. word += letter;
  403. if (obj[letter].end) {
  404. return { i: index + 1, w: word, t: obj[letter].type };
  405. }
  406. return check(obj[letter], str, index + 1, word);
  407. } else {
  408. return { i: -1, w: word };
  409. }
  410. }
  411. }
  412.  
  413. function initTree() {
  414. for (const item of Object.keys(riverCrabs)) {
  415. init(wordTree, item, 0);
  416. }
  417.  
  418. function init(obj, str, index) {
  419. if (!obj[str[index]]) {
  420. obj[str[index]] = {};
  421. }
  422. if (str.length - 1 == index) {
  423. obj[str[index]].end = true;
  424. obj[str[index]].type = riverCrabs[str];
  425. } else {
  426. Reflect.deleteProperty(obj[str[index]], "end");
  427. obj[str[index]] = init(obj[str[index]], str, index + 1);
  428. }
  429. return obj;
  430. }
  431. }
  432.  
  433. function toast(msg, time = 2000, type = "error") {
  434. let id = Math.random() * 1000 >> 1;
  435. let dom = document.createElement("span");
  436. dom.innerHTML = `<div class="link-toast ${type} link-toast-${id}" style="bottom:${105 + toastCount++ * 50}px"><span class="toast-text">${msg}</span></div>`;
  437. dom = dom.firstChild;
  438. let panel = document.querySelector("#chat-control-panel-vm");
  439. panel.append(dom);
  440. setTimeout(() => {
  441. toastCount--;
  442. dom.remove();
  443. }, time);
  444. }
  445.  
  446. // 魔改fetch,拦截B站自己的弹幕发送请求
  447. // 2024-9-9 作废:暂时没法修改输入框的限长
  448. // function proxyFetch() {
  449. // const { fetch: originalFetch } = unsafeWindow;
  450. // unsafeWindow.fetch = async (...args) => {
  451. // let [resource, config] = args;
  452. // let isDanmu = resource.startsWith("//api.live.bilibili.com/msg/send");
  453. // if (isDanmu) {
  454. // // console.log("请求", resource, config);
  455. // // 图片表情
  456. // let emotion = config && config.data && config.data.emoticonOptions;
  457. // if (!emotion) {
  458. // // 拦截弹幕请求,直接返回一个假的
  459. // return new Response("{\"code\":1}", { status: 200 });
  460. // }
  461. // }
  462. // const response = await originalFetch(resource, config);
  463. // if (isDanmu) {
  464. // // console.log("返回", response);
  465. // }
  466. // return response;
  467. // };
  468. // }
  469.  
  470. })();
  471.  
  472. // 大表情
  473. // bubble: 0
  474. // msg: upower_[Minicatty_吃瓜]
  475. // color: 16777215
  476. // mode: 1
  477. // dm_type: 1
  478. // emoticonOptions: [object Object]
  479. // fontsize: 25
  480. // rnd: 1725808681
  481. // roomid: 2323777
  482. // csrf: 1
  483. // csrf_token: 1
  484.  
  485. // @人
  486. // bubble: 0
  487. // msg: [狗叫]123
  488. // color: 16777215
  489. // mode: 1
  490. // room_type: 0
  491. // jumpfrom: 0
  492. // reply_mid: 23237777
  493. // reply_attr: 0
  494. // replay_dmid: 42945fa0764e7be6388b4dfd3666ddb356
  495. // statistics: {"appId":100,"platform":5}
  496. // fontsize: 25
  497. // rnd: 1725808681
  498. // roomid: 2323777
  499. // csrf: 1
  500. // csrf_token: 1
  501.  
  502. // 普通弹幕
  503. // bubble: 0
  504. // msg: [狗叫]123
  505. // color: 16777215
  506. // mode: 1
  507. // room_type: 0
  508. // jumpfrom: 0
  509. // reply_mid: 0
  510. // reply_attr: 0
  511. // replay_dmid:
  512. // statistics: {"appId":100,"platform":5}
  513. // fontsize: 25
  514. // rnd: 1725808681
  515. // roomid: 2323777
  516. // csrf: 1
  517. // csrf_token: 1