您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
自动下拉加载页面,并累积统计整个页面中评论区内的用户等级数据(包括翻页前的内容)。每个评论区只处理一次,累积所有数据:对同一用户在同一等级只计数一次,同时记录重复次数(即每个用户出现次数减 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或关注我们的公众号极客氢云获取最新地址