b站视频评论区规则屏蔽黑名单

按自定义的规则 (昵称, uid, 评论内容, 等级; 等于, 包含, 正则, 等级小于) 屏蔽 (新版) b站视频评论区的评论 (等有空再完善其他类型的评论区)

// ==UserScript==
// @name             b站视频评论区规则屏蔽黑名单
// @namespace    /DBI/bili-reply-blacklist
// @version          0.1
// @description   按自定义的规则 (昵称, uid, 评论内容, 等级; 等于, 包含, 正则, 等级小于) 屏蔽 (新版) b站视频评论区的评论 (等有空再完善其他类型的评论区)
// @author          DBI
// @match           https://www.bilibili.com/video/*
// @icon              https://www.bilibili.com/favicon.ico
// @run-at          document-start
// @grant           GM_setValue
// @grant           GM_getValue
// @grant           GM_registerMenuCommand
// @grant           GM_setClipboard
// @license         MIT
// ==/UserScript==

(function (callback) {
	const wait = () => setTimeout(() => {
		if (document.querySelector('#comment > div > div > div > div.reply-warp > div.reply-list')) {
			console.log('[b站评论区黑名单] 正在运行');
			callback();
		} else {
			wait();
		}
	}, 10);
	wait();
})( () => {
    // 黑名单规则列表
    // 格式: [ { type: 'usernameEqual', value: '233' } ]
    let blacklist = GM_getValue("blacklist", []);
    // 支持的黑名单规则类型
    const blacklistTypes = {
        usernameEqual    : { display: '昵称等于', label: '昵称为 xxx 则屏蔽'                     , help: '若昵称等于输入的内容则屏蔽该评论, 区分大小写.' },
        usernameHas       : { display: '昵称包含', label: '昵称包含 xxx 则屏蔽'                  , help: '若昵称包含输入的内容则屏蔽该评论, 区分大小写.' },
        usernameRegexp : { display: '昵称正则', label: '昵称匹配正则表达式 xxx 则屏蔽' , help: '若昵称匹配输入的正则表达式则屏蔽该评论. 正则表达式在线测试网站: https://regexr-cn.com/' },
        uidEqual               : { display: 'uid等于' , label: 'uid为 xxx 则屏蔽'                       , help: '若uid为输入的内容则屏蔽该评论.' },
        replyHas               : { display: '评论包含', label: '评论包含 xxx 则屏蔽'                 , help: '若评论包含输入的内容则屏蔽该评论, 区分大小写.' },
        replyEqual            : { display: '评论等于', label: '评论为 xxx 则屏蔽'                     , help: '若评论为输入的内容则屏蔽该评论, 区分大小写.' },
        replyRegexp         : { display: '评论正则', label: '评论匹配正则表达式 xxx 则屏蔽' , help: '若评论内容匹配输入的正则表达式则屏蔽该评论. 正则表达式在线测试网站: https://regexr-cn.com/' },
        levelUnder            : { display: '等级小于', label: '等级小于 x 则屏蔽'                     , help: '若评论者的等级 (lv) 小于输入值 (阿拉伯数字, 0-7, 不含本数; 硬核会员 (lv6 + 小闪电) 表示为 7) 则屏蔽该评论.' },
    };
    // 得到 "用于友好地向用户展示规则列表" 的文本
    const getBlasklistDisplayText = () => {
        // 黑名单规则个数
        let blacklistLength = blacklist.length;
        // 如果没有规则
        if (blacklistLength < 1) return "目前没有任何黑名单规则.\n";
        let t = "以下是所有规则, 若显示不完全请按键盘上的 F12 并在打开的页面进入控制台 (Console) 查看:\n规则ID   规则类型    值\n";
        for (let i = 0; i < blacklistLength; i++) {
            let rule = blacklist[i];
            let type = blacklistTypes[rule.type].display;
            t += `${i}        ${type}      ${rule.value}\n`;
        }
        return t;
    };
    GM_registerMenuCommand("展示黑名单规则列表", () => {
        const b = getBlasklistDisplayText();
        console.log(b);
        alert(b);
    });
    // 循环为脚本设置增加 "添加规则" 选项
    for (let typeName in blacklistTypes) {
        let type = blacklistTypes[typeName];
        GM_registerMenuCommand("添加规则: " + type.label, () => {
            let value = prompt(`[${type.label}]\n${type.help}`);
            if (!value) return;
            if (typeName == 'levelUnder' && isNaN(value * 1)) return;
            blacklist.push({ type: typeName, value});
            GM_setValue('blacklist', blacklist);
            alert('添加成功, 刷新页面后生效');
        });
    }
    GM_registerMenuCommand("删除某个规则", () => {
        console.log(getBlasklistDisplayText());
        const id = prompt("输入规则ID即可删除黑名单规则.\n" + getBlasklistDisplayText());
        // 输入为空就 return
        if (id == '' || isNaN(id * 1) || id < 0 || id >= blacklist.length) return;
        // 删除黑名单规则数组对应下标的元素
        blacklist.splice(id * 1, 1);
        // 保存
        GM_setValue('blacklist', blacklist);
        alert('删除成功, 刷新页面后生效');
    });
    GM_registerMenuCommand("导出规则列表", () => {
        const blacklistText = JSON.stringify(blacklist);
        GM_setClipboard(blacklistText, 'text');
        alert("以下是导出的规则列表, 已复制到剪贴板:\n\n" + blacklistText);
    });
    GM_registerMenuCommand("导入规则列表", () => {
        const blacklistText = prompt("请在下面的输入框粘贴规则, 新规则将与旧规则合并.");
        if (!blacklistText) return;
        let theBlacklist = [];
        // 输入若非 JSON 则 return
        try { theBlacklist = JSON.parse(blacklistText); } catch { return; }
        // 合并
        // 不直接 [...blacklist, ...theBlacklist] 是因为无法判断输入 JSON 是否符合 blacklist 格式
        theBlacklist.forEach(rule => {
            if (!blacklistTypes[rule.type] || !rule.value) return;
            blacklist.push({ type: rule.type, value: rule.value});
        });
        // 保存
        GM_setValue('blacklist', blacklist);
        const blasklistDisplayText = getBlasklistDisplayText()
        console.log(blasklistDisplayText);
        alert("导入成功, 刷新页面后生效.\n" + blasklistDisplayText);
    });
    let isHighlightBlackReply = GM_getValue("isHighlightBlackReply", false);
    GM_registerMenuCommand((isHighlightBlackReply ? '[✔️已启用]' : '[❌已禁用]') + " 高亮符合规则的评论而不是删除", () => {
        isHighlightBlackReply = !isHighlightBlackReply;
        GM_setValue('isHighlightBlackReply', isHighlightBlackReply);
        alert("高亮符合规则的评论而不是删除.\n将会以红色为背景色将符合黑名单规则的评论高亮而不是删除, 用来测试目标评论是否被规则匹配.\n目前已" + (isHighlightBlackReply ? '启用' : '禁用') + ", 刷新页面后生效.")
    });

    // 正式的脚本逻辑

    // 选择需要监视变动的节点
    const targetNode = document.querySelector('#comment > div > div > div > div.reply-warp > div.reply-list');
    // 监视包括子元素
    const config = { attributes: false, childList: true, subtree: true };
    // 根据提供的信息返回是否在用户定义的屏蔽规则 (黑名单) 里
    const isInBlacklist = ({ username = '', uid = '0', reply = '', level = '0' }) => {
        username = username.trim();
        reply = reply.trim();
        level *= 1;
        // 昵称是否包含字符串
        for (let rule of blacklist) {
            switch (rule.type) {
                case 'usernameEqual':
                    // 昵称等于
                    if (username == rule.value) return true;
                    break;
                case 'usernameHas':
                    // 昵称含有
                    if (username.includes(rule.value)) return true;
                    break;
                case 'usernameRegexp':
                    // 昵称正则
                    if ((new RegExp(rule.value)).exec(username) != null) return true;
                    break;
                case 'uidEqual':
                    // uid 等于
                    if (uid == rule.value) return true;
                    break;
                case 'replyHas':
                    // 评论包含
                    if (reply.includes(rule.value)) return true;
                    break;
                case 'replyEqual':
                    // 评论等于
                    if (reply == rule.value) return true;
                    break;
                case 'replyRegexp':
                    // 评论正则
                    if ((new RegExp(rule.value)).exec(reply) != null) return true;
                    break;
                case 'levelUnder':
                    // 等级小于
                    if (level < (rule.value * 1)) return true;
                    break;
            }
        };
        return false;
    };
    // 处理评论的回复 (div.sub-reply-item)
    const processSubReplyItem = node => {
       // 获取用户名所在 div
        const usernameElement = node.querySelector('div.sub-user-info > div.sub-user-name');
        // 获取等级图标元素
        const levelElement = node.querySelector('div.sub-user-info > i.sub-user-level');
        // 存放评论内容的 span
        const replyElement = node.querySelector('span.reply-content-container.sub-reply-content > span.reply-content');
        // 从等级图标元素的 class 里获取等级
        let level = '';
        // 是否为硬核会员 (lv6 + 小闪电, level-hardcore)
        if (levelElement.classList.contains('level-hardcore')) {
            // 是硬核会员
            level = '7';
        } else {
            // 不是硬核会员, 需要遍历 class 寻找
            for (let theClass of levelElement.classList) {
                // 用正则表达式寻找
                let regexrLevel = (/(?:level\-)(\d)/gim).exec(theClass);
                if (regexrLevel) {
                    // 找到了
                    level = regexrLevel[1];
                    // 不用再找了
                    break;
                }
            }
        }
        // 最终得到的信息
        const infos = {
            username: usernameElement.innerText ?? '',
            // uid 在 用户名元素 的 dataset 里有
            uid: usernameElement.dataset.userId ?? '',
            reply: replyElement.innerText ?? '',
            level,
        };
        // 如果在黑名单规则里则移除元素
        if (isInBlacklist(infos)) {
            if (isHighlightBlackReply) {
                node.style.backgroundColor = 'red';
            } else {
                node.remove();
            }
        }
    };
    // 处理评论 (div.reply-item)
    const processReplyItem = node => {
        // 获取用户名所在 div
        const usernameElement = node.querySelector('div.root-reply-container > div.content-warp > div.user-info > div.user-name');
        // 获取等级图标元素
        const levelElement = node.querySelector('div.root-reply-container > div.content-warp > div.user-info > i.user-level');
        // 存放评论内容的 span
        const replyElement = node.querySelector('div.root-reply-container > div.content-warp > div.root-reply > span.reply-content-container.root-reply > span.reply-content');
        // 从等级图标元素的 class 里获取等级
        let level = '';
        // 是否为硬核会员 (lv6 + 小闪电, level-hardcore)
        if (levelElement.classList.contains('level-hardcore')) {
            // 是硬核会员
            level = '7';
        } else {
            // 不是硬核会员, 需要遍历 class 寻找
            for (let theClass of levelElement.classList) {
                // 用正则表达式寻找
                let regexrLevel = (/(?:level\-)(\d)/gim).exec(theClass);
                if (regexrLevel) {
                    // 找到了
                    level = regexrLevel[1];
                    // 不用再找了
                    break;
                }
            }
        }
        // 最终得到的信息
        const infos = {
            username: usernameElement.innerText ?? '',
            // uid 在 用户名元素 的 dataset 里有
            uid: usernameElement.dataset.userId ?? '',
            reply: replyElement.innerText ?? '',
            level,
        };
        // 判断是否在黑名单里
        if (isInBlacklist(infos)) {
            // 如果在黑名单规则里
            // 高亮或移除元素
            if (isHighlightBlackReply) { node.style.backgroundColor = 'red'; } else { node.remove(); }
            // 无需继续处理评论的回复
            return;
        }
        // 处理评论的回复
        // 得到回复的列表
        const subReplies = node.querySelectorAll('div.sub-reply-container > div.sub-reply-list > div.sub-reply-item');
        // 遍历, 处理
        subReplies.forEach(subReply => processSubReplyItem(subReply));
    };
    // 有变动时执行的回调函数
    const callback = function(mutationsList, observer) {
        // 遍历监视结果 (此结果包括所有变动, 增删改)
        for (let mutation of mutationsList) {
            // 遍历增加的节点列表
            for (let node of mutation.addedNodes) {
                // 如果是纯文本节点则忽略
                if (!node.classList) continue;
                // 判断是评论还是评论的回复
                if (node.classList.contains('sub-reply-item')) {
                    // 如果是评论的回复
                    processSubReplyItem(node);
                } else if (node.classList.contains('reply-item')) {
                    // 如果是评论
                    processReplyItem(node);
                }
            }
        }
    };
    // 创建一个监视器实例并传入回调函数
    const observer = new MutationObserver(callback);
    // 以上述配置开始监视目标节点
    observer.observe(targetNode, config);
});


// 附:
// MutationObserver API 参考: https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver