Greasy Fork is available in English.

BV2AV + 视频统计

支持 Safari | 干净 URL | 视频统计 | V 家成就

// ==UserScript==
// @name         BV2AV + 视频统计
// @description  支持 Safari | 干净 URL | 视频统计 | V 家成就
// @version      3.1.5
// @license      MIT
// @author       Joseph Chris <joseph@josephcz.xyz>
// @icon         https://www.bilibili.com/favicon.ico
// @namespace    https://github.com/baobao1270/util-scripts/blob/main/tampermonkey/bilibili-vcutils
// @homepageURL  https://github.com/baobao1270/util-scripts/blob/main/tampermonkey/bilibili-vcutils
// @supportURL   mailto:tampermonkey-support@josephcz.xyz
// @compatible   firefox
// @compatible   safari
// @compatible   chrome
// @compatible   edge
// @match        *://www.bilibili.com/video/*
// @match        *://www.bilibili.com/list/watchlater*
// @match        *://www.bilibili.com/list/ml*
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==


const VCUtil = {
    ConfigFlags: {
        video:      { stdurl: true, stdurlAll: true, stat: true },
        watchlater: { stdurl: true, stdurlAll: true, stat: true },
        medialist:  { stdurl: true, stdurlAll: true, stat: true },
        timezoneSlotsCount: 3,
        timezoneSlotDefault: ['PVG', 'NRT', 'LAX'],
    },
};

VCUtil.Convert = {
    bv2av: function (bvid) {
        const XOR_CODE = 23442827791579n;
        const MASK_CODE = 2251799813685247n;
        const BASE = 58n;
        const data = 'FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf';

        const bvidArr = Array.from(bvid);
        [bvidArr[3], bvidArr[9]] = [bvidArr[9], bvidArr[3]];
        [bvidArr[4], bvidArr[7]] = [bvidArr[7], bvidArr[4]];
        bvidArr.splice(0, 3);
        const tmp = bvidArr.reduce((pre, bvidChar) => pre * BASE + BigInt(data.indexOf(bvidChar)), 0n);
        return Number((tmp & MASK_CODE) ^ XOR_CODE);
    },

    av2bv: function (aid) {
        const XOR_CODE = 23442827791579n;
        const MAX_AID = 1n << 51n;
        const BASE = 58n;
        const data = 'FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf';
        const bytes = ['B', 'V', '1', '0', '0', '0', '0', '0', '0', '0', '0', '0'];

        let bvIndex = bytes.length - 1;
        let tmp = (MAX_AID | BigInt(aid)) ^ XOR_CODE;
        while (tmp > 0) {
            bytes[bvIndex] = data[Number(tmp % BigInt(BASE))];
            tmp = tmp / BASE;
            bvIndex -= 1;
        }
        [bytes[3], bytes[9]] = [bytes[9], bytes[3]];
        [bytes[4], bytes[7]] = [bytes[7], bytes[4]];
        return bytes.join('');
    },
};

VCUtil.URL = {
    Info: function () {
        if (location.pathname.startsWith("/video/")) {
            const videoId = window.location.pathname
                .slice('/video/'.length)
                .replace(/\/$/, '');
            const avid = videoId.startsWith('av') ? Number(videoId.slice(2)) : VCUtil.Convert.bv2av(videoId);
            const part = new URL(window.location.href).searchParams.get("p") || 1;
            return {
                avid: avid, bvid: VCUtil.Convert.av2bv(avid), part: part, type: 'video',
                standardUrl: `https://www.bilibili.com/video/av${avid}${part > 1 ? `?p=${part}` : ''}`
            };
        }
        if (location.pathname.startsWith("/list/watchlater")) {
            const bvid = new URL(window.location.href).searchParams.get("bvid");
            const part = new URL(window.location.href).searchParams.get("p") || 1;
            const oid = new URL(window.location.href).searchParams.get("oid");
            return {
                avid: VCUtil.Convert.bv2av(bvid), bvid: bvid, part: part, type: 'watchlater',
                standardUrl: `https://www.bilibili.com/list/watchlater?oid=${oid}&bvid=${bvid}${part > 1 ? `&p=${part}` : ''}`
            };
        }
        if (location.pathname.startsWith("/list/ml")) {
            const mlid = location.pathname.slice('/list/ml'.length);
            const bvid = new URL(window.location.href).searchParams.get("bvid");
            const part = new URL(window.location.href).searchParams.get("p") || 1;
            const oid = new URL(window.location.href).searchParams.get("oid");
            return {
                avid: VCUtil.Convert.bv2av(bvid), bvid: bvid, part: part, type: 'medialist',
                standardUrl: `https://www.bilibili.com/list/ml${mlid}?oid=${oid}&bvid=${bvid}${part > 1 ? `&p=${part}` : ''}`
            };
        }
        return null;
    },

    Standardize: function (urlInfo) {
        if (!urlInfo.standardUrl) return;
        if (urlInfo.standardUrl !== window.location.href) {
            console.log("[B站视频统计] [URL] 当前 URL:", window.location.href);
            console.log("[B站视频统计] [URL] 重定向到标准URL:", urlInfo.standardUrl);
            history.replaceState(null, '', urlInfo.standardUrl);
        }
    },

    StandardizeAllUrl: function () {
        const links = Array.from(document.querySelectorAll('a[href]'));
        links.forEach(link => {
            const pathname = new URL(link.href).pathname;
            if (!pathname.startsWith('/video/')) return;
            const videoId = pathname.slice('/video/'.length).replace(/\/$/, '');
            const avid = videoId.startsWith('av') ? Number(videoId.slice(2)) : VCUtil.Convert.bv2av(videoId);
            const part = new URL(link.href).searchParams.get("p") || 1;
            link.href = `https://www.bilibili.com/video/av${avid}${part > 1 ? `?p=${part}` : ''}`;
        });
    },
};

VCUtil.Stat = {
    InfoBoxClass: '.vcutil-stat',
    FormatTimezoneSlots: function (block, timestamp) {
        for (let slotId = 0; slotId < VCUtil.ConfigFlags.timezoneSlotsCount; slotId++) {
            const { current } = VCUtil.MenuCommand.CurrentAndNextTimezone(slotId);
            const { IATA, timezone } = current;
            console.log(`[B站视频统计] [Stat] 时区槽位 ${slotId + 1} = IATA: ${IATA}, 时区: ${timezone}`);
            if (timezone === "Timestamp") {
                block.AddText(`${IATA} ${timestamp}`, true);
            } else {
                block.AddText(`${IATA} ${VCUtil.Stat.Format.Timezone(timestamp, timezone)}`, true);
            }
        }
    },
    FetchData: async function (avid, part) {
        if (document.querySelector(VCUtil.Stat.InfoBoxClass) && document.querySelector(VCUtil.Stat.InfoBoxClass).getAttribute('data-avid') === avid.toString()) return;
        const url = `https://api.bilibili.com/medialist/gateway/base/resource/info?rid=${avid}&type=2`;
        console.log("[B站视频统计] [Stat] FetchData:", `HTTP GET ${url}`);
        const response = await fetch(url);
        console.log("[B站视频统计] [Stat] FetchData: Response", response);
        const json = await response.json();
        console.log("[B站视频统计] [Stat] FetchData: JSON", json);
        if (json == undefined) return;
        const data = json.data;
        if (data == undefined) return;

        const format = VCUtil.Stat.Format;
        const block = VCUtil.Stat
            .BuildInfoBox(avid)
            .AddText(`${format.HumanReadableNumber(data.cnt_info.play)} 播放    `)
            .AddText(format.Tname(data.tid))
            .AddText(`av${avid}`, true)
            .AddText(VCUtil.Convert.av2bv(avid), true)
            .AddText(`cid=${data.pages[part - 1].id}`, true)
            .AddText((data.tid === 30) ? format.VocaloidAchievement(data.cnt_info.play) : "非 VU 区视频")
            .AddLineBreak();

        block.AddText('发布');
        VCUtil.Stat.FormatTimezoneSlots(block, data.pubtime);
        block.AddLineBreak();
        block.AddText('投稿');
        VCUtil.Stat.FormatTimezoneSlots(block, data.ctime);
        block.AddLineBreak();

        if (data.tid === 30) {
            block.AddLink('TDD',`https://tdd.bunnyxt.com/video/av${avid}`)
        } else {
            block.AddText('---');
        }

        block.AddLink('封面', data.cover);
        block.AddLink('短链接', `https://b23.tv/av${avid}`);
        block.AddLink('API', url);
        VCUtil.Stat.UpdateLayout();
    },

    UpdateLayout: function () {
        const infoBox = document.querySelector(VCUtil.Stat.InfoBoxClass);
        if (infoBox) {
            document.querySelector(".video-info-meta").parentElement.style.paddingBottom = `${infoBox.clientHeight + 80}px`;
        }
    },

    BuildInfoBox: function (avid) {
        const infoBox = document.createElement('div');
        infoBox.className = VCUtil.Stat.InfoBoxClass.slice(1);
        infoBox.setAttribute('data-avid', avid.toString());
        infoBox.style.color = "#999";
        infoBox.style.fontSize = "12px";
        infoBox.style.lineHeight = "1.5";
        infoBox.style.position = "absolute";
        infoBox.style.zIndex = "10";
        infoBox.style.paddingTop = "10px";
        Array.from(document.querySelectorAll(VCUtil.Stat.InfoBoxClass)).forEach(e => e.remove());
        document.querySelector(".video-info-meta").parentElement.appendChild(infoBox);
        const builder = {
            AddText: function (text, code = false) {
                const textBox = document.createElement("span");
                textBox.innerText = text + " ";
                textBox.style.marginRight = "13px";
                if (code === true) { textBox.style.fontFamily = "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', 'JetBrains Mono', monospace"; }
                infoBox.appendChild(textBox);
                return builder;
            },
            AddBlod(text) {
                const boldBox = document.createElement("span");
                boldBox.innerText = text;
                boldBox.style.fontWeight = "bold";
                boldBox.style.marginRight = "13px";
                infoBox.appendChild(boldBox);
                return builder;
            },
            AddLink: function (text, url) {
                const linkBox = document.createElement("span");
                linkBox.innerHTML = `<a target="_blank" href="${url}">${text}</a>`;
                linkBox.style.marginRight = "13px";
                infoBox.appendChild(linkBox);
                return builder;
            },
            AddLineBreak: function () {
                infoBox.appendChild(document.createElement("br"));
                return builder;
            },
        };
        return builder;
    },
};

VCUtil.Stat.Format = {
    TidMapping: [{ "id": 1, "name": "动画(主分区)" }, { "id": 24, "name": "MAD·AMV" }, { "id": 25, "name": "MMD·3D" }, { "id": 47, "name": "短片·手书" }, { "id": 257, "name": "配音" }, { "id": 210, "name": "手办·模玩" }, { "id": 86, "name": "特摄" }, { "id": 253, "name": "动漫杂谈" }, { "id": 27, "name": "综合" }, { "id": 13, "name": "番剧(主分区)" }, { "id": 51, "name": "资讯" }, { "id": 152, "name": "官方延伸" }, { "id": 32, "name": "完结动画" }, { "id": 33, "name": "连载动画" }, { "id": 167, "name": "国创(主分区)" }, { "id": 153, "name": "国产动画" }, { "id": 168, "name": "国产原创相关" }, { "id": 169, "name": "布袋戏" }, { "id": 170, "name": "资讯" }, { "id": 195, "name": "动态漫·广播剧" }, { "id": 3, "name": "音乐(主分区)" }, { "id": 28, "name": "原创音乐" }, { "id": 31, "name": "翻唱" }, { "id": 30, "name": "VOCALOID·UTAU" }, { "id": 59, "name": "演奏" }, { "id": 193, "name": "MV" }, { "id": 29, "name": "音乐现场" }, { "id": 130, "name": "音乐综合" }, { "id": 243, "name": "乐评盘点" }, { "id": 244, "name": "音乐教学" }, { "id": 194, "name": "电音(已下线)" }, { "id": 129, "name": "舞蹈(主分区)" }, { "id": 20, "name": "宅舞" }, { "id": 154, "name": "舞蹈综合" }, { "id": 156, "name": "舞蹈教程" }, { "id": 198, "name": "街舞" }, { "id": 199, "name": "明星舞蹈" }, { "id": 200, "name": "国风舞蹈" }, { "id": 255, "name": "手势·网红舞" }, { "id": 4, "name": "游戏(主分区)" }, { "id": 17, "name": "单机游戏" }, { "id": 171, "name": "电子竞技" }, { "id": 172, "name": "手机游戏" }, { "id": 65, "name": "网络游戏" }, { "id": 173, "name": "桌游棋牌" }, { "id": 121, "name": "GMV" }, { "id": 136, "name": "音游" }, { "id": 19, "name": "Mugen" }, { "id": 36, "name": "知识(主分区)" }, { "id": 201, "name": "科学科普" }, { "id": 124, "name": "社科·法律·心理(原社科人文、原趣味科普人文)" }, { "id": 228, "name": "人文历史" }, { "id": 207, "name": "财经商业" }, { "id": 208, "name": "校园学习" }, { "id": 209, "name": "职业职场" }, { "id": 229, "name": "设计·创意" }, { "id": 122, "name": "野生技术协会" }, { "id": 39, "name": "演讲·公开课(已下线)" }, { "id": 96, "name": "星海(已下线)" }, { "id": 98, "name": "机械(已下线)" }, { "id": 188, "name": "科技(主分区)" }, { "id": 95, "name": "数码(原手机平板)" }, { "id": 230, "name": "软件应用" }, { "id": 231, "name": "计算机技术" }, { "id": 232, "name": "科工机械 (原工业·工程·机械)" }, { "id": 233, "name": "极客DIY" }, { "id": 189, "name": "电脑装机(已下线)" }, { "id": 190, "name": "摄影摄像(已下线)" }, { "id": 191, "name": "影音智能(已下线)" }, { "id": 234, "name": "运动(主分区)" }, { "id": 235, "name": "篮球" }, { "id": 249, "name": "足球" }, { "id": 164, "name": "健身" }, { "id": 236, "name": "竞技体育" }, { "id": 237, "name": "运动文化" }, { "id": 238, "name": "运动综合" }, { "id": 223, "name": "汽车(主分区)" }, { "id": 258, "name": "汽车知识科普" }, { "id": 245, "name": "赛车" }, { "id": 246, "name": "改装玩车" }, { "id": 247, "name": "新能源车" }, { "id": 248, "name": "房车" }, { "id": 240, "name": "摩托车" }, { "id": 227, "name": "购车攻略" }, { "id": 176, "name": "汽车生活" }, { "id": 224, "name": "汽车文化(已下线)" }, { "id": 225, "name": "汽车极客(已下线)" }, { "id": 226, "name": "智能出行(已下线)" }, { "id": 160, "name": "生活(主分区)" }, { "id": 138, "name": "搞笑" }, { "id": 250, "name": "出行" }, { "id": 251, "name": "三农" }, { "id": 239, "name": "家居房产" }, { "id": 161, "name": "手工" }, { "id": 162, "name": "绘画" }, { "id": 21, "name": "日常" }, { "id": 254, "name": "亲子" }, { "id": 76, "name": "美食圈(重定向)" }, { "id": 75, "name": "动物圈(重定向)" }, { "id": 163, "name": "运动(重定向)" }, { "id": 176, "name": "汽车(重定向)" }, { "id": 174, "name": "其他(已下线)" }, { "id": 211, "name": "美食(主分区)" }, { "id": 76, "name": "美食制作(原[生活]->[美食圈])" }, { "id": 212, "name": "美食侦探" }, { "id": 213, "name": "美食测评" }, { "id": 214, "name": "田园美食" }, { "id": 215, "name": "美食记录" }, { "id": 217, "name": "动物圈(主分区)" }, { "id": 218, "name": "喵星人" }, { "id": 219, "name": "汪星人" }, { "id": 220, "name": "动物二创" }, { "id": 221, "name": "野生动物" }, { "id": 222, "name": "小宠异宠" }, { "id": 75, "name": "动物综合" }, { "id": 119, "name": "鬼畜(主分区)" }, { "id": 22, "name": "鬼畜调教" }, { "id": 26, "name": "音MAD" }, { "id": 126, "name": "人力VOCALOID" }, { "id": 216, "name": "鬼畜剧场" }, { "id": 127, "name": "教程演示" }, { "id": 155, "name": "时尚(主分区)" }, { "id": 157, "name": "美妆护肤" }, { "id": 252, "name": "仿妆cos" }, { "id": 158, "name": "穿搭" }, { "id": 159, "name": "时尚潮流" }, { "id": 164, "name": "健身(重定向)" }, { "id": 192, "name": "风尚标(已下线)" }, { "id": 202, "name": "资讯(主分区)" }, { "id": 203, "name": "热点" }, { "id": 204, "name": "环球" }, { "id": 205, "name": "社会" }, { "id": 206, "name": "综合" }, { "id": 165, "name": "广告(主分区)" }, { "id": 166, "name": "广告(已下线)" }, { "id": 5, "name": "娱乐(主分区)" }, { "id": 71, "name": "综艺" }, { "id": 241, "name": "娱乐杂谈" }, { "id": 242, "name": "粉丝创作" }, { "id": 137, "name": "明星综合" }, { "id": 131, "name": "Korea相关(已下线)" }, { "id": 181, "name": "影视(主分区)" }, { "id": 182, "name": "影视杂谈" }, { "id": 183, "name": "影视剪辑" }, { "id": 85, "name": "小剧场" }, { "id": 184, "name": "预告·资讯" }, { "id": 256, "name": "短片" }, { "id": 177, "name": "纪录片(主分区)" }, { "id": 37, "name": "人文·历史" }, { "id": 178, "name": "科学·探索·自然" }, { "id": 179, "name": "军事" }, { "id": 180, "name": "社会·美食·旅行" }, { "id": 23, "name": "电影(主分区)" }, { "id": 147, "name": "华语电影" }, { "id": 145, "name": "欧美电影" }, { "id": 146, "name": "日本电影" }, { "id": 83, "name": "其他国家" }, { "id": 11, "name": "电视剧(主分区)" }, { "id": 185, "name": "国产剧" }, { "id": 187, "name": "海外剧" }],
    HumanReadableNumber: function (num) {
        var result = '', counter = 0;
        num = (num || 0).toString();
        for (var i = num.length - 1; i >= 0; i--) {
            counter++;
            result = num.charAt(i) + result;
            if (!(counter % 3) && i != 0) { result = ',' + result; }
        }
        return result;
    },

    VocaloidAchievement: function (plays) {
        if (plays >= 10000000) return "神话";
        if (plays >= 5000000) return "申舌";
        if (plays >= 1000000) return "传说";
        if (plays >= 500000) return "专兑";
        if (plays >= 100000) return "殿堂";
        return "待成就";
    },

    Tname: function (tid) {
        return VCUtil.Stat.Format.TidMapping.find(e => e.id === tid)?.name || "未知分区";
    },

    Timezone: function (timestamp, timezone) {
        return new Date(timestamp * 1000).toLocaleString('sv-SE', {timeZone: timezone});
    }
};

VCUtil.Entry = () => {
    const urlInfo = VCUtil.URL.Info();
    if (urlInfo === null) return;

    const flag = VCUtil.ConfigFlags[urlInfo.type];
    if (flag.stdurl) VCUtil.URL.Standardize(urlInfo);
    if (flag.stdurlAll) VCUtil.URL.StandardizeAllUrl();
    if (flag.stat) { VCUtil.Stat.FetchData(urlInfo.avid, urlInfo.part); }
}

VCUtil.MenuCommand = {
    MenuCommands: [],
    TimezonesMapping: [
        { IATA: "PVG", timezone: "Asia/Shanghai" },
        { IATA: "NRT", timezone: "Asia/Tokyo" },
        { IATA: "LAX", timezone: "America/Los_Angeles" },
        { IATA: "JFK", timezone: "America/New_York" },
        { IATA: "LHR", timezone: "Europe/London" },
        { IATA: "MUC", timezone: "Europe/Berlin" },
        { IATA: "KIV", timezone: "Europe/Kiev" },
        { IATA: "SVO", timezone: "Europe/Moscow" },
        { IATA: "SYD", timezone: "Australia/Sydney" },
        { IATA: "UNIX", timezone: "Timestamp" },
    ],
    CurrentAndNextTimezone: function (slotId) {
        const currentIATA = GM_getValue(`VCUtil_Timezone_${slotId}`, VCUtil.ConfigFlags.timezoneSlotDefault[slotId]);
        const currentIndex = VCUtil.MenuCommand.TimezonesMapping.findIndex(e => e.IATA === currentIATA);
        const current = VCUtil.MenuCommand.TimezonesMapping[currentIndex];
        console.log(`[B站视频统计] [Menu] 时区槽位 ${slotId + 1} 当前`, current);
        const nextIndex = (currentIndex + 1) % VCUtil.MenuCommand.TimezonesMapping.length;
        const next = VCUtil.MenuCommand.TimezonesMapping[nextIndex];
        console.log(`[B站视频统计] [Menu] 时区槽位 ${slotId + 1} 下个`, next);
        return { current: current, next: next };
    },
    ChangeTimezoneSetting: function (slotId) {
        const { next } = VCUtil.MenuCommand.CurrentAndNextTimezone(slotId);
        GM_setValue(`VCUtil_Timezone_${slotId}`, next.IATA);
        VCUtil.MenuCommand.Register();
        console.log(`[B站视频统计] [Menu] 时区槽位 ${slotId + 1} 设置成功`);
    },
    Register: function () {
        VCUtil.MenuCommand.MenuCommands.forEach(meunCommandId => GM_unregisterMenuCommand(meunCommandId));
        VCUtil.MenuCommand.MenuCommands = [];
        for (let slotId = 0; slotId < VCUtil.ConfigFlags.timezoneSlotsCount; slotId++) {
            const { current, next } = VCUtil.MenuCommand.CurrentAndNextTimezone(slotId);
            const meunCommandId = GM_registerMenuCommand(
                `切换槽位 ${slotId + 1} 时区 ${current.IATA} -> ${next.IATA}`,
                () => VCUtil.MenuCommand.ChangeTimezoneSetting(slotId),
            );
            VCUtil.MenuCommand.MenuCommands.push(meunCommandId);
        }
    }
};

(() => {
    'use strict';
    console.log("[B站视频统计] 脚本已加载");
    console.log("[B站视频统计] 功能开关:", VCUtil.ConfigFlags);

    const observer = new MutationObserver((_mutationList, _observed) => VCUtil.Entry());
    const el = document.querySelector('#app');
    console.log("[B站视频统计] 挂载到元素:", el);
    observer.observe(el, { childList: true, subtree: true });

    VCUtil.MenuCommand.Register();
})();