blivemedal

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

// ==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()
})();