Greasy Fork

Greasy Fork is available in English.

Bilibili 收藏集奖励筛查脚本

调用 API 来收集自己的 Bilibili 收藏集,并筛选未领取的奖励。注意,一套收藏集中至少存在一张卡牌才能本项目的接口被检测到!

安装此脚本
作者推荐脚本

您可能也喜欢Bilibili 动态筛选

安装此脚本
// ==UserScript==
// @name         Bilibili 收藏集奖励筛查脚本
// @namespace    Schwi
// @version      1.8
// @description  调用 API 来收集自己的 Bilibili 收藏集,并筛选未领取的奖励。注意,一套收藏集中至少存在一张卡牌才能本项目的接口被检测到!
// @author       Schwi
// @match        *://*.bilibili.com/*
// @connect      api.bilibili.com
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @noframes
// @supportURL   https://github.com/cyb233/script
// @icon         https://www.bilibili.com/favicon.ico
// @license      GPL-3.0
// ==/UserScript==

(function () {
    "use strict";

    let collectionCount = 0; // 收藏集数量
    let totalCardNum = 0; // 卡片总数

    const REDEEM_ITEM_TYPE = {
        Card: 1,
        Emoji: 2,
        Pendant: 3,
        Suit: 4,
        MaterialCombination: 5,
        AudioCard: 6,
        Jump: 7,
        Cdk: 8,
        RealGoods: 9,
        LimitMaterialCombination: 10,
        CustomReward: 11,
        DynamicEmoji: 15,
        DiamondAvatar: 1000,
        CollectorMedal: 1001,
    };

    /**
     * 别问我为啥这么写,B站前端JS就是这么判断的
     *
     * @param {object} reward 每条奖励的信息
     * @param {string} scene 不知道是啥,还没研究明白,先这么写着
     * @returns boolean 这个奖励是否能被领取
     */
    function canGetReward(reward, scene = "milestone") {
        const curTime = new Date().getTime();
        const has_redeemed_cnt = reward.has_redeemed_cnt;
        const redeem_item_type = reward.redeem_item_type;
        const total_stock = reward.total_stock;
        const remain_stock = reward.remain_stock;
        const redeem_cond_type = reward.redeem_cond_type;
        const owned_item_amount = reward.owned_item_amount;
        const require_item_amount = reward.require_item_amount;
        const unlock_condition = reward.unlock_condition;
        const redeem_count = reward.redeem_count;
        const end_time = reward.end_time;
        const unlock_condition_1 = unlock_condition || {};
        const unlocked = unlock_condition_1.unlocked;
        const lock_type = unlock_condition_1.lock_type;
        const unlock_threshold = unlock_condition_1.unlock_threshold;
        const expire_at = unlock_condition_1.expire_at;
        let exceedReceiveTime = false;
        if ([REDEEM_ITEM_TYPE.CollectorMedal, REDEEM_ITEM_TYPE.DiamondAvatar].includes(redeem_item_type)) {
            exceedReceiveTime = curTime > end_time;
        } else {
            if (!(curTime > end_time)) {
                exceedReceiveTime = true;
            }
            if (!reward.effective_forever) {
                exceedReceiveTime = true;
            }
        }
        if (unlocked || "milestone" === scene) {
            if (!(has_redeemed_cnt && [REDEEM_ITEM_TYPE.CustomReward].includes(redeem_item_type))) {
                if (!(has_redeemed_cnt && "card_number" !== redeem_cond_type)) {
                    if (!((+total_stock > -1 && +remain_stock <= 0) || exceedReceiveTime)) {
                        if (!("custom" === redeem_cond_type || [REDEEM_ITEM_TYPE.DiamondAvatar].includes(redeem_item_type))) {
                            if (!((owned_item_amount || 0) < require_item_amount)) {
                                return true
                            }
                        }
                    }
                }
            }
        }
        return false
    }

    const defaultFilters = {
        已集齐: { type: "checkbox", filter: (item, input) => item.owned >= item.total },
        未集齐: { type: "checkbox", filter: (item, input) => item.owned < item.total },
        未领奖励: {
            type: "checkbox", filter: (item, input) =>
                item.lottery.collect_list.collect_infos?.some(
                    (lottery) =>
                        canGetReward(lottery)
                )
                ||
                item.lottery.collect_list.collect_chain?.some(
                    (lottery) =>
                        canGetReward(lottery)
                )
        },
        搜索: {
            type: "text",
            filter: (item, input) => {
                const searchText = input.toLocaleUpperCase();
                const title = item.title.toLocaleUpperCase();
                const name = item.name.toLocaleUpperCase();
                const userinfos = item.act.related_user_infos;

                return title.includes(searchText) || name.includes(searchText) ||
                    (userinfos && Object.values(userinfos).some(userinfo => {
                        const userName = userinfo.nickname.toLocaleUpperCase();
                        const userId = userinfo.uid.toString().toLocaleUpperCase();
                        return userName.includes(searchText) || userId.includes(searchText);
                    }))
            }
        },
        排序: {
            type: "select",
            options: [
                { value: "按拥有卡片数量", text: "按拥有卡片数量", sort: (a, b) => b.num - a.num },
                { value: "按名称", text: "按名称", sort: (a, b) => a.title.localeCompare(b.title) },
                { value: "按卡池大小", text: "按卡池大小", sort: (a, b) => b.total - a.total },
                { value: "按集齐卡片数量", text: "按集齐卡片数量", sort: (a, b) => b.owned - a.owned },
                { value: "按销量", text: "按销量", sort: (a, b) => b.sale - a.sale }
            ],
            filter: (item, input) => true
        }
    };

    // 创建进度条容器
    function createProgressBar(totalTasks) {
        const progressContainer = document.createElement("div");
        progressContainer.style.position = "fixed";
        progressContainer.style.top = "50%";
        progressContainer.style.left = "50%";
        progressContainer.style.transform = "translate(-50%, -50%)";
        progressContainer.style.width = "80%";
        progressContainer.style.padding = "10px";
        progressContainer.style.backgroundColor = "#fff";
        progressContainer.style.borderRadius = "10px";
        progressContainer.style.boxShadow = "0 4px 8px rgba(0, 0, 0, 0.2)";
        progressContainer.style.zIndex = "10000";
        progressContainer.style.textAlign = "center";

        const progressTitle = document.createElement("h3");
        progressTitle.textContent = "任务进行中...";
        progressContainer.appendChild(progressTitle);

        const progressBar = document.createElement("progress");
        progressBar.style.width = "100%";
        progressBar.max = totalTasks;
        progressBar.value = 0;
        progressContainer.appendChild(progressBar);

        const progressText = document.createElement("p");
        progressText.style.marginTop = "10px";
        progressText.textContent = `0/${totalTasks} 完成`;
        progressContainer.appendChild(progressText);

        document.body.appendChild(progressContainer);

        return {
            update: function (currentTask) {
                progressBar.value = currentTask;
                progressText.textContent = `${currentTask}/${totalTasks} 完成`;
            },
            hide: function () {
                document.body.removeChild(progressContainer);
            }
        };
    }

    // 工具函数:创建 dialog
    function createDialog(id, title, content) {
        let dialog = document.createElement('div');
        dialog.id = id;
        dialog.style.position = 'fixed';
        dialog.style.top = '5%';
        dialog.style.left = '5%';
        dialog.style.width = '90%';
        dialog.style.height = '90%';
        dialog.style.backgroundColor = '#fff';
        dialog.style.border = '1px solid #ccc';
        dialog.style.boxShadow = '0 0 10px rgba(0,0,0,0.5)';
        dialog.style.zIndex = '9999';
        dialog.style.display = 'none';
        dialog.style.overflow = 'hidden';

        let header = document.createElement('div');
        header.style.display = 'flex';
        header.style.justifyContent = 'space-between';
        header.style.alignItems = 'center';
        header.style.padding = '10px';
        header.style.borderBottom = '1px solid #ccc';
        header.style.backgroundColor = '#f9f9f9';

        let titleElement = document.createElement('span');
        titleElement.textContent = title;
        header.appendChild(titleElement);

        let closeButton = document.createElement('button');
        closeButton.textContent = '关闭';
        closeButton.style.backgroundColor = '#ff4d4f';
        closeButton.style.color = '#fff';
        closeButton.style.border = 'none';
        closeButton.style.borderRadius = '5px';
        closeButton.style.cursor = 'pointer';
        closeButton.style.padding = '5px 10px';
        closeButton.style.transition = 'background-color 0.3s';
        closeButton.onmouseover = () => { closeButton.style.backgroundColor = '#d93637'; };
        closeButton.onmouseout = () => { closeButton.style.backgroundColor = '#ff4d4f'; };
        closeButton.onclick = () => dialog.remove();
        header.appendChild(closeButton);

        dialog.appendChild(header);

        let contentArea = document.createElement('div');
        contentArea.innerHTML = content;
        contentArea.style.padding = '10px';
        dialog.appendChild(contentArea);

        document.body.appendChild(dialog);

        return {
            dialog: dialog,
            header: header,
            titleElement: titleElement,
            closeButton: closeButton,
            contentArea: contentArea
        };
    }

    // 发起 API 请求的函数
    function apiRequest(url, callback, retryCount = 0) {
        // 为url添加时间戳参数防范风控
        const ts = Date.now();
        let urlObj = new URL(url, location.origin);
        urlObj.searchParams.set('_ts', ts);
        const finalUrl = urlObj.toString();

        console.debug(`正在请求: ${finalUrl}`);
        GM_xmlhttpRequest({
            method: "GET",
            url: finalUrl,
            onload: function (response) {
                try {
                    const data = JSON.parse(response.responseText);
                    console.debug(`来自 ${finalUrl} 的响应:`, data);
                    callback(data);
                } catch (error) {
                    console.error(`解析来自 ${finalUrl} 的响应时出错:`, error);
                    callback(null);
                }
            },
            onerror: function (error) {
                console.error(`请求 ${finalUrl} 失败:`, error);
                // 失败重试,最多3次,每次等待1秒
                if (retryCount < 3) {
                    setTimeout(() => {
                        apiRequest(url, callback, retryCount + 1);
                    }, 1000);
                } else {
                    callback(null);
                }
            },
        });
    }

    // 显示筛选结果的对话框
    function showResultsDialog(collectList) {
        const { dialog, titleElement } = createDialog('resultsDialog', `收藏集(${collectList.length}/${collectList.length}/${collectionCount})总卡片张数 ${totalCardNum}`, '');

        let gridContainer = document.createElement('div');
        gridContainer.style.display = 'grid';
        gridContainer.style.gridTemplateColumns = 'repeat(auto-fill,minmax(200px,1fr))';
        gridContainer.style.gap = '10px';
        gridContainer.style.padding = '10px';
        gridContainer.style.height = 'calc(90% - 50px)';
        gridContainer.style.overflowY = 'auto';
        gridContainer.style.alignContent = 'flex-start';

        const deal = (collectList) => {
            let checkedFilters = [];
            let sortOption = defaultFilters["排序"].options[0]; // 默认排序
            for (let key in defaultFilters) {
                const f = defaultFilters[key];
                const filter = filterButtonsContainer.querySelector(`#${key}`);
                let checkedFilter;
                switch (f.type) {
                    case 'checkbox':
                        checkedFilter = { ...f, value: filter.checked };
                        break;
                    case 'text':
                        checkedFilter = { ...f, value: filter.value };
                        break;
                    case 'select':
                        checkedFilter = { ...f, value: filter.value };
                        // 记录当前排序选项
                        sortOption = f.options.find(opt => opt.value === filter.value) || f.options[0];
                        break;
                }
                checkedFilters.push(checkedFilter);
            }
            collectList.forEach(item => {
                item.display = checkedFilters.every(f => f.type === "select" ? true : (f.value ? f.filter(item, f.value) : true));
            });

            // 排序
            collectList.sort(sortOption.sort);

            const filteredList = collectList.filter(item => item.display);
            const filteredTotalCards = filteredList.reduce((sum, item) => sum + item.num, 0); // 计算筛选后的总卡片张数
            titleElement.textContent = `收藏集(${filteredList.length}/${collectList.length}/${collectionCount})总卡片张数 ${totalCardNum}`;

            observer.disconnect();
            renderedCount = 0;
            gridContainer.innerHTML = '';
            renderBatch();
        };

        // 封装生成筛选按钮的函数
        const createFilterButtons = (filters, list) => {
            let mainContainer = document.createElement('div');
            mainContainer.style.display = 'flex';
            mainContainer.style.flexWrap = 'wrap';
            mainContainer.style.width = '100%';

            for (let key in filters) {
                let filter = filters[key];
                let input;
                if (filter.type === 'select') {
                    input = document.createElement('select');
                    input.id = key;
                    input.style.marginRight = '5px';
                    filter.options.forEach(opt => {
                        let option = document.createElement('option');
                        option.value = opt.value;
                        option.textContent = opt.text;
                        input.appendChild(option);
                    });
                } else {
                    input = document.createElement('input');
                    input.type = filter.type;
                    input.id = key;
                    input.style.marginRight = '5px';
                    if (filter.type === 'text') {
                        input.style.border = '1px solid #ccc';
                        input.style.padding = '5px';
                        input.style.borderRadius = '5px';
                    }
                }

                let label = document.createElement('label');
                label.htmlFor = key;
                label.textContent = key;
                label.style.display = 'flex';
                label.style.alignItems = 'center';
                label.style.marginRight = '5px';

                let container = document.createElement('div');
                container.style.display = 'flex';
                container.style.alignItems = 'center';
                container.style.marginRight = '10px';

                if (filter.type === 'checkbox' || filter.type === 'radio') {
                    (function (list, filter, input) {
                        input.addEventListener('change', () => deal(list));
                    })(list, filter, input);
                    container.appendChild(input);
                    container.appendChild(label);
                } else if (filter.type === 'select') {
                    (function (list, filter, input) {
                        input.addEventListener('change', () => deal(list));
                    })(list, filter, input);
                    container.appendChild(label);
                    container.appendChild(input);
                } else {
                    let timeout;
                    (function (list, filter, input) {
                        input.addEventListener('input', () => {
                            clearTimeout(timeout);
                            timeout = setTimeout(() => deal(list), 1000);
                        });
                    })(list, filter, input);
                    container.appendChild(label);
                    container.appendChild(input);
                }

                mainContainer.appendChild(container);
            }

            return mainContainer;
        };

        const filterButtonsContainer = document.createElement('div');
        filterButtonsContainer.style.marginBottom = '10px';
        filterButtonsContainer.style.display = 'flex';
        filterButtonsContainer.style.flexWrap = 'wrap';
        filterButtonsContainer.style.gap = '10px';
        filterButtonsContainer.style.padding = '10px';
        filterButtonsContainer.style.alignItems = 'center';

        filterButtonsContainer.appendChild(createFilterButtons(defaultFilters, collectList));

        const createCardItem = (item) => {
            let card = document.createElement('div');
            card.style.position = "relative";
            card.style.border = "1px solid #ddd";
            card.style.borderRadius = "10px";
            card.style.overflow = "hidden";
            card.style.height = "200px";
            card.style.backgroundImage = `url(${item.act.act_square_img})`;
            card.style.backgroundSize = "cover";
            card.style.backgroundPosition = "center";
            card.style.display = "flex";
            card.style.flexDirection = "column";
            card.style.justifyContent = "flex-end";
            card.style.padding = "10px";
            card.style.color = "#fff";

            const numBadge = document.createElement("div");
            numBadge.textContent = item.num;
            numBadge.style.position = "absolute";
            numBadge.style.top = "10px";
            numBadge.style.right = "10px";
            numBadge.style.backgroundColor = "rgba(0, 0, 0, 0.7)";
            numBadge.style.color = "#fff";
            numBadge.style.padding = "5px 10px";
            numBadge.style.borderRadius = "10px";
            numBadge.style.fontSize = "14px";
            numBadge.style.fontWeight = "bold";
            card.appendChild(numBadge);

            const ownedTotalBadge = document.createElement("div");
            ownedTotalBadge.textContent = `${item.owned} / ${item.total}${item.owned === item.total ? ' 👑' : ''}`;
            ownedTotalBadge.style.position = "absolute";
            ownedTotalBadge.style.top = "10px";
            ownedTotalBadge.style.left = "10px";
            ownedTotalBadge.style.backgroundColor = "rgba(0, 0, 0, 0.7)";
            ownedTotalBadge.style.color = "#fff";
            ownedTotalBadge.style.padding = "5px 10px";
            ownedTotalBadge.style.borderRadius = "10px";
            ownedTotalBadge.style.fontSize = "14px";
            ownedTotalBadge.style.fontWeight = "bold";
            card.appendChild(ownedTotalBadge);

            const titleContainer = document.createElement("div");
            titleContainer.style.background = "rgba(0, 0, 0, 0.5)";
            titleContainer.style.backdropFilter = "blur(5px)";
            titleContainer.style.borderRadius = "5px";
            titleContainer.style.padding = "5px";
            titleContainer.style.marginBottom = "5px";

            const cardTitle = document.createElement("div");
            cardTitle.style.fontWeight = "bold";
            cardTitle.style.textShadow = "0 2px 4px rgba(0, 0, 0, 0.8)";
            cardTitle.textContent = item.title;

            const subtitleContainer = document.createElement("div");
            subtitleContainer.style.display = "flex";
            subtitleContainer.style.justifyContent = "space-between";
            subtitleContainer.style.fontSize = "14px";
            subtitleContainer.style.marginTop = "2px";

            const cardSubtitle = document.createElement("span");
            cardSubtitle.textContent = item.name;

            const cardSale = document.createElement("span");
            cardSale.textContent = `销量: ${item.sale}`;

            subtitleContainer.appendChild(cardSubtitle);
            subtitleContainer.appendChild(cardSale);

            titleContainer.appendChild(cardTitle);
            titleContainer.appendChild(subtitleContainer);

            const link = document.createElement("a");
            link.href = item.url;
            link.target = "_blank";
            link.textContent = "查看详情";
            link.style.backgroundColor = "rgba(0, 0, 0, 0.6)";
            link.style.color = "#fff";
            link.style.padding = "5px 10px";
            link.style.borderRadius = "5px";
            link.style.textDecoration = "none";
            link.style.textAlign = "center";

            card.appendChild(titleContainer);
            card.appendChild(link);

            return card;
        };

        const batchSize = 50;
        let renderedCount = 0;

        const renderBatch = () => {
            const renderList = collectList.filter(item => item.display);
            for (let i = 0; i < batchSize && renderedCount < renderList.length; i++, renderedCount++) {
                const cardItem = createCardItem(renderList[renderedCount]);
                cardItem.style.display = renderList[renderedCount].display ? 'flex' : 'none';
                gridContainer.appendChild(cardItem);
            }
            if (renderedCount < renderList.length) {
                observer.observe(gridContainer.lastElementChild);
            } else {
                observer.disconnect();
            }
        };

        const observer = new IntersectionObserver((entries) => {
            if (entries[0].isIntersecting) {
                observer.unobserve(entries[0].target);
                renderBatch();
            }
        });

        collectList.forEach(item => {
            item.display = true;
        });

        renderBatch();

        dialog.appendChild(filterButtonsContainer);
        dialog.appendChild(gridContainer);
        dialog.style.display = 'block';
    }

    // 修改主函数调用筛选结果对话框
    function collectDigitalCards() {
        console.log("开始收集收藏集...");
        const collectionUrl =
            "https://api.bilibili.com/x/vas/smelt/my_decompose/info?scene=1";
        let collectList = [];

        apiRequest(collectionUrl, function (collectionData) {
            if (!collectionData || collectionData.code !== 0) {
                const errorMsg = `获取收藏列表失败: ${collectionData ? collectionData.message : "无响应"}`
                console.error(errorMsg);
                alert(errorMsg)
                return;
            }
            if (!collectionData.data.list) {
                const errorMsg = `获取收藏列表失败: 您没有收藏集`
                console.error(errorMsg);
                alert(errorMsg)
                return;
            }

            totalCardNum = collectionData.data.list.reduce((acc, item) => acc + item.card_num, 0);

            console.log("成功获取收藏列表:", collectionData.data.list);
            console.log("卡片总数:", collectionData.data.list.reduce((acc, item) => acc + item.card_num, 0));
            const collections = collectionData.data.list;
            collectionCount = collections.length;
            let processedCollections = 0;

            const progressBar = createProgressBar(collectionCount);

            collections.forEach((collection, index) => {
                console.debug(`处理收藏: ${collection.act_name}(ID: ${collection.act_id})`);
                const detailUrl = `https://api.bilibili.com/x/vas/dlc_act/act/basic?act_id=${collection.act_id}`;

                apiRequest(detailUrl, function (detailData) {
                    if (!detailData || detailData.code !== 0) {
                        console.error(
                            `获取 ${collection.act_name}(act_id:${collection.act_id}) 的基本信息失败:`,
                            detailData ? detailData.message : "无响应"
                        );
                        processedCollections++;
                        progressBar.update(processedCollections);
                        checkCompletion();
                        return;
                    }

                    console.debug(
                        `成功获取 ${collection.act_name}(act_id:${collection.act_id}) 的基本信息:`,
                        detailData.data
                    );
                    const lotteries = detailData.data.lottery_list;
                    let processedLotteries = 0;

                    lotteries.forEach((lottery) => {
                        console.debug(
                            `处理详情: ${lottery.lottery_name} (ID: ${lottery.lottery_id})`
                        );
                        const item_owned_cnt = lottery.item_owned_cnt;
                        const item_total_cnt = lottery.item_total_cnt;
                        const total_sale_amount = lottery.total_sale_amount;

                        const cardDetailUrl = `https://api.bilibili.com/x/vas/dlc_act/lottery_home_detail?act_id=${collection.act_id}&lottery_id=${lottery.lottery_id}`;

                        apiRequest(cardDetailUrl, function (cardData) {
                            if (!cardData || cardData.code !== 0) {
                                console.error(
                                    `获取 ${collection.act_name}(act_id:${collection.act_id}&lottery_id:${lottery.lottery_id}) 的详情失败:`,
                                    cardData ? cardData.message : "无响应"
                                );
                                processedLotteries++;
                                progressBar.update(processedCollections);
                                checkLotteryCompletion();
                                return;
                            }

                            console.debug(
                                `成功获取 ${collection.act_name}[${cardData.data.name}](act_id:${collection.act_id}&lottery_id:${lottery.lottery_id}) 的详情:`,
                                cardData.data
                            );
                            collectList.push({
                                title: detailData.data.act_title,
                                name: cardData.data.name,
                                num: collection.card_num,
                                owned: item_owned_cnt,
                                total: item_total_cnt,
                                sale: total_sale_amount,
                                url: `https://www.bilibili.com/blackboard/activity-Mz9T5bO5Q3.html?id=${collection.act_id}&type=dlc`,
                                act: detailData.data,
                                lottery: cardData.data
                            });
                            processedLotteries++;
                            progressBar.update(processedCollections);
                            checkLotteryCompletion();
                        });

                        function checkLotteryCompletion() {
                            if (processedLotteries === lotteries.length) {
                                processedCollections++;
                                progressBar.update(processedCollections);
                                checkCompletion();
                            }
                        }
                    });
                });

            });

            function checkCompletion() {
                if (processedCollections === collectionCount) {
                    console.log("所有收藏已处理。");
                    console.log("最终收集列表:", collectList);

                    collectList = collectList.filter((collectItem) => collectItem.owned);

                    progressBar.hide();
                    showResultsDialog(collectList);
                }
            }
        });
    }

    GM_registerMenuCommand("检查收藏集", collectDigitalCards);
})();