// ==UserScript== // @name bilibili 视频弹幕统计|下载|查询发送者 // @namespace https://github.com/ZBpine/bili-danmaku-statistic // @version 1.1.4 // @description 获取B站视频页弹幕数据,并生成统计页面 // @author ZBpine // @icon https://i0.hdslb.com/bfs/static/jinkela/long/images/favicon.ico // @match https://www.bilibili.com/video/* // @match https://www.bilibili.com/list/watchlater* // @grant none // @license MIT // @run-at document-end // @downloadURL none // ==/UserScript== (function () { 'use strict'; var biliCrc2Mid = function () { /* 函数来源 https://github.com/shafferjohn/bilibili-search/blob/master/crc32.js */ const CRCPOLYNOMIAL = 0xEDB88320; var startTime = new Date().getTime(), crctable = new Array(256), create_table = function () { var crcreg, i, j; for (i = 0; i < 256; ++i) { crcreg = i; for (j = 0; j < 8; ++j) { if ((crcreg & 1) != 0) { crcreg = CRCPOLYNOMIAL ^ (crcreg >>> 1); } else { crcreg >>>= 1; } } crctable[i] = crcreg; } }, crc32 = function (input) { if (typeof (input) != 'string') input = input.toString(); var crcstart = 0xFFFFFFFF, len = input.length, index; for (var i = 0; i < len; ++i) { index = (crcstart ^ input.charCodeAt(i)) & 0xff; crcstart = (crcstart >>> 8) ^ crctable[index]; } return crcstart; }, crc32lastindex = function (input) { if (typeof (input) != 'string') input = input.toString(); var crcstart = 0xFFFFFFFF, len = input.length, index; for (var i = 0; i < len; ++i) { index = (crcstart ^ input.charCodeAt(i)) & 0xff; crcstart = (crcstart >>> 8) ^ crctable[index]; } return index; }, getcrcindex = function (t) { //if(t>0) //t-=256; for (var i = 0; i < 256; i++) { if (crctable[i] >>> 24 == t) return i; } return -1; }, deepCheck = function (i, index) { var tc = 0x00, str = '', hash = crc32(i); tc = hash & 0xff ^ index[2]; if (!(tc <= 57 && tc >= 48)) return [0]; str += tc - 48; hash = crctable[index[2]] ^ (hash >>> 8); tc = hash & 0xff ^ index[1]; if (!(tc <= 57 && tc >= 48)) return [0]; str += tc - 48; hash = crctable[index[1]] ^ (hash >>> 8); tc = hash & 0xff ^ index[0]; if (!(tc <= 57 && tc >= 48)) return [0]; str += tc - 48; hash = crctable[index[0]] ^ (hash >>> 8); return [1, str]; }; create_table(); var index = new Array(4); console.log('初始化耗时:' + (new Date().getTime() - startTime) + 'ms'); return function (input) { var ht = parseInt('0x' + input) ^ 0xffffffff, snum, i, lastindex, deepCheckData; for (i = 3; i >= 0; i--) { index[3 - i] = getcrcindex(ht >>> (i * 8)); snum = crctable[index[3 - i]]; ht ^= snum >>> ((3 - i) * 8); } for (i = 0; i < 100000000; i++) { lastindex = crc32lastindex(i); if (lastindex == index[3]) { deepCheckData = deepCheck(i, index) if (deepCheckData[0]) break; } } if (i == 100000000) return -1; console.log('总耗时:' + (new Date().getTime() - startTime) + 'ms'); return i + '' + deepCheckData[1]; } } // 插入按钮 function insertButton() { const btn = document.createElement('div'); btn.id = 'danmaku-stat-btn'; btn.innerHTML = ` 弹幕统计
`; btn.style.position = 'fixed'; btn.style.left = '-100px'; // 露出约20px图标 btn.style.bottom = '40px'; btn.style.zIndex = '9997'; btn.style.width = '120px'; btn.style.height = '40px'; btn.style.backgroundColor = 'transparent'; btn.style.color = '#00ace5'; btn.style.borderRadius = '20px'; btn.style.cursor = 'pointer'; btn.style.fontSize = '16px'; btn.style.display = 'flex'; btn.style.alignItems = 'center'; btn.style.justifyContent = 'space-between'; btn.style.boxShadow = '0 0 5px rgba(0, 172, 229, 0.3)'; btn.style.transition = 'left 0.3s ease-in-out, background-color 0.2s ease-in-out'; btn.onmouseenter = () => { btn.style.left = '10px'; btn.style.backgroundColor = 'rgba(255, 255, 255, 0.8)'; btn.style.border = '1px solid #00ace5'; }; btn.onmouseleave = () => { btn.style.left = '-100px'; btn.style.backgroundColor = 'transparent'; btn.style.border = 'none'; }; btn.onclick = openIframe; const style = document.createElement('style'); style.textContent = ` #danmaku-stat-btn .label { margin-left: 20px; white-space: nowrap; color: #00ace5; } #danmaku-stat-btn .icon-wrapper { display: flex; align-items: center; justify-content: center; margin-right: 8px; flex-shrink: 0; } `; document.head.appendChild(style); document.body.appendChild(btn); } // 打开iframe面板 function openIframe() { if (document.getElementById('danmaku-stat-iframe')) return; // 创建蒙层 const overlay = document.createElement('div'); overlay.id = 'danmaku-stat-overlay'; overlay.style.position = 'fixed'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.width = '100%'; overlay.style.height = '100%'; overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'; overlay.style.zIndex = '9998'; overlay.onclick = () => { document.getElementById('danmaku-stat-iframe')?.remove(); overlay.remove(); }; document.body.appendChild(overlay); // 创建iframe const iframe = document.createElement('iframe'); iframe.id = 'danmaku-stat-iframe'; iframe.style.position = 'fixed'; iframe.style.top = '15%'; iframe.style.left = '15%'; iframe.style.width = '70%'; iframe.style.height = '70%'; iframe.style.backgroundColor = '#fff'; iframe.style.zIndex = '9999'; iframe.style.padding = '20px'; iframe.style.overflow = 'hidden'; iframe.style.borderRadius = '8px'; iframe.style.boxShadow = '0 0 10px rgba(0,0,0,0.5)'; iframe.onload = () => initIframeApp(iframe); document.body.appendChild(iframe); } // iframe里初始化Vue应用 async function initIframeApp(iframe) { const doc = iframe.contentDocument; const win = iframe.contentWindow; // 引入外部库 const addScript = (src) => new Promise(resolve => { const script = doc.createElement('script'); script.src = src; script.onload = resolve; doc.head.appendChild(script); }); const addCss = (href) => { const link = doc.createElement('link'); link.rel = 'stylesheet'; link.href = href; doc.head.appendChild(link); }; addCss('https://cdn.jsdelivr.net/npm/element-plus/dist/index.css'); await addScript('https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js'); await addScript('https://cdn.jsdelivr.net/npm/element-plus/dist/index.full.min.js'); await addScript('https://cdn.jsdelivr.net/npm/echarts@5'); // 创建挂载点 const appRoot = doc.createElement('div'); appRoot.id = 'danmaku-app'; doc.body.style.margin = '0'; doc.body.appendChild(appRoot); // 挂载Vue const { createApp, ref, onMounted } = win.Vue; const ELEMENT_PLUS = win.ElementPlus; const ECHARTS = win.echarts; class DanmakuManager { constructor(danmakuList) { this.original = danmakuList; this.filtered = [...danmakuList]; } reset() { this.filtered = [...this.original]; } filter(regex) { this.filtered = this.original.filter(d => regex.test(d.content)); } getSortedDanmakus() { return [...this.filtered].sort((a, b) => a.progress - b.progress); } getStats() { const countMap = {}; for (const d of this.filtered) { countMap[d.midHash] = (countMap[d.midHash] || 0) + 1; } return Object.entries(countMap) .map(([user, count]) => ({ user, count })) .sort((a, b) => b.count - a.count); } getDanmakusByUser(midHash) { return this.filtered.filter(d => d.midHash === midHash); } getOriginDanmakusByUser(midHash) { return this.original.filter(d => d.midHash === midHash); } } const app = createApp({ setup() { const displayedDanmakus = ref([]); const filterText = ref('(哈|呵|h|ha|HA|H+|233+)+'); const originDanmakuCount = ref(0); const currentUserMidHash = ref(''); const danmakuCount = ref({ user: 0, dm: 0 }); const videoData = ref({}); let manager = null; let chart = null; function formatProgress(ms) { const s = Math.floor(ms / 1000); const min = String(Math.floor(s / 60)).padStart(2, '0'); const sec = String(s % 60).padStart(2, '0'); return `${min}:${sec}`; } function formatCtime(t) { const d = new Date(t * 1000); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); } function formatTime(ts) { const d = new Date(ts * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } function parseDanmakuXml(xmlText) { const parser = new DOMParser(); const xmlDoc = parser.parseFromString(xmlText, 'application/xml'); const dElements = xmlDoc.getElementsByTagName('d'); const danmakus = []; for (const d of dElements) { const pAttr = d.getAttribute('p'); if (!pAttr) continue; const parts = pAttr.split(','); if (parts.length < 8) continue; danmakus.push({ progress: parseFloat(parts[0]) * 1000, mode: parseInt(parts[1]), fontsize: parseInt(parts[2]), color: parseInt(parts[3]), ctime: parseInt(parts[4]), midHash: parts[6], id: parts[7], weight: parseInt(parts[8]), content: d.textContent.trim() }); } return danmakus; } function midHashOnClick() { if (!currentUserMidHash.value) return; ELEMENT_PLUS.ElMessageBox.confirm( '是否尝试反查用户ID?(可能需要一段时间)', '提示', { confirmButtonText: '是', cancelButtonText: '否', type: 'warning', } ).then(() => { // 开始反查用户ID var midcrc = new biliCrc2Mid(); var result = midcrc(currentUserMidHash.value); if (result && result !== '-1') { ELEMENT_PLUS.ElMessageBox.alert( `已查到用户ID: 点击访问用户空间 `, '查找成功', { dangerouslyUseHTMLString: true, confirmButtonText: '确定', type: 'success', } ); } else { ELEMENT_PLUS.ElMessage.error('未能查到用户ID或用户不存在'); } }).catch(() => { // 用户点击了取消,只复制midHash navigator.clipboard.writeText(currentUserMidHash.value).then(() => { ELEMENT_PLUS.ElMessage.success('midHash已复制到剪贴板'); }).catch(() => { ELEMENT_PLUS.ElMessage.error('复制失败'); }); }); displayedDanmakus.value = manager.getOriginDanmakusByUser(currentUserMidHash.value).sort((a, b) => a.progress - b.progress); } function updateChart(stats) { const chartEl = doc.getElementById('chart'); if (!chartEl) { console.warn('chart容器还没渲染好,稍后重试'); setTimeout(() => updateChart(stats), 100); // 100ms后重试 return; } if (!chart) { chart = ECHARTS.init(chartEl); chart.on('click', (params) => { const selected = params.name; currentUserMidHash.value = selected; displayedDanmakus.value = manager.getDanmakusByUser(selected).sort((a, b) => a.progress - b.progress); }); } const userNames = stats.map(item => item.user); const counts = stats.map(item => item.count); const maxCount = Math.max(...counts); chart.setOption({ tooltip: {}, title: { text: '用户弹幕统计' }, grid: { left: 100 }, xAxis: { type: 'value', min: 0, max: Math.ceil(maxCount * 1.1), // 横轴最大值略大一点 scale: false }, yAxis: { type: 'category', data: userNames, inverse: true }, dataZoom: [ { type: 'slider', yAxisIndex: 0, startValue: 0, endValue: userNames.length >= 20 ? 19 : userNames.length, width: 10 } ], series: [{ type: 'bar', data: counts, label: { show: true, position: 'right', // 在条形右边显示 formatter: '{c}', // 显示数据本身 fontSize: 12 } }] }); } function handleRowClick(row) { if (!chart) return; const userMid = row.midHash; const option = chart.getOption(); const index = option.yAxis[0].data.indexOf(userMid); if (index >= 0) { chart.setOption({ yAxis: { axisLabel: { formatter: function (value) { if (value === userMid) { return '{a|' + value + '}'; } else { return value; } }, rich: { a: { color: '#5470c6', fontWeight: 'bold' } } } }, dataZoom: [{ startValue: Math.min(option.yAxis[0].data.length - 20, Math.max(0, index - 9)), endValue: Math.min(option.yAxis[0].data.length - 1, Math.max(0, index - 9) + 19) }] }); } } function applyFilter() { currentUserMidHash.value = ''; try { const regex = new RegExp(filterText.value, 'i'); manager.filter(regex); displayedDanmakus.value = manager.getSortedDanmakus(); const stats = manager.getStats(); danmakuCount.value = { user: stats.length, dm: displayedDanmakus.value.length } updateChart(stats); } catch (e) { alert('无效正则表达式'); } } function resetFilter() { currentUserMidHash.value = ''; manager.reset(); displayedDanmakus.value = manager.getSortedDanmakus(); const stats = manager.getStats(); danmakuCount.value = { user: stats.length, dm: displayedDanmakus.value.length } updateChart(stats); } async function getVideoData() { const url = location.href; let bvid = null; // 判断是否为 watchlater 链接 if (url.includes('/list/watchlater')) { const match = url.match(/[?&]bvid=(BV\w+)/); if (match) { bvid = match[1]; } } else { const match = url.match(/\/video\/(BV\w+)/); if (match) { bvid = match[1]; } } if (!bvid) { console.error('找不到 BVID'); return null; } try { const res = await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`); const json = await res.json(); if (json && json.data) { return json.data; } else { console.error('获取视频基本信息失败', json); return null; } } catch (e) { console.error('请求出错', e); return null; } } onMounted(async () => { videoData.value = await getVideoData(); const oid = videoData.value.cid; if (!oid) { alert('无法找到视频chatid'); return; } const res = await fetch(`https://api.bilibili.com/x/v1/dm/list.so?oid=${oid}`); const text = await res.text(); const data = parseDanmakuXml(text); manager = new DanmakuManager(data); originDanmakuCount.value = data.length; // 原始弹幕数量 displayedDanmakus.value = manager.getSortedDanmakus(); const stats = manager.getStats(); danmakuCount.value = { user: stats.length, dm: displayedDanmakus.value.length } updateChart(stats); }); return { displayedDanmakus, filterText, applyFilter, resetFilter, danmakuCount, videoData, originDanmakuCount, currentUserMidHash, midHashOnClick, handleRowClick, formatProgress, formatCtime, formatTime }; }, template: `

{{ videoData.title || '加载中...' }}

BVID: {{ videoData.bvid }}
UP主: {{ videoData.owner.name }}
发布时间:{{ videoData.pubdate ? formatTime(videoData.pubdate) : '-' }}
截止 {{ formatTime(Math.floor(Date.now()/1000)) }} 载入实时弹幕 {{ originDanmakuCount }}

共有 {{ danmakuCount.user }} 位不同用户发送了 {{ danmakuCount.dm }} 条弹幕
` }); app.use(ELEMENT_PLUS); app.mount('#danmaku-app'); } insertButton(); })(); /* 图标来源 https://www.iconfont.cn/ */