Bilibili 盲盒统计

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

安裝腳本?
作者推薦腳本

您可能也會喜歡 Bilibili 动态筛选

安裝腳本
// ==UserScript==
// @name         Bilibili 盲盒统计
// @namespace    Schwi
// @version      0.4
// @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;
        }
      }
    }
  }

  // https://api.live.bilibili.com/gift/v3/live/gift_config
  /*
  const resp = await fetch('https://api.live.bilibili.com/gift/v3/live/gift_config').then(resp => resp.json())
  const gifts = resp.data
  gifts.find(gift => gift.id === 1)?.name
  gifts.find(gift => gift.name === '')?.id
  */
  // 盲盒信息,percentage为官方公示概率(不包含活动倍率)
  let giftInfo;
  try {
    giftInfo = await apiRequest('https://shuvi.moe/sync-bilibili-gifts/bili-gift-box.json');
  } catch (error) {
    console.error('获取盲盒信息失败:', error);
    // 如果获取失败,使用本地存储的最基本的盲盒信息
    giftInfo = {
      "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);

    // {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
  function showResultsDialog(groupedGiftStats) {
    const { dialog, titleElement, closeButton, contentArea } = createDialog('resultsDialog', '盲盒统计结果', '');

    // 获取排序后的 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);

})();