// ==UserScript==
// @name bilibili 视频弹幕统计|下载|查询发送者
// @namespace https://github.com/ZBpine/bili-danmaku-statistic
// @version 1.7.1
// @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]{5,}$/, 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: `
EPID:
BVID:
UP主:
发布时间:
截止
总弹幕数:
筛选:
✔
结果:共有 {{ danmakuCount.filtered }} 条弹幕
MID:
认证:
勋章: