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

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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%;
    }
`);