stats.nailv.live - Bilibili直播数据统计

In case I don't see ya, good afternoon, good evening and good night.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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/[email protected]/dist/chart.umd.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/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%;
    }
`);