Greasy Fork

Greasy Fork is available in English.

blivemedal

拯救B站直播换牌子的用户体验

当前为 2024-06-04 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         blivemedal
// @namespace    http://tampermonkey.net/
// @version      0.10.2
// @description  拯救B站直播换牌子的用户体验
// @author       xfgryujk
// @include      /https?:\/\/live\.bilibili\.com\/?\??.*/
// @include      /https?:\/\/live\.bilibili\.com\/\d+\??.*/
// @include      /https?:\/\/live\.bilibili\.com\/(blanc\/)?\d+\??.*/
// @require      https://lf6-cdn-tos.bytecdntp.com/cdn/expire-1-M/vue/2.6.14/vue.js
// @require      https://lf6-cdn-tos.bytecdntp.com/cdn/expire-1-M/vuex/3.6.2/vuex.js
// @require      https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/axios/0.26.0/axios.js
// @require      https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/element-ui/2.15.7/index.js
// @resource     element-ui-css https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/element-ui/2.15.7/theme-chalk/index.css
// @grant        GM_getResourceText
// ==/UserScript==

// grant不能是none,为了和网页的全局变量隔离。直播间网页全局变量有Vue,会导致element-ui出错

(function () {
  async function main() {
    initLib()
    initCss()
    await waitForLoaded()
    initUi()
  }

  function initLib() {
    let css = GM_getResourceText('element-ui-css')
    // 不是通过URL引用的,要修复相对URL
    css = css.replace(/url\(fonts\//g, 'url(https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/element-ui/2.15.7/theme-chalk/fonts/')
    let styleElement = unsafeWindow.document.createElement('style')
    styleElement.innerText = css
    unsafeWindow.document.head.appendChild(styleElement)
  }

  function initCss() {
    let css = `
      /* 屏蔽原来的牌子按钮 */
      .medal-section {
        display: none !important;
      }

      /* 屏蔽选牌子对话框,防止刷新时闪烁 */
      .dialog-ctnr.medal {
        display: none !important;
      }
    `
    let styleElement = unsafeWindow.document.createElement('style')
    styleElement.innerText = css
    unsafeWindow.document.head.appendChild(styleElement)
  }

  async function waitForLoaded(timeout = 10 * 1000) {
    return new Promise((resolve, reject) => {
      let startTime = new Date()
      function poll() {
        if (isLoaded()) {
          resolve()
          return
        }
        if (new Date() - startTime > timeout) {
          reject(new Error(`[blivemedal] 等待加载超时,page=${unsafeWindow.location.href}`))
          return
        }
        setTimeout(poll, 1000)
      }
      poll()
    })
  }

  function isLoaded() {
    if (document.querySelector('#control-panel-ctnr-box') === null) {
      return false
    }
    return true
  }

  function loadConfig() {
    let config
    try {
      config = JSON.parse(unsafeWindow.localStorage.blivemedalConfig || '{}')
    } catch {
      config = {}
    }

    if (config.autoWearMedal === undefined) {
      config.autoWearMedal = false
    }
    if (config.autoWearDefaultMedal === undefined) {
      config.autoWearDefaultMedal = false
    }
    if (config.defaultMedalId === undefined) {
      config.defaultMedalId = ''
    }
    return config
  }

  function saveConfig(config) {
    unsafeWindow.localStorage.blivemedalConfig = JSON.stringify(config)
  }

  let store = new Vuex.Store({
    state: {
      config: loadConfig(),

      medals: [],
      curMedal: null
    },
    mutations: {
      setMedals(state, medals) {
        state.medals = medals
      },
      setCurMedal(state, curMedal) {
        state.curMedal = curMedal
      },
      setConfigItems(state, config) {
        for (let name in config) {
          state.config[name] = config[name]
        }
        saveConfig(state.config)
      }
    },
    actions: {
      async updateMedals({ commit }) {
        commit('setMedals', getMedalsAsync())
      },
      async updateCurMedal({ commit }) {
        commit('setCurMedal', await getCurMedal())
      }
    }
  })

  function initUi() {
    let panelElement = unsafeWindow.document.querySelector('#control-panel-ctnr-box')
    let myMedalButtonElement = unsafeWindow.document.createElement('div')
    panelElement.appendChild(myMedalButtonElement)

    new Vue({
      el: myMedalButtonElement,
      store: store,
      components: {
        MedalDialog
      },
      template: `
        <div>
          <el-button type="primary" style="font-size: 12px; min-width: 80px; height: 24px; padding: 6px 12px;"
            @click="showMedalDialog"
          >
            {{ curMedal === null ? '勋章' : curMedal.medal_name }}
          </el-button>
          <medal-dialog ref="medalDialog"></medal-dialog>
        </div>
      `,
      computed: {
        ...Vuex.mapState({
          config: state => state.config,
          curMedal: state => state.curMedal
        })
      },
      async created() {
        await this.tryAutoWearMedal()
        this.updateCurMedal()
      },
      methods: {
        ...Vuex.mapActions([
          'updateCurMedal'
        ]),
        async tryAutoWearMedal() {
          if (!this.config.autoWearMedal) {
            return
          }

          try {
            let medalInfo = unsafeWindow.__NEPTUNE_IS_MY_WAIFU__.roomInfoRes.data.anchor_info.medal_info
            if (medalInfo !== null) {
              await wearMedal(medalInfo.medal_id)
              return
            }
          } catch {
          }

          try {
            if (this.config.autoWearDefaultMedal && this.config.defaultMedalId !== '') {
              await sleep(1000)
              await wearMedal(this.config.defaultMedalId)
            }
          } catch {
          }
        },
        showMedalDialog() {
          this.$refs.medalDialog.showDialog()
        }
      }
    })
  }

  let MedalDialog = {
    name: 'MedalDialog',
    template: `
      <el-dialog :visible.sync="dialogVisible" title="我的粉丝勋章" top="60px" width="850px" :modal="false" append-to-body>
        <div style="line-height: 40px">
          <el-checkbox label="进入直播间时自动佩戴勋章" :value="config.autoWearMedal"
            @change="value => setConfigItems({ autoWearMedal: value })"
          ></el-checkbox>
          <el-checkbox v-show="config.autoWearMedal" label="没有对应勋章时佩戴" :value="config.autoWearDefaultMedal"
            @change="value => setConfigItems({ autoWearDefaultMedal: value })"
          ></el-checkbox>
          <el-select v-show="config.autoWearMedal" style="margin-left: 16px; width: 240px"
            filterable :value="config.defaultMedalId" @change="value => setConfigItems({ defaultMedalId: value })"
          >
            <el-option v-for="item in sortedMedals" :key="item.medal.medal_id"
              :label="item.anchor_info.nick_name + ' / ' + item.medal.medal_name" :value="item.medal.medal_id"
            >
              <span>{{ item.anchor_info.nick_name }}</span>
              <span style="float: right; color: #8492a6; font-size: 13px">{{ item.medal.medal_name }}</span>
            </el-option>
          </el-select>
        </div>
        <div>
          <el-button icon="el-icon-refresh" @click="refreshMedals">刷新勋章</el-button>
          <el-input type="primary" v-model="query" placeholder="搜索" clearable style="margin-left: 70px; width: 180px"></el-input>
        </div>

        <el-table :data="medalsTableData" stripe height="80vh">
          <el-table-column label="勋章" prop="medal.medal_name" width="100" sortable
            :sort-method="(a, b) => a.medal.medal_name.localeCompare(b.medal.medal_name)"
          >
            <template slot-scope="scope">
              <el-tag :type="scope.row.medal.is_lighted ? '' : 'info'">{{ scope.row.medal.medal_name }}</el-tag>
            </template>
          </el-table-column>
          <el-table-column label="等级" prop="medal.level" width="80" sortable></el-table-column>
          <el-table-column label="主播昵称" prop="anchor_info.nick_name" width="200" sortable
            :sort-method="(a, b) => a.anchor_info.nick_name.localeCompare(b.anchor_info.nick_name)"
          >
            <template slot-scope="scope">
              <el-link type="primary" :underline="false" target="_blank" :href="'https://live.bilibili.com/' + scope.row.room_info.room_id">
                {{ scope.row.anchor_info.nick_name }}
              </el-link>
              <el-badge v-if="scope.row.room_info.living_status" is-dot></el-badge>
            </template>
          </el-table-column>
          <el-table-column label="亲密度/原力值" prop="medal.intimacy" width="140" sortable>
            <template slot-scope="scope">
              {{ scope.row.medal.intimacy }} / {{ scope.row.medal.next_intimacy }}
            </template>
          </el-table-column>
          <el-table-column label="本日亲密度/原力值" prop="medal.today_feed" width="160" sortable>
            <template slot-scope="scope">
              {{ scope.row.medal.today_feed }} / {{ scope.row.medal.day_limit }}
            </template>
          </el-table-column>
          <el-table-column label="操作" width="120">
            <template slot-scope="scope">
              <el-button v-if="curMedal !== null && scope.row.medal.medal_id === curMedal.medal_id"
                type="info" size="mini" @click="takeOffMedal"
              >取消佩戴</el-button>
              <el-button v-else type="primary" size="mini" @click="wearMedal(scope.row)">佩戴</el-button>
            </template>
          </el-table-column>
        </el-table>
      </el-dialog>
    `,
    data() {
      return {
        dialogVisible: false,
        query: ''
      }
    },
    computed: {
      ...Vuex.mapState({
        config: state => state.config,
        medals: state => state.medals,
        curMedal: state => state.curMedal
      }),
      medalsTableData() {
        if (this.query === '') {
          return this.sortedMedals
        }

        let query = this.query.toLowerCase()
        let res = []
        for (let medal of this.sortedMedals) {
          if (medal.medal.medal_name.toLowerCase().indexOf(query) !== -1
              || medal.anchor_info.nick_name.toLowerCase().indexOf(query) !== -1
          ) {
            res.push(medal)
          }
        }
        return res
      },
      sortedMedals() {
        let curRoomId
        try {
          curRoomId = unsafeWindow.BilibiliLive.ROOMID
        } catch {
          curRoomId = 0
        }

        let curMedal = []
        let curRoomMedal = []
        let medals = []
        for (let medal of this.medals) {
          if (this.curMedal !== null && medal.medal.medal_id === this.curMedal.medal_id) {
            curMedal.push(medal)
          } else if (medal.room_info.room_id === curRoomId) {
            curRoomMedal.push(medal)
          } else {
            medals.push(medal)
          }
        }

        // 不是当前牌子或当前房间牌子的按 (等级降序, 亲密度降序, 牌子ID升序) 排序
        medals.sort((a, b) => {
          let aKey = [-a.medal.level, -a.medal.intimacy, a.medal.medal_id]
          let bKey = [-b.medal.level, -b.medal.intimacy, b.medal.medal_id]
          for (let i = 0; i < aKey.length; i++) {
            let diff = aKey[i] - bKey[i]
            if (diff !== 0) {
              return diff
            }
          }
          return 0
        })

        return [...curMedal, ...curRoomMedal, ...medals]
      }
    },
    methods: {
      ...Vuex.mapMutations([
        'setConfigItems'
      ]),
      ...Vuex.mapActions({
        doUpdateMedals: 'updateMedals',
        doUpdateCurMedal: 'updateCurMedal'
      }),
      showDialog() {
        // 只自动加载一次
        if (this.medals.length === 0) {
          this.updateMedals()
        }
        this.updateCurMedal()
        this.dialogVisible = true
      },
      refreshMedals() {
        this.updateMedals()
        this.updateCurMedal()
        refreshBilibiliCurMedalCache()
      },
      async updateMedals() {
        try {
          await this.doUpdateMedals()
        } catch (e) {
          this.$message.error(e)
        }
      },
      async updateCurMedal() {
        try {
          await this.doUpdateCurMedal()
        } catch (e) {
          this.$message.error(e)
        }
      },
      async wearMedal(medal) {
        try {
          await wearMedal(medal.medal.medal_id)
        } catch (e) {
          this.$message.error(e)
          return
        }
        this.updateCurMedal()
      },
      async takeOffMedal() {
        try {
          await takeOffMedal()
        } catch (e) {
          this.$message.error(e)
          return
        }
        this.updateCurMedal()
      }
    }
  }

  let apiClient = axios.create({
    baseURL: 'https://api.live.bilibili.com',
    withCredentials: true
  })

  function getMedalsAsync() {
    let res = []
    let addedMedalIds = new Set()

    async function doGetMedalsAsync() {
      // 获取第一页和总页数
      let rsp
      try {
        rsp = await getPage(1)
      } catch (e) {
        console.error('获取勋章列表第 1 页失败:', e)
        return
      }
      pushResFromRsp(rsp)

      // 并发获取剩下的页
      if (rsp.page_info.total_page <= 1) {
        return
      }
      let pageQueue = []
      for (let page = 2; page <= rsp.page_info.total_page; page++) {
        pageQueue.push(page)
      }
      const WORKER_NUM = 8
      let workerPromises = []
      for (let i = 0; i < WORKER_NUM; i++) {
        workerPromises.push(worker(pageQueue))
      }
      await Promise.all(workerPromises)
    }

    async function worker(pageQueue) {
      while (true) {
        let page = pageQueue.shift()
        if (page === undefined) {
          break
        }

        let rsp
        try {
          rsp = await getPage(page)
        } catch (e) {
          console.error(`获取勋章列表第 ${page} 页失败:`, e)
          continue
        }
        pushResFromRsp(rsp)
      }
    }

    function pushResFromRsp(rsp) {
      for (let medals of [rsp.special_list, rsp.list]) {
        for (let medal of medals) {
          if (addedMedalIds.has(medal.medal.medal_id)) {
            continue
          }
          addedMedalIds.add(medal.medal.medal_id)
          res.push(medal)
        }
      }
    }

    async function getPage(page) {
      let rsp = (await apiClient.get('/xlive/app-ucenter/v1/fansMedal/panel', {
        params: {
          page_size: 10, // 目前没有发现这个接口有尺寸限制,为了防止以后被背刺,还是一次请求10个
          page: page
        }
      })).data
      if (rsp.code !== 0) {
        throw new Error(rsp.message)
      }
      return rsp.data
    }

    doGetMedalsAsync()
    return res
  }

  async function getCurMedal() {
    let csrfToken = getCsrfToken()
    let data = new FormData()
    data.append('source', 1)
    data.append('uid', unsafeWindow.BilibiliLive.UID)
    data.append('target_id', unsafeWindow.BilibiliLive.ANCHOR_UID)
    data.append('csrf_token', csrfToken)
    data.append('csrf', csrfToken)
    let rsp = (await apiClient.post('/live_user/v1/UserInfo/get_weared_medal', data)).data
    if (rsp.code !== 0) {
      throw new Error(rsp.message)
    }
    let curMedal = rsp.data
    if (curMedal.medal_id === undefined) {
      // 没佩戴牌子
      curMedal = null
    }
    return curMedal
  }

  async function wearMedal(medalId) {
    let csrfToken = getCsrfToken()
    let data = new FormData()
    data.append('medal_id', medalId)
    data.append('csrf_token', csrfToken)
    data.append('csrf', csrfToken)
    let rsp = (await apiClient.post('/xlive/web-room/v1/fansMedal/wear', data)).data
    if (rsp.code !== 0) {
      throw new Error(rsp.message)
    }
    refreshBilibiliCurMedalCache()
  }

  async function takeOffMedal() {
    let csrfToken = getCsrfToken()
    let data = new FormData()
    data.append('csrf_token', csrfToken)
    data.append('csrf', csrfToken)
    let rsp = (await apiClient.post('/xlive/web-room/v1/fansMedal/take_off', data)).data
    if (rsp.code !== 0) {
      throw new Error(rsp.message)
    }
    refreshBilibiliCurMedalCache()
  }

  function getCsrfToken() {
    let match = unsafeWindow.document.cookie.match(/\bbili_jct=(.+?)(?:;|$)/)
    if (match === null) {
      return ''
    }
    return match[1]
  }

  function refreshBilibiliCurMedalCache() {
    let originalMedalButton = unsafeWindow.document.querySelector('.medal-section .fans-medal-item')
    if (originalMedalButton === null) {
      return
    }
    originalMedalButton.click()
    setTimeout(() => originalMedalButton.click(), 0)
  }

  async function sleep(time) {
    return new Promise(resolve => window.setTimeout(resolve, time))
  }

  main()
})();