// ==UserScript== // @name bilibili 视频弹幕统计|下载|查询发送者 // @namespace https://github.com/ZBpine/bili-danmaku-statistic // @version 1.7.0 // @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* // @match https://www.bilibili.com/bangumi/play/ep* // @match https://space.bilibili.com/* // @grant none // @license MIT // @run-at document-end // @downloadURL none // ==/UserScript== (function () { 'use strict'; // iframe里初始化统计面板应用 async function initIframeApp(iframe, dataParam, panelInfoParam) { 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'); await addScript('https://cdn.jsdelivr.net/npm/echarts-wordcloud@2/dist/echarts-wordcloud.min.js'); await addScript('https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js'); await addScript('https://cdn.jsdelivr.net/npm/dom-to-image-more@3.5.0/dist/dom-to-image-more.min.js'); // 创建挂载点 const appRoot = doc.createElement('div'); appRoot.id = 'danmaku-app'; doc.body.style.margin = '0'; doc.body.appendChild(appRoot); // 挂载Vue const { createApp, ref, onMounted, nextTick, h } = win.Vue; const ELEMENT_PLUS = win.ElementPlus; const ECHARTS = win.echarts; const app = createApp({ setup() { const converter = new BiliMidHashConverter(); const displayedDanmakus = ref([]); const filterText = ref('^(哈|呵|h|ha|H|HA|233+)+$'); const currentFilt = ref(''); const currentSubFilt = ref({}); const subFiltHistory = ref([]); const danmakuCount = ref({ origin: 0, filtered: 0 }); const videoData = ref(dataParam.videoData || {}); const isTableVisible = ref(true); const isTableAutoH = ref(false); const loading = ref(true); const panelInfo = ref(panelInfoParam); const visibleCharts = ref(['user', 'wordcloud', 'density', 'date', 'hour', 'pool']); const chartHover = ref(null); const danmakuList = { original: [], filtered: [], current: [] }; const charts = { user: { instance: null, expandedH: false, render(data) { const countMap = {}; for (const d of data) { countMap[d.midHash] = (countMap[d.midHash] || 0) + 1; } const stats = Object.entries(countMap) .map(([user, count]) => ({ user, count })) .sort((a, b) => b.count - a.count); const userNames = stats.map(item => item.user); const counts = stats.map(item => item.count); const maxCount = Math.max(...counts); const sc = this.expandedH ? 20 : 8; this.instance.setOption({ tooltip: {}, title: { text: '用户弹幕统计', subtext: `共 ${userNames.length} 位用户` }, 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 >= sc ? sc - 1 : userNames.length, width: 20 } ], series: [{ type: 'bar', data: counts, label: { show: true, position: 'right', // 在条形右边显示 formatter: '{c}', // 显示数据本身 fontSize: 12 } }] }); }, async onClick(params) { const selectedUser = params.name; await updateDispDanmakus( false, danmakuList.filtered.filter(d => d.midHash === selectedUser), { chart: 'user', value: selectedUser, labelVNode: (h) => h('span', [ '用户', h(ELEMENT_PLUS.ElLink, { type: 'primary', onClick: () => midHashOnClick(selectedUser), style: 'vertical-align: baseline;' }, selectedUser), '发送' ]) } ); } }, wordcloud: { instance: null, expandedH: false, render(data) { const freq = {}; data.forEach(d => { d.content.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, ' ').split(/\s+/).forEach(w => { if (w.length >= 2) freq[w] = (freq[w] || 0) + 1; }); }); const list = Object.entries(freq).map(([name, value]) => ({ name, value })); this.instance.setOption({ title: { text: '弹幕词云' }, tooltip: {}, series: [{ type: 'wordCloud', gridSize: 8, sizeRange: [12, 40], rotationRange: [0, 0], shape: 'circle', data: list }] }); }, async onClick(params) { const keyword = params.name; const regex = new RegExp(keyword, 'i'); await updateDispDanmakus( false, danmakuList.filtered.filter(d => regex.test(d.content)), { chart: 'wordcloud', value: keyword, labelVNode: (h) => h('span', [ '包含词语', h(ELEMENT_PLUS.ElTag, { type: 'info', size: 'small', style: 'vertical-align: baseline;' }, keyword) ]) } ); } }, density: { instance: null, expandedH: false, render(data) { const duration = videoData.value.duration * 1000; // ms const minutes = duration / 1000 / 60; // 动态设置 bin 数量 let binCount = 100; if (minutes <= 10) binCount = 60; else if (minutes <= 30) binCount = 90; else if (minutes <= 60) binCount = 60; else binCount = 30; const bins = new Array(binCount).fill(0); data.forEach(d => { const idx = Math.floor((d.progress / duration) * binCount); bins[Math.min(idx, bins.length - 1)]++; }); const dataPoints = []; for (let i = 0; i < binCount; i++) { const timeSec = Math.floor((i * duration) / binCount / 1000); dataPoints.push({ value: [timeSec, bins[i]], name: formatProgress(timeSec * 1000) }); } this.instance.setOption({ title: { text: '弹幕密度分布' }, tooltip: { trigger: 'axis', formatter: function (params) { const sec = params[0].value[0]; return `时间段:${formatProgress(sec * 1000)}
弹幕数:${params[0].value[1]}`; }, axisPointer: { type: 'line' } }, xAxis: { type: 'value', name: '时间', min: 0, max: Math.ceil(duration / 1000), axisLabel: { formatter: val => formatProgress(val * 1000) } }, yAxis: { type: 'value', name: '弹幕数量' }, series: [{ data: dataPoints, type: 'line', smooth: true, areaStyle: {} // 可选加背景区域 }] }); }, async onClick(params) { const targetTime = params.value[0] * 1000; const list = displayedDanmakus.value; if (!list.length) return; // 找到最接近的弹幕 index let closestIndex = 0; let minDiff = Math.abs(list[0].progress - targetTime); for (let i = 1; i < list.length; i++) { const diff = Math.abs(list[i].progress - targetTime); if (diff < minDiff) { closestIndex = i; minDiff = diff; } } // 使用 Element Plus 表格 ref 滚动到该行 nextTick(() => { const rows = doc.querySelectorAll('.el-table__body-wrapper tbody tr'); const row = rows?.[closestIndex]; if (row) { row.scrollIntoView({ behavior: 'smooth', block: 'center' }); const original = row.style.backgroundColor; row.style.transition = 'background-color 0.3s ease'; row.style.backgroundColor = '#ecf5ff'; setTimeout(() => { row.style.backgroundColor = original || ''; }, 1500); } }); } }, date: { instance: null, expandedH: false, render(data) { const countMap = {}; data.forEach(d => { const date = formatTime(d.ctime).split(' ')[0]; countMap[date] = (countMap[date] || 0) + 1; }); // 按日期升序排序 const sorted = Object.entries(countMap).sort((a, b) => new Date(a[0]) - new Date(b[0])); const x = sorted.map(([date]) => date); const y = sorted.map(([, count]) => count); const totalDays = x.length; const startIdx = Math.max(0, totalDays - 30); // 只显示最近30天 this.instance.setOption({ title: { text: '发送日期分布' }, tooltip: {}, xAxis: { type: 'category', data: x }, yAxis: { type: 'value', name: '弹幕数量' }, dataZoom: [ { type: 'slider', startValue: startIdx, endValue: totalDays - 1, xAxisIndex: 0, height: 20 } ], series: [{ type: 'bar', data: y }] }); }, async onClick(params) { const selectedDate = params.name; await updateDispDanmakus( false, danmakuList.filtered.filter(d => formatTime(d.ctime).startsWith(selectedDate)), { chart: 'date', value: selectedDate, labelVNode: (h) => h('span', [ '日期', h(ELEMENT_PLUS.ElTag, { type: 'info', size: 'small', style: 'vertical-align: baseline;' }, selectedDate) ]) } ); } }, hour: { instance: null, expandedH: false, render(data) { const hours = new Array(24).fill(0); data.forEach(d => { const hour = new Date(d.ctime * 1000).getHours(); hours[hour]++; }); this.instance.setOption({ title: { text: '发送时间分布' }, tooltip: {}, xAxis: { type: 'category', data: hours.map((_, i) => i + '时') }, yAxis: { type: 'value', name: '弹幕数量' }, series: [{ type: 'bar', data: hours }] }); }, async onClick(params) { const selectedHour = parseInt(params.name); await updateDispDanmakus( false, danmakuList.filtered.filter(d => { const h = new Date(d.ctime * 1000).getHours(); return h === selectedHour; }), { chart: 'hour', value: selectedHour, labelVNode: (h) => h('span', [ '每天', h(ELEMENT_PLUS.ElTag, { type: 'info', size: 'small', style: 'vertical-align: baseline;' }, selectedHour), '点' ]) } ); } }, pool: { instance: null, expandedH: false, render(data) { const labelMap = { 0: '普通池', 1: '字幕池', 2: '特殊池', 3: '互动池' }; // 动态统计出现过的 pool 值 const poolMap = {}; data.forEach(d => { const key = d.pool; poolMap[key] = (poolMap[key] || 0) + 1; }); const keys = Object.keys(poolMap); const xData = keys.map(k => labelMap[k] ?? `pool:${k}`); const yData = keys.map(k => poolMap[k]); this._poolIndexMap = Object.fromEntries(xData.map((label, i) => [label, Number(keys[i])])); this.instance.setOption({ title: { text: '弹幕池分布' }, tooltip: {}, xAxis: { type: 'category', data: xData }, yAxis: { type: 'value', name: '弹幕数量' }, series: [{ type: 'bar', data: yData }] }); }, async onClick(params) { const poolLabel = params.name; const poolVal = this._poolIndexMap?.[poolLabel]; await updateDispDanmakus( false, danmakuList.filtered.filter(d => d.pool === poolVal), { chart: 'pool', value: poolLabel, labelVNode: (h) => h('span', [ h(ELEMENT_PLUS.ElTag, { type: 'info', size: 'small', style: 'vertical-align: baseline;' }, poolLabel) ]) } ); } } }; 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')}`; } async function shareImage() { const html2canvas = win.html2canvas; const domtoimage = win.domtoimage; if (!html2canvas || !domtoimage) { ELEMENT_PLUS.ElMessage.error('截图库加载失败'); return; } const titleWrapper = doc.getElementById('wrapper-title'); const tableWrapper = doc.getElementById('wrapper-table'); const chartWrapper = doc.getElementById('wrapper-chart'); if (!titleWrapper || !tableWrapper || !chartWrapper) { ELEMENT_PLUS.ElMessage.error('找不到截图区域'); return; } loading.value = true; try { titleWrapper.style.paddingBottom = '10px'; //dom-to-image-more会少截 tableWrapper.style.paddingBottom = '40px'; await nextTick(); const loadImage = (blob) => new Promise((resolve) => { const img = new Image(); img.onload = () => resolve(img); img.src = URL.createObjectURL(blob); }); const scale = window.devicePixelRatio; //title使用dom-to-image-more截图,table和chart使用html2canvas截图 const titleBlob = await domtoimage.toBlob(titleWrapper, { style: { transform: `scale(${scale})`, transformOrigin: 'top left' }, width: titleWrapper.offsetWidth * scale, height: titleWrapper.offsetHeight * scale }); const titleImg = await loadImage(titleBlob); //foreignObjectRendering开启则Echart无法显示,关闭则el-tag没有文字。 // const [titleCanvas, tableCanvas, chartCanvas] = await Promise.all([ // html2canvas(titleWrapper, { // useCORS: true, backgroundColor: '#fff', scale: scale, // foreignObjectRendering: true // }), // html2canvas(tableWrapper, { useCORS: true, backgroundColor: '#fff', scale: scale }), // html2canvas(chartWrapper, { useCORS: true, backgroundColor: '#fff', scale: scale }) // ]); let tableCanvas = null; let chartCanvas = null; if (isTableVisible.value) { tableCanvas = await html2canvas(tableWrapper, { useCORS: true, backgroundColor: '#fff', scale }); } else { tableCanvas = document.createElement('canvas'); tableCanvas.width = 0; tableCanvas.height = 0; } chartCanvas = await html2canvas(chartWrapper, { useCORS: true, backgroundColor: '#fff', scale }); // 计算总大小 const totalWidth = Math.max(titleImg.width, tableCanvas.width, chartCanvas.width) * 1.1; const totalHeight = titleImg.height + tableCanvas.height + chartCanvas.height; // 合并成一张新 canvas const finalCanvas = document.createElement('canvas'); finalCanvas.width = totalWidth; finalCanvas.height = totalHeight; const ctx = finalCanvas.getContext('2d'); // 绘制 ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, totalWidth, totalHeight); let y = 0; ctx.drawImage(titleImg, (totalWidth - titleImg.width) / 2, y); y += titleImg.height; if (tableCanvas.height > 0) { ctx.drawImage(tableCanvas, (totalWidth - tableCanvas.width) / 2, y); y += tableCanvas.height; } if (chartCanvas.height > 0) { ctx.drawImage(chartCanvas, (totalWidth - chartCanvas.width) / 2, y); } // 输出图片 finalCanvas.toBlob(blob => { const blobUrl = URL.createObjectURL(blob); ELEMENT_PLUS.ElMessageBox({ title: '截图预览', dangerouslyUseHTMLString: true, message: ` `, showCancelButton: true, confirmButtonText: '保存图片', cancelButtonText: '关闭', }).then(() => { const link = doc.createElement('a'); link.download = `${videoData.value.bvid}_danmaku_statistics.png`; link.href = blobUrl; link.click(); URL.revokeObjectURL(blobUrl); // 可选:释放内存 }).catch(() => { URL.revokeObjectURL(blobUrl); }); }); } catch (err) { console.error(err); ELEMENT_PLUS.ElMessage.error('截图生成失败'); } finally { titleWrapper.style.paddingBottom = ''; tableWrapper.style.paddingBottom = ''; loading.value = false; } } function midHashOnClick(midHash) { ELEMENT_PLUS.ElMessageBox.confirm( `是否尝试反查用户ID?

可能需要一段时间,且10位数以上ID容易查错

`, '提示', { dangerouslyUseHTMLString: true, confirmButtonText: '是', cancelButtonText: '否', type: 'warning', } ).then(() => { // 开始反查用户ID var result = converter.hashToMid(midHash); if (result && result !== -1) { ELEMENT_PLUS.ElMessageBox.alert( `已查到用户ID: 点击访问用户空间

此ID通过弹幕哈希本地计算得出,非官方公开数据,请谨慎使用

`, '查找成功', { dangerouslyUseHTMLString: true, confirmButtonText: '确定', type: 'success', } ); } else { ELEMENT_PLUS.ElMessage.error('未能查到用户ID或用户不存在'); } }).catch((err) => { console.error(err); // 用户点击了取消,只复制midHash navigator.clipboard.writeText(midHash).then(() => { ELEMENT_PLUS.ElMessage.success('midHash已复制到剪贴板'); }).catch(() => { ELEMENT_PLUS.ElMessage.error('复制失败'); }); }); } function renderChart(chart) { const el = doc.getElementById('chart-' + chart); if (!el) return; if (!charts[chart].instance) { el.style.height = charts[chart].expandedH ? '100%' : '50%'; charts[chart].instance = ECHARTS.init(el); // 防止重复绑定 charts[chart].instance.off('click'); if (typeof charts[chart].onClick === 'function') { charts[chart].instance.on('click', (params) => { // 传递this charts[chart].onClick(params); }); } } charts[chart].render(danmakuList.filtered); } function disposeChart(chart) { if (charts[chart].instance && charts[chart].instance.dispose) { charts[chart].instance.dispose(); charts[chart].instance = null; } } function expandChart(chart) { charts[chart].expandedH = !charts[chart].expandedH; disposeChart(chart); renderChart(chart); } function moveChartUp(chart) { const idx = visibleCharts.value.indexOf(chart); if (idx > 0) { visibleCharts.value.splice(idx, 1); visibleCharts.value.splice(idx - 1, 0, chart); } } function moveChartDown(chart) { const idx = visibleCharts.value.indexOf(chart); if (idx < visibleCharts.value.length - 1) { visibleCharts.value.splice(idx, 1); visibleCharts.value.splice(idx + 1, 0, chart); } } function moveChartOut(chart) { const idx = visibleCharts.value.indexOf(chart); if (idx !== -1) { visibleCharts.value.splice(idx, 1); disposeChart(chart); } } function locateUserInChart(midHash) { if (!charts.user.instance) return; const option = charts.user.instance.getOption(); const index = option.yAxis[0].data.indexOf(midHash); if (index === -1) { ELEMENT_PLUS.ElMessageBox.alert( `未在当前图表中找到用户 ${midHash}`, '未找到用户', { type: 'warning', dangerouslyUseHTMLString: true, confirmButtonText: '确定' } ); return; } const sc = charts.user.expandedH ? 20 : 8; const scup = charts.user.expandedH ? 9 : 3; if (index >= 0) { charts.user.instance.setOption({ yAxis: { axisLabel: { formatter: function (value) { if (value === midHash) { return '{a|' + value + '}'; } else { return value; } }, rich: { a: { color: '#5470c6', fontWeight: 'bold' } } } }, dataZoom: [{ startValue: Math.min(option.yAxis[0].data.length - sc, Math.max(0, index - scup)), endValue: Math.min(option.yAxis[0].data.length - 1, Math.max(0, index - scup) + sc - 1) }] }); } ELEMENT_PLUS.ElMessage.success(`已定位到用户 ${midHash}`); } function handleRowClick(row) { let el = doc.getElementById('wrapper-chart'); if (!el) return; while (el && el !== doc.body) { //寻找可以滚动的父级元素 const overflowY = getComputedStyle(el).overflowY; const canScroll = overflowY === 'scroll' || overflowY === 'auto'; if (canScroll && el.scrollHeight > el.clientHeight) { el.scrollTo({ top: 0, behavior: 'smooth' }); break; } el = el.parentElement; } locateUserInChart(row.midHash); } function promptLocateUser() { ELEMENT_PLUS.ElMessageBox.prompt('请输入要定位的 midHash 用户 ID:', '定位用户', { confirmButtonText: '定位', cancelButtonText: '取消', inputPattern: /^[a-fA-F0-9]{8}$/, inputErrorMessage: '请输入正确的 midHash(十六进制格式)' }).then(({ value }) => { locateUserInChart(value.trim()); }).catch(() => { /* 用户取消 */ }); } async function updateDispDanmakus(ifchart = false, data = danmakuList.filtered, subFilt = {}) { loading.value = true; await nextTick(); await new Promise(resolve => setTimeout(resolve, 10)); //等待v-loading渲染 try { danmakuList.current = [...data]; displayedDanmakus.value = danmakuList.current; currentSubFilt.value = subFilt; danmakuCount.value.filtered = danmakuList.filtered.length; if (ifchart) { for (const chart of visibleCharts.value) { renderChart(chart); } } await nextTick(); } catch (err) { console.error(err); ELEMENT_PLUS.ElMessage.error('数据显示错误'); } finally { loading.value = false; } } async function clearSubFilter() { await updateDispDanmakus(); } async function commitSubFilter() { try { if (Object.keys(currentSubFilt.value).length) { subFiltHistory.value.push({ ...currentSubFilt.value }); } danmakuList.filtered = [...danmakuList.current]; await updateDispDanmakus(true); } catch (e) { console.error(e); ELEMENT_PLUS.ElMessage.error('提交子筛选失败'); } } async function applyFilter() { try { subFiltHistory.value = []; const regex = new RegExp(filterText.value, 'i'); danmakuList.filtered = danmakuList.original.filter(d => regex.test(d.content)); currentFilt.value = regex; await updateDispDanmakus(true); } catch (e) { console.warn(e); alert('无效正则表达式'); } } async function resetFilter() { subFiltHistory.value = []; danmakuList.filtered = [...danmakuList.original]; currentFilt.value = ''; await updateDispDanmakus(true); } onMounted(async () => { if ((!dataParam?.videoData && !dataParam?.episodeData) || !Array.isArray(dataParam?.danmakuData)) { ELEMENT_PLUS.ElMessageBox.alert( '初始化数据缺失,无法加载弹幕统计面板。请确认主页面传入了有效数据。', '错误', { type: 'error' } ); dataParam.danmakuData = []; } if (dataParam.epid && dataParam.episodeData) { let ep = null; let sectionTitle = null; if (Array.isArray(dataParam.episodeData.episodes)) { ep = dataParam.episodeData.episodes.find(e => e.ep_id === dataParam.epid || e.id === dataParam.epid); if (ep) { sectionTitle = ep.show_title; } } if (!ep && Array.isArray(dataParam.episodeData.section)) { for (const section of dataParam.episodeData.section) { ep = section.episodes?.find(e => e.ep_id === dataParam.epid || e.id === dataParam.epid); if (ep) { sectionTitle = section.title + ':' + ep.show_title; break; } } } if (ep) { Object.assign(videoData.value, { bvid: ep.bvid, cid: ep.cid, epid: ep.ep_id || ep.id, section_title: sectionTitle, title: ep.share_copy || ep.show_title || ep.long_title || ep.title, duration: ep.duration / 1000, pic: ep.cover, owner: { mid: dataParam.episodeData.up_info?.mid, name: dataParam.episodeData.up_info?.uname, face: dataParam.episodeData.up_info?.avatar }, pubdate: ep.pub_time, stat: { view: ep.stat?.play || dataParam.episodeData.stat.views, danmaku: ep.stat?.danmakus || dataParam.episodeData.stat.danmakus, reply: ep.stat?.reply || dataParam.episodeData.stat.reply, coin: ep.stat?.coin || dataParam.episodeData.stat.coins, like: ep.stat?.likes || dataParam.episodeData.stat.likes, } }); } } if (videoData.value?.pic?.startsWith('http:')) { videoData.value.pic = videoData.value.pic.replace(/^http:/, 'https:'); } if (videoData.value?.owner?.face?.startsWith('http:')) { videoData.value.owner.face = videoData.value.owner.face.replace(/^http:/, 'https:'); } if (videoData.value.pages) { if (!isNaN(dataParam.p) && videoData.value.pages[dataParam.p - 1]) { videoData.value.page_cur = videoData.value.pages[dataParam.p - 1]; videoData.value.duration = videoData.value.page_cur.duration; } else if (videoData.value.pages[0]) { videoData.value.duration = videoData.value.pages[0].duration; } } danmakuList.original = [...dataParam.danmakuData].sort((a, b) => a.progress - b.progress); danmakuList.filtered = [...danmakuList.original]; danmakuCount.value.origin = danmakuList.original.length; await updateDispDanmakus(true); }); return { h, displayedDanmakus, filterText, applyFilter, resetFilter, videoData, danmakuCount, currentFilt, currentSubFilt, subFiltHistory, loading, isTableVisible, isTableAutoH, panelInfo, visibleCharts, chartHover, expandChart, moveChartUp, moveChartDown, moveChartOut, midHashOnClick, handleRowClick, promptLocateUser, clearSubFilter, commitSubFilter, formatProgress, formatCtime, formatTime, shareImage }; }, template: `

{{ videoData.title || '加载中...' }}
视频封面

第 {{ videoData.page_cur.page }} P:{{ videoData.page_cur.part }} {{ videoData.section_title }}

UP主: {{ videoData.owner.name }}

UP主头像

发布时间: {{ videoData.pubdate ? formatTime(videoData.pubdate) : '-' }}
截止 {{ formatTime(Math.floor(Date.now()/1000)) }} 播放量: {{ videoData.stat.view || '-' }}
总弹幕数: {{ videoData.stat.danmaku || '-' }} ,载入实时弹幕 {{ danmakuCount.origin }}

结果:共有 {{ danmakuCount.filtered }} 条弹幕

弹幕列表
{{ isTableVisible ? '▲ 收起' : '▼ 展开' }}
` }); app.use(ELEMENT_PLUS); app.mount('#danmaku-app'); } // iframe里初始化用户面板应用 async function initUserIframeApp(iframe, userData) { 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'); const appRoot = doc.createElement('div'); appRoot.id = 'user-space-app'; doc.body.style.margin = '0'; doc.body.appendChild(appRoot); const { createApp, ref, onMounted, computed } = win.Vue; const ELEMENT_PLUS = win.ElementPlus; const app = createApp({ setup() { const converter = new BiliMidHashConverter(); const card = ref(userData.card || {}); const stats = ref(userData || {}); const officialRoleMap = { 0: '无', 1: '个人认证 - 知名UP主', 2: '个人认证 - 大V达人', 3: '机构认证 - 企业', 4: '机构认证 - 组织', 5: '机构认证 - 媒体', 6: '机构认证 - 政府', 7: '个人认证 - 高能主播', 9: '个人认证 - 社会知名人士' }; const officialInfo = computed(() => { const o = card.value?.Official; if (!o || o.type === -1) return null; return { typeText: officialRoleMap[o.role] || '未知认证', title: o.title || '(无标题)', desc: o.desc || '' }; }); function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => { ELEMENT_PLUS.ElMessage.success('midHash 已复制到剪贴板'); }).catch(() => { ELEMENT_PLUS.ElMessage.error('复制失败'); }); } onMounted(async () => { card.value.midHash = converter.midToHash(card.value.mid || '') }); return { card, stats, officialInfo, copyToClipboard }; }, template: `

{{ card.name }} {{ card.sex }} LV{{ card.level_info.current_level }} 大会员

{{ card.sign || '这位用户很神秘,什么都没写。' }}

MID: {{ card.mid }} Hash: {{ card.midHash }}

认证: {{ officialInfo.typeText }} {{ officialInfo.title }} ({{ officialInfo.desc }})

勋章: {{ card.nameplate.name }} {{ card.nameplate.level }} - {{ card.nameplate.condition }}

挂件: {{ card.pendant.name }}

` }); app.use(win.ElementPlus); app.mount('#user-space-app'); } // B站mid与hash转换 class BiliMidHashConverter { constructor() { this.crcTable = this._createCRCTable(); } _createCRCTable() { const table = new Array(256); const CRCPOLYNOMIAL = 0xEDB88320; 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; } } table[i] = crcreg; } return table; } /** * mid → hash(用于弹幕中 midHash 显示) */ midToHash(mid) { let crc = 0xFFFFFFFF; const input = mid.toString(); for (let i = 0; i < input.length; i++) { const byte = input.charCodeAt(i); crc = (crc >>> 8) ^ this.crcTable[(crc ^ byte) & 0xFF]; } return ((crc ^ 0xFFFFFFFF) >>> 0).toString(16); } /** * 尝试通过 midHash 反查 mid(暴力逆向) * 若失败返回 -1 * @param {string} hashStr 16进制字符串(如 '6c2b67a9') * @param {number} maxTry 最大尝试次数(默认一亿) */ hashToMid(hashStr, maxTry = 100_000_000) { var index = new Array(4); var ht = parseInt('0x' + hashStr) ^ 0xffffffff, snum, i, lastindex, deepCheckData; for (i = 3; i >= 0; i--) { index[3 - i] = this._getCRCIndex(ht >>> (i * 8)); snum = this.crcTable[index[3 - i]]; ht ^= snum >>> ((3 - i) * 8); } for (i = 0; i < maxTry; i++) { lastindex = this._crc32LastIndex(i); if (lastindex == index[3]) { deepCheckData = this._deepCheck(i, index) if (deepCheckData[0]) break; } } if (i == 100000000) return -1; return i + '' + deepCheckData[1]; } _crc32(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) ^ this.crcTable[index]; } return crcstart; } _crc32LastIndex(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) ^ this.crcTable[index]; } return index; } _getCRCIndex(t) { //if(t>0) //t-=256; for (var i = 0; i < 256; i++) { if (this.crcTable[i] >>> 24 == t) return i; } return -1; } _deepCheck(i, index) { var tc = 0x00, str = '', hash = this._crc32(i); tc = hash & 0xff ^ index[2]; if (!(tc <= 57 && tc >= 48)) return [0]; str += tc - 48; hash = this.crcTable[index[2]] ^ (hash >>> 8); tc = hash & 0xff ^ index[1]; if (!(tc <= 57 && tc >= 48)) return [0]; str += tc - 48; hash = this.crcTable[index[1]] ^ (hash >>> 8); tc = hash & 0xff ^ index[0]; if (!(tc <= 57 && tc >= 48)) return [0]; str += tc - 48; hash = this.crcTable[index[0]] ^ (hash >>> 8); return [1, str]; } } // 获取数据 class BiliDanmakuUtils { constructor() { this.bvid = null; this.p = null; this.epid = null; this.type = null; this.cid = null; this.videoData = null; this.episodeData = null; this.danmakuData = null; this.danmakuXmlText = null; this.logStyle = { tag: 'Danmaku Statistic', style: 'background: #00a2d8; color: white; padding: 2px 6px; border-radius: 3px;', errorStyle: 'background: #ff4d4f; color: white; padding: 2px 6px; border-radius: 3px;' }; } logTag(...args) { console.log(`%c${this.logStyle.tag}`, this.logStyle.style, ...args); } logTagError(...args) { console.error(`%c${this.logStyle.tag}`, this.logStyle.errorStyle, ...args); } 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]), pool: parseInt(parts[5]), midHash: parts[6], dmid: parts[7], weight: parseInt(parts[8]), content: d.textContent.trim() }); } this.logTag(`解析弹幕xml文本完成,共 ${danmakus.length} 条弹幕`); return danmakus; } parseBiliUrl(url) { this.bvid = null; this.p = null; this.epid = null; const bvidMatch = url.match(/BV[a-zA-Z0-9]+/); if (bvidMatch) this.bvid = bvidMatch[0]; if (this.bvid) { const pMatch = url.match(/[?&]p=(\d+)/); if (pMatch) { const parsedP = parseInt(pMatch[1], 10); if (!isNaN(parsedP) && parsedP >= 1) { this.p = parsedP; } } if (this.p) { this.logTag(`解析 URL 得到 BVID=${this.bvid}, 分页p=${this.p}`); } else { this.logTag(`解析 URL 得到 BVID=${this.bvid}`); } } else { const epidMatch = url.match(/ep(\d+)/); if (epidMatch) { this.epid = parseInt(epidMatch[1]); } else { this.logTagError(`URL=${url} 解析未找到 ID 信息`); } } } _findCid() { if (this.bvid) { this.cid = this.videoData.pages[this.p - 1]?.cid || this.videoData.cid; return this.cid } if (this.epid) { if (Array.isArray(this.episodeData.episodes)) { const ep = this.episodeData.episodes.find(e => e.ep_id === this.epid || e.id === this.epid); if (ep) { this.cid = ep.cid; return this.cid } } if (Array.isArray(this.episodeData.section)) { for (const section of this.episodeData.section) { const ep = section.episodes?.find(e => e.ep_id === this.epid || e.id === this.epid); if (ep) { this.cid = ep.cid; return this.cid } } } } } async getVideoData() { if (!this.bvid) return null; try { const res = await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${this.bvid}`); const json = await res.json(); if (json && json.data) { this.videoData = json.data; this.logTag('获取视频信息成功'); return this.videoData; } else throw new Error(`视频信息接口请求失败,json:${json}`); } catch (e) { this.logTagError('请求视频信息失败:', e); return null; } } async getEpisodeData() { if (!this.epid) return null; try { const res = await fetch(`https://api.bilibili.com/pgc/view/web/season?ep_id=${this.epid}`); const json = await res.json(); if (json && json.result) { this.episodeData = json.result; this.logTag('获取剧集信息成功'); return this.episodeData; } else throw new Error(`剧集信息接口请求失败,json:${json}`); } catch (e) { this.logTagError('请求剧集信息失败:', e); return null; } } async getDanmakuData() { try { this._findCid(); if (!this.cid) throw new Error('ChatID 缺失'); const res = await fetch(`https://api.bilibili.com/x/v1/dm/list.so?oid=${this.cid}`); if (!res.ok) throw new Error(`弹幕接口请求失败,状态码:${res.status}`); this.danmakuXmlText = await res.text(); this.danmakuData = this.parseDanmakuXml(this.danmakuXmlText); this.logTag('获取弹幕数据成功'); return this.danmakuData; } catch (err) { this.logTagError('获取弹幕数据失败:', err); return null; } } async fetchAllData(url) { this.parseBiliUrl(url); await this.getVideoData(); await this.getEpisodeData(); await this.getDanmakuData(); return { videoData: this.videoData, danmakuData: this.danmakuData }; } async getUserCardData(mid) { try { const res = await fetch(`https://api.bilibili.com/x/web-interface/card?mid=${mid}&photo=true`); const json = await res.json(); if (json.code === 0) { this.logTag(`获取用户名片成功:${mid}`); return json.data; } else { throw new Error(json.message || '获取用户信息失败'); } } catch (e) { this.logTagError('请求用户信息失败:', e); return { card: { mid } }; } } } const dmUtils = new BiliDanmakuUtils(); // 插入按钮 function insertButton() { const btn = document.createElement('div'); btn.id = 'danmaku-stat-btn'; btn.innerHTML = ` 弹幕统计
`; Object.assign(btn.style, { position: 'fixed', left: '-100px', bottom: '40px', zIndex: '9997', width: '120px', height: '40px', backgroundColor: 'transparent', color: '#00ace5', borderTopRightRadius: '20px', borderBottomRightRadius: '20px', cursor: 'pointer', fontSize: '16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', boxShadow: '0 0 5px rgba(0, 172, 229, 0.3)', 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.9)'; btn.style.border = '1px solid #00ace5'; }; btn.onmouseleave = () => { btn.style.left = '-100px'; btn.style.backgroundColor = 'transparent'; btn.style.border = 'none'; }; btn.onclick = openPanel; document.body.appendChild(btn); } // 打开iframe弹幕统计面板 function openPanel() { if (document.getElementById('danmaku-stat-iframe')) { console.warn('统计面板已打开'); return; } // 创建蒙层 const overlay = document.createElement('div'); overlay.id = 'danmaku-stat-overlay'; overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 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.cssText = ` position: fixed; top: 15%; left: 15%; width: 70%; height: 70%; background-color: #fff; z-index: 9999; padding: 20px; overflow: hidden; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); `; const match = location.href.match(/^https:\/\/space\.bilibili\.com\/(\d+)/); const isUserPage = !!match; iframe.onload = async () => { try { if (isUserPage) { const mid = match[1]; const userData = await dmUtils.getUserCardData(mid); await initUserIframeApp(iframe, userData); } else { await dmUtils.fetchAllData(location.href); await initIframeApp(iframe, dmUtils, { type: 0, newPanel: function (type) { if (type == 0) { openPanelInNewTab(); dmUtils.logTag('[主页面] 新建子页面'); } } }); } } catch (err) { dmUtils.logTagError('初始化失败:', err); alert(`面板加载失败:${err.message}`); } }; document.body.appendChild(iframe); } // 打开新标签页弹幕统计面板 function openPanelInNewTab() { let bTitle = 'Bilibili'; if (dmUtils.bvid) bTitle = dmUtils.bvid; else if (dmUtils.epid) bTitle = 'ep' + dmUtils.epid; const htmlContent = ` ${bTitle} 弹幕统计 `; const blob = new Blob([htmlContent], { type: 'text/html' }); const blobUrl = URL.createObjectURL(blob); const newWin = window.open(blobUrl, '_blank'); if (!newWin) { alert('浏览器阻止了弹出窗口'); return; } } // 保存弹幕统计面板 function savePanel() { let bTitle = 'Bilibili'; if (dmUtils.bvid) bTitle = dmUtils.bvid; else if (dmUtils.epid) bTitle = 'ep' + dmUtils.epid; const htmlContent = ` ${bTitle} 弹幕统计 `; const blob = new Blob([htmlContent], { type: 'text/html' }); const blobUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = blobUrl; link.download = `${bTitle}_danmaku_statistics.html`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } // 监听新标签页消息 window.addEventListener('message', (event) => { if (event.data?.type === 'DMSTATS_REQUEST_DATA') { dmUtils.logTag('[主页面] 收到数据请求'); event.source.postMessage(dmUtils, '*'); } else if (event.data?.type === 'DMSTATS_REQUEST_SAFE') { dmUtils.logTag('[主页面] 收到保存请求'); savePanel(); } }); insertButton(); })();