// ==UserScript==
// @name stats.nailv.live - Bilibili直播数据统计
// @namespace http://tampermonkey.net/
// @license MIT
// @version 0.1.6
// @description In case I don't see ya, good afternoon, good evening and good night.
// @author NailvCoronation
// @match https://live.bilibili.com/*
// @icon https://nailv.live/static/images/favicon.ico
// @require https://cdn.jsdelivr.net/npm/chart.js@4.2.1/dist/chart.umd.min.js
// @require https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@2.2.1/dist/chartjs-plugin-annotation.min.js
// @require https://code.jquery.com/jquery-3.6.0.min.js
// @grant GM_addStyle
// @run-at document-idle
// @noframes
// ==/UserScript==
const channelApi = 'https://api.ukamnads.icu/api/v2/channel?uid='
const streamApi = 'https://api.ukamnads.icu/api/v2/live?includeExtra=true&liveId='
const nMinute = 10 // TODO: custom interval
const roomId = document.URL.split('/').pop().split('?')[0]
var uid = 0
var charts = []
const chartTitles = ['弹幕', '活跃用户', '高能', '营收', '互动/高能比例', '新观众']
var streamId = 0
var lastTenStreams = []
var oldViewers = new Set()
var windowActivated = false
function sleep(sec) {
return new Promise(resolve => setTimeout(resolve, sec * 1000));
}
async function getLiveStatus(roomId) {
if (roomId === '' || isNaN(Number(roomId)))
return false
try {
let resp = await fetch(`https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom?room_id=${roomId}`)
resp = await resp.json()
if (resp.data.room_info.live_status === 0) {
return false
}
uid = resp.data.room_info.uid
return true
} catch (e) {
return false
}
}
function getNMinuteIntervals(startTimestamp, endTimestamp) {
const nMinutes = nMinute * 60 * 1000;
const intervals = [];
const start = new Date(startTimestamp);
const end = new Date(endTimestamp);
for (let time = start; time < end; time.setTime(time.getTime() + nMinutes)) {
intervals.push(time.getTime());
}
return intervals;
}
function findClosestTimestampBefore(timestamp, timestamps) {
const idx = timestamps.findIndex((t) => t >= timestamp);
if (idx === 0) {
return timestamps[0];
}
if (idx === -1) {
return timestamps[timestamps.length - 1]
}
return timestamps[idx - 1];
}
function getDataset(stream) {
const actions = stream.danmakus
const nMinuteIntervals = getNMinuteIntervals(actions[0].sendDate, actions[actions.length - 1].sendDate)
let danmakuNum = {}
let activeViewers = {}
let income = {}
let newViewers = {}
let onlineNum = {}
let viewers = new Set()
nMinuteIntervals.forEach(interval => {
danmakuNum[interval] = 0
activeViewers[interval] = new Set()
income[interval] = 0
newViewers[interval] = 0
onlineNum[interval] = []
})
actions.filter(action => [0, 1, 2, 3].includes(action.type))
.forEach(action => {
let interval = findClosestTimestampBefore(action.sendDate, nMinuteIntervals)
if (action.type === 0)
danmakuNum[interval] += 1
else
income[interval] += action.price
activeViewers[interval].add(action.uId)
if (!viewers.has(action.uId) && !oldViewers.has(action.uId))
newViewers[interval] += 1
viewers.add(action.uId)
})
Object.entries(stream.live.extra.onlineRank).forEach(kv => {
const time = kv[0]
const online = kv[1]
const interval = findClosestTimestampBefore(time, nMinuteIntervals)
onlineNum[interval].push(online)
})
return [
{ // danmakus
labels: nMinuteIntervals.map(ts => (new Date(ts)).toLocaleTimeString('zh-CN', { timeStyle: 'short' })),
datasets: [{
data: Object.values(danmakuNum),
}]
},
{ // activeViewer
labels: nMinuteIntervals.map(ts => (new Date(ts)).toLocaleTimeString('zh-CN', { timeStyle: 'short' })),
datasets: [{
data: Object.values(activeViewers).map(s => s.size),
}]
},
{ // online
labels: nMinuteIntervals.map(ts => (new Date(ts)).toLocaleTimeString('zh-CN', { timeStyle: 'short' })),
datasets: [{
data: Object.values(onlineNum) // array of arrays
.map(arr => (arr.reduce((sum, x) => sum + x, 0) / arr.length).toFixed(1)),
}]
},
{ // income
labels: nMinuteIntervals.map(ts => (new Date(ts)).toLocaleTimeString('zh-CN', { timeStyle: 'short' })),
datasets: [{
data: Object.values(income),
}]
},
{ // viewer/online ratio
labels: nMinuteIntervals.map(ts => (new Date(ts)).toLocaleTimeString('zh-CN', { timeStyle: 'short' })),
datasets: [{
data: Object.keys(onlineNum).map((ts, idx) => {
let online = onlineNum[ts].reduce((sum, x) => sum + x, 0) / onlineNum[ts].length
let viewer = activeViewers[ts].size
return (viewer / online).toFixed(3)
}).map((ratio, idx) => idx === 0? NaN: ratio),
}]
},
{ // newViewer
labels: nMinuteIntervals.map(ts => (new Date(ts)).toLocaleTimeString('zh-CN', { timeStyle: 'short' })),
datasets: [{
data: Object.values(newViewers),
}]
},
]
}
async function updateCharts() {
if (windowActivated == false) {
await sleep(1)
await updateCharts()
return
}
if (!await getLiveStatus(roomId))
return
let resp
let data
try {
resp = await fetch(streamApi + streamId)
resp = await resp.json()
if (resp.code !== 200)
throw new Error(resp.message)
data = resp.data.data
} catch (e) {
$('#floating-window-title').html(`${new Date().toLocaleTimeString('zh-CN')}获取Danmakus数据失败`)
console.log(e)
await sleep(30)
await updateCharts()
return
}
$('#floating-window-title').html(`本场直播数据`)
data.danmakus.sort((a, b) => a.sendDate - b.sendDate)
if (data.danmakus.length === 0) {
await sleep(10)
await updateCharts()
return
}
const datasets = getDataset(data)
charts.forEach((chart, idx) => {
chart.data = datasets[idx]
chart.update('none')
})
await sleep(30)
await updateCharts()
}
async function initChart() {
while (true) {
try {
let resp = await fetch(channelApi + uid)
resp = await resp.json()
if (resp.code !== 200)
throw new Error(resp.message)
const streams = resp.data.lives.map(s => s.liveId)
if (resp.data.channel.livingInfo) {
// 👀
streamId = resp.data.channel.livingInfo.liveId
const lastTenSids = streams.slice(1, 11)
lastTenStreams = []
await initLastTenStreams(lastTenSids)
addAnnotations()
return
}
} catch (e) {
console.log(e)
}
await sleep(10)
}
}
function addAnnotations() {
// add annotation for danmakus, activeViewer and onlineNum
// namely, idx = 0, 1 and 2
const baseOptions = {
type: 'line',
drawTime: 'beforeDatasetsDraw',
borderColor: '#CE3B29',
borderWidth: 2,
label: {
display: true,
content: `过去${lastTenStreams.length}场直播均值`,
backgroundColor: 'transparent',
color: 'black',
textStrokeColor: 'white',
textStrokeWidth: 3,
}
}
const avgDanmakuNum = lastTenStreams.reduce((sum, s) => sum + s.danmakuCount, 0)
const minutes = lastTenStreams.reduce((sum, s) => sum + (s.endTime - s.startTime) / 1000 / 60, 0)
const danmakuOptions = {
...baseOptions,
display: isNaN(avgDanmakuNum / minutes * nMinute) ? false : true,
yMin: avgDanmakuNum / minutes * nMinute,
yMax: avgDanmakuNum / minutes * nMinute,
}
charts[0].options.plugins.annotation = { annotations: { options: danmakuOptions } }
charts[0].update()
let viewerPerNMinutes = []
lastTenStreams.forEach(stream => {
for (let idx = 0; idx < stream.viewerPerMinute.length; idx+=nMinute) {
let data = stream.viewerPerMinute.slice(idx, idx + nMinute).map(d => d.uids)
let temp = new Set()
data.forEach(d => d.forEach(x => temp.add(x)))
viewerPerNMinutes.push(temp)
}
})
const viewerNum = viewerPerNMinutes.reduce((sum, x) => sum + x.size, 0) / viewerPerNMinutes.length
const viewerNumOptions = {
...baseOptions,
display: isNaN(viewerNum) ? false : true,
yMin: viewerNum,
yMax: viewerNum,
}
charts[1].options.plugins.annotation = { annotations: { options: viewerNumOptions } }
charts[1].update()
const onlineNum = lastTenStreams
.filter(s => !isNaN(s.onlineNum))
.reduce((sum, s) => sum + s.onlineNum, 0) / lastTenStreams.filter(s => !isNaN(s.onlineNum)).length
const onlineNumOptions = {
...baseOptions,
display: isNaN(onlineNum) ? false : true,
yMin: onlineNum,
yMax: onlineNum,
}
charts[2].options.plugins.annotation = { annotations: { options: onlineNumOptions } }
charts[2].update()
}
async function initLastTenStreams(sids) {
if (sids.length === 0)
return
let promises = []
sids.forEach(sid => {
let promise = fetch(streamApi + sid)
.then(resp => resp.json())
.then(stream => {
if (stream.code !== 200)
throw new Error(stream.message)
let danmakus = stream.data.data.danmakus
.filter(action => [0, 1, 2, 3].includes(action.type))
.sort((a, b) => a.sendDate - b.sendDate)
danmakus.forEach(d => oldViewers.add(d.uId))
let viewerPerMinute = danmakus.length > 0? [{ time: danmakus[0].sendDate, uids: new Set() }]: []
danmakus.forEach(danmaku => {
while (danmaku.sendDate - viewerPerMinute[viewerPerMinute.length - 1].time > 1000 * 60) {
viewerPerMinute.push({ time: viewerPerMinute[viewerPerMinute.length - 1].time + 1000 * 60, uids: new Set() })
}
viewerPerMinute[viewerPerMinute.length - 1].uids.add(danmaku.uId)
})
stream = stream.data.data.live
lastTenStreams.push({
id: stream.liveId,
startTime: stream.startDate,
endTime: stream.stopDate,
danmakuCount: stream.danmakusCount,
income: stream.totalIncome,
viewerCount: stream.interactionCount,
onlineNum: (Object.values(stream.extra.onlineRank).slice(Object.values(stream.extra.onlineRank).length * 0.1).reduce((sum, x) => sum + x, 0) / (Object.values(stream.extra.onlineRank).length * 0.9)),
viewerPerMinute: viewerPerMinute,
})
sids.splice(sids.indexOf(sid), 1)
})
.catch(e => {
console.log(e)
})
promises.push(promise)
})
await Promise.all(promises)
if (sids.length !== 0)
await sleep(5)
await initLastTenStreams(sids)
}
(async () => {
'use strict';
let $icon = $('<img>', {
id: 'floating-window-icon',
src: 'https://nailv.live/static/images/favicon.ico',
alt: 'Expand window'
}).appendTo('body');
$icon.on('click', function () {
windowActivated = !windowActivated
$window.toggle();
});
let $window = $('<div>', {
id: 'floating-window'
}).appendTo('body');
let title = $('<h4>', {
id: 'floating-window-title'
}).appendTo('#floating-window')
title.html('本场直播数据')
// Chart canvas
const canvasDiv = $('<div>', {
id: 'canvas-div'
}).appendTo('#floating-window')
let ctxes = []
ctxes.push($('<canvas>', {
class: 'chart-canvas'
}).appendTo('#canvas-div')[0].getContext('2d'))
ctxes.push($('<canvas>', {
class: 'chart-canvas'
}).appendTo('#canvas-div')[0].getContext('2d'))
ctxes.push($('<canvas>', {
class: 'chart-canvas'
}).appendTo('#canvas-div')[0].getContext('2d'))
ctxes.push($('<canvas>', {
class: 'chart-canvas'
}).appendTo('#canvas-div')[0].getContext('2d'))
ctxes.push($('<canvas>', {
class: 'chart-canvas'
}).appendTo('#canvas-div')[0].getContext('2d'))
ctxes.push($('<canvas>', {
class: 'chart-canvas'
}).appendTo('#canvas-div')[0].getContext('2d'))
ctxes.forEach((ctx, idx) => {
charts.push(new Chart(ctx, {
type: 'line',
options: {
borderColor: '#648140',
color: '#90EE90',
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: '#E2E6AF',
bodyColor: '#000000',
titleColor: '#000000',
displayColors: false,
footerFont: { size: 10 },
callbacks: {
label: (context) => chartTitles[idx] + ':' + context.parsed.y,
title: (context) => context[0].label + '~' + new Date((new Date('2022/09/17 ' + context[0].label)).getTime() + (nMinute - 1) * 60 * 1000).toLocaleTimeString('zh-CN', { timeStyle: 'short' })
}
},
title: {
display: true,
text: chartTitles[idx]
},
},
scales: {
y: {
beginAtZero: true
}
}
}
}))
})
$('<p>', { id: 'ad' }).appendTo('#floating-window')
.html('更多数据,请访问<a href="https://stats.nailv.live" style="text-decoration: none;" target="_blank">stats.nailv.live</a>')
while (true) {
if (await getLiveStatus(roomId)) {
title.html('本场直播数据')
canvasDiv.show()
await initChart()
await updateCharts() //recursive function, return when current stream ends
} else {
title.html('未在直播')
canvasDiv.hide()
await sleep(15)
}
}
})();
GM_addStyle(`
#floating-window-icon {
position: fixed;
top: 10%;
right: 3%;
transform: translate(50%, -50%);
width: 40px;
height: 40px;
background-color: #ccc;
border-radius: 50%;
cursor: pointer;
z-index: 10000;
}
#floating-window {
position: fixed;
top: 10%;
right: 3%;
width: 20%;
max-height: 60%;
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
z-index: 9999;
display: none;
overflow-y: auto;
}
#floating-window-title {
text-align: center;
}
#ad {
text-align: center;
bottom: 0;
}
.chart-canvas {
width: 100%;
}
`);