B站评论区等级图片用户去重统计【累积统计+重复次数记录(除首次)+加速下拉+6/h归组+自动展开回复】

自动下拉加载页面,并累积统计整个页面中评论区内的用户等级数据(包括翻页前的内容)。每个评论区只处理一次,累积所有数据:对同一用户在同一等级只计数一次,同时记录重复次数(即每个用户出现次数减 1);等级1~5分别统计,等级6与h归为一组并单独记录h占比。遇到“点击查看”或“下一页”按钮时暂停下拉,先展开回复后再继续下拉。支持嵌套 shadow DOM。新增:控制面板按钮样式更醒目;覆盖层中增加累计用户(包括重复)统计。

// ==UserScript==
// @name         B站评论区等级图片用户去重统计【累积统计+重复次数记录(除首次)+加速下拉+6/h归组+自动展开回复】 
// @namespace    http://tampermonkey.net/
// @version      2.4.2
// @description  自动下拉加载页面,并累积统计整个页面中评论区内的用户等级数据(包括翻页前的内容)。每个评论区只处理一次,累积所有数据:对同一用户在同一等级只计数一次,同时记录重复次数(即每个用户出现次数减 1);等级1~5分别统计,等级6与h归为一组并单独记录h占比。遇到“点击查看”或“下一页”按钮时暂停下拉,先展开回复后再继续下拉。支持嵌套 shadow DOM。新增:控制面板按钮样式更醒目;覆盖层中增加累计用户(包括重复)统计。
// @author
// @match        https://www.bilibili.com/video/*
// @grant        none
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    // ---------------------- 配置参数 ----------------------
    const config = {
        autoScrollStep: 700,             // 每次下拉的像素值
        autoScrollDelay: 200,            // 下拉间隔时间(毫秒)
        updateIntervalTime: 2000,        // 定时更新统计数据的间隔(毫秒)
        expandClickDelay: 1000,          // 每次点击“点击查看”或“下一页”按钮后的等待时间(毫秒)
        scrollWaitAfterExpand: 1000,     // 展开完成后等待时间再恢复下拉(毫秒)
        observerDebounceDelay: 1000      // MutationObserver 的防抖延时(毫秒)
    };

    // ---------------------- 全局变量 ----------------------
    let isRunning = false;             // 当前是否正在统计
    let updateTimer = null;            // 定时更新统计的定时器
    let observer = null;               // MutationObserver 实例
    let observerDebounceTimeout = null; // 防抖定时器

    // 全局累积数据:记录各等级中已处理的评论用户
    const globalCounts = {
        "1": 0,
        "2": 0,
        "3": 0,
        "4": 0,
        "5": 0,
        "6h": 0
    };
    const globalUnique = {
        "1": new Set(),
        "2": new Set(),
        "3": new Set(),
        "4": new Set(),
        "5": new Set(),
        "6h": new Set(), // 合并等级6与h
        "h": new Set()   // 专门记录标记为 "h" 的用户
    };
    let previousStats = null;  // 保存上一次统计结果(用于对比数据变化)

    // ---------------------- 工具函数 ----------------------
    /**
     * 递归查找指定选择器对应的所有元素,包括所有嵌套的 open shadow DOM 内的元素
     * @param {string} selector - CSS 选择器,如 "div#info" 或 "button"
     * @param {Node} root - 查找起始节点,默认为 document
     * @returns {Element[]} - 匹配的元素数组
     */
    function deepQuerySelectorAll(selector, root = document) {
        let results = Array.from(root.querySelectorAll(selector));
        const elements = root.querySelectorAll("*");
        for (const el of elements) {
            if (el.shadowRoot) {
                results = results.concat(deepQuerySelectorAll(selector, el.shadowRoot));
            }
        }
        return results;
    }

    /**
     * 获取按钮显示的文本内容。优先使用 innerText,如为空则尝试从内部 slot 获取。
     * @param {HTMLElement} btn
     * @returns {string}
     */
    function getButtonLabel(btn) {
        let label = btn.innerText && btn.innerText.trim();
        if (label) return label;
        const labelSpan = btn.querySelector("span.button__label");
        if (labelSpan) {
            const slotElem = labelSpan.querySelector("slot");
            if (slotElem && typeof slotElem.assignedNodes === "function") {
                label = slotElem.assignedNodes({ flatten: true })
                    .map(node => node.textContent)
                    .join("").trim();
                if (label) return label;
            }
            return labelSpan.textContent.trim();
        }
        return "";
    }

    /**
     * 检查页面中是否存在待展开的按钮(文本中包含“点击查看”或“下一页”,不区分大小写)。
     * @returns {boolean} 存在至少一个此类按钮返回 true,否则返回 false。
     */
    function hasExpansionButtons() {
        const buttons = deepQuerySelectorAll("button");
        return buttons.some(btn => {
            const label = getButtonLabel(btn);
            if (!label) return false;
            const lower = label.toLowerCase();
            return lower.includes("点击查看") || lower.includes("下一页");
        });
    }

    // ---------------------- 累积统计相关函数 ----------------------
    /**
     * 遍历当前页面中的评论区(div#info),提取用户ID和等级图片,
     * 对于未处理的评论区(未标记 data-processed),根据评论中等级信息更新全局累积数据。
     */
    function updateAggregatedData() {
        const infoAreas = deepQuerySelectorAll("div#info");
        infoAreas.forEach(info => {
            // 已处理过则跳过
            if (info.getAttribute("data-processed") === "true") return;
            const userNameElem = info.querySelector("div#user-name");
            if (!userNameElem) return;
            const userId = userNameElem.getAttribute("data-user-profile-id");
            if (!userId) return;
            const levelElem = info.querySelector("div#user-level img");
            if (!levelElem) return;
            const src = levelElem.getAttribute("src") || levelElem.getAttribute("data-src") || "";
            const match = src.match(/level_([1-6]|h)\.svg(\?.*)?$/i);
            if (match) {
                let level = match[1].toLowerCase();
                if (["1", "2", "3", "4", "5"].includes(level)) {
                    globalCounts[level] = (globalCounts[level] || 0) + 1;
                    globalUnique[level].add(userId);
                } else if (level === "6" || level === "h") {
                    globalCounts["6h"] = (globalCounts["6h"] || 0) + 1;
                    globalUnique["6h"].add(userId);
                    if (level === "h") {
                        globalUnique["h"].add(userId);
                    }
                }
            }
            // 标记为已处理,避免重复统计
            info.setAttribute("data-processed", "true");
        });
    }

    /**
     * 根据全局累积数据计算统计结果,返回统计数据对象。
     * @returns {object}
     */
    function getAggregatedStats() {
        const counts = {
            "1": globalUnique["1"].size,
            "2": globalUnique["2"].size,
            "3": globalUnique["3"].size,
            "4": globalUnique["4"].size,
            "5": globalUnique["5"].size
        };
        const count6h = globalUnique["6h"].size;
        const countH = globalUnique["h"].size;
        const totalUnique = Object.values(counts).reduce((sum, cnt) => sum + cnt, 0) + count6h;
        // 计算重复次数:每个等级重复次数 = 全部出现次数 - 唯一数
        const dup = {
            "1": (globalCounts["1"] || 0) - globalUnique["1"].size,
            "2": (globalCounts["2"] || 0) - globalUnique["2"].size,
            "3": (globalCounts["3"] || 0) - globalUnique["3"].size,
            "4": (globalCounts["4"] || 0) - globalUnique["4"].size,
            "5": (globalCounts["5"] || 0) - globalUnique["5"].size,
            "6h": (globalCounts["6h"] || 0) - globalUnique["6h"].size
        };
        const totalDup = Object.values(dup).reduce((sum, d) => sum + d, 0);
        // 计算累计所有出现的次数(包括重复),即全局累计的评论区数
        const totalIncludingDuplicates =
            (globalCounts["1"] || 0) +
            (globalCounts["2"] || 0) +
            (globalCounts["3"] || 0) +
            (globalCounts["4"] || 0) +
            (globalCounts["5"] || 0) +
            (globalCounts["6h"] || 0);
        return { counts, count6h, countH, totalUnique, duplicate: totalDup, duplicateByLevel: dup, totalIncludingDuplicates };
    }

    /**
     * 累积更新统计数据,并在控制台输出变化信息,同时更新页面右侧的覆盖层显示数据。
     */
    function updateStatistics() {
        updateAggregatedData();
        const data = getAggregatedStats();

        console.log("累计重复次数:", data.duplicate, "; 各等级重复情况:", data.duplicateByLevel);

        if (previousStats) {
            for (const level in data.counts) {
                const prev = previousStats.counts[level] || 0;
                const curr = data.counts[level] || 0;
                if (curr < prev) {
                    console.log(`统计减少:level_${level} 从 ${prev} 下降到 ${curr}。可能原因:页面更新或部分评论被替换。`);
                }
            }
            if (data.count6h < previousStats.count6h) {
                console.log(`统计减少:level_6/h 从 ${previousStats.count6h} 下降到 ${data.count6h}。可能原因:页面更新或评论折叠。`);
            }
            if (data.totalUnique < previousStats.totalUnique) {
                console.log(`总统计减少:总用户数从 ${previousStats.totalUnique} 下降到 ${data.totalUnique}。`);
            }
        }
        previousStats = data;
        updateOverlay(data);
    }

    /**
     * 更新或创建覆盖层,显示当前统计结果。
     * @param {object} data 统计数据对象
     */
    function updateOverlay(data) {
        let overlay = document.getElementById("levelStatsOverlay");
        if (!overlay) {
            overlay = document.createElement("div");
            overlay.id = "levelStatsOverlay";
            overlay.style.position = "fixed";
            overlay.style.top = "60px";
            overlay.style.right = "10px";
            overlay.style.zIndex = "9999";
            overlay.style.backgroundColor = "rgba(0,0,0,0.7)";
            overlay.style.color = "#fff";
            overlay.style.padding = "10px";
            overlay.style.borderRadius = "5px";
            overlay.style.fontSize = "14px";
            overlay.style.lineHeight = "1.5";
            document.body.appendChild(overlay);
        }
        const { counts, count6h, countH, totalUnique, duplicate, duplicateByLevel, totalIncludingDuplicates } = data;
        let html = `<div style="font-weight:bold; margin-bottom:5px;">评论区等级统计</div>`;
        if (totalUnique === 0) {
            html += `<div>当前统计:0</div>`;
        } else {
            html += `<div>累计用户数(唯一):${totalUnique}</div>`;
            html += `<div>累计用户(包括重复):${totalIncludingDuplicates}</div>`;
            for (const level in counts) {
                const cnt = counts[level];
                const percent = totalUnique ? ((cnt / totalUnique) * 100).toFixed(2) : "0.00";
                html += `<div>level_${level}: ${cnt} 个 (${percent}%)</div>`;
            }
            if (count6h > 0) {
                const combinedPercent = totalUnique ? ((count6h / totalUnique) * 100).toFixed(2) : "0.00";
                const hPercent = count6h ? ((countH / count6h) * 100).toFixed(2) : "0.00";
                html += `<div>level_6/h: ${count6h} 个 (${combinedPercent}%)</div>`;
                html += `<div style="margin-left:10px;">其中 h: ${countH} 个 (${hPercent}% of 6/h)</div>`;
            }
            html += `<div style="margin-top:5px; color:#ff0;">累计重复次数(除首次):${duplicate}</div>`;
        }
        overlay.innerHTML = html;
    }

    // ---------------------- 自动下拉与自动展开 ----------------------
    /**
     * 递归展开所有“点击查看”或“下一页”按钮对应的回复,
     * 直到页面中不再检测到此类按钮为止。
     */
    async function expandRepliesRecursively() {
        while (true) {
            const buttons = deepQuerySelectorAll("button").filter(btn => {
                const label = getButtonLabel(btn);
                return label && (label.toLowerCase().includes("点击查看") || label.toLowerCase().includes("下一页"));
            });
            if (buttons.length === 0) break;
            const btn = buttons[0];
            btn.scrollIntoView({ block: 'center', inline: 'nearest' });
            try {
                btn.click();
                console.log("点击展开按钮:", getButtonLabel(btn));
            } catch (e) {
                console.error("点击展开按钮失败:", e);
            }
            await new Promise(resolve => setTimeout(resolve, config.expandClickDelay));
        }
    }

    /**
     * 自动下拉加载页面的主循环:
     * 1. 检测是否存在待展开的按钮,若存在则暂停下拉并先展开回复;
     * 2. 否则执行下拉操作;
     * 3. 到达页面底部后结束循环。
     */
    async function autoScrollLoop() {
        while (isRunning) {
            if (hasExpansionButtons()) {
                console.log("检测到待展开按钮,暂停下拉等待展开...");
                await expandRepliesRecursively();
                console.log(`展开完成,等待 ${config.scrollWaitAfterExpand}ms 后恢复下拉...`);
                await new Promise(resolve => setTimeout(resolve, config.scrollWaitAfterExpand));
            } else {
                window.scrollBy(0, config.autoScrollStep);
            }
            // 若已经到达页面底部,则停止自动下拉
            if ((window.innerHeight + window.pageYOffset) >= document.body.scrollHeight - 10) {
                console.log("已到达页面底部,停止自动下拉。");
                break;
            }
            await new Promise(resolve => setTimeout(resolve, config.autoScrollDelay));
        }
    }

    // ---------------------- MutationObserver 监听 ----------------------
    /**
     * 启动 MutationObserver,监听 DOM 变化(子节点变化及 subtree),
     * 防抖后调用 updateStatistics() 更新统计数据。
     */
    function startObserver() {
        observer = new MutationObserver(() => {
            if (observerDebounceTimeout) clearTimeout(observerDebounceTimeout);
            observerDebounceTimeout = setTimeout(() => {
                updateStatistics();
            }, config.observerDebounceDelay);
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }

    // ---------------------- 启动与暂停 ----------------------
    /**
     * 开始统计流程:
     * 1. 启动自动下拉加载;
     * 2. 启动定时更新统计数据;
     * 3. 启动 MutationObserver 监听 DOM 变化。
     */
    function startProcess() {
        if (isRunning) return;
        isRunning = true;
        console.log("开始实时统计……");
        // 启动自动下拉加载(异步循环)
        autoScrollLoop();
        // 启动定时更新统计数据
        updateTimer = setInterval(updateStatistics, config.updateIntervalTime);
        // 启动 DOM 变化监听
        startObserver();
    }

    /**
     * 暂停/取消统计流程:
     * 停止自动下拉、定时更新和 MutationObserver 监听。
     */
    function pauseProcess() {
        if (!isRunning) return;
        isRunning = false;
        console.log("暂停/取消统计");
        if (updateTimer) {
            clearInterval(updateTimer);
            updateTimer = null;
        }
        if (observer) {
            observer.disconnect();
            observer = null;
        }
        if (observerDebounceTimeout) {
            clearTimeout(observerDebounceTimeout);
            observerDebounceTimeout = null;
        }
    }

    // ---------------------- 控制面板 ----------------------
    /**
     * 创建页面右上角的控制面板,包含“开始统计”和“暂停/取消”按钮。
     */
    function createControlPanel() {
        if (document.getElementById("controlPanel")) return;
        const panel = document.createElement("div");
        panel.id = "controlPanel";
        panel.style.position = "fixed";
        panel.style.top = "10px";
        panel.style.right = "10px";
        panel.style.zIndex = "10000";
        panel.style.backgroundColor = "rgba(0,0,0,0.7)";
        panel.style.padding = "10px";
        panel.style.borderRadius = "5px";
        panel.style.fontSize = "14px";
        panel.style.color = "#fff";

        const startBtn = document.createElement("button");
        startBtn.textContent = "开始统计";
        startBtn.style.marginRight = "5px";
        // 增加醒目样式
        startBtn.style.backgroundColor = "#4CAF50";  // 绿色背景
        startBtn.style.color = "#fff";
        startBtn.style.border = "none";
        startBtn.style.padding = "5px 10px";
        startBtn.style.borderRadius = "3px";
        startBtn.style.cursor = "pointer";
        startBtn.addEventListener("click", startProcess);
        panel.appendChild(startBtn);

        const pauseBtn = document.createElement("button");
        pauseBtn.textContent = "暂停/取消";
        // 增加醒目样式
        pauseBtn.style.backgroundColor = "#F44336";  // 红色背景
        pauseBtn.style.color = "#fff";
        pauseBtn.style.border = "none";
        pauseBtn.style.padding = "5px 10px";
        pauseBtn.style.borderRadius = "3px";
        pauseBtn.style.cursor = "pointer";
        pauseBtn.addEventListener("click", pauseProcess);
        panel.appendChild(pauseBtn);

        document.body.appendChild(panel);
    }

    // ---------------------- 初始化 ----------------------
    createControlPanel();

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址