Greasy Fork

Greasy Fork is available in English.

Bilibili 盲盒统计

调用 API 来收集自己的 Bilibili 盲盒概率,公示概率真的准确吗?(受API限制,获取的记录大约只有最近2个自然月,本脚本会本地持久化储存记录)

当前为 2025-05-06 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Bilibili 盲盒统计
// @namespace    Schwi
// @version      0.7
// @description  调用 API 来收集自己的 Bilibili 盲盒概率,公示概率真的准确吗?(受API限制,获取的记录大约只有最近2个自然月,本脚本会本地持久化储存记录)
// @author       Schwi
// @match        *://*.bilibili.com/*
// @connect      api.live.bilibili.com
// @connect      shuvi.moe
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @noframes
// @supportURL   https://github.com/cyb233/script
// @icon         https://www.bilibili.com/favicon.ico
// @license      GPL-3.0
// ==/UserScript==

(async function () {
  'use strict';

  const api = {
    getBlindBox: (nextId = 0, month = '', pageSize = 100) => `https://api.live.bilibili.com/xlive/fuxi-interface/gift/blindGiftStream?nextId=${nextId}&month=${month}&pageSize=${pageSize}`,
    getBlindBoxByIds: (ids = [], nextId = 0, month = '', size = 100) => `https://api.live.bilibili.com/xlive/fuxi-interface/BlindBoxController/getRecordsByIds?_ts_rpc_args_=[${ids},${nextId},"${month}",${size}]`
  }

  const boxOrder = ['星月盲盒', '心动盲盒', '奇遇盲盒', '闪耀盲盒', '至尊盲盒']

  // API 请求函数
  async function apiRequest(url, retry = 3) {
    for (let attempt = 1; attempt <= retry; attempt++) {
      try {
        const response = await GM.xmlHttpRequest({
          method: 'GET',
          url: url,
        });
        const data = JSON.parse(response.responseText);
        return data;
      } catch (e) {
        if (attempt === retry) {
          throw e;
        }
      }
    }
  }


  // 盲盒信息,percentage为官方公示概率(不包含活动倍率)
  const getGiftInfo = (() => {
    let giftInfo = null;
    return async function () {
      if (giftInfo) {
        return giftInfo;
      }

      try {
        giftInfo = await apiRequest('https://gift.shuvi.moe/bili-gift-box.json');
        return giftInfo;
      } catch (error) {
        console.error('获取盲盒信息失败:', error);
        // 如果获取失败,使用本地存储的最基本的盲盒信息
        return {
          "32649": {
            "id": 32649,
            "name": "星月盲盒",
            "price": 50,
            "gifts": [
              { "id": 32698, "name": "小蛋糕", "price": 15, "percentage": 20, "subGifts": {} },
              { "id": 32694, "name": "星与月", "price": 25, "percentage": 24.3, "subGifts": {} },
              { "id": 32075, "name": "情书", "price": 52, "percentage": 23.15, "subGifts": {} },
              { "id": 34188, "name": "少女祈祷", "price": 66, "percentage": 20, "subGifts": {} },
              { "id": 32695, "name": "冲鸭", "price": 99, "percentage": 10.3, "subGifts": {} },
              { "id": 32700, "name": "星河入梦", "price": 199, "percentage": 2, "subGifts": {} },
              { "id": 32692, "name": "落樱缤纷", "price": 600, "percentage": 0.25, "subGifts": {} }
            ]
          },
          "32251": {
            "id": 32251,
            "name": "心动盲盒",
            "price": 150,
            "gifts": [
              { "id": 32125, "name": "电影票", "price": 20, "percentage": 6, "subGifts": {} },
              { "id": 32126, "name": "棉花糖", "price": 90, "percentage": 44.5, "subGifts": {} },
              { "id": 32128, "name": "爱心抱枕", "price": 160, "percentage": 45.56, "subGifts": {} },
              { "id": 32281, "name": "绮彩权杖", "price": 400, "percentage": 3.7, "subGifts": {} },
              { "id": 34082, "name": "时空之站", "price": 1000, "percentage": 0.12, "subGifts": {} },
              { "id": 34894, "name": "蛇形护符", "price": 2000, "percentage": 0.08, "subGifts": {} },
              { "id": 32132, "name": "浪漫城堡", "price": 22330, "percentage": 0.04, "subGifts": {} }
            ]
          },
          "34052": {
            "id": 34052,
            "name": "奇遇盲盒",
            "price": 330,
            "gifts": [
              { "id": 34059, "name": "魔力球", "price": 50, "percentage": 5, "subGifts": {} },
              { "id": 34058, "name": "精灵兔", "price": 100, "percentage": 41.67, "subGifts": {} },
              { "id": 34057, "name": "许愿神灯", "price": 400, "percentage": 49, "subGifts": {} },
              { "id": 34530, "name": "梦幻花车", "price": 1000, "percentage": 4, "subGifts": {} },
              { "id": 34055, "name": "奇遇巴士", "price": 2000, "percentage": 0.13, "subGifts": {} },
              { "id": 34054, "name": "星愿飞船", "price": 8000, "percentage": 0.1, "subGifts": {} },
              { "id": 32683, "name": "奇幻古堡", "price": 28880, "percentage": 0.1, "subGifts": {} }
            ]
          },
          "32368": {
            "id": 32368,
            "name": "闪耀盲盒",
            "price": 500,
            "gifts": [
              { "id": 32360, "name": "璀璨钻石", "price": 200, "percentage": 9.96, "subGifts": {} },
              { "id": 32359, "name": "旅行日记", "price": 300, "percentage": 36, "subGifts": {} },
              { "id": 34000, "name": "机械幻想", "price": 510, "percentage": 50.1, "subGifts": {} },
              { "id": 34082, "name": "时空之站", "price": 1000, "percentage": 3.4, "subGifts": {} },
              { "id": 34894, "name": "蛇形护符", "price": 2000, "percentage": 0.28, "subGifts": {} },
              { "id": 34895, "name": "金蛇献福", "price": 5000, "percentage": 0.16, "subGifts": {} },
              { "id": 32356, "name": "幻影飞船", "price": 30000, "percentage": 0.1, "subGifts": {} }
            ]
          },
          "32369": {
            "id": 32369,
            "name": "至尊盲盒",
            "price": 1000,
            "gifts": [
              { "id": 32360, "name": "璀璨钻石", "price": 200, "percentage": 0.1, "subGifts": {} },
              { "id": 32281, "name": "绮彩权杖", "price": 400, "percentage": 22.75, "subGifts": {} },
              { "id": 32363, "name": "许愿精灵", "price": 888, "percentage": 35, "subGifts": {} },
              { "id": 33999, "name": "星际启航", "price": 1010, "percentage": 40.14, "subGifts": {} },
              { "id": 34894, "name": "蛇形护符", "price": 2000, "percentage": 1.45, "subGifts": {} },
              { "id": 34895, "name": "金蛇献福", "price": 5000, "percentage": 0.32, "subGifts": {} },
              { "id": 32361, "name": "奇幻之城", "price": 32000, "percentage": 0.24, "subGifts": {} }
            ]
          }
        }
      }
    };
  })();

  // 去重合并记录并存储
  function saveGiftList(newGifts) {
    const storedGifts = GM_getValue('allGiftList', []);
    const mergedGifts = [...storedGifts, ...newGifts].reduce((acc, gift) => {
      if (!acc.some(existingGift => existingGift.id === gift.id)) {
        acc.push(gift);
      }
      return acc;
    }, []);
    GM_setValue('allGiftList', mergedGifts);
    return mergedGifts;
  }

  // 工具函数:创建 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';
    contentArea.style.overflowY = 'auto'; // 允许垂直滚动
    contentArea.style.height = 'calc(100% - 40px)'; // 减去 header 的高度
    dialog.appendChild(contentArea);

    document.body.appendChild(dialog);

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

  // 循环请求盲盒数据
  async function fetchAllBlindBoxes() {
    let nextId = 0;
    let month = '';
    let isMore = 1;

    const allGiftList = [];

    // 创建进度弹窗
    let { dialog: progressDialog, contentArea: progressContentArea } = createDialog('progressDialog', '盲盒数据收集进度', `<p>已收集盲盒数:<span id='collectedCount'>0</span></p>`);
    progressDialog.style.display = 'block';

    while (isMore) {
      try {
        const response = await apiRequest(api.getBlindBox(nextId, month));
        if (response.code === 0 && response.data) {
          const { list, params } = response.data;
          list.forEach(gift => {
            gift.id = parseInt(gift.id, 10);
            gift.originalGiftId = parseInt(gift.originalGiftId, 10);
            gift.giftId = parseInt(gift.giftId, 10);
            gift.giftNum = parseInt(gift.giftNum, 10);
            delete gift.giftImg;
          })
          allGiftList.push(...list);
          console.log('当前盲盒数据:', list, params);
          nextId = params.nextId;
          month = params.month;
          isMore = params.isMore;

          // 更新进度弹窗
          progressContentArea.querySelector('#collectedCount').textContent = allGiftList.length;

        } else {
          console.error('API 返回错误:', response.message);
          break;
        }
      } catch (error) {
        console.error('请求失败:', error);
        break;
      }
    }

    // 关闭进度弹窗
    progressDialog.remove();

    // 去重并存储
    const mergedGiftList = saveGiftList(allGiftList);
    console.log('合并后的盲盒数据:', mergedGiftList);

    const giftInfo = await getGiftInfo()

    // {originalGiftId: {giftId: giftName}} 格式化,仅保存giftInfo中gifts及subGifts中不存在的礼物
    const giftMap = {};
    mergedGiftList.forEach(gift => {
      const { originalGiftId, originalGiftName, giftId, giftName } = gift;
      if (!giftMap[originalGiftId]) {
        giftMap[originalGiftId] = { name: originalGiftName };
      }
      const giftInfoEntry = giftInfo[originalGiftId]?.gifts.find(g => g.id === giftId || Object.values(g.subGifts).some(gift => gift.id === giftId));
      if (!giftInfoEntry) {
        giftMap[originalGiftId][giftId] = giftName;
      }
    });
    console.log('礼物 ID 映射(按 originalGiftId 分组):', giftMap);

    // 根据 originalGiftId 分组统计 giftId 数量
    const groupedGiftStats = {};
    mergedGiftList.forEach(gift => {
      const { originalGiftId, originalGiftName, giftId, giftName, giftNum } = gift;
      if (!groupedGiftStats[originalGiftId]) {
        groupedGiftStats[originalGiftId] = {
          originalGiftName,
          totalCount: 0,
          gifts: {}
        };
      }

      // 检查 giftId 是否属于 subGifts
      let mainGiftId = giftId;
      const giftInfoEntry = giftInfo[originalGiftId]?.gifts.find(g => g.id === giftId || Object.values(g.subGifts).some(gift => gift.id === giftId));
      if (giftInfoEntry) {
        mainGiftId = giftInfoEntry.id;
      }

      if (!groupedGiftStats[originalGiftId].gifts[mainGiftId]) {
        groupedGiftStats[originalGiftId].gifts[mainGiftId] = {
          giftName: giftInfoEntry?.name || giftName,
          count: 0,
          percentage: 0
        };
      }

      groupedGiftStats[originalGiftId].totalCount += giftNum;
      groupedGiftStats[originalGiftId].gifts[mainGiftId].count += giftNum;
    });

    // 计算每个 giftId 的百分比概率
    Object.values(groupedGiftStats).forEach(group => {
      Object.values(group.gifts).forEach(gift => {
        gift.percentage = ((gift.count / group.totalCount) * 100).toFixed(2) + '%';
      });
    });

    console.log('按 originalGiftId 分组的盲盒统计:', groupedGiftStats);

    // 显示结果弹窗
    showResultsDialog(groupedGiftStats);
  }

  // 显示结果 dialog
  async function showResultsDialog(groupedGiftStats) {
    const { dialog, titleElement, closeButton, contentArea } = createDialog('resultsDialog', '盲盒统计结果', '');
    const giftInfo = await getGiftInfo()
    // 获取排序后的 originalGiftId 数组
    const sortedOriginalGiftIds = Object.entries(groupedGiftStats)
      .sort(([originalGiftIdA, groupA], [originalGiftIdB, groupB]) => {
        const nameA = groupA.originalGiftName;
        const nameB = groupB.originalGiftName;

        const indexA = boxOrder.indexOf(nameA);
        const indexB = boxOrder.indexOf(nameB);

        if (indexA === -1 && indexB === -1) return 0;
        if (indexA === -1) return 1;
        if (indexB === -1) return -1;

        return indexA - indexB;
      })
      .map(([originalGiftId]) => originalGiftId);

    // 循环创建每个盲盒的表格
    sortedOriginalGiftIds.forEach(originalGiftId => {
      const group = groupedGiftStats[originalGiftId];

      // 创建标题
      let title = document.createElement('h2');
      title.textContent = `${group.originalGiftName} (总抽数: ${group.totalCount})`;
      title.style.marginTop = '20px';
      contentArea.appendChild(title);

      // 创建表格
      let table = document.createElement('table');
      table.style.width = '100%';
      table.style.borderCollapse = 'collapse';
      table.style.margin = '10px 0';

      // 创建表头
      let thead = table.createTHead();
      let headerRow = thead.insertRow();
      let headers = ['礼物名称', '数量', '你的概率', '公示概率'];
      headers.forEach(headerText => {
        let th = document.createElement('th');
        th.textContent = headerText;
        th.style.padding = '8px';
        th.style.border = '1px solid #ddd';
        th.style.textAlign = 'left';
        headerRow.appendChild(th);
      });

      // 创建表体
      let tbody = table.createTBody();

      // 获取排序后的 gifts 数组
      const sortedGifts = Object.entries(group.gifts).sort(([giftIdA, giftA], [giftIdB, giftB]) => {
        const giftInfoA = giftInfo[originalGiftId]?.gifts.find(g => g.id === parseInt(giftIdA));
        const giftInfoB = giftInfo[originalGiftId]?.gifts.find(g => g.id === parseInt(giftIdB));

        if (!giftInfoA && !giftInfoB) return 0;
        if (!giftInfoA) return 1;
        if (!giftInfoB) return -1;

        const indexA = giftInfo[originalGiftId].gifts.indexOf(giftInfoA);
        const indexB = giftInfo[originalGiftId].gifts.indexOf(giftInfoB);
        return indexA - indexB;
      });

      sortedGifts.forEach(([giftId, gift]) => {
        let row = tbody.insertRow();
        let cell1 = row.insertCell();
        let cell2 = row.insertCell();
        let cell3 = row.insertCell();
        let cell4 = row.insertCell();

        let giftLink = document.createElement('a');
        giftLink.href = `https://shuvi.moe/sync-bilibili-gifts/#${giftId}`;
        giftLink.textContent = gift.giftName;
        giftLink.target = '_blank'; // 在新标签页中打开
        cell1.appendChild(giftLink);

        cell2.textContent = gift.count;
        cell3.textContent = gift.percentage;

        // 获取公示概率
        const officialPercentage = giftInfo[originalGiftId]?.gifts.find(g => g.id === parseInt(giftId))?.percentage;
        cell4.textContent = officialPercentage ? officialPercentage + '%' : 'N/A';

        [cell1, cell2, cell3, cell4].forEach(cell => {
          cell.style.padding = '8px';
          cell.style.border = '1px solid #ddd';
          cell.style.textAlign = 'left';
        });
      });

      // 将表格添加到弹窗内容区域
      contentArea.appendChild(table);
    });

    dialog.style.display = 'block';
  }

  // 注册菜单项
  GM_registerMenuCommand("检查盲盒数据", fetchAllBlindBoxes);

})();