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

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

目前為 2024-10-10 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         b站直播聊天室弹幕发送增强
// @namespace    http://tampermonkey.net/
// @version      0.4.2
// @description  原理是分开发送。接管了发送框,会提示屏蔽词
// @author       Pronax
// @include      /https:\/\/live\.bilibili\.com\/(blanc\/)?\d+/
// @icon         http://bilibili.com/favicon.ico
// @grant        GM_addStyle
// @run-at		 document-end
// @require      https://greasyfork.org/scripts/439903-blive-room-info-api/code/blive_room_info_api.js?version=1037039
// ==/UserScript==

// todo 分段指针
// todo 接管全屏输入栏
// todo 自动判断屏蔽词 23237777

; (async function () {
    'use strict';

    if (!document.cookie.match(/bili_jct=(\w*); /)) { return; }

    // proxyFetch();    // 魔改fetch

    let jct = document.cookie.match(/bili_jct=(\w*); /)[1];
    let roomId = await ROOM_INFO_API.getRid();
    let toastCount = 0;
    let isProcessing = false;
    let formData = new FormData();
    formData.set("bubble", 0);
    formData.set("color", 16777215);
    formData.set("mode", 1);
    formData.set("fontsize", 25);
    formData.set("roomid", roomId);

    const LIMIT = await ROOM_INFO_API.getDanmuLength(roomId);

    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" };
    let wordTree = {};
    initTree();

    // 弹出框CSS
    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%)}");
    // 原始粉丝牌
    // GM_addStyle("#control-panel-ctnr-box .medal-section{padding-left:0}");
    // 发送按钮 CSS
    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;}");
    // 输入框及母盒子
    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;}");
    // 插件输入框及背景框的公共CSS
    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;}");
    // @别人的提示标志
    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)}");

    // 用于回复@别人
    let at = {
        username: undefined,
        uid: undefined,
        calDom: undefined,
        set user(username) {
            this.username = username;
            if (!this.username) {
                this.uid = undefined;
                let inputParentBox = document.querySelector(".chat-input-new");
                requestAnimationFrame(() => {
                    inputParentBox.classList.remove("at");
                });
                inputArea.dom.style.textIndent = '';
                return;
            }
            // 计算字符长度赋值给text-indent
            at.calDom.innerText = "@" + this.username;
            // 查找UID
            let danmuDom = document.querySelector(`.chat-item[data-uname="${this.username}"]`);
            if (danmuDom && danmuDom.dataset.uid) {
                this.uid = danmuDom.dataset.uid;
                let inputParentBox = document.querySelector(".chat-input-new");
                requestAnimationFrame(() => {
                    inputParentBox.classList.add("at");
                });
                inputArea.dom.dataset.at = this.username;
                inputArea.bgDom.dataset.at = this.username;
                let width = parseInt(getComputedStyle(at.calDom).width) + 15; // 我也不知道为什么加这个数
                inputArea.dom.style.textIndent = width + 'px';
            } else {
                console.warn(`初始化@数据失败:无法找到${this.username}的弹幕`);
            }
        }
    }
    let inputArea = {
        dom: undefined,
        bgDom: undefined,
        limitHintDom: undefined,
        _textValue: '',
        _htmlValue: '',
        _internalTimeout: undefined,
        set textValue(val) {
            this._textValue = val;
            this.bgDom.innerText = this._textValue;
            this.bgDom.scrollTop = this.dom.scrollTop;
            // 屏蔽词过滤
            clearTimeout(this._internalTimeout);
            this._internalTimeout = setTimeout(() => {
                inputArea.htmlValue = filter(inputArea.textValue);
            }, 170);
            // 指示器内容更新
            if (this.textValue.length > LIMIT) {
                this.limitHintDom.classList.add("over");
            } else {
                this.limitHintDom.classList.remove("over");
            }
            this.limitHintDom.innerText = `${this.textValue.length}/${LIMIT}`;
        },
        get textValue() { return this._textValue; },
        set htmlValue(val) {
            this._htmlValue = val;
            this.bgDom.innerHTML = this._htmlValue;
        },
        get htmlValue() { return this._htmlValue; },
    }

    let deadLine = Date.now() + 7000;
    let itv = setInterval(() => {
        if (Date.now() > deadLine) {
            clearInterval(itv);
            console.log("弹幕发送增强-无法找到加载点");
        }
        let textarea = document.querySelector(".chat-input-new>.textarea-panel");
        if (textarea) {
            console.log("弹幕发送增强-加载完毕");
            clearInterval(itv);

            // 原版打标签
            textarea.id = "originInputArea";

            // SC的大按钮改小按钮
            // GM_addStyle(".icon-left-part-new .super-chat{width: 65px !important;}");
            // let scText = document.querySelector(".super-chat-text");
            // scText && (scText.innerText = "SC");

            // 长度提示
            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)}");
            inputArea.limitHintDom = document.createElement("span");
            inputArea.limitHintDom.className = "text-limit-hint none-select p-absolute";
            inputArea.limitHintDom.innerText = "0/" + LIMIT;
            textarea.parentNode.after(inputArea.limitHintDom);

            // 背景
            GM_addStyle("#liveDanmuInputBackground{opacity:0;left:0;top:50%;transform:translateY(-50%);color:transparent;overflow-y:auto;}.chat-input-focus #liveDanmuInputBackground{opacity:1}");
            inputArea.bgDom = document.createElement("div");
            inputArea.bgDom.id = "liveDanmuInputBackground";
            inputArea.bgDom.className = "textarea-panel p-absolute dp-i-block input-area";
            for (let item of Object.keys(textarea.dataset)) {
                inputArea.bgDom.dataset[item] = textarea.dataset[item];
            }
            textarea.after(inputArea.bgDom);

            // 输入框
            GM_addStyle("#liveDanmuInputArea{padding:5px 0;z-index:1;position:relative;background-color:transparent;resize:none;}");
            GM_addStyle(".f-word{background-color:var(--Ly4)}.fire-word{background-color:var(--Or5)}");
            GM_addStyle("#liveDanmuInputArea.default{text-indent:.5rem;width: 97%;}");
            // 用input配合一个div背景。如果用contentEditable来搞,容易搞坏光标定位和编辑栈,已放弃
            inputArea.dom = textarea.cloneNode();
            inputArea.dom.id = "liveDanmuInputArea";
            inputArea.dom.classList.add("input-area");
            inputArea.dom.classList.add("dp-block");
            inputArea.dom.classList.remove("default-height");

            inputArea.dom.addEventListener("scroll", (e) => {
                inputArea.bgDom.scrollTop = e.target.scrollTop;
            });
            inputArea.dom.addEventListener("focus", (e) => {
                e.target.classList.add("focus");
                e.target.classList.remove("default");
                textarea.dispatchEvent(new Event('focus'));
                // document.querySelector(".chat-input-ctnr-new").classList.add("chat-input-focus");

            });
            inputArea.dom.addEventListener("blur", (e) => {
                e.target.classList.add("default");
                e.target.classList.remove("focus");
                textarea.dispatchEvent(new Event('blur'));
                // document.querySelector(".chat-input-ctnr-new").classList.remove("chat-input-focus");
            });
            inputArea.dom.addEventListener("input", (e) => {
                inputArea.textValue = e.target.value;
            });
            inputArea.dom.addEventListener("keydown", (e) => {
                if (e.key == 'Enter' || e.keyCode == 13) {
                    // 回车在输入框里没有任何作用,发出去会变成一个空格,且好像会造成奇怪的bug,这里直接拦截掉
                    e.preventDefault();
                    e.stopPropagation();
                    // 回车发送
                    if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) {
                    } else {
                        dealDanmu(e.target);
                    }
                } else if (e.key == 'Backspace' || e.keyCode == 8) {
                    const startOffset = inputArea.dom.selectionStart; // 光标开始位置
                    const endOffset = inputArea.dom.selectionEnd;     // 光标结束位置(如果有选中的内容)
                    // 在首位删除时,如果存在@标识,那就抹掉
                    if (startOffset === 0 && endOffset === 0 && document.querySelector(".chat-input-new").classList.contains("at")) {
                        at.user = undefined;
                    }
                }
            });
            let sendBtn = document.querySelector(".chat-input-ctnr-new .right-action-btn");
            if (sendBtn) {
                sendBtn.addEventListener("click", (e) => {
                    dealDanmu(inputArea.dom);
                });
            }
            textarea.after(inputArea.dom);

            // 适配新版小表情
            GM_addStyle(".emotion-recent-wrap{display:none !important;}");  // 最近使用  懒,一刀切了吧
            let emojiBtn = document.querySelector(".emoticons-panel");
            emojiBtn.addEventListener("click", () => {
                let deadline = Date.now() + 1000;
                (function init() {
                    let emojiPanel = document.querySelector(".emoji-wrap");
                    if (emojiPanel) {
                        emojiPanel.onclick = e => {
                            let target = e.target;
                            if (e.target.tagName == "IMG") {
                                target = e.target.parentNode;
                            }
                            inputArea.dom.value = inputArea.dom.value + target.title;
                            inputArea.dom.dispatchEvent(new Event('input'));
                            cleanOriginInput();
                        }
                    } else if (Date.now() < deadline) {
                        requestIdleCallback(init, { timeout: 1000 });
                    }
                })();
            });

            // @别人
            setTimeout(() => {  // 页面刚加载完毕时,只有一个菜单,没有@按钮,所以注册一个监听器等首次打开菜单时才注册事件到@按钮上
                let observer = new MutationObserver(function (mutations) {
                    let danmuMenuAt = document.querySelector(".danmaku-menu .at-this-guy");
                    if (danmuMenuAt) {
                        danmuMenuAt.addEventListener("click", async (e) => {
                            let menu = e.target.closest(".danmaku-menu");
                            let username = menu.querySelector(".username").innerText;
                            at.user = username;

                            inputArea.dom.classList.add("focus");
                            inputArea.dom.classList.remove("default");

                            cleanOriginInput(textarea);
                        });
                        // 首次触发后@按钮就一直在,不需要监听DOM了
                        observer.disconnect();
                        observer = null;
                    }
                });
                observer.observe(document.querySelector(".danmaku-menu"), {
                    childList: true,
                    subtree: true
                });
            }, 3000);
            // @别人 用来测量字符长度的小框
            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;}");
            at.calDom = document.createElement("span");
            at.calDom.id = "liveDanmuAtLabel";
            at.calDom.removeAttribute("placeholder");
            at.calDom.className = "p-absolute none-select at-label";
            for (let item of Object.keys(textarea.dataset)) {
                at.calDom.dataset[item] = textarea.dataset[item];
            }
            textarea.before(at.calDom);

            textarea.style.display = "none";
        }
    }, 100);

    async function dealDanmu(textarea) {
        let msg = textarea.value;
        if (!msg) { toast("请输入内容", 1500, "info"); return; }
        if (isProcessing) { toast("弹幕正在发送中", 1500, "info"); return; }
        isProcessing = true;
        let page = 1;
        let segment = LIMIT;
        if (msg.length > segment) {
            // 自动平均每条弹幕的长度
            while (msg.length / segment % 1 < 0.7 && msg.length / segment % 1 != 0) {
                segment--;
            }
            page = Math.ceil(msg.length / segment);
            console.log(`长度:${msg.length} 间隔:${segment} 分页:${page}`);
        }
        let count = 0;
        do {
            let str = msg.substr(0, segment);
            // console.log("发送", str); await sleep(count++ ? 500 + Math.random() * 1000 >> 1 : 0);
            let result = await sendMsg(str, count++ ? 500 + Math.random() * 1000 >> 1 : 0);
            if (at.uid) { at.user = undefined; } // 首条@过后就不@了
            msg = msg.substr(segment);
            textarea.value = msg;
            textarea.dispatchEvent(new Event('input'));
        } while (msg.length > 0);
        isProcessing = false;
    };

    function filter(str) {
        let result = testStr(str);
        for (let word of result) {
            str = str.replaceAll(word.w, `<span class="${word.t}-word">${word.w}</span>`);
        }
        str = str.replaceAll(/\n/g, "<br>"); // 替换换行
        // str = str.replaceAll(/\s/g, "&nbsp;"); // 替换空格,不替换的话多空格在HTML会缩成1个
        return str;
    }

    // 清理原版的输入框内容 如果有内容会卡在输入状态,粉丝牌就不见了
    function cleanOriginInput(textarea) {
        if (!textarea) {
            textarea = document.querySelector("#originInputArea");
        }
        if (textarea) {
            textarea.value = '';
            textarea.dispatchEvent(new Event('input'));
        }
    }

    async function sendMsg(msg, timer = 500) {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                jct = document.cookie.match(/bili_jct=(\w*); /)[1];
                formData.set("csrf", jct);
                formData.set("csrf_token", jct);
                formData.set("msg", msg);
                formData.set("rnd", Math.floor(new Date() / 1000));
                if (at.uid) {   // 补充@别人的参数
                    formData.set("reply_mid", at.uid);
                } else {
                    formData.delete("reply_mid");
                }
                fetch("//api.live.bilibili.com/msg/send", {
                    credentials: 'include',
                    method: 'POST',
                    body: formData
                })
                    .then(response => response.json())
                    .then(result => {
                        if (result.code != 0 || result.msg != "") {
                            switch (result.msg) {
                                case "f":
                                    result.msg = "含有敏感词";
                                    break;
                                case "fire":
                                    result.msg = "弹幕含有违禁词汇";
                                    break;
                                case "k":
                                    result.msg = "内容含有房间屏蔽词";
                                    break;
                                default:
                                    result.msg = result.message;
                            }
                            if (result.code == -111) {
                                jct = document.cookie.match(/bili_jct=(\w*); /)[1];
                                formData.set("csrf", jct);
                                formData.set("csrf_token", jct);
                            }
                            toast(result.msg);
                            isProcessing = false;
                            reject(result);
                        } else {
                            resolve(true);
                        }
                    })
                    .catch(err => {
                        console.log("发送弹幕出错:", err);
                        toast(err.msg || err.message);
                        isProcessing = false;
                        reject(err);
                    });
            }, timer);
        });
    }

    function testStr(str) {
        let result = [];
        for (let index = 0; index < str.length; index++) {
            let r = check(wordTree, str, index);
            if (r.i >= 0) {
                result.push({
                    s: index,
                    e: r.i,
                    w: r.w,
                    t: r.t
                });
                index = r.i - 1;  // 减一用于抵消++
            }
        }
        return result;

        function check(obj, str, index, word = "") {
            let letter = str[index];
            if (str.length > index && obj[letter]) {
                word += letter;
                if (obj[letter].end) {
                    return { i: index + 1, w: word, t: obj[letter].type };
                }
                return check(obj[letter], str, index + 1, word);
            } else {
                return { i: -1, w: word };
            }
        }
    }

    function initTree() {
        for (const item of Object.keys(riverCrabs)) {
            init(wordTree, item, 0);
        }

        function init(obj, str, index) {
            if (!obj[str[index]]) {
                obj[str[index]] = {};
            }
            if (str.length - 1 == index) {
                obj[str[index]].end = true;
                obj[str[index]].type = riverCrabs[str];
            } else {
                Reflect.deleteProperty(obj[str[index]], "end");
                obj[str[index]] = init(obj[str[index]], str, index + 1);
            }
            return obj;
        }
    }

    function toast(msg, time = 2000, type = "error") {
        let id = Math.random() * 1000 >> 1;
        let dom = document.createElement("span");
        dom.innerHTML = `<div class="link-toast ${type} link-toast-${id}" style="bottom:${105 + toastCount++ * 50}px"><span class="toast-text">${msg}</span></div>`;
        dom = dom.firstChild;
        let panel = document.querySelector("#chat-control-panel-vm");
        panel.append(dom);
        setTimeout(() => {
            toastCount--;
            dom.remove();
        }, time);
    }

    // 魔改fetch,拦截B站自己的弹幕发送请求
    // 2024-9-9 作废:暂时没法修改输入框的限长
    // function proxyFetch() {
    //     const { fetch: originalFetch } = unsafeWindow;
    //     unsafeWindow.fetch = async (...args) => {
    //         let [resource, config] = args;
    //         let isDanmu = resource.startsWith("//api.live.bilibili.com/msg/send");
    //         if (isDanmu) {
    //             // console.log("请求", resource, config);
    //             // 图片表情
    //             let emotion = config && config.data && config.data.emoticonOptions;
    //             if (!emotion) {
    //                 // 拦截弹幕请求,直接返回一个假的
    //                 return new Response("{\"code\":1}", { status: 200 });
    //             }
    //         }
    //         const response = await originalFetch(resource, config);
    //         if (isDanmu) {
    //             // console.log("返回", response);
    //         }
    //         return response;
    //     };
    // }

})();

// 大表情
// bubble: 0
// msg: upower_[Minicatty_吃瓜]
// color: 16777215
// mode: 1
// dm_type: 1
// emoticonOptions: [object Object]
// fontsize: 25
// rnd: 1725808681
// roomid: 2323777
// csrf: 1
// csrf_token: 1

// @人
// bubble: 0
// msg: [狗叫]123
// color: 16777215
// mode: 1
// room_type: 0
// jumpfrom: 0
// reply_mid: 23237777
// reply_attr: 0
// replay_dmid: 42945fa0764e7be6388b4dfd3666ddb356
// statistics: {"appId":100,"platform":5}
// fontsize: 25
// rnd: 1725808681
// roomid: 2323777
// csrf: 1
// csrf_token: 1

// 普通弹幕
// bubble: 0
// msg: [狗叫]123
// color: 16777215
// mode: 1
// room_type: 0
// jumpfrom: 0
// reply_mid: 0
// reply_attr: 0
// replay_dmid:
// statistics: {"appId":100,"platform":5}
// fontsize: 25
// rnd: 1725808681
// roomid: 2323777
// csrf: 1
// csrf_token: 1