Vstats Kit

Show median peak, true average, add change date arrows on month stats page, change hololive channel.

当前为 2024-06-13 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Vstats Kit
// @namespace    http://tampermonkey.net/
// @version      1.42
// @description  Show median peak, true average, add change date arrows on month stats page, change hololive channel.
// @author       Irushia
// @license      MIT
// @match        https://www.vstats.jp/channels/1:*/*
// @exclude      https://www.vstats.jp/channels/1:*/overall
// @icon         https://www.google.com/s2/favicons?sz=64&domain=vstats.jp
// @run-at       document-end
// @grant        GM_registerMenuCommand
// ==/UserScript==

(() => {
    class Stats {
        constructor(hourswatched, hourstream, median, vidNum, liveNum, preNum) {
            this.hourswatched = hourswatched; // string
            this.hourstream = hourstream; // string
            this.median = median; // int
            this.average = this.avgCalc(); // int
            this.vidNum = vidNum; // int
            this.liveNum = liveNum; // int
            this.preNum = preNum; // int
        }
        avgCalc() {
            const hw = parseInt(this.hourswatched.replace(/,/g, ""));
            const hs = toFloat(this.hourstream);
            return Math.round(hw / hs);
        }
        toString() {
            return `動画:${this.vidNum}本\nライブ配信:${this.liveNum}本\n
            同接中央値:${formatNumberWithCommas(this.median)}\n
            同接平均値:${formatNumberWithCommas(this.average)}\n
            総視聴時間:${this.hourswatched}\n配信時間:${this.hourstream}\n
            プレミア公開:${this.preNum}本`;
        }
    }

    const HololiveChs = [
        { id: "UCFTLzh12_nrtzqBPsTCqenA", name: "Aki" },
        { id: "UCyl1z3jo3XHR1riLFKG5UAg", name: "Amelia" },
        { id: "UC727SQYUvx5pDDGQpTICNWg", name: "Anya" },
        { id: "UCMGfV7TVTmHhEErVJg1oHBQ", name: "Ao" },
        { id: "UC1opHUrw8rvnsadT-iGp7Cg", name: "Aqua" },
        { id: "UC7fk0CB07ly8oSl0aqKkqFg", name: "Ayame" },
        { id: "UC0TXe_LYZ4scaW2XMyi5_kw", name: "AZKi" },
        { id: "UCgmPnx-EEeOrZSg5Tiw7ZRQ", name: "Baelz" },
        { id: "UC9p_lqQ0FEDz327Vgf5JwqA", name: "Bijou" },
        { id: "UCUKD-uaobj9jiqB-VXt71mA", name: "Botan" },
        { id: "UCL_qhgtOy0dy1Agp8vkySQg", name: "Calliope" },
        { id: "UCIBY1ollUsauvVi4hW4cumw", name: "Chloe" },
        { id: "UC1suqwovbL1kzsoaZgFZLKg", name: "Choco" },
        { id: "UCO_aKKYxn4tvrqPjcTzZ6EQ", name: "Fauna" },
        { id: "UCvInZx9h3jC2JzsIzoOebWg", name: "Flare" },
        { id: "UCdn5BQ06XqgXoAxIhbqw5Rg", name: "Fubuki" },
        { id: "UCt9H_RpQzhxzlyBxFqrdHqA", name: "Fuwamoco" },
        { id: "UCoSrY_IQQVpmIRZ9Xf-y93g", name: "Gura" },
        { id: "UC1CfXB_kRs3C-zaeTG3oGyg", name: "Haato" },
        { id: "UC1iA6_NT4mtAcIII6ygrvCw", name: "Hajime" },
        { id: "UCMwGHR0BTZuLsmjY_NT5Pwg", name: "Ina'nis" },
        { id: "UCAoy6rzhSf4ydcYjJw3WoVg", name: "Iofiteen" },
        { id: "UC_vMYWcDjmfdpH6r4TTn1MQ", name: "Iroha" },
        { id: "UC8rcEBzJSleTkf_-agPM20g", name: "IRyS" },
        { id: "UCZLZ8Jjx_RN2CXloOmgTHVg", name: "Kaela" },
        { id: "UCWQtYtq9EOB4-I5P-3fh8lA", name: "Kanade" },
        { id: "UCZlDXzGoo7d44bwdNObFacg", name: "Kanata" },
        { id: "UCHsx4Hqa-1ORjQTh9TYDhww", name: "Kiara" },
        { id: "UCjLEmnpCNeisMxy134KPwWw", name: "Kobo" },
        { id: "UChAnqc_AY5_I3Px5dig3X1Q", name: "Korone" },
        { id: "UC6eWCld0KwmyHFbAqK3V-Rw", name: "Koyori" },
        { id: "UCmbs8T6MWqUHP1tIQvSgKrg", name: "Kronii" },
        { id: "UCFKOVgVbGmX65RxO3EtH3iw", name: "Lamy" },
        { id: "UCENwRMx5Yh42zWpzURebzTw", name: "Laplus" },
        { id: "UCs9_O1tRPMQTHQ-N_L6FU2g", name: "Lui" },
        { id: "UCa9Y57gfeY0Zro_noHRVrnw", name: "Luna" },
        { id: "UCCzUftO8KOVkV4wQG1vkUvg", name: "Marine" },
        { id: "UCQ0UDLQCjY0rmuxCDE38FGg", name: "Matsuri" },
        // {id: "UCD8HOxPs4Xvsm8H0ZxXGiBw", name: "Mel"},
        { id: "UC-hM6YJuNYVAmUWxeIr9FeA", name: "Miko" },
        { id: "UCp-5t9SrOQwXMU7iIjQfARg", name: "Mio" },
        { id: "UCP0BspO_AMEe3aQqqpo89Dg", name: "Moona" },
        { id: "UC3n5uGu18FoCy23ggWWp8tA", name: "Mumei" },
        { id: "UCAWSyEs_Io8MtpY3m-zqILA", name: "Nene" },
        { id: "UC_sFNM0z0MWm9A6WlKPuMMg", name: "Nerissa" },
        { id: "UCdyqAaZDKHXg4Ahi7VENThQ", name: "Noel" },
        { id: "UCvaTdHTWBGv3MKj3KVqJVCw", name: "Okayu" },
        { id: "UCYz_5n-uDuChHtLo7My1HnQ", name: "Ollie" },
        { id: "UC1DCedRgGHBdm81E1llLhOQ", name: "Pekora" },
        { id: "UCK9V2B22uJYu3N7eR_BT9QA", name: "Polka" },
        { id: "UCdXAk5MpyLD8594lm_OvtGQ", name: "Raden" },
        { id: "UChgTyjG-pdNvxxhdsXfHQ5Q", name: "Reine" },
        { id: "UCtyWhCj3AqKh2dXctLkDtng", name: "Ririka" },
        { id: "UCOyYb1c43VlX9rc_lT6NKQw", name: "Risu" },
        { id: "UCDqI2jOz0weumE8s7paEk6g", name: "Roboco" },
        // {id: "UCl_gCybOJRIgOXw6Qb4qJzQ", name: "Rushia"},
        // {id: "UCsUj0dszADCGbF3gNrQEuSQ", name: "Sana"},
        { id: "UCXTpFs_3PqI41qX2d9tL2Rw", name: "Shion" },
        { id: "UCgnfPPb9JI3e9A4cXHnWbyg", name: "Shiori" },
        { id: "UCp6993wxpyDPHUpavwDFqgg", name: "Sora" },
        { id: "UCvzGlP9oQwU--Y0r9id_jnA", name: "Subaru" },
        { id: "UC5CwaMl1eIgY8h02uZw7u8A", name: "Suisei" },
        { id: "UC1uv2Oq6kNxgATlCiez59hw", name: "Towa" },
        { id: "UCqm3BQLlJfvkTsX_hvm0UmA", name: "Watame" },
        { id: "UCTvHWSfBZgtxE4sILOaurIQ", name: "Zeta" },
    ];

    function findEle(ele, title) {
        const value = ele.querySelector(`[title="${title}"]`);
        return value && value.textContent !== "---" ? parseInt(value.textContent.replace(/,/g, ""), 10) : 0;
    }

    function sortList() {
        const divClass = "row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 g-2 g-lg-3";
        const divEle = document.getElementsByClassName(divClass)[0];

        Array.from(divEle.children)
            .sort((a, b) => findEle(b, "最大視聴者数") - findEle(a, "最大視聴者数"))
            .forEach((col) => divEle.appendChild(col));
    }

    function getMedian(arr) {
        const sortedArr = arr.sort((a, b) => a - b);
        const mid = Math.floor(sortedArr.length / 2);
        return sortedArr.length % 2 === 0 ? (sortedArr[mid - 1] + sortedArr[mid]) / 2 : sortedArr[mid];
    }

    function formatNumberWithCommas(num) {
        return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
    }

    function arrIndex(arr, index) {
        if (!arr) return null;
        return arr[index];
    }

    function toInt(str) {
        if (!str || str === "---") return 0;
        return parseInt(str.replace(/,/g, ""), 10);
    }

    function toFloat(str) {
        if (!str || str === "0:00") return 0.0;
        return (parseInt(str.split(":")[0]) + parseInt(str.split(":")[1]) / 60).toFixed(2);
    }

    function addTime(time1, time2) {
        const [h1, m1] = time1.split(":").map(Number);
        const [h2, m2] = time2.split(":").map(Number);
        const totalMinutes = h1 * 60 + m1 + (h2 * 60 + m2);
        const hours = Math.floor(totalMinutes / 60);
        const minutes = totalMinutes % 60;
        return `${hours}:${minutes.toString().padStart(2, "0")}`;
    }

    function editStats() {
        const divClass = "row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 g-2 g-lg-3";
        const divEle = document.getElementsByClassName(divClass)[0];
        const eleList = divEle.children;

        let peakList = [];
        let hourstream = "0:00";

        for (let i = 0; i < eleList.length; i++) {
            const peak = findEle(eleList[i], "最大視聴者数");
            if (peak > 0) {
                peakList.push(peak);
                const hs = eleList[i].querySelector(`[title="放送時間"]`).textContent.trim();
                hourstream = addTime(hourstream, hs);
            }
        }

        if (peakList.length === 0) return;

        const statsEle = document.querySelector("h5");

        const stats = new Stats(
            statsEle.innerHTML.match(/総視聴時間:\s*([\d,]+)/)[1],
            hourstream,
            getMedian(peakList).toFixed(0),
            toInt(arrIndex(statsEle.innerHTML.match(/動画:\s*(\d+)/), 1)),
            peakList.length,
            toInt(arrIndex(statsEle.innerHTML.match(/プレミア公開:\s*(\d+)/), 1))
        );


        statsEle.innerHTML = stats.toString();
    }

    function addDateChangeArrow() {
        const url = new URL(window.location.href);
        const [channel, date] = url.pathname.split("/").slice(-2);
        const [year, month] = date.split("-").map(Number);

        const prevDate = new Date(year, month - 2, 1);
        const nextDate = new Date(year, month, 1);

        const prevMonthUrl = `/channels/${channel}/${prevDate.getFullYear()}-${prevDate.getMonth() + 1}`;
        const nextMonthUrl = `/channels/${channel}/${nextDate.getFullYear()}-${nextDate.getMonth() + 1}`;

        const dateNavElement = document.querySelector("body > main > div.content.mt-3 > div > div:nth-child(1) > div:nth-child(2) > h4");
        dateNavElement.insertAdjacentHTML("afterbegin", `<a href="${prevMonthUrl}" class="link-dark"><i class="fas fa-angle-left" aria-hidden="true"></i></a>`);
        dateNavElement.insertAdjacentHTML("beforeend", `<a href="${nextMonthUrl}" class="link-dark"><i class="fas fa-angle-right" aria-hidden="true"></i></a>`);
    }

    function addChannelChangeArrow() {
        const url = new URL(window.location.href);
        const [channel, date] = url.pathname.split("/").slice(-2);
        const channelId = channel.split(":")[1];

        const index = HololiveChs.findIndex((ch) => ch.id === channelId);
        if (index === -1) return;

        const prevIndex = index === 0 ? HololiveChs.length - 1 : index - 1;
        const nextIndex = index === HololiveChs.length - 1 ? 0 : index + 1;

        const prevHtml = `<a href="/channels/1:${HololiveChs[prevIndex].id}/${date}" class="link-dark"><i class="fas fa-angle-left" aria-hidden="true"></i></a>`;
        const nextHtml = `<a href="/channels/1:${HololiveChs[nextIndex].id}/${date}" class="link-dark"><i class="fas fa-angle-right" aria-hidden="true"></i></a>`;

        const channelNavElement = document.querySelector("body > main > div.content.mt-3 > div > div:nth-child(1) > div.col-12.d-flex.justify-content-start.align-items-center.py-2 > img");
        channelNavElement.insertAdjacentHTML("beforebegin", prevHtml);
        channelNavElement.insertAdjacentHTML("afterend", nextHtml);
    }

    function main() {
        GM_registerMenuCommand("Sort", sortList);
        addDateChangeArrow();
        addChannelChangeArrow();
        editStats();
    }

    main();
})();