Bilibili Liveroom Filter

Filtering Bilibili liveroom, batch management, export, import banlist...

// ==UserScript==
// @name              Bilibili Liveroom Filter
// @name:zh-CN        哔哩哔哩直播间屏蔽工具
// @description       Filtering Bilibili liveroom, batch management, export, import banlist...
// @description:zh-CN 哔哩哔哩直播间屏蔽工具,支持管理列表,批量屏蔽,导出、导入列表等……
// @author            jc3213
// @namespace         https://github.com/jc3213/userscript
// @supportURL        https://github.com/jc3213/userscript/issues
// @homepageURL       https://github.com/jc3213/userscript
// @license           MIT
// @match             https://live.bilibili.com/*
// @grant             GM_getValue
// @grant             GM_setValue
// @noframes          
// @icon              https://i0.hdslb.com/bfs/static/jinkela/long/images/512.png
// @compatible        chrome
// @compatible        firefox
// @compatible        edge
// @compatible        opera
// @compatible        safari
// @compatible        kiwi
// @compatible        qq
// @compatible        via
// @compatible        brave
// @version           2025.6.2.1
// ==/UserScript==

'use strict'
let storage = GM_getValue('storage', { every: [] })
let showRooms = { every: [] }
let firstRun = true
let bilicss = document.createElement('style')
bilicss.textContent = '.bililive-button {background-color: #00ADEB; border-radius: 5px; color: #ffffff; cursor: pointer; font-size: 16px; padding: 3px 10px; user-select: none; text-align: center;} .bililive-button:hover {filter: contrast(75%);} .bililive-button:active {filter: contrast(45%);} '

let area = location.pathname.slice(1)
if (isNaN(area)) {
    biliLiveSpecialArea()
}
else {
    PromiseSelector('.header-info-ctnr > .rows-content').then((liver) => biliLiveShowRoom(liver, area)).catch((error) => biliLiveShowFrame(area))
}

async function biliLiveSpecialArea() {
    let area = await PromiseSelector('#room-card-list')
    biliLiveManagerDeployed(area)
    document.querySelectorAll('.index_item_JSGkw').forEach(biliLiveShowCover)
    let observer = new MutationObserver((mutationsList) => {
        mutationsList.forEach((mutation) => {
            if (mutation.type === 'childList') {
                mutation.addedNodes.forEach((node) => {
                    if (node.tagName === 'DIV' && node.className === 'index_item_JSGkw') {
                        biliLiveShowCover(node)
                    }
                })
            }
        })
    })
    observer.observe(area, { childList: true, subtree: true })
}

const balloonHandlers = {
    'bililive-block': (id, liver) => {
        event.preventDefault()
        if (confirm('确定要永久屏蔽【 ' + liver + ' 】的直播间吗?')) {
            blockLiveRoom(id, liver)
            GM_setValue('storage', storage)
        }
    },
    'bililive-image': (id, liver, title, image) => {
        event.preventDefault()
        if (confirm('确定要打开直播《 ' + title + ' 》的封面吗?')) {
            open(image, '_blank')
        }
    }
}

async function biliLiveShowCover(node) {
    if (node.cover) {
        return
    }

    let pane = node.children[0]
    let room = pane.href
    let id = room.slice(room.lastIndexOf('/') + 1, room.indexOf('?'))
    let [top, center] = pane.children[1].children
    let thumb = top.children[0].style['background-image']
    let image = 'https' + thumb.slice(thumb.indexOf(':'), thumb.lastIndexOf('@'))
    let [name, user] = center.children[1].children
    let title = name.textContent.trim()
    let liver = user.children[0].textContent.trim()

    let menu = document.createElement('div')
    menu.className = 'bililive-balloon'
    menu.innerHTML = '<div id="bililive-block" class="bililive-button">屏蔽直播间</div><div id="bililive-image" class="bililive-button">查看封面图</div></div'
    menu.addEventListener('click', (event) => {
        let handler = balloonHandlers[event.target.id]
        if (handler) {
            event.preventDefault()
            handler(id, liver, title, image)
        }
    })

    showRooms[id] = node
    showRooms.every.push(node)

    center.after(menu)
    node.cover = true
    node.style.display = storage[id] ? 'none' : ''
    node.addEventListener('mouseover', (event) => { menu.style.display = 'flex' })
    node.addEventListener('mouseout', (event) => { menu.style.display = '' })
}

async function biliLiveShowRoom(menu, id, xid) {
    let [upper, lower] = menu.children
    let left = upper.children[0]
    let liver = left.children[0].textContent.trim()
    let area = lower.children[0].children[1].children[0].href

    if (storage[id] && !confirm('【 ' + liver + ' 】的直播间已被屏蔽,是否继续观看?')) {
        open(area, '_self')
    }

    let block = document.createElement('div')
    block.textContent = '屏蔽直播间'
    block.className = 'bililive-button'
    block.addEventListener('click', (event) => {
        if (confirm('确定要永久屏蔽【 ' + liver + ' 】的直播间吗?')) {
            blockLiveRoom(id, liver)
            if (xid) {
                blockLiveRoom(xid, liver)
            }
            GM_setValue('storage', storage)
            open(area, '_self')
        }
    })

    bilicss.textContent += '.bililive-button {margin-left: 10px;}'
    left.append(block, bilicss)
}

async function biliLiveShowFrame(id) {
    let iframe = await PromiseSelector('iframe[src*="live.bilibili.com"]')
    let menu = await PromiseSelector('.rows-ctnr.rows-content', iframe.contentDocument)
    let xid = iframe.src.match(/\/(\d+)/)[1]
    biliLiveShowRoom(menu, id, xid)
}

function addToFilterList(id, liver) {
    let cell = document.createElement('div')
    cell.innerHTML = '<div></div><div></div>'

    let [room, user] = cell.children

    user.textContent = liver
    room.textContent = id
    room.addEventListener('click', (event) => {
        if (storage[id] && confirm('确定要解除对【 ' + liver + ' 】的屏蔽吗?')) {
            cell.remove()
            let index = storage.every.findIndex((i) => i === id)
            storage.every.splice(index, 1)
            delete storage[id]
            GM_setValue('storage', storage)
            unblockLiveRoom(id)
        }
    })

    showRooms.table.appendChild(cell)
}

function blockLiveRoom(id, liver) {
    if (!storage[id]) {
        storage.every.push(id)
        storage[id] = liver
        let room = showRooms[id]
        if (room) {
            room.style.display = 'none'
        }
        if (!firstRun) {
            addToFilterList(id, liver)
        }
    }
}

function unblockLiveRoom(id) {
    let room = showRooms[id]
    if (room) {
        room.style.display = ''
    }
}

function biliLiveManagerDeployed(area) {
    let pane = document.createElement('div')
    pane.className = 'bililive-container'
    pane.innerHTML = `
<div class="bililive-button">管理列表</div>
<div class="bililive-manager">
    <div id="bililive-block" class="bililive-button">批量屏蔽</div>
    <div class="bililive-button"><label for="bililive-import">bili导入列表</label></div>
    <div id="bililive-export" class="bililive-button">导出列表</div>
    <div id="bililive-clear" class="bililive-button">清空列表</div>
    <textarea rows="6"></textarea>
    <div class="bililive-table bililive-thead">
        <div>直播间ID</div>
        <div>主播昵称</div>
    </div>
    <div class="bililive-table bililive-tbody"></div>
</div>
<input id="bililive-import" type="file" accept=".json">
<a></a>
`

    let [menu, popup, upload, saver] = pane.children
    let [batch, , fileDl, clear, entry, thead, tbody] = popup.children

    upload.addEventListener('change', async (event) => {
        let file = upload.files[0]
        if (confirm('确定要导入屏蔽列表【' + file.name.slice(0, -5) + '】吗?')) {
            let json = await PromiseFileReader(file)
            json.forEach(({ id, liver }) => blockLiveRoom(id, liver))
            GM_setValue('storage', storage)
            upload.value = ''
        }
    })

    batch.addEventListener('click', (event) => {
        if (confirm('确定要屏蔽列表中的直播间吗?')) {
            entry.value.match(/[^\r\n]+/g)?.forEach((str) => {
                var rule = str.match(/(\d+)[\\/:*?"<>|[\](){}+\-`,.;!@#%^&]+(.+)/)
                if (rule?.length === 3) {
                    blockLiveRoom(rule[1], rule[2])
                }
            })
            GM_setValue('storage', storage)
            entry.value = ''
        }
    })

    fileDl.addEventListener('click', (event) => {
        if (confirm('确定要导出当前屏蔽列表吗?')) {
            let output = []
            storage.every.forEach((id) => output.push({ id, liver: storage[id] }))
            let blob = new Blob([JSON.stringify(output, null, 4)], { type: 'application/json' })
            saver.href = URL.createObjectURL(blob)
            saver.download = 'bilibili直播间屏蔽列表'
            saver.click()
        }
    })

    clear.addEventListener('click', (event) => {
        if (confirm('确定要清空当前屏蔽列表吗?')) {
            storage.every.forEach(unblockLiveRoom)
            storage = { every: [] }
            GM_setValue('storage', storage)
            tbody.innerHTML = ''
        }
    })

    menu.addEventListener('click', (event) => {
        if (firstRun) {
            storage.every.forEach((id) => addToFilterList(id, storage[id]))
            firstRun = false
        }
        popup.classList.toggle('bililive-popup')
    })

    showRooms.table = tbody

    document.getElementsByClassName('tabs')[0].appendChild(pane)

    bilicss.textContent += `.bililive-button {flex: 1;}
.bililive-balloon {display: none; gap: 5px; margin: 8px 12px 0px 6px;}
.bililive-container {position: relative;}
.bililive-container > input, .bililive-container > a {display: none;}
.bililive-manager {background-color: #ffffff; border: 1px solid #000000; display: none; font-size: 16px; padding: 5px; margin-top: 3px; position: absolute; width: 520px; z-index: 3213;}
.bililive-manager > textarea {font-size: 16px; margin: 3px 0px; padding: 5px; resize: none;}
.bililive-manager > textarea, .bililive-manager > .bililive-table {flex-basis: 100%;}
.bililive-popup {display: flex; gap: 3px; flex-wrap: wrap;}
.bililive-thead, .bililive-tbody > div {display: flex;}
.bililive-thead > *, .bililive-tbody > div > * {border: 1px solid #ffffff; flex: 1; padding: 5px; text-align: center; user-select: text !important;}
.bililive-thead > * {background-color: #000000; color: #ffffff;}
.bililive-tbody {height: 480px; scroll-y: auto; border: 1px solid #000000;}
.bililive-tbody > * > :first-child {background-color: #FF6699; color: #ffffff; cursor: pointer;}
.bililive-tbody > * > :first-child:active {contrast(45%);}
.bililive-tbody > :nth-child(2n) > :last-child {background-color: #E2E3E4;}
.bililive-tbody > :nth-child(2n + 1) > :last-child {background-color: #F1F2F3;}
`
    area.append(bilicss)
}

function PromiseFileReader(file) {
    return new Promise((resolve, reject) => {
        let reader = new FileReader()
        reader.readAsText(file)
        reader.onload = () => resolve(JSON.parse(reader.result))
    })
}

function PromiseSelector(selector, anchor = document) {
    return new Promise((resolve, reject) => {
        let quota = 15
        let timer = setInterval(() => {
            let node = anchor.querySelector(selector)
            if (node) {
                clearInterval(timer)
                resolve(node)
            }
            if (--quota === 0) {
                clearInterval(timer)
                reject()
            }
        }, 200)
    })
}