Greasy Fork

Greasy Fork is available in English.

哔哩哔哩(B站|Bilibili)收藏夹Fix(隐藏视频检测)

检测收藏夹中被UP主设置为仅自己可见的视频

目前为 2024-12-24 提交的版本,查看 最新版本

// ==UserScript==
// @name              bilibili favlist hidden video detection
// @name:zh-CN        哔哩哔哩(B站|Bilibili)收藏夹Fix(隐藏视频检测)
// @name:zh-TW        嗶哩嗶哩(B站|Bilibili)收藏夾Fix(隱藏影片檢測)
// @namespace         http://tampermonkey.net/
// @version           7
// @description       detect videos in favlist that only visiable to upper
// @description:zh-CN 检测收藏夹中被UP主设置为仅自己可见的视频
// @description:zh-TW 檢測收藏夾中被UP主設定為僅自己可見的影片
// @author            YTB0710
// @match             https://space.bilibili.com/*
// @connect           bilibili.com
// @grant             GM_openInTab
// @grant             GM_setValue
// @grant             GM_getValue
// @grant             GM_xmlhttpRequest
// @grant             GM_cookie
// ==/UserScript==

(function () {
    'use strict';

    const localizedText = {
        'UPDATES': {
            'zh-CN': '更新内容:<br>其他: 优化脚本部分逻辑',
            'zh-TW': '更新內容:<br>其他: 優化腳本部分邏輯'
        },
        'VIDEOS_PER_PAGE': {
            'zh-CN': '每页展示视频数量',
            'zh-TW': '每頁顯示影片數量'
        },
        'VIDEOS_PER_PAGE_WITH_PROMPT': {
            'zh-CN': '每页最多可以展示的视频数量(一般为36或40)',
            'zh-TW': '每頁最多可以顯示的影片數量(一般為36或40)'
        },
        'INPUT_AV_OR_BV_HERE': {
            'zh-CN': '在此输入AV号或BV号',
            'zh-TW': '在此輸入AV號或BV號'
        },
        'DETECT_HIDDEN_VIDEO': {
            'zh-CN': '检测隐藏视频',
            'zh-TW': '檢測隱藏影片'
        },
        'GET_VIDEO_INFO': {
            'zh-CN': '查询视频信息',
            'zh-TW': '查詢影片資訊'
        },
        'REMOVE_VIDEO': {
            'zh-CN': '取消收藏',
            'zh-TW': '取消收藏'
        },
        'ADD_VIDEO': {
            'zh-CN': '添加收藏',
            'zh-TW': '新增收藏'
        },
        'INPUT_AV': {
            'zh-CN': '请输入AV号',
            'zh-TW': '請輸入AV號'
        },
        'INPUT_BV': {
            'zh-CN': '请输入BV号',
            'zh-TW': '請輸入BV號'
        },
        'POSSIBLE_OPERATIONS_IF_ERROR': {
            'zh-CN': '如果出现问题, 以下操作可能有帮助: 切换至按最近收藏排序, 刷新页面',
            'zh-TW': '如果出現問題, 以下操作可能有幫助: 切換至按最近收藏排序, 重新載入頁面'
        },
        'POSSIBLE_OPERATIONS_IF_ERROR_NEW_FRESH_PAGE': {
            'zh-CN': '如果出现问题, 以下操作可能有帮助: 检查每页展示视频数量是否正确, 切换至按最近收藏排序, 刷新页面',
            'zh-TW': '如果出現問題, 以下操作可能有幫助: 檢查每頁顯示影片數量是否正確, 切換至按最近收藏排序, 重新載入頁面'
        },
        'NO_HIDDEN_VIDEO_ON_THIS_PAGE': {
            'zh-CN': '本页没有隐藏的视频',
            'zh-TW': '本頁沒有隱藏的影片'
        },
        'POSITION_ON_THIS_PAGE': {
            'zh-CN': '在本页的位置',
            'zh-TW': '在本頁的位置'
        },
        'POSITION_ON_THIS_PAGE_WITH_PROMPT': {
            'zh-CN': '在本页的位置(从1开始)',
            'zh-TW': '在本頁的位置(從1開始)'
        },
        'AV': {
            'zh-CN': 'AV号',
            'zh-TW': 'AV號'
        },
        'BV': {
            'zh-CN': 'BV号',
            'zh-TW': 'BV號'
        },
        'API_RESPONSE_CONTENT': {
            'zh-CN': 'B站接口响应内容:',
            'zh-TW': 'B站介面回應內容:'
        },
        'FID_NOT_FOUND_ERROR': {
            'zh-CN': '无法获取当前收藏夹的fid, 刷新页面可能有帮助',
            'zh-TW': '無法獲取當前收藏夾的fid, 重新載入頁面可能有幫助'
        },
        'INVALID_VIDEOS_PER_PAGE_ERROR': {
            'zh-CN': '每页展示视频数量不正确, 请重新输入',
            'zh-TW': '每頁顯示影片數量不正確, 請重新輸入'
        },
        'REQUEST_TIMEOUT_ERROR': {
            'zh-CN': '请求超时',
            'zh-TW': '請求逾時'
        },
        'REQUEST_FAILED_ERROR': {
            'zh-CN': '请求失败',
            'zh-TW': '請求失敗'
        },
        'COOKIE_READ_ERROR': {
            'zh-CN': '无法读取cookie, 更新Tampermonkey可能有帮助',
            'zh-TW': '無法讀取cookie, 更新Tampermonkey可能有幫助'
        },
        'UNKNOWN_ERROR': {
            'zh-CN': '发生未知错误, 请反馈该问题',
            'zh-TW': '發生未知錯誤, 請反饋該問題'
        },
    };

    const currentVersion = 7;
    const preferredLanguage = getPreferredLanguage();

    const AVRegex = /^[1-9]\d*$/;
    const BVRegex = /^BV[A-Za-z0-9]{10}$/;
    const startsWithAVRegex = /^av/i;
    const favlistURLRegex = /https:\/\/space\.bilibili\.com\/\d+\/favlist.*/;
    const pagenationCountRegex = /共 (\d+) 页 \/ (\d+) 个/;
    const fidFromURLRegex = /fid=(\d+)/;
    const BVFromURLRegex = /video\/(\w{12})/;

    let onFavlistPage = false;

    const sideObserver = new MutationObserver((mutations, observer) => {
        if (document.querySelector('div.favlist-aside')) {
            observer.disconnect();
            main(true);
            return;
        }
        if (document.querySelector('div.fav-sidenav')) {
            observer.disconnect();
            main(false);
            return;
        }
    });

    checkURL();

    const originalPushState = history.pushState;
    history.pushState = function (...args) {
        originalPushState.apply(this, args);
        checkURL();
    };

    const originalReplaceState = history.replaceState;
    history.replaceState = function (...args) {
        originalReplaceState.apply(this, args);
        checkURL();
    };

    window.addEventListener('popstate', checkURL);

    function checkURL() {
        if (favlistURLRegex.test(location.href)) {
            if (!onFavlistPage) {
                onFavlistPage = true;
                sideObserver.observe(document.body, { subtree: true, childList: true, attributes: false, characterData: false });
            }
        } else {
            if (onFavlistPage) {
                onFavlistPage = false;
                sideObserver.disconnect();
            }
        }
    }

    function getPreferredLanguage() {
        const languages = navigator.languages || [navigator.language];
        for (const lang of languages) {
            if (lang === 'zh-CN') {
                return 'zh-CN';
            }
            if (lang === 'zh-TW') {
                return 'zh-TW';
            }
            if (lang === 'zh-HK') {
                return 'zh-TW';
            }
        }
        return 'zh-CN';
    }

    function getLocalizedText(key) {
        return localizedText[key][preferredLanguage];
    }

    function main(newFreshSpace) {

        const newFreshSpaceAppend = newFreshSpace ? '-newFreshSpace' : '';

        let videosPerPage;
        if (newFreshSpace) {
            videosPerPage = window.innerWidth < 1760 ? 40 : 36;
        } else {
            videosPerPage = 20;
        }

        const storedVersion = GM_getValue('version', 0);
        let displayUpdate = false;
        if (storedVersion !== currentVersion) {
            GM_setValue('version', currentVersion);
            if (storedVersion) {
                displayUpdate = true;
            }
        }

        const usageCount = GM_getValue(newFreshSpace ? 'usageCountNewFreshSpace' : 'usageCount', 0);
        const displayPrompt = usageCount < 10 ? '_WITH_PROMPT' : '';
        if (displayPrompt) {
            GM_setValue(newFreshSpace ? 'usageCountNewFreshSpace' : 'usageCount', usageCount + 1);
        }

        const style = document.createElement('style');
        style.textContent = `
            .fix-div {
                padding: 2px;
            }
            .fix-div-newFreshSpace {
                padding: 2px 0;
            }
            .fix-divControls {
                border-top: 1px solid #e4e9f0;
            }
            .fix-divMessage {
                line-height: 1.5;
            }
            .fix-inputTextA-newFreshSpace, .fix-inputTextB, .fix-inputTextB-newFreshSpace {
                box-sizing: content-box;
                border: 1px solid #cccccc;
                border-radius: 3px;
                line-height: 1;
            }
            .fix-inputTextA-newFreshSpace {
                width: 20px;
                height: 16px;
                padding: 4px;
                font-size: 16px;
            }
            .fix-inputTextB {
                width: 150px;
                height: 14px;
                padding: 3px;
                font-size: 14px;
            }
            .fix-inputTextB-newFreshSpace {
                width: 170px;
                height: 16px;
                padding: 4px;
                font-size: 16px;
            }
            .fix-labelA-newFreshSpace {
                line-height: 1;
            }
            .fix-button, .fix-button-newFreshSpace {
                border: 1px solid #cccccc;
                border-radius: 3px;
                line-height: 1;
                cursor: pointer;
            }
            .fix-button {
                padding: 3px;
                font-size: 14px;
            }
            .fix-button-newFreshSpace {
                padding: 4px;
                font-size: 16px;
            }
        `;
        document.head.appendChild(style);

        const divSide = document.querySelector(newFreshSpace ? 'div.favlist-aside' : 'div.fav-sidenav');
        if (!newFreshSpace) {
            divSide.querySelector('a.watch-later').style.borderBottom = '1px solid #eeeeee';
        }

        const divControls = document.createElement('div');
        divControls.classList.add('fix-div' + newFreshSpaceAppend);
        if (!newFreshSpace) {
            divControls.classList.add('fix-divControls');
        }
        divSide.appendChild(divControls);

        let inputTextA;
        if (newFreshSpace) {
            const divVideosPerPage = document.createElement('div');
            divVideosPerPage.classList.add('fix-div-newFreshSpace');
            divControls.appendChild(divVideosPerPage);

            const labelA = document.createElement('label');
            labelA.classList.add('fix-labelA-newFreshSpace');
            labelA.innerText = getLocalizedText('VIDEOS_PER_PAGE' + displayPrompt);
            divVideosPerPage.appendChild(labelA);

            inputTextA = document.createElement('input');
            inputTextA.type = 'text';
            inputTextA.classList.add('fix-inputTextA-newFreshSpace');
            inputTextA.value = videosPerPage;
            labelA.insertAdjacentElement('beforeend', inputTextA);
        }

        const divInputTextB = document.createElement('div');
        divInputTextB.classList.add('fix-div' + newFreshSpaceAppend);
        divControls.appendChild(divInputTextB);

        const inputTextB = document.createElement('input');
        inputTextB.type = 'text';
        inputTextB.classList.add('fix-inputTextB' + newFreshSpaceAppend);
        inputTextB.placeholder = getLocalizedText('INPUT_AV_OR_BV_HERE');
        divInputTextB.appendChild(inputTextB);

        const divButtonA = document.createElement('div');
        divButtonA.classList.add('fix-div' + newFreshSpaceAppend);
        divControls.appendChild(divButtonA);

        const buttonA = document.createElement('button');
        buttonA.type = 'button';
        buttonA.classList.add('fix-button' + newFreshSpaceAppend);
        buttonA.innerText = getLocalizedText('DETECT_HIDDEN_VIDEO');
        buttonA.addEventListener('click', async () => {
            try {
                clearMessage();

                let fid;
                if (newFreshSpace) {
                    const fidFromURLMatch = location.href.match(fidFromURLRegex);
                    if (fidFromURLMatch) {
                        fid = fidFromURLMatch[1];
                    } else {
                        addMessage(getLocalizedText('FID_NOT_FOUND_ERROR'));
                        return;
                    }
                } else {
                    fid = document.querySelector('.fav-item.cur').getAttribute('fid');
                }

                let currentPageActualVideos;
                if (newFreshSpace) {
                    currentPageActualVideos = document.querySelectorAll('div.bili-video-card__wrap');
                } else {
                    currentPageActualVideos = document.querySelectorAll('li.small-item');
                }

                if (newFreshSpace) {
                    // const pagenationBtnNums = document.querySelectorAll('button.vui_pagenation--btn-num');
                    // const totalPages = parseInt(pagenationBtnNums[pagenationBtnNums.length - 1].innerText, 10);
                    // const totalVideos = parseInt(document.querySelector('.vui_sidebar-item--active > div.vui_sidebar-item-right').innerText, 10);

                    const spanPagenationGoCount = document.querySelector('span.vui_pagenation-go__count');
                    if (spanPagenationGoCount) {
                        const pagenationCountMatch = spanPagenationGoCount.innerText.match(pagenationCountRegex);
                        const totalPages = parseInt(pagenationCountMatch[1], 10);
                        const totalVideos = parseInt(pagenationCountMatch[2], 10);

                        if (totalPages !== 1) {
                            videosPerPage = parseInt(inputTextA.value, 10);
                            if (videosPerPage < currentPageActualVideos.length || videosPerPage < Math.ceil(totalVideos / totalPages) || videosPerPage > Math.floor((totalVideos - 1) / (totalPages - 1))) {
                                addMessage(getLocalizedText('INVALID_VIDEOS_PER_PAGE_ERROR'));
                                return;
                            }
                        }
                    }
                }

                let currentPage;
                if (newFreshSpace) {
                    const pagenation = document.querySelector('button.vui_pagenation--btn-num.vui_button--active');
                    if (!pagenation) {
                        currentPage = 1;
                    } else {
                        currentPage = parseInt(pagenation.innerText, 10);
                    }
                } else {
                    currentPage = parseInt(document.querySelector('li.be-pager-item-active > a').innerText, 10);
                }

                addMessage(getLocalizedText('POSSIBLE_OPERATIONS_IF_ERROR' + (newFreshSpace ? '_NEW_FRESH_PAGE' : '')), true);

                const response = await new Promise((resolve, reject) => {
                    GM.xmlHttpRequest({
                        method: 'GET',
                        url: `https://api.bilibili.com/x/v3/fav/resource/ids?media_id=${fid}`,
                        timeout: 5000,
                        responseType: 'json',
                        onload: (res) => resolve(res),
                        onerror: () => reject(Error(getLocalizedText('REQUEST_FAILED_ERROR'))),
                        ontimeout: () => reject(Error(getLocalizedText('REQUEST_TIMEOUT_ERROR')))
                    });
                });
                const currentFavlistAVBVs = response.response.data;
                const startIndex = (currentPage - 1) * videosPerPage;
                const currentPageExpectedAVBVs = currentFavlistAVBVs.slice(startIndex, startIndex + videosPerPage);
                let currentPageActualBVs;
                if (newFreshSpace) {
                    currentPageActualBVs = Array.from(currentPageActualVideos).map(video => video.querySelector('a.bili-cover-card').getAttribute('href').match(BVFromURLRegex)[1]);
                } else {
                    currentPageActualBVs = Array.from(currentPageActualVideos).map(video => video.getAttribute('data-aid'));
                }
                const hiddenAVBVs = currentPageExpectedAVBVs.filter(currentPageExpectedAVBV => !currentPageActualBVs.includes(currentPageExpectedAVBV.bvid));
                if (!hiddenAVBVs.length) {
                    addMessage(getLocalizedText('NO_HIDDEN_VIDEO_ON_THIS_PAGE'));
                    return;
                }
                hiddenAVBVs.forEach(hiddenAVBV => {
                    addMessage(`${getLocalizedText('POSITION_ON_THIS_PAGE' + displayPrompt)}: ${currentPageExpectedAVBVs.findIndex(currentPageExpectedAVBV => currentPageExpectedAVBV.bvid === hiddenAVBV.bvid) + 1}`);
                    addMessage(`${getLocalizedText('AV')}: ${hiddenAVBV.id}`);
                    addMessage(`${getLocalizedText('BV')}: ${hiddenAVBV.bvid}`);
                });

            } catch (error) {
                catchUnknownError(error);
            }
        });
        divButtonA.appendChild(buttonA);

        const divButtonB = document.createElement('div');
        divButtonB.classList.add('fix-div' + newFreshSpaceAppend);
        divControls.appendChild(divButtonB);

        const buttonB = document.createElement('button');
        buttonB.type = 'button';
        buttonB.classList.add('fix-button' + newFreshSpaceAppend);
        buttonB.innerText = getLocalizedText('GET_VIDEO_INFO');
        buttonB.addEventListener('click', () => {
            try {
                const BV = inputTextB.value;
                if (!BVRegex.test(BV)) {
                    addMessage(getLocalizedText('INPUT_BV'));
                    return;
                }
                GM_openInTab(`https://www.biliplus.com/video/${BV}`, { active: true, insert: false, setParent: true });
                GM_openInTab(`https://xbeibeix.com/video/${BV}`, { insert: false, setParent: true });
                GM_openInTab(`https://www.jijidown.com/video/${BV}`, { insert: false, setParent: true });

            } catch (error) {
                catchUnknownError(error);
            }
        });
        divButtonB.appendChild(buttonB);

        const divButtonC = document.createElement('div');
        divButtonC.classList.add('fix-div' + newFreshSpaceAppend);
        divControls.appendChild(divButtonC);

        const buttonC = document.createElement('button');
        buttonC.type = 'button';
        buttonC.classList.add('fix-button' + newFreshSpaceAppend);
        buttonC.innerText = getLocalizedText('REMOVE_VIDEO');
        buttonC.addEventListener('click', () => {
            try {
                GM_cookie.list({ name: 'bili_jct' }, async (cookies, error) => {
                    if (error) {
                        addMessage(getLocalizedText('COOKIE_READ_ERROR'));
                        addMessage(error.stack, true);
                        console.error(error);
                        return;
                    }
                    try {
                        let AV = inputTextB.value;
                        if (startsWithAVRegex.test(AV)) {
                            AV = AV.slice(2);
                        }
                        if (!AVRegex.test(AV)) {
                            addMessage(getLocalizedText('INPUT_AV'));
                            return;
                        }
                        let fid;
                        if (newFreshSpace) {
                            const fidFromURLMatch = location.href.match(fidFromURLRegex);
                            if (fidFromURLMatch) {
                                fid = fidFromURLMatch[1];
                            } else {
                                addMessage(getLocalizedText('FID_NOT_FOUND_ERROR'));
                                return;
                            }
                        } else {
                            fid = document.querySelector('.fav-item.cur').getAttribute('fid');
                        }
                        const csrf = cookies[0].value;
                        const data = `resources=${AV}%3A2&media_id=${fid}&platform=web&csrf=${csrf}`;
                        const response = await new Promise((resolve, reject) => {
                            GM.xmlHttpRequest({
                                method: 'POST',
                                url: 'https://api.bilibili.com/x/v3/fav/resource/batch-del',
                                data: data,
                                timeout: 5000,
                                headers: {
                                    'Content-Length': data.length,
                                    'Content-Type': 'application/x-www-form-urlencoded'
                                },
                                onload: (res) => resolve(res),
                                onerror: () => reject(Error(getLocalizedText('REQUEST_FAILED_ERROR'))),
                                ontimeout: () => reject(Error(getLocalizedText('REQUEST_TIMEOUT_ERROR')))
                            });
                        });
                        const json = response.response;
                        addMessage(getLocalizedText('API_RESPONSE_CONTENT'));
                        addMessage(json, true);

                    } catch (error) {
                        catchUnknownError(error);
                    }
                });

            } catch (error) {
                catchUnknownError(error);
            }
        });
        divButtonC.appendChild(buttonC);

        const divButtonD = document.createElement('div');
        divButtonD.classList.add('fix-div' + newFreshSpaceAppend);
        divControls.appendChild(divButtonD);

        const buttonD = document.createElement('button');
        buttonD.type = 'button';
        buttonD.classList.add('fix-button' + newFreshSpaceAppend);
        buttonD.innerText = getLocalizedText('ADD_VIDEO');
        buttonD.addEventListener('click', () => {
            try {
                GM_cookie.list({ name: 'bili_jct' }, async (cookies, error) => {
                    if (error) {
                        addMessage(getLocalizedText('COOKIE_READ_ERROR'));
                        addMessage(error.stack, true);
                        console.error(error);
                        return;
                    }
                    try {
                        let AV = inputTextB.value;
                        if (startsWithAVRegex.test(AV)) {
                            AV = AV.slice(2);
                        }
                        if (!AVRegex.test(AV)) {
                            addMessage(getLocalizedText('INPUT_AV'));
                            return;
                        }
                        let fid;
                        if (newFreshSpace) {
                            const fidFromURLMatch = location.href.match(fidFromURLRegex);
                            if (fidFromURLMatch) {
                                fid = fidFromURLMatch[1];
                            } else {
                                addMessage(getLocalizedText('FID_NOT_FOUND_ERROR'));
                                return;
                            }
                        } else {
                            fid = document.querySelector('.fav-item.cur').getAttribute('fid');
                        }
                        const csrf = cookies[0].value;
                        const data = `rid=${AV}&type=2&add_media_ids=${fid}&csrf=${csrf}`;
                        const response = await new Promise((resolve, reject) => {
                            GM.xmlHttpRequest({
                                method: 'POST',
                                url: 'https://api.bilibili.com/x/v3/fav/resource/deal',
                                data: data,
                                timeout: 5000,
                                headers: {
                                    'Content-Length': data.length,
                                    'Content-Type': 'application/x-www-form-urlencoded'
                                },
                                onload: (res) => resolve(res),
                                onerror: () => reject(Error(getLocalizedText('REQUEST_FAILED_ERROR'))),
                                ontimeout: () => reject(Error(getLocalizedText('REQUEST_TIMEOUT_ERROR')))
                            });
                        });
                        const json = response.response;
                        addMessage(getLocalizedText('API_RESPONSE_CONTENT'));
                        addMessage(json, true);

                    } catch (error) {
                        catchUnknownError(error);
                    }
                });

            } catch (error) {
                catchUnknownError(error);
            }
        });
        divButtonD.appendChild(buttonD);

        const divMessage = document.createElement('div');
        divMessage.classList.add('fix-div' + newFreshSpaceAppend);
        divMessage.classList.add('fix-divMessage');
        divControls.appendChild(divMessage);

        if (displayUpdate) {
            setTimeout(() => {
                addMessage(getLocalizedText('UPDATES'));
            }, 300);
        }

        function addMessage(msg, smallFontSize) {
            let px;
            if (smallFontSize) {
                px = newFreshSpace ? 11 : 10;
            } else {
                px = newFreshSpace ? 13 : 12;
            }
            const p = document.createElement('p');
            p.innerHTML = msg;
            p.style.fontSize = `${px}px`;
            divMessage.appendChild(p);
            p.scrollIntoView({ behavior: 'instant', block: 'nearest' });
        }

        function clearMessage() {
            while (divMessage.firstChild) {
                divMessage.removeChild(divMessage.firstChild);
            }
        }

        function catchUnknownError(error) {
            addMessage(getLocalizedText('UNKNOWN_ERROR'));
            addMessage(error.stack, true);
            console.error(error);
        }
    }
})();