// ==UserScript==
// @name bilibili 视频弹幕统计|下载|查询发送者
// @namespace https://github.com/ZBpine/bili-danmaku-statistic
// @version 2.0.2
// @description 获取B站弹幕数据,并生成统计页面。
// @author ZBpine
// @icon https://www.bilibili.com/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 GM_xmlhttpRequest
// @connect api.bilibili.com
// @license MIT
// @run-at document-end
// @downloadURL none
// ==/UserScript==
(async () => {
'use strict';
const consoleColor = {
log: 'background: #01a1d6;',
error: 'background: #d63601;',
warn: 'background: #d6a001;',
}
const console = new Proxy(window.console, {
get(target, prop) {
const original = target[prop];
if (typeof original === 'function' && (prop === 'log' || prop === 'error' || prop === 'warn')) {
return (...args) => original.call(target, '%cDanmaku Statistic', consoleColor[prop] +
' color: white; padding: 2px 6px; border-radius: 3px; font-weight: bold;', ...args);
}
return original;
}
});
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) { return new Promise(resolve => { this.addEl('link', { rel: 'stylesheet', href, onload: resolve }); }); }
addStyle(cssText) { this.addEl('style', { textContent: cssText }); }
}
// iframe里初始化统计面板应用
async function initIframeApp(iframe, dataMgr, panelInfoParam) {
const doc = iframe.contentDocument;
const win = iframe.contentWindow;
// 引入外部库
const loader = new ResourceLoader(doc);
await Promise.all([
loader.addCss('https://cdn.jsdelivr.net/npm/element-plus/dist/index.css'),
loader.addScript('https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js'),
loader.addScript('https://cdn.jsdelivr.net/npm/echarts@5')
]);
await Promise.all([
loader.addScript('https://cdn.jsdelivr.net/npm/element-plus/dist/index.full.min.js'),
loader.addScript('https://cdn.jsdelivr.net/npm/@element-plus/icons-vue/dist/index.iife.min.js'),
loader.addScript('https://cdn.jsdelivr.net/npm/echarts-wordcloud@2/dist/echarts-wordcloud.min.js'),
loader.addScript('https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js'),
loader.addScript('https://cdn.jsdelivr.net/npm/dom-to-image-more@3.5.0/dist/dom-to-image-more.min.js')
]);
const [createVideoInfoPanel, createDanmukuTable] = await Promise.all([
import(statPath + 'docs/VideoInfoPanel.js'),
import(statPath + 'docs/DanmukuTable.js')
]).then(([v, d]) => [v.default, d.default]);
const VideoInfoPanel = createVideoInfoPanel(win.Vue, win.ElementPlus);
const DanmukuTable = createDanmukuTable(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 } = 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('VideoInfoPanel', VideoInfoPanel);
app.component('DanmukuTable', DanmukuTable);
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() : '');
}
});
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 videoInfo = reactive(dataMgr.info || {});
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: 'dm-stat',
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),
filterJudge: 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)),
filterJudge: 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 = videoInfo.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),
filterJudge: 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,
filterJudge: 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))
];
},
saveChartVisable() {
DmstatStorage.set('chartsVisible', this.chartsVisible);
},
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;
this.saveChartVisable();
},
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);
this.saveChartVisable();
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,
videoInfo,
registerChartAction,
formatProgress
}
registerChartAction(chartName, chartDef);
charts[chartName] = { instance: null, ...chartDef };
if (visible && !this.chartsVisible.includes(chartName)) {
this.chartsVisible.push(chartName);
this.saveChartVisable();
}
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 = statPath + 'docs/chart-list.json';
const res = await fetch(url);
this.remoteChartList = await res.json();
} catch (e) {
console.error(e);
}
},
async importRemoteChart(meta) {
const { name, url, title, path } = meta;
if (!name || (!url && path) || charts[name]) return;
try {
loading.value = true;
await nextTick();
let resource = url;
if (path) resource = statPath + path;
const res = await fetch(resource);
const code = await res.text();
this.addChartCode(code);
} catch (e) {
console.error(e);
ELEMENT_PLUS.ElMessage.error('加载失败');
} finally {
loading.value = false;
}
}
});
const dataLoader = reactive({
load: async (getter = async () => { }, desc = null) => {
try {
dataLoader.progress.visible = true;
dataLoader.progress.current = 0;
dataLoader.progress.total = 0;
dataLoader.progress.text = '';
await nextTick();
const added = await getter();
const data = dataMgr.data;
if (!data || !Array.isArray(data.danmakuData)) {
ELEMENT_PLUS.ElMessageBox.alert(
'初始化数据缺失,无法加载弹幕统计面板。请确认主页面传入了有效数据。',
'错误',
{ type: 'error' }
);
data.danmakuData = [];
}
danmakuList.original = [...data.danmakuData].sort((a, b) => a.progress - b.progress);
danmakuCount.value.origin = danmakuList.original.length;
await resetFilter();
if (desc) ELEMENT_PLUS.ElMessage.success(`成功新增${desc}弹幕:${added} 条`);
} catch (e) {
ELEMENT_PLUS.ElMessage.error('载入弹幕错误:' + e.message);
console.error(e);
} finally {
dataLoader.progress.visible = false;
}
},
progress: {
visible: false,
current: 0,
total: 0,
text: ''
},
clear: async () => {
dataMgr.clearDmList();
await dataLoader.load();
ELEMENT_PLUS.ElMessage.success('已清除弹幕列表');
},
loadXml: async () => {
await dataLoader.load(async () => { return await dataMgr.getDanmakuXml(); }, ' XML ');
},
loadPb: async () => {
await dataLoader.load(async () => {
return await dataMgr.getDanmakuPb((current, total, segIndex, count) => {
dataLoader.progress.current = current;
dataLoader.progress.total = total;
dataLoader.progress.text = `弹幕片段:第 ${segIndex} 段(${current}/${total}) ${count} 条弹幕`;
});
}, ' Protobuf ');
},
selectedMonth: '',
disabledMonth(date) {
const pub = videoInfo.pubtime;
if (!pub) return false;
const min = new Date(pub * 1000);
const max = new Date();
return date < new Date(min.getFullYear(), min.getMonth()) ||
date >= new Date(max.getFullYear(), max.getMonth() + 1);
},
loadHis: async () => {
if (!dataLoader.selectedMonth) {
ELEMENT_PLUS.ElMessage.warning('请选择月份');
return;
}
await dataLoader.load(async () => {
return await dataMgr.getDanmakuHisPb(dataLoader.selectedMonth, (current, total, date, count) => {
dataLoader.progress.current = current;
dataLoader.progress.total = total;
dataLoader.progress.text = `历史弹幕:${date}(${current}/${total}) ${count} 条弹幕`;
});
}, ` ${dataLoader.selectedMonth} 历史`);
}
})
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 downloadData() {
let bTitle = dmData.info?.id?.replace(/[\\/:*?"<>|]/g, '_') || 'Bilibili';
const filename = `${bTitle}.json`;
const jsonString = JSON.stringify(dmData.data, null, 4);
// 创建一个包含 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 = `${videoInfo.id}_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) => { let list = []; if (typeof subFilt.filterJudge === 'function') { list = danmakuList.current.filter(d => subFilt.filterJudge(d)) } else { list = danmakuList.current; ELEMENT_PLUS.ElMessage.warning('图表缺少筛选判断方法'); } 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.filterJudge === 'function'); for (const filt of activeFilters) { if (filt.exclude) filtered = filtered.filter(d => !filt.filterJudge(d)); else filtered = filtered.filter(d => filt.filterJudge(d)); } 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, exclude: false }); } 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 () => { 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 dataLoader.load(); }); return { h, displayedDanmakus, excludeFilter, filterText, applyFilter, resetFilter, videoInfo, danmakuCount, currentFilt, currentSubFilt, subFiltHistory, dataLoader, loading, isTableVisible, isTableAutoH, scrollToTime, panelInfo, chartsActions, chartHover, chartConfig, clearSubFilter, commitSubFilter, applyActiveSubFilters, handleRowClick, shareImage, downloadData }; }, template: `
筛选
MID:
认证:
勋章: