// ==UserScript==
// @name bilibili 视频弹幕统计|下载|查询发送者
// @namespace https://github.com/ZBpine/bili-danmaku-statistic
// @version 1.6.3
// @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 } = 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 originDanmakuCount = ref(0);
const currentFilt = ref('');
const currentSubFilt = ref({});
const danmakuCount = ref({ user: 0, dm: 0 });
const videoData = ref(dataParam.videoData || {});
const isTableVisible = ref(true);
const isTableAutoH = ref(false);
const loading = ref(true);
const isExpandedUserChart = ref(false);
const panelInfo = ref(panelInfoParam);
const visibleCharts = ref(['user', 'wordcloud', 'density', 'date', 'hour']);
const chartHover = ref(null);
const charts = {
user: null,
wordcloud: null,
density: null,
date: null,
hour: null
};
let manager = null;
class DanmakuManager {
constructor(danmakuList) {
this.original = [...danmakuList].sort((a, b) => a.progress - b.progress);
this.filtered = [...this.original]; // 保持同步顺序
}
reset() {
this.filtered = [...this.original];
}
filter(regex) {
this.filtered = this.original.filter(d => regex.test(d.content));
}
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);
}
}
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() {
if (!currentSubFilt.value.user) return;
ELEMENT_PLUS.ElMessageBox.confirm(
`是否尝试反查用户ID?
可能需要一段时间,且10位数以上ID容易查错
`, '提示', { dangerouslyUseHTMLString: true, confirmButtonText: '是', cancelButtonText: '否', type: 'warning', } ).then(() => { // 开始反查用户ID var result = converter.hashToMid(currentSubFilt.value.user); 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(currentSubFilt.value.user).then(() => { ELEMENT_PLUS.ElMessage.success('midHash已复制到剪贴板'); }).catch(() => { ELEMENT_PLUS.ElMessage.error('复制失败'); }); }); } 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); // 销毁 ECharts 实例 const inst = charts[chart]; if (inst && inst.dispose) { inst.dispose(); charts[chart] = null; } } } function renderUserChart(stats) { const el = doc.getElementById('chart-user'); if (!el) return; if (!charts.user) { el.style.height = isExpandedUserChart.value ? '100%' : '50%'; charts.user = ECHARTS.init(el); charts.user.on('click', async (params) => { const selected = params.name; await updateDispDanmakus( manager.filtered.filter(d => d.midHash === selected), { user: selected } ); }); // 点击标题切换展开状态 charts.user.getZr().on('click', function (params) { if (params.offsetY >= 0 && params.offsetY <= 40) { isExpandedUserChart.value = !isExpandedUserChart.value; if (charts.user) { charts.user.dispose(); charts.user = null; } renderUserChart(stats); // 重新绘制 } }); } const userNames = stats.map(item => item.user); const counts = stats.map(item => item.count); const maxCount = Math.max(...counts); const sc = isExpandedUserChart.value ? 20 : 8; charts.user.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 >= sc ? sc - 1 : userNames.length, width: 20 } ], series: [{ type: 'bar', data: counts, label: { show: true, position: 'right', // 在条形右边显示 formatter: '{c}', // 显示数据本身 fontSize: 12 } }] }); } function renderWordCloud(data) { const el = doc.getElementById('chart-wordcloud'); if (!el) return; if (!charts.wordcloud) { charts.wordcloud = ECHARTS.init(el); charts.wordcloud.on('click', async (params) => { const keyword = params.name; const regex = new RegExp(keyword, 'i'); await updateDispDanmakus( manager.filtered.filter(d => regex.test(d.content)), { wordcloud: keyword } ); }); } else charts.wordcloud.clear(); 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 })); charts.wordcloud.setOption({ title: { text: '弹幕词云' }, tooltip: {}, series: [{ type: 'wordCloud', gridSize: 8, sizeRange: [12, 40], rotationRange: [0, 0], shape: 'circle', data: list }] }); } function renderDensityChart(data) { const el = doc.getElementById('chart-density'); if (!el) return; if (!charts.density) { charts.density = ECHARTS.init(el); charts.density.on('click', function (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); } }); }); } 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) }); } charts.density.setOption({ title: { text: '弹幕密度分布' }, tooltip: { trigger: 'axis', formatter: function (params) { const sec = params[0].value[0]; return `时间段:${formatProgress(sec * 1000)}
EPID:
BVID:
UP主:
发布时间:
截止
总弹幕数:
筛选:
共有 {{ danmakuCount.user }} 位不同用户发送了 {{ danmakuCount.dm }} 条弹幕
用户
MID:
认证:
勋章: