高亮个别用户的弹幕

高亮个别用户的弹幕, 有时候找一些特殊人物(其他直播主出现在直播房间)用

安裝腳本?
作者推薦腳本

您可能也會喜歡 斗虫数据直播间可视化

安裝腳本
// ==UserScript==
// @name         高亮个别用户的弹幕
// @namespace    http://tampermonkey.net/
// @version      0.7.24
// @description  高亮个别用户的弹幕, 有时候找一些特殊人物(其他直播主出现在直播房间)用
// @author       Eric Lam
// @include      https://sc.chinaz.com/tag_yinxiao/tongzhi.html
// @include      /https?:\/\/live\.bilibili\.com\/(blanc\/)?\d+\??.*/
// @include      /https?:\/\/eric2788\.github\.io\/scriptsettings\/highlight-user(\/)?/
// @include      /https?:\/\/eric2788\.neeemooo\.com\/scriptsettings\/highlight-user(\/)?/
// @require      https://cdn.jsdelivr.net/npm/jquery@3.5.1/dist/jquery.min.js
// @require      https://cdn.jsdelivr.net/gh/google/brotli@5692e422da6af1e991f9182345d58df87866bc5e/js/decode.js
// @require      https://greasyfork.org/scripts/417560-bliveproxy/code/bliveproxy.js?version=1045452
// @require      https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/toastr.js/2.1.4/toastr.min.js
// @require      https://cdn.jsdelivr.net/npm/js-md5@0.7.3/build/md5.min.js
// @grant        GM.xmlHttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        unsafeWindow
// @run-at       document-start
// @connect      api.bilibili.com
// @website      https://eric2788.github.io/scriptsettings/highlight-user
// @homepage     https://eric2788.neeemooo.com/scriptsettings/highlight-user
// ==/UserScript==

(async function () {
    'use strict';

    const defaultSettings = {
        highlightUsers: [
            396024008, // 日本兄贵
            604890122, // 日本兄贵
            623441609, // 凤玲天天 (DD)
            1618670884, // 日本兄贵
            406805563, // 乙女音
            2299184, // 古守
            198297, // 冰糖
            1576121 // paryi
        ],
        settings: {
            color: '#FFFF00',
            opacity: 1.0,
            playAudio: false,
            playAudioDanmu: false,
            join_notify_duration: 5000,
            join_notify_position: "bottom-left",
            volume: {
                danmu: 1.0,
                join: 1.0
            }
        }
    }

    const defaultSounds = {
        join: '//downsc.chinaz.net/Files/DownLoad/sound1/201911/12221.mp3',
        danmu: '//downsc.chinaz.net/Files/DownLoad/sound1/202003/12643.mp3'
    }

    const storage = GM_getValue('settings', defaultSettings)
    const sounds = GM_getValue('sounds', defaultSounds)
    const { highlightUsers, settings: currentSettings } = storage
    const settings = { ...defaultSettings.settings, ...currentSettings }
    console.debug(highlightUsers)
    console.debug(settings)


    async function generateWbi() {
        const url = 'https://api.bilibili.com/x/web-interface/nav';
        // get wbi keys
        const data = await GM.xmlHttpRequest({
            method: "GET",
            headers: {
                'Content-type': 'application/json',
                'Referer': 'https://www.bilibili.com',
                'Origin': 'https://www.bilibili.com'
            },
            url
        });

        console.log(`response for ${url}: ${data?.response ?? data}`);
        const res = JSON.parse(data.response);

        const { img_url, sub_url } = res.data.wbi_img;

        const img_key = img_url.substring(img_url.lastIndexOf('/') + 1, img_url.length).split('.')[0];
        const sub_key = sub_url.substring(sub_url.lastIndexOf('/') + 1, sub_url.length).split('.')[0]

        const mixinKeyEncTab = [
            46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
            33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
            61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
            36, 20, 34, 44, 52
        ]

        const orig = img_key + sub_key;

        let temp = ''
        mixinKeyEncTab.forEach((n) => {
            temp += orig[n]
        })

        return temp.slice(0, 32)
    }

    // ==== check update
    const { key, update } = GM_getValue('wbi_salt', { key: '', update: new Date('1970/01/01') });
    const now = new Date();

    // over a day
    if (!key || Math.abs(now - update) > (86400 * 1000)) {
        const wbiKey = await generateWbi();
        console.info('wbi key salt updated: '+wbiKey);
        GM_setValue('wbi_salt', { key: wbiKey, update: now });
    }
    // ====


    // gener w_rid
    /* reference
    def w_rid():  # 每次请求生成w_rid参数
      wts = str(int(time.time()))  # 时间戳
      c = "72136226c6a73669787ee4fd02a74c27"  # 尾部固定值,根据imgKey,subKey计算得出
      b = "mid=" + uid + "&platform=web&token=&web_location=1550101"
      a = b + "&wts=" + wts + c  # mid + platform + token + web_location + 时间戳wts + 一个固定值
      return hashlib.md5(a.encode(encoding='utf-8')).hexdigest()
    */
    function w_rid(uid, wts) {
        const { key: c } = GM_getValue('wbi_salt')
        const b = "mid=" + uid + "&platform=web&token=&web_location=1550101"
        const a = b + "&wts=" + wts + c  // mid + platform + token + web_location + 时间戳wts + 一个固定值
        const m = md5.create()
        m.update(a)
        return m.hex()
    }

    async function requestUserInfo(mid) {
        let error = null;
        const baseUrls = [
            () => `https://api.bilibili.com/x/space/acc/info?mid=${mid}&jsonp=jsonp`, // 已經失效
            () => `https://api.bilibili.com/x/space/wbi/acc/info?mid=${mid}&jsonp=jsonp`, // 已經失效
            () => {
                const now = Math.round(Date.now() / 1000);
                return `https://api.bilibili.com/x/space/wbi/acc/info?platform=web&token=&web_location=1550101&wts=${now}&mid=${mid}&w_rid=${w_rid(mid, now)}`
            }
        ]
        for (const base of baseUrls) {
            try {
                const url = base();
                console.info(`正在使用 ${url} 進行請求...`)
                return await webRequest(url)
            } catch (err) {
                console.error(`請求時出現錯誤: ${err?.message ?? err}`);
                console.warn(`嘗試使用下一個API`)
                error = err;
            }
        }
        console.warn('沒有可以使用的下一個API,將拋出錯誤')
        throw error;
    }

    if (location.origin == 'https://live.bilibili.com') {
        console.log('using highlight filter')

        function hexToNum(color) {
            const hex = color.substr(1)
            return parseInt(hex, 16)
        }

        $(document.head).append(`<link href="https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/toastr.js/2.1.4/toastr.min.css" rel="stylesheet" />`)

        const audio = {
            join: new Audio(sounds.join),
            danmu: new Audio(sounds.danmu)
        }
        audio.join.volume = settings.volume.join
        audio.danmu.volume = settings.volume.danmu
        const highlights = new Set()
        const highlightsMapper = new Map()
        toastr.options = {
            "closeButton": false,
            "debug": false,
            "newestOnTop": true,
            "progressBar": true,
            "positionClass": `toast-${settings.join_notify_position}`,
            "preventDuplicates": false,
            "onclick": null,
            "showDuration": "300",
            "hideDuration": "1000",
            "timeOut": `${settings.join_notify_duration}`,
            "extendedTimeOut": "1000",
            "showEasing": "swing",
            "hideEasing": "linear",
            "showMethod": "fadeIn",
            "hideMethod": "fadeOut"
        }

        const elements = ['.danmaku-item-container']

        async function launch() {
            console.debug('launching highlight filter...')
            while (!unsafeWindow.bliveproxy) {
                console.log('cannot not find bliveproxy, wait one second')
                await sleep(1000)
            }
            while (!elements.some(s => $(s).length > 0)) {
                console.log('cannot not find element, wait one second')
                await sleep(1000)
            }

            function handleUserEnter(uid, uname) {
                console.debug(`user enter: ${uid} (${uname})`)
                if (!highlightUsers.includes(uid)) return
                console.log(`name: ${uname} has enter this live room`)
                toastr.info(`你所关注的用户 ${uname} 已进入此直播间。`, `噔噔咚!`)
                if (settings.playAudio) audio.join.play()
            }

            console.debug('bliveproxy injected.')
            unsafeWindow.bliveproxy.addCommandHandler('DANMU_MSG', command => {
                // console.log(command);
                const userId = command.info[2][0]
                console.debug(`user send danmu: ${userId}`)
                if (!highlightUsers.includes(userId)) return
                console.debug('detected highlighted user: ' + userId)

                // /* 新版直播间无法改写弹幕信息 👇 -> 刪除 dm_v2 後成功
                command.info[0][13] = "{}" // 把那些圖片彈幕打回原形
                if (settings.color) {
                    command.info[0][3] = hexToNum(settings.color)
                }
                command.info[1] += `(${command.info[2][1]})`
                console.debug(`converted danmaku: ${command.info[1]}`)
                highlights.add(command.info[1])

                // trying to delete this field to make edit happen
                delete command.dm_v2;

                // */
                // highlightsMapper.set(command.info[1], command.info[2][1]);
                if (settings.playAudioDanmu) audio.danmu.play()
            })
            unsafeWindow.bliveproxy.addCommandHandler('INTERACT_WORD', ({ data }) => {
                const { uid, uname } = data
                handleUserEnter(uid, uname)
            })
            unsafeWindow.bliveproxy.addCommandHandler('ENTRY_EFFECT', async ({ data }) => {
                const { uid } = data
                if (!highlightUsers.includes(uid)) return
                let username;
                try {
                    const cache = GM_getValue(uid, null)
                    if (cache != null && cache.name != `无法索取用户资讯`) {
                        username = cache.name
                    } else {
                        const { name } = await requestUserInfo(uid)
                        username = name
                    }
                    console.debug(`成功辨别舰长 ${uid} 名称为 ${name}`)
                } catch (err) {
                    console.error(`索取大航海用户资讯错误: ${err}`)
                    console.warn(`将使用 uid 作为名称`)
                    username = `(UID: ${uid})`
                }
                handleUserEnter(uid, username)
            })
            if (settings.opacity) {
                const config = { attributes: false, childList: true, subtree: true }
                function danmakuCheckCallback(mutationsList) {
                    for (const mu of mutationsList) {
                        for (const node of mu.addedNodes) {
                            console.log('node', node);
                            const danmaku = node?.innerText?.trim() ?? node?.data?.trim()
                            console.log('danmaku', danmaku)
                            if (danmaku === undefined || danmaku === '') continue
                            if (!highlights.has(danmaku)) continue
                            // if (!highlightsMapper.has(danmaku)) continue;
                            // const user = highlightsMapper.get(danmaku);
                            // console.debug('highlighting danmaku: ', danmaku, ' with user: ', user)
                            const n = node.innerText !== undefined ? node : node.parentElement
                            const jimaku = $(n)
                            jimaku.css('opacity', `${settings.opacity}`)
                            //jimaku.css('color', `${settings.color}`)
                            //jimaku.text(`${danmaku}(${user})`);
                            highlights.delete(danmaku)
                            // highlightsMapper.delete(danmaku)
                        }
                    }
                }
                const danmakuObserver = new MutationObserver((mu, obs) => danmakuCheckCallback(mu))
                danmakuObserver.observe($('.danmaku-item-container')[0], config)
            }
        }

        await launch()
    } else if (["https://eric2788.github.io", "https://eric2788.neeemooo.com", "http://127.0.0.1:5500"].includes(location.origin)) {
        while (!unsafeWindow.mdui) {
            console.debug('cannot find mdui, wait one second')
            await sleep(1000)
        }
        const $ = mdui.$
        async function appendUser(userId) {
            if ($(`#${userId}`).length > 0) {
                mdui.alert('该用户已在列表内')
                return false
            }
            try {
                const lastUpdate = GM_getValue('last.update', new Date())
                const haveData = GM_getValue(userId, null) != null
                const today = new Date()
                if (!haveData || Math.abs(today - lastUpdate) > (86400 * 1000 * 7)) {
                    console.log('cache outdated, updating user info...')
                    const { name, face } = await requestUserInfo(userId)
                    GM_setValue(userId, { name, face })
                    GM_setValue('last.update', new Date())
                    console.log('user info updated and saved to cache.')
                } else {
                    console.log('loading user info from cache.')
                }
                const { name, face } = GM_getValue(userId, { name: `无法索取用户资讯`, face: '' })
                $('#hightlight-users').append(`
                    <label class="mdui-list-item mdui-ripple">
                        <div class="mdui-checkbox">
                            <input type="checkbox" id="${userId}"/>
                            <i class="mdui-checkbox-icon"></i>
                        </div>
                        <div class="mdui-list-item-avatar"><img src="${face}"/></div>
                        <div class="mdui-list-item-content">${name} (${userId})</div>
                   </label>
                `)
                return true;
            } catch (err) {
                console.warn(err)
                if (err.code == -412) {
                    const { name, face } = GM_getValue(userId, { name: `无法索取用户资讯`, face: '' })
                    $('#hightlight-users').append(`
                    <label class="mdui-list-item mdui-ripple">
                        <div class="mdui-checkbox">
                            <input type="checkbox" id="${userId}"/>
                            <i class="mdui-checkbox-icon"></i>
                        </div>
                        <div class="mdui-list-item-avatar"><img src="${face}"/></div>
                        <div class="mdui-list-item-content">${name} (${userId})</div>
                   </label>
                  `)
                    return true;
                } else {
                    mdui.alert(`无法索取 ${userId} 的用户资讯: ${err.message}`)
                    return false;
                }
            } finally {
                $(`#${userId}`).on('change', e => {
                    if (getTicked().length > 0) {
                        $('#delete-btn').show()
                    } else {
                        $('#delete-btn').hide()
                    }
                })
            }
        }


        function getTicked() {
            return $('#hightlight-users').find('.mdui-checkbox > input').filter((i, e) => $(e).prop('checked')).map((i, e) => $(e).attr('id'))
        }

        $('#delete-btn').on('click', e => {
            getTicked().each((i, id) => $(`#${id}`).parents('.mdui-list-item').remove())
            GM_setValue('settings', getSettings())
            mdui.snackbar('删除并保存成功')
            $('#delete-btn').hide()
        })

        $('#user-add').on('keypress', async (e) => {
            if (e.which != 13) return
            if (!$('#user-add')[0].checkValidity()) return
            if (await appendUser(e.target.value)) {
                GM_setValue('settings', getSettings())
                mdui.snackbar('新增并保存成功')
                e.target.value = ''
            }
        });

        $('#save-btn').on('click', e => {
            if (!$('form')[0].checkValidity()) {
                mdui.snackbar('保存失败,请检查格式或漏填')
                return
            }
            GM_setValue('settings', getSettings())
            mdui.snackbar('保存成功')
        })

        $('#try-listen-join').on('click', () => {
            const audio = new Audio(sounds.join)
            audio.volume = parseVolume('#volume-join')
            $('#try-listen-join').attr('disabled', '')
            audio.addEventListener('canplaythrough', () => {
                audio.play()
                $('#try-listen-join').removeAttr('disabled')
            })
        })

        $('#try-listen-danmu').on('click', () => {
            const audio = new Audio(sounds.danmu)
            audio.volume = parseVolume('#volume-danmu')
            $('#try-listen-danmu').attr('disabled', '')
            audio.addEventListener('canplaythrough', () => {
                audio.play()
                $('#try-listen-danmu').removeAttr('disabled')
            })
        })

        const joinNotifyPosSelect = new mdui.Select('#join-notify-position', { position: 'bottom' })


        $('#import-setting').on('click', async () => {
            try {
                const area = $('#setting-area').val()
                const { highlightUsers, settings: currentSettings } = JSON.parse(area)
                const settings = { ...defaultSettings.settings, ...currentSettings }
                $('.mdui-list-item').remove() // clear old data
                await initializeSettings({ highlightUsers, settings })
                mdui.snackbar('设定档导入成功,请记得按下保存')
                $('#setting-area').val('')
            } catch (err) {
                console.error(err)
                mdui.snackbar('设定档导入失败,请检查格式有没有错误')
            }
        })

        $('#export-setting').on('click', () => {
            const area = JSON.stringify(getSettings())
            $('#setting-area').val(area)
            const text = $('#setting-area')[0]
            text.select();
            text.setSelectionRange(0, 99999);
            document.execCommand("copy")
            mdui.snackbar('设定档已导出并复制成功')
            $('#setting-area').val('')
        })

        async function initializeSettings({ highlightUsers, settings }) {
            await Promise.all(highlightUsers.map((id) => appendUser(id)))
            $('#opacity')[0].valueAsNumber = settings.opacity
            $('#color').val(settings.color)
            $('#color-picker').val(settings.color)
            $('#color-picker-btn').css('color', settings.color)
            $('#play-audio').prop('checked', settings.playAudio)
            $('#play-audio-danmu').prop('checked', settings.playAudioDanmu)
            $('#join-notify-duration')[0].valueAsNumber = settings.join_notify_duration
            $('#join-notify-position').val(settings.join_notify_position)
            $('#volume-danmu').val(settings.volume.danmu * 100)
            $('#volume-join').val(settings.volume.join * 100)
            mdui.updateSliders()
            joinNotifyPosSelect.handleUpdate()
            $('#list-loading').hide()
        }

        await initializeSettings({ highlightUsers, settings })

        function getSettings() {
            const users = new Set()
            $('#hightlight-users').find('.mdui-checkbox > input').map((i, e) => parseInt($(e).attr('id'))).filter((i, e) => !!e).each((i, e) => users.add(e))
            const settings = {
                opacity: $('#opacity')[0].valueAsNumber,
                color: $('#color')[0].checkValidity() ? $('#color').val() : '',
                playAudio: $('#play-audio').prop('checked'),
                playAudioDanmu: $('#play-audio-danmu').prop('checked'),
                join_notify_duration: $('#join-notify-duration')[0].valueAsNumber,
                join_notify_position: $('#join-notify-position').val(),
                volume: {
                    danmu: parseVolume('#volume-danmu'),
                    join: parseVolume('#volume-join')
                }
            }
            return { highlightUsers: [...users], settings }
        }

        function parseVolume(element) {
            const val = $(element)[0].value
            if (val == 0) return 0.0
            return parseFloat((val / 100).toFixed(2)) || 1.0
        }

    } else if (location.origin === 'https://sc.chinaz.com') {
        while ($('div.audio-class').length == 0) {
            await sleep(1000)
        }
        $('div.audio-class').empty();
        $('div.audio-class')
            .append(`<a href="javascript: void(0)" id="danmu-select">选为弹幕通知</a>`)
            .append('<a href="javascript: void(0)" id="join-select">选为进入通知</a>')
        $('a#danmu-select').on('click', e => {
            e.preventDefault();
            if (!window.confirm('确定选择为弹幕通知音效?')) return
            const url = $(e.target).parents('.audio-item').children('audio').attr('src')
            if (!url) {
                alert('选择失败,无效的URL')
                return
            }
            sounds.danmu = url
            GM_setValue('sounds', sounds)
            alert('设置成功')
        })

        $('a#join-select').on('click', e => {
            e.preventDefault();
            if (!window.confirm('确定选择为进入通知音效?')) return
            const url = $(e.target).parents('.audio-item').children('audio').attr('src')
            if (!url) {
                alert('选择失败,无效的URL')
                return
            }
            sounds.join = url
            GM_setValue('sounds', sounds)
            alert('设置成功')
        })
    }
})().catch(console.error);

async function webRequest(url) {
    const data = await GM.xmlHttpRequest({
        method: "GET",
        headers: {
            'Content-type': 'application/json',
            'Referer': 'https://www.bilibili.com',
            'Origin': 'https://www.bilibili.com'
        },
        url
    })
    console.log(`response for ${url}: ${data?.response ?? data}`);
    const res = JSON.parse(data.response)
    if (res.code !== 0) throw res
    return res.data
}

async function sleep(ms) {
    return new Promise((res,) => setTimeout(res, ms))
}