// ==UserScript== // @name bilibili 视频弹幕统计|下载|查询发送者 // @namespace https://github.com/ZBpine/bili-danmaku-statistic // @version 1.10.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== (async () => { 'use strict'; class ResourceLoader { constructor(doc = document) { this.doc = doc; } addEl(tag, attrs = {}, parent = this.doc.head) { const el = this.doc.createElement(tag); Object.assign(el, attrs); parent.appendChild(el); return el; } addScript(src) { return new Promise(resolve => { this.addEl('script', { src, onload: resolve }); }); } addCss(href) { this.addEl('link', { rel: 'stylesheet', href }); } addStyle(cssText) { this.addEl('style', { textContent: cssText }); } } // iframe里初始化统计面板应用 async function initIframeApp(iframe, dataParam, panelInfoParam) { const doc = iframe.contentDocument; const win = iframe.contentWindow; // 引入外部库 const loader = new ResourceLoader(doc); loader.addCss('https://cdn.jsdelivr.net/npm/element-plus/dist/index.css'); await loader.addScript('https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js'); await loader.addScript('https://cdn.jsdelivr.net/npm/element-plus/dist/index.full.min.js'); await loader.addScript('https://cdn.jsdelivr.net/npm/@element-plus/icons-vue/dist/index.iife.min.js'); await loader.addScript('https://cdn.jsdelivr.net/npm/echarts@5'); await loader.addScript('https://cdn.jsdelivr.net/npm/echarts-wordcloud@2/dist/echarts-wordcloud.min.js'); await loader.addScript('https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js'); await loader.addScript('https://cdn.jsdelivr.net/npm/dom-to-image-more@3.5.0/dist/dom-to-image-more.min.js'); loader.addStyle(`.base { vertical-align: baseline; }`); const DanmukuTableFactory = (await import('https://cdn.jsdelivr.net/gh/ZBpine/bili-danmaku-statistic/docs/DanmukuTable.js')).default; const DanmukuTable = DanmukuTableFactory(win.Vue, win.ElementPlus); // 创建挂载点 const appRoot = doc.createElement('div'); appRoot.id = 'danmaku-app'; doc.body.style.margin = '0'; doc.body.appendChild(appRoot); // 挂载Vue const { createApp, ref, reactive, onMounted, nextTick, h, computed, watch } = win.Vue; const ELEMENT_PLUS = win.ElementPlus; const ECHARTS = win.echarts; const ICONS = win.ElementPlusIconsVue; const app = createApp({ setup() { ['Setting', 'Plus', 'Delete', 'Download', 'User', 'PictureFilled'].forEach(key => { app.component('ElIcon' + key, ICONS[key]); }); app.component('DanmukuTable', DanmukuTable); app.component('ImagePopoverLink', { props: { imgSrc: String, alt: String, width: Number, height: Number, rounded: { type: Boolean, default: false }, linkStyle: { type: String, default: '' } }, setup(props, { slots }) { const imgStyle = computed(() => ({ maxWidth: '100%', maxHeight: '100%', borderRadius: props.rounded ? '50%' : '0%' })); return () => { if (!props.imgSrc) return null; return h(ELEMENT_PLUS.ElPopover, { placement: 'right', popperStyle: `width: ${props.width}px; height: ${props.height}px; padding: 10px; box-sizing: content-box;` }, { default: () => h('div', { style: { display: 'flex', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%' } }, [ h('img', { src: props.imgSrc, alt: props.alt, style: imgStyle.value }) ]), reference: () => h(ELEMENT_PLUS.ElLink, { href: props.imgSrc, target: '_blank', type: 'primary', style: props.linkStyle }, slots.default ? slots.default() : '查看') }); }; } }); app.component('ActionTag', { props: { type: { type: String, default: 'info' }, title: String, onClick: Function }, setup(props, { slots }) { return () => h(win.ElementPlus.ElTag, { type: props.type, size: 'small', effect: 'light', round: true, title: props.title, style: { marginLeft: '4px', verticalAlign: 'baseline', cursor: 'pointer', aspectRatio: '1/1', padding: '0' }, onClick: props.onClick }, () => slots.default ? slots.default() : ''); } }); app.component('InfoLine', { props: { label: String, suffix: String, value: Object, href: String, type: String }, setup(props) { const isLink = !!props.href; const finalType = props.type || (isLink ? 'primary' : 'info'); return () => { return [ props.label ? `${props.label} ` : ' ', isLink ? h(ELEMENT_PLUS.ElLink, { href: props.href, target: '_blank', type: finalType, style: 'vertical-align: baseline;' }, () => String(props.value)) : h(ELEMENT_PLUS.ElTag, { type: finalType, size: 'small', style: 'vertical-align: baseline;' }, () => String(props.value)), props.suffix ? ` ${props.suffix}` : ' ' ]; }; } }); const converter = new BiliMidHashConverter(); const displayedDanmakus = ref([]); const excludeFilter = ref(false); 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 = reactive(dataParam.videoData || {}); const isTableVisible = ref(true); const isTableAutoH = ref(false); const scrollToTime = ref(null); const loading = ref(true); const panelInfo = ref(panelInfoParam); const danmakuList = { original: [], //原始 filtered: [], //正则筛选后 current: [] //子筛选提交后 }; const DmstatStorage = { key: 'dmstat', getConfig() { return JSON.parse(localStorage.getItem(this.key) || '{}'); }, setConfig(obj) { localStorage.setItem(this.key, JSON.stringify(obj)); }, get(key, fallback = undefined) { const config = this.getConfig(); return config[key] ?? fallback; }, set(key, value) { const config = this.getConfig(); config[key] = value; this.setConfig(config); }, remove(key) { const config = this.getConfig(); delete config[key]; this.setConfig(config); }, clear() { localStorage.removeItem(this.key); } }; const charts = { user: { title: '用户弹幕统计', instance: null, expandedH: false, actions: [ { key: 'locateUser', icon: '⚲', title: '定位用户', method: 'locate' } ], 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, applySubFilter }) { const selectedUser = params.name; await applySubFilter({ value: selectedUser, filterFn: (data) => data.filter(d => d.midHash === selectedUser), labelVNode: (h) => h('span', [ '用户', h(ELEMENT_PLUS.ElLink, { type: 'primary', onClick: () => queryMidFromHash(selectedUser), style: 'vertical-align: baseline;' }, selectedUser), '发送' ]) }); }, locateInChart(midHash) { if (!this.instance) return; const option = this.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 = this.expandedH ? 20 : 8; const scup = this.expandedH ? 9 : 3; if (index >= 0) { this.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}`); }, locate() { ELEMENT_PLUS.ElMessageBox.prompt('请输入要定位的 midHash 用户 ID:', '定位用户', { confirmButtonText: '定位', cancelButtonText: '取消', inputPattern: /^[a-fA-F0-9]{5,}$/, inputErrorMessage: '请输入正确的 midHash(十六进制格式)' }).then(({ value }) => { this.locateInChart(value.trim()); }).catch((err) => { if (err !== 'cancel') { console.error(err); ELEMENT_PLUS.ElMessage.error('定位失败'); } }); } }, wordcloud: { title: '弹幕词云', instance: null, expandedH: false, segmentWorker: null, usingSegmentit: false, actions: [ { key: 'deepSegment', icon: '📝', title: '使用深度分词', method: 'enableDeepSegment' } ], async enableDeepSegment() { this.usingSegmentit = !this.usingSegmentit; try { loading.value = true; await nextTick(); await this.render(danmakuList.current); ELEMENT_PLUS.ElMessage.success('已切换模式'); } catch (err) { console.error(err); ELEMENT_PLUS.ElMessage.error('渲染错误'); } finally { loading.value = false; } }, async initWorker() { if (this.segmentWorker) return; const workerCode = ` var startTime = new Date().getTime(); importScripts('https://cdn.jsdelivr.net/npm/segmentit@2.0.3/dist/umd/segmentit.min.js'); const segmentit = Segmentit.useDefault(new Segmentit.Segment()); console.log('Segmentit初始化耗时:' + (new Date().getTime() - startTime) + 'ms'); function compressRepeats(text, maxRepeat = 3) { for (let len = 1; len <= 8; len++) { const regex = new RegExp('((.{1,' + len + '}))\\\\1{' + maxRepeat + ',}', 'g'); text = text.replace(regex, (m, _1, word) => word.repeat(maxRepeat)); } return text; } onmessage = function (e) { startTime = new Date().getTime(); const data = e.data; const freq = {}; for (const d of data) { if (!d.content || d.content.length < 2) continue; const safeContent = compressRepeats(d.content); const words = segmentit .doSegment(safeContent) .map(w => w.w) .filter(w => w.length >= 2); new Set(words).forEach(word => { freq[word] = (freq[word] || 0) + 1; }); } const list = Object.entries(freq) .map(([name, value]) => ({ name, value })) .sort((a, b) => b.value - a.value) .slice(0, 1000); console.log('Segmentit分词耗时:' + (new Date().getTime() - startTime) + 'ms'); postMessage(list); }; `; const blob = new Blob([workerCode], { type: 'application/javascript' }); this.segmentWorker = new Worker(URL.createObjectURL(blob)); }, async render(data) { if (!this.usingSegmentit) { const freq = {}; data.forEach(d => { if (!d.content) return; const words = d.content.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, ' ') .split(/\s+/).filter(w => w.length >= 2); new Set(words).forEach(w => { freq[w] = (freq[w] || 0) + 1; }); }); const list = Object.entries(freq) .map(([name, value]) => ({ name, value })) .sort((a, b) => b.value - a.value) .slice(0, 1000); this.instance.setOption({ title: { text: '弹幕词云' }, tooltip: {}, series: [{ type: 'wordCloud', data: list, gridSize: 8, sizeRange: [12, 40], rotationRange: [0, 0], shape: 'circle', }] }); return; } // 深度模式:调用 Worker + Segmentit await this.initWorker(); return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('词云分词超时')), 10000); this.segmentWorker.onmessage = (e) => { clearTimeout(timeout); const list = e.data; this.instance.setOption({ title: { text: '弹幕词云[深度分词]' }, tooltip: {}, series: [{ type: 'wordCloud', gridSize: 8, sizeRange: [12, 40], rotationRange: [0, 0], shape: 'circle', data: list }] }); resolve(); }; this.segmentWorker.onerror = (err) => { clearTimeout(timeout); console.error('[词云Worker错误]', err); reject(err); }; this.segmentWorker.postMessage(data); }); }, async onClick({ params, applySubFilter }) { const keyword = params.name; await applySubFilter({ value: keyword, filterFn: (data) => data.filter(d => new RegExp(keyword, 'i').test(d.content)), labelVNode: (h) => h('span', [ '包含词语', h(ELEMENT_PLUS.ElTag, { type: 'info', size: 'small', style: 'vertical-align: baseline;' }, keyword) ]) }); } }, density: { title: '弹幕密度分布', instance: null, refresh: true, rangeMode: false, clickBuffer: [], actions: [ { key: 'toggleRange', icon: '🧭', title: '切换范围选择模式', method: 'toggleRangeMode' }, { key: 'clearRange', icon: '-', title: '清除范围', method: 'clearSubFilt' } ], toggleRangeMode() { this.rangeMode = !this.rangeMode; this.clickBuffer = []; this.instance.setOption({ title: { text: '弹幕密度分布' + (this.rangeMode ? '[范围模式]' : '') }, series: [{ markLine: null, markArea: null }] }); ELEMENT_PLUS.ElMessage.success(`已${this.rangeMode ? '进入' : '退出'}范围选择模式`); }, render(data) { const duration = videoData.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: '弹幕密度分布' + (this.rangeMode ? '[范围模式]' : '') }, 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: [{ markLine: null, markArea: null, data: dataPoints, type: 'line', smooth: true, areaStyle: {} // 可选加背景区域 }] }); }, async onClick({ params, applySubFilter }) { const sec = params.value[0]; if (!this.rangeMode) { // 默认模式:直接跳转 scrollToTime.value = sec * 1000; return; } this.clickBuffer.push(sec); // 第一次点击,添加辅助线 if (this.clickBuffer.length === 1) { this.instance.setOption({ series: [{ markLine: { silent: true, animation: false, symbol: 'none', data: [ { xAxis: sec, lineStyle: { color: 'red', type: 'dashed' }, label: { formatter: `起点:${formatProgress(sec * 1000)}`, position: 'end', color: 'red' } } ] } }] }); ELEMENT_PLUS.ElMessage.info('请点击结束时间'); return; } // 第二次点击,清除临时标记 + 应用时间范围筛选 const [startSec, endSec] = this.clickBuffer.sort((a, b) => a - b); const startMs = startSec * 1000; const endMs = endSec * 1000; this.clickBuffer = []; // 使用 markArea 高亮选中范围 this.instance.setOption({ series: [{ markLine: null, markArea: { silent: true, itemStyle: { color: 'rgba(255, 100, 100, 0.2)' }, data: [ [ { xAxis: startSec }, { xAxis: endSec } ] ] } }] }); await applySubFilter({ value: `${formatProgress(startMs)} ~ ${formatProgress(endMs)}`, filterFn: (data) => data.filter(d => d.progress >= startMs && d.progress <= endMs), labelVNode: (h) => h('span', [ '时间段:', h(ELEMENT_PLUS.ElTag, { type: 'info', size: 'small', style: 'vertical-align: baseline;' }, `${formatProgress(startMs)} ~ ${formatProgress(endMs)}`) ]) }); }, clearSubFilt() { this.clickBuffer = []; this.instance.setOption({ series: [{ markLine: null, markArea: null }] }); } } }; const chartsActions = reactive({ remove: { icon: '⨉', title: '移除图表', apply: () => true, handler: (chart) => { const idx = chartConfig.chartsVisible.indexOf(chart); if (idx !== -1) { chartConfig.chartsVisible.splice(idx, 1); disposeChart(chart); } } }, moveDown: { icon: '▼', title: '下移图表', apply: () => true, handler: async (chart) => { const idx = chartConfig.chartsVisible.indexOf(chart); if (idx < chartConfig.chartsVisible.length - 1) { chartConfig.chartsVisible.splice(idx, 1); chartConfig.chartsVisible.splice(idx + 1, 0, chart); await nextTick(); const el = doc.getElementById('chart-' + chart); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } }, moveUp: { icon: '▲', title: '上移图表', apply: () => true, handler: async (chart) => { const idx = chartConfig.chartsVisible.indexOf(chart); if (idx > 0) { chartConfig.chartsVisible.splice(idx, 1); chartConfig.chartsVisible.splice(idx - 1, 0, chart); await nextTick(); const el = doc.getElementById('chart-' + chart); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } }, refresh: { icon: '↻', title: '刷新', apply: chart => 'refresh' in charts[chart], handler: async (chart) => { loading.value = true; await nextTick(); disposeChart(chart); await renderChart(chart); loading.value = false; } }, expandH: { icon: '⇕', title: '展开/收起', apply: chart => 'expandedH' in charts[chart], handler: async (chart) => { loading.value = true; await nextTick(); charts[chart].expandedH = !charts[chart].expandedH; disposeChart(chart); await renderChart(chart); loading.value = false; } } }); const chartHover = ref(null); const chartConfig = reactive({ show: false, // 是否显示设置弹窗 chartsAvailable: [], // 所有图表(默认+自定义) chartsVisible: [], // 当前勾选可见图表 oldChartsVisible: [], customInputVisible: false, // 是否展开自定义添加区域 newChartCode: `{ name: 'leadingDigit', title: '例子-弹幕中数字首位分布', expandedH: false, render(data) { const digitCount = { '1': 0, '2': 0, '3': 0, '4': 0, '5': 0, '6': 0, '7': 0, '8': 0, '9': 0 }; const digitRegex = /\\d+/g; data.forEach(d => { const matches = d.content.match(digitRegex); if (!matches) return; matches.forEach(numStr => { const firstDigit = numStr.replace(/^0+/, '')[0]; if (firstDigit && digitCount[firstDigit] !== undefined) { digitCount[firstDigit]++; } }); }); const labels = Object.keys(digitCount); const counts = labels.map(d => digitCount[d]); const total = counts.reduce((a, b) => a + b, 0); const percentages = counts.map(c => ((c / total) * 100).toFixed(2)); this.instance.setOption({ title: { text: '弹幕中数字首位分布' }, tooltip: { trigger: 'axis', formatter: function (params) { const p = params[0]; return "首位数字:" + p.name + "
数量:" + p.value + "
占比:" + percentages[labels.indexOf(p.name)] + "%"; } }, xAxis: { type: 'category', data: labels, name: '首位数字' }, yAxis: { type: 'value', name: '出现次数' }, series: [{ type: 'bar', data: counts, label: { show: true, position: 'top', formatter: (val) => percentages[val.dataIndex] + "%" } }] }); }, async onClick({ params, applySubFilter, ELEMENT_PLUS }) { const selectedDigit = params.name; await applySubFilter({ value: selectedDigit, filterFn: (data) => data.filter(d => (d.content.match(/\\d+/g) || []).some(n => n.replace(/^0+/, '')[0] === selectedDigit)), labelVNode: (h) => h('span', [ '首位数字为 ', h(ELEMENT_PLUS.ElTag, { type: 'info', size: 'small', style: 'vertical-align: baseline;' }, selectedDigit) ]) }); } }` , // 自定义图表代码 remoteChartList: [], // 远程图表加载数据列表 open() { this.show = true; this.oldChartsVisible = [...this.chartsVisible]; this.sortChartsAvailable(); }, sortChartsAvailable() { const visible = this.chartsVisible; const visibleSet = new Set(this.chartsVisible); const fullList = Object.entries(charts).map(([key, def]) => ({ key, title: def?.title || key, isCustom: key.startsWith('custom_') })); this.chartsAvailable = [ ...visible.map(k => fullList.find(c => c.key === k)).filter(Boolean), ...fullList.filter(c => !visibleSet.has(c.key)) ]; }, async cheackChartChange() { this.sortChartsAvailable(); const newVisible = [...this.chartsVisible]; const oldVisible = this.oldChartsVisible; const removed = oldVisible.filter(k => !newVisible.includes(k)); for (const chart of removed) disposeChart(chart); const added = newVisible.filter(k => !oldVisible.includes(k)); for (const chart of added) await renderChart(chart); this.oldChartsVisible = newVisible; }, removeCustomChart(name) { const cfg = DmstatStorage.getConfig(); delete cfg.customCharts?.[name]; DmstatStorage.setConfig(cfg); const idx = this.chartsVisible.indexOf(name); if (idx >= 0) this.chartsVisible.splice(idx, 1); disposeChart(name); delete charts[name]; this.open(); // 重新加载配置 ELEMENT_PLUS.ElMessage.success(`已删除图表 ${name}`); }, addStorageChart(chartName, chartCode) { const custom = DmstatStorage.get('customCharts', {}); custom[chartName] = chartCode; DmstatStorage.set('customCharts', custom); }, isCostomAdded(name) { const key = 'custom_' + name; return chartConfig.chartsAvailable.some(c => c.key === key); }, addChartCode(code) { const chartDef = eval('(' + code + ')'); const chartName = 'custom_' + (chartDef.name.replace(/\s+/g, '_') || Date.now()); if (charts[chartName]) return ELEMENT_PLUS.ElMessage.warning('图表已存在'); chartDef.title = chartDef.title || `🧩 ${chartName}`; this.addChartDef(chartName, chartDef); this.addStorageChart(chartName, code); this.open(); ELEMENT_PLUS.ElMessage.success(`已添加图表 ${chartDef.title}`); }, addChartDef(chartName, chartDef, visible = true) { if (!chartName || typeof chartDef !== 'object') { console.warn('chartName 必须为字符串,chartDef 必须为对象'); return; } chartDef.ctx = { ELEMENT_PLUS, ECHARTS, chartsActions, displayedDanmakus, danmakuCount, danmakuList, videoData, registerChartAction, formatProgress, formatTime } registerChartAction(chartName, chartDef); charts[chartName] = { instance: null, ...chartDef }; if (visible && !chartConfig.chartsVisible.includes(chartName)) { chartConfig.chartsVisible.push(chartName); } nextTick(() => { renderChart(chartName); }); }, addCustomChart() { try { this.addChartCode(this.newChartCode) this.customInputVisible = false; } catch (e) { console.error(e); ELEMENT_PLUS.ElMessage.error('图表代码错误'); } }, async loadRemoteList() { try { const url = `https://cdn.jsdelivr.net/gh/ZBpine/bili-danmaku-statistic/docs/chart-list.json?t=${Date.now()}`; const res = await fetch(url); this.remoteChartList = await res.json(); } catch (e) { console.error(e); } }, async importRemoteChart(meta) { const { name, url, title } = meta; if (!name || !url || charts[name]) return; try { loading.value = true; await nextTick(); const res = await fetch(url); const code = await res.text(); this.addChartCode(code); } catch (e) { console.error(e); ELEMENT_PLUS.ElMessage.error('加载失败'); } finally { loading.value = false; } } }); watch(() => chartConfig.chartsVisible, (newVal, oldVal) => { DmstatStorage.set('chartsVisible', chartConfig.chartsVisible); }, { deep: true }); function registerChartAction(chartName, chartDef) { if (!Array.isArray(chartDef.actions)) return; chartDef.actions.forEach(({ key, icon, title, method }) => { if (!key || !method) return; chartsActions[`${chartName}:${key}`] = { icon, title, apply: chart => chart === chartName, handler: async chart => { try { await charts[chart][method](); } catch (e) { console.error(`[chartsActions] ${chart}.${method} 执行失败`, e); } } }; }); } 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 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 downloadData() { const data = { bvid: dataParam.bvid, p: dataParam.p, epid: dataParam.epid, cid: dataParam.cid, videoData, episodeData: dataParam.episodeData, danmakuData: danmakuList.original, fetchtime: dataParam.fetchtime } let bTitle = 'Bilibili'; if (data.bvid) bTitle = data.bvid; else if (data.epid) bTitle = 'ep' + data.epid; const filename = `${bTitle}.json`; const jsonString = JSON.stringify(data, null, 2); // null, 2 用于格式化 JSON,使其更易读 // 创建一个包含 JSON 数据的 Blob 对象 const blob = new Blob([jsonString], { type: 'application/json' }); // 创建一个临时的 URL 对象 const url = URL.createObjectURL(blob); // 创建一个隐藏的 元素用于触发下载 const a = document.createElement('a'); a.href = url; a.download = filename; // 设置下载的文件名 document.body.appendChild(a); a.click(); // 模拟点击触发下载 // 移除临时的 元素和 URL 对象 document.body.removeChild(a); URL.revokeObjectURL(url); console.log(`已触发下载,文件名为: ${filename}`); } 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.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 queryMidFromHash(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) => { if ((err !== 'cancel')) console.error(err); // 用户点击了取消,只复制midHash navigator.clipboard.writeText(midHash).then(() => { ELEMENT_PLUS.ElMessage.success('midHash已复制到剪贴板'); }).catch(() => { ELEMENT_PLUS.ElMessage.error('复制失败'); }); }); } 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; } charts.user.locateInChart(row.midHash); } async 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) => { charts[chart].onClick({ params, data: danmakuList.current, applySubFilter: (subFilt) => { const list = typeof subFilt.filterFn === 'function' ? subFilt.filterFn(danmakuList.current) : danmakuList.current; return updateDispDanmakus(false, list, { chart, ...subFilt }); }, ELEMENT_PLUS }); }); } } try { await charts[chart].render(danmakuList.current); } catch (err) { console.error(`图表${chart}渲染错误`, err); ELEMENT_PLUS.ElMessage.error(`图表${chart}渲染错误`); } await nextTick(); } function disposeChart(chart) { if (charts[chart].instance && charts[chart].instance.dispose) { charts[chart].instance.dispose(); charts[chart].instance = null; } } async function updateDispDanmakus(ifchart = false, data = danmakuList.current, subFilt = {}) { loading.value = true; await nextTick(); await new Promise(resolve => setTimeout(resolve, 10)); //等待v-loading渲染 try { if (typeof panelInfo.value?.updateCallback === 'function') { panelInfo.value.updateCallback(danmakuList.current); } displayedDanmakus.value = data; currentSubFilt.value = subFilt; danmakuCount.value.filtered = danmakuList.current.length; if (ifchart) { for (const chart of chartConfig.chartsVisible) { await renderChart(chart); } } await nextTick(); } catch (err) { console.error(err); ELEMENT_PLUS.ElMessage.error('数据显示错误'); } finally { loading.value = false; } } async function applyActiveSubFilters() { try { let filtered = danmakuList.filtered; const activeFilters = subFiltHistory.value.filter(f => f.enabled && typeof f.filterFn === 'function'); for (const filt of activeFilters) { filtered = filt.filterFn(filtered); } danmakuList.current = filtered; await updateDispDanmakus(true); } catch (e) { console.error(e); ELEMENT_PLUS.ElMessage.error('子筛选应用失败'); } } async function commitSubFilter() { if (Object.keys(currentSubFilt.value).length) { subFiltHistory.value.push({ ...currentSubFilt.value, enabled: true }); } await applyActiveSubFilters(); } async function clearSubFilter() { if (currentSubFilt.value.chart) { const chart = currentSubFilt.value.chart; if (typeof charts[chart]?.clearSubFilt === 'function') { charts[chart].clearSubFilt(); } } await updateDispDanmakus(); } async function applyFilter() { try { subFiltHistory.value = []; const regex = new RegExp(filterText.value, 'i'); danmakuList.filtered = danmakuList.original.filter(d => excludeFilter.value ? !regex.test(d.content) : regex.test(d.content) ); danmakuList.current = [...danmakuList.filtered]; currentFilt.value = regex; await updateDispDanmakus(true); } catch (e) { console.warn(e); alert('无效正则表达式'); } } async function resetFilter() { subFiltHistory.value = []; danmakuList.filtered = [...danmakuList.original]; danmakuList.current = [...danmakuList.filtered]; 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, { 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, } }); } } videoData.fetchtime = dataParam?.fetchtime || Math.floor(Date.now() / 1000); if (videoData?.pic?.startsWith('http:')) { videoData.pic = videoData.pic.replace(/^http:/, 'https:'); } if (videoData?.owner?.face?.startsWith('http:')) { videoData.owner.face = videoData.owner.face.replace(/^http:/, 'https:'); } if (videoData.pages) { if (!isNaN(dataParam.p) && videoData.pages[dataParam.p - 1]) { videoData.page_cur = videoData.pages[dataParam.p - 1]; videoData.duration = videoData.page_cur.duration; } else if (videoData.pages[0]) { videoData.duration = videoData.pages[0].duration; } } danmakuList.original = [...dataParam.danmakuData].sort((a, b) => a.progress - b.progress); danmakuList.filtered = [...danmakuList.original]; danmakuList.current = [...danmakuList.filtered]; danmakuCount.value.origin = danmakuList.original.length; for (const [chartName, chartDef] of Object.entries(charts)) { registerChartAction(chartName, chartDef); } window.addCustomChart = function (chartName, chartDef) { chartConfig.addChartDef(chartName, chartDef); console.log(`✅ 已添加图表 "${chartDef.title || chartName}"`); }; const customCharts = DmstatStorage.get('customCharts', {}); for (const [name, code] of Object.entries(customCharts)) { try { const def = eval('(' + code + ')'); chartConfig.addChartDef(name, def, false); } catch (e) { console.warn(`无法加载图表 ${name}`, e); } } chartConfig.chartsVisible = DmstatStorage.get('chartsVisible', Object.keys(charts)); chartConfig.loadRemoteList(); await updateDispDanmakus(true); }); return { h, displayedDanmakus, excludeFilter, filterText, applyFilter, resetFilter, videoData, danmakuCount, currentFilt, currentSubFilt, subFiltHistory, loading, isTableVisible, isTableAutoH, scrollToTime, panelInfo, chartsActions, chartHover, chartConfig, clearSubFilter, commitSubFilter, applyActiveSubFilters, handleRowClick, formatTime, shareImage, downloadData }; }, template: `

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

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




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

弹幕列表
{{ isTableVisible ? '▲ 收起' : '▼ 展开' }}
筛选 取消筛选
{{ item.title }} 可选图表 🌐 获取列表 自定义图表 {{ chartConfig.customInputVisible ? '收起' : '➕ 添加图表' }}
✅ 添加
` }); app.use(ELEMENT_PLUS); app.mount('#danmaku-app'); } // iframe里初始化用户面板应用 async function initUserIframeApp(iframe, userData) { const doc = iframe.contentDocument; const win = iframe.contentWindow; const loader = new ResourceLoader(doc); loader.addCss('https://cdn.jsdelivr.net/npm/element-plus/dist/index.css'); await loader.addScript('https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js'); await loader.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'); } // 插入按钮 function insertButton(isUserPage) { const btn = document.createElement('div'); btn.id = 'danmaku-stat-btn'; btn.innerHTML = ` ${isUserPage ? '用户信息' : '弹幕统计'}
`; 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(async (iframe) => { if (isUserPage) { const mid = location.href.match(/\/(\d+)/)?.[1]; const userData = await dmUtils.getUserCardData(mid); return initUserIframeApp(iframe, userData); } else { await dmUtils.fetchAllData(location.href); return initIframeApp(iframe, dmUtils, { type: 0, newPanel: function (type) { if (type == 0) { openPanelInNewTab(); dmUtils.logTag('[主页面] 新建子页面'); } } }); } }); }; document.body.appendChild(btn); } // 打开iframe弹幕统计面板 function openPanel(initFn) { if (document.getElementById('danmaku-stat-iframe')) { console.warn('统计面板已打开'); return; } // 创建蒙层 const overlay = document.createElement('div'); overlay.id = 'danmaku-stat-overlay'; Object.assign(overlay.style, { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.5)', 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'; Object.assign(iframe.style, { position: 'fixed', top: '15%', left: '15%', width: '70%', height: '70%', backgroundColor: '#fff', zIndex: '9999', padding: '20px', overflow: 'hidden', borderRadius: '8px', boxShadow: '0 0 10px rgba(0, 0, 0, 0.5)' }); iframe.onload = async () => { try { if (typeof initFn === 'function') { await initFn(iframe); } else { console.warn('initFn 未传入或不是函数'); } } catch (err) { console.error('初始化统计面板失败:', err); alert('初始化失败:' + err.message); } }; document.body.appendChild(iframe); } function generatePanelBlob(panelInfoText) { 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' }); return URL.createObjectURL(blob); } // 打开新标签页弹幕统计面板 function openPanelInNewTab() { const blobUrl = generatePanelBlob(`{ type: 1, newPanel: function (type) { if (type == 1) { if (window.opener) { dmUtils.logTag('[子页面] 请求保存页面'); window.opener.postMessage({ type: 'DMSTATS_REQUEST_SAFE' }, '*'); } } } }`); 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 blobUrl = generatePanelBlob(`{ type: 2, newPanel: function (type) { dmUtils.logTag('未定义操作'); } }`); 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); } const urlOfUtils = 'https://cdn.jsdelivr.net/gh/ZBpine/bili-danmaku-statistic/docs/BiliDanmakuUtils.js'; const urlOfConverter = 'https://cdn.jsdelivr.net/gh/ZBpine/bili-danmaku-statistic/docs/BiliMidHashConverter.js'; const { BiliDanmakuUtils } = await import(urlOfUtils); const { BiliMidHashConverter } = await import(urlOfConverter); const dmUtils = new BiliDanmakuUtils(); dmUtils.logStyle.tag = 'Danmaku Statistic'; // 监听新标签页消息 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(); } }); if (location.hostname.includes('space.bilibili.com')) { insertButton(true); } else if (location.hostname.includes('www.bilibili.com')) { insertButton(false); } })();