Greasy Fork is available in English.

bilibili订阅+

bilibili导航添加订阅按钮以及订阅列表

2020/04/23時点のページです。最新版はこちら。

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name         bilibili订阅+
// @namespace    https://github.com/YanxinTang/Tampermonkey
// @version      0.6.5
// @description  bilibili导航添加订阅按钮以及订阅列表
// @author       tyx1703
// @include      *.bilibili.com/*
// @license      MIT
// @noframes
// @require     https://cdn.jsdelivr.net/npm/vue@2/dist/vue.min.js
// @exclude     *://live.bilibili.com/*
// @exclude     *://manga.bilibili.com/*
// @exclude     *://bw.bilibili.com/*
// @exclude     *://show.bilibili.com/*
// ==/UserScript==

(function() {
  'use strict';

  const DedeUserID = getCookie('DedeUserID');
  const loginStatus = DedeUserID !== '';
  if (!loginStatus) {
    log("少侠请先登录~  哔哩哔哩 (゜-゜)つロ 干杯~")
    return;
  }

  style();
  getNavList().then(navList => main(navList));

  function main(navList) {
    const subscribeMenuEl = document.createElement('div');
    subscribeMenuEl.setAttribute('id', 'subscribe');
    navList.appendChild(subscribeMenuEl);

    const ListItem = {
      name: 'ListItem',
      props: {
        link: {
          type: String,
          required: true,
        },
        cover: {
          type: String,
          required: true
        },
        title: {
          type: String,
          required: true,
        },
        tag: {
          type: String,
          required: true,
        }
      },
      template: `
      <li>
        <a :href="link" target="_blank">
          <img :src="cover" :alt="title" class="season-cover" />
          <span class="season-name">
            {{ title }}
          </span>
          <span class="season-tag">
            {{ tag }}
          </span>
        </a>
      </li>
      `,
    }

    const List = {
      name: 'List',
      components: { ListItem },
      props: {
        list: {
          type: Array,
          default: () => [],
        }
      },
      template: `
      <ul ref="list">
        <ListItem
          v-for="item in list"
          :key="item.id"
          :link="item.link"
          :cover="item.cover"
          :title="item.title"
          :tag="item.tag"
        />
      </ul>
      `,
    }

    const subscribe = new Vue({
      el: subscribeMenuEl,
      components: { List },
      data: {
        show: false,
        bangumis: [],
        cinemas: [],
        floowings: [],
        loadflag: true,
        pages: {
          bangumi: -1,
          cinema: -1,
          floowing: -1
        },             // count of pages
        page: {
          bangumi: 1,
          cinema: 1,
          floowing: 1
        },              // current page
        perPage: 15,
        mid: '',
        activeTab: 'bangumi',
        $_mouseOverTimer: null,
      },
      created() {
        this.mid = DedeUserID;
        this.getSubscribe(this.activeTab);
      },
      updated(){
        this.loadflag = true; // allow loading after update data
      },
      computed: {
        subscribePageLink() {
          return `//space.bilibili.com/${this.mid}/bangumi`;
        },
        list() {
          const key = this.activeTab;
          if (key === 'bangumi') { return this.bangumis };
          if (key === 'cinema') { return this.cinemas };
          if (key === 'floowing') { return this.floowings }
        },
        href(){
          const urls = {
            bangumi: `//space.bilibili.com/${this.mid}/bangumi`,
          }
          return urls[this.activeTab];
        }
      },
      methods: {
        dataKey(key) {
          if (key === 'bangumi') { return 'bangumis' };
          if (key === 'cinema') { return 'cinemas' };
          if (key === 'floowing') { return 'floowings' };
        },
        switchTab(key) {
          this.activeTab = key;
          const dataKey = this.dataKey(key);
          if (this[dataKey].length <= 0) {
            this.getSubscribe(key);
          }
        },
        getListData(key) {
          const page = this.page[key];
          const dataKey = this.dataKey(key);
          const urls = {
            bangumi: `//api.bilibili.com/x/space/bangumi/follow/list?type=1&follow_status=0&pn=${page}&ps=${this.perPage}&vmid=${this.mid}`,
            cinema: `//api.bilibili.com/x/space/bangumi/follow/list?type=2&follow_status=0&pn=${page}&ps=${this.perPage}&vmid=${this.mid}`,
          }
          const url = urls[key];
          return fetch(url, {
            method: 'GET',
            credentials: 'include',
          })
            .then((response) => {
              if (response.ok) {
                return response.json()
              } else {
                return Promise.reject(new Error(`${response.url}: ${response.status}`))
              }
            })
            .then((data) => {
              const newData = data.data.list.map(item => ({
                id: item.season_id || item.media_id || item.mid,
                link: item.url,
                cover: item.cover,
                title: item.title,
                tag: item.new_ep.index_show,
              }));
              this[dataKey] = [...this[dataKey], ...newData]
              if (this.pages[key] <= 0) {
                const total = data.data.total;
                this.pages[key] = Math.ceil(total / this.perPage);
              }
              this.page[key]++;
              log('Load successfully ^.^')
            })
            .catch(error => {
              log(error)
            })
        },
        getFloowings() {
          const key = 'floowing';
          const dataKey = this.dataKey(key);
          const page = this.page[key];
          const url = `//api.bilibili.com/x/relation/followings?vmid=${this.mid}&pn=${page}&ps=${this.perPage}&order=desc`;
          return fetch(url, {
            method: 'GET',
            credentials: 'include',
          })
            .then((response) => {
              if (response.ok) {
                return response.json()
              } else {
                return Promise.reject(new Error(`${response.url}: ${response.status}`))
              }
            })
            .then((data) => {
              const newData = data.data.list.map(item => ({
                link: `//space.bilibili.com/${item.mid}/`,
                cover: item.face,
                title: item.uname,
                tag: '已关注',
              }));
              this[dataKey] = [...this[dataKey], ...newData]
              if (this.pages[key] <= 0) {
                const total = data.data.total;
                this.pages[key] = Math.ceil(total / this.perPage);
              }
              this.page[key]++;
              log('Load successfully ^.^')
            })
            .catch(error => {
              log(error)
            })
        },
        getSubscribe(key) {
          switch (key) {
            case 'bangumi':
              this.getListData('bangumi');
              break;
            case 'cinema':
              this.getListData('cinema');
              break;
            case 'floowing':
              this.getFloowings();
            default: 
              break;
          }
        },
        onmouseover(){
          this.$data.$_mouseOverTimer = setTimeout(() => {
            this.show = true;
            clearInterval(this.$data.$_mouseOverTimer);
          }, 100);
        },
        onmouseleave(){
          this.show = false;
          clearInterval(this.$data.$_mouseOverTimer);
        },
        onscroll() {          
          const key = this.activeTab;
          const list = this.$refs.list.$refs.list;
          if(this.loadflag
            && this.page[key] <= this.pages[key]
            && list.scrollHeight - list.scrollTop - 50 <=  list.clientHeight
            ){
            this.loadflag = false;  // refuse to load
            this.getSubscribe(this.activeTab);
          }
        }
      },
      template: `
        <div class="item"
          @mouseover.once="onmouseover"
          @mouseleave="onmouseleave"
        >
          <a :href="subscribePageLink" target="_blank"><span class="name" @mouseover="onmouseover">订阅</span></a>
          <transition name="slide-fade">
            <div id="subscribe-list-wrapper"
              :class="{ 'isActive': show }"
              v-if="show">
              <div class="subscribe-list">
                <div class="tab-bar">
                  <div class="tab-item" :class="{ active: activeTab === 'bangumi' }" @click="switchTab('bangumi')">追番</div>
                  <div class="tab-item" :class="{ active: activeTab === 'cinema' }" @click="switchTab('cinema')">追剧</div>
                  <div class="tab-item" :class="{ active: activeTab === 'floowing' }" @click="switchTab('floowing')">关注</div>
                </div>
                <List :list="list" @scroll.native.stop="onscroll" ref="list"/>
              </div>
            </div>
          </transition>
        </div>
      `,
    })
  }

  /**
   * get nav list on the right of header
   * @param {Function} main 
   */
  function getNavList () {
    const userCenter = document.body.querySelector('.nav-user-center');
    return new Promise((resolve) => {
      if (userCenter) {
        const userNavMenu = userCenter.querySelector('.user-con.signin');
        if(userNavMenu) {
          // It can get userNavMenu direcyly without waiting at sometime
          // See detail at https://greasyfork.org/zh-CN/forum/discussion/76143/x
          log("Get nav menu list directly");
          resolve(userNavMenu);
        } else {
          const observer = new MutationObserver((mutations, observer) => {
            for (const mutation of mutations) {
              if (mutation.addedNodes.length > 0) {
                const addedNode = mutation.addedNodes[0];
                if(isNavList(addedNode)){
                  log('Get nav menu list by observing');
                  resolve(addedNode);
                  observer.disconnect();
                  break;
                }
              }
            }
          });
          observer.observe(userCenter, {
            childList: true,
            subtree: false,
          });
        }
      } else {
        // Find user nav menu per 100ms
        const timer = setInterval(() => {
          const userNavMenu = document.body.querySelector('.nav-user-center>.user-con.signin');
          if (userNavMenu) {
            log('Get nav menu list by timer');
            resolve(userNavMenu);
            clearInterval(timer);
          }
        }, 100);
      }
    });
    
  }

  /**
   * check if specified node is the nav list
   * @param {*} node 
   * @returns {boolean}          - 
   */
  function isNavList(node) {
    if (
      node
      && node.tagName
      && node.tagName.toLowerCase() === 'div'
      && node.classList.contains('user-con') 
      && node.classList.contains('signin')
    ) {
      return true
    }
    return false;
  }

  /**
   * @returns void
   */
  function style() {
    let head = document.head || document.getElementsByTagName('head')[0];
    let style = document.createElement('style');

    style.textContent += `
      #subscribe-list-wrapper {
        width: 250px;
        position: absolute;
        top: 100%;
        left: -110px;
        padding-top: 12px;
        text-align: left;
        font-size: 12px;
        z-index: 10;
        transition: all .3s ease-out .25s;
      }

      #subscribe-list-wrapper .tab-bar {
        display: flex;
        flex-flow: row nowrap;
        align-items: center;
        font-size: 12px;
        color: #999;
        line-height: 16px;
        height: 48px;
        padding-left: 20px;
        user-select: none;
        border-bottom: 1px solid #e7e7e7;
        cursor: default;
      }

      #subscribe-list-wrapper .tab-bar .tab-item {
        display: flex;
        border-radius: 12px;
        cursor: pointer;
        margin: 0 24px 0 0;
        transition: 0.3s ease;
        z-index: 1;
      }

      #subscribe-list-wrapper .tab-bar .tab-item.active {
        background-color: #00a1d6;
        color: #fff;
        padding: 4px 10px;
        margin: 0 14px 0 -10px;
      }

      #subscribe-list-wrapper .subscribe-list {
        width: 100%;
        height: 100%;
        background: #fff;
        box-shadow: rgba(0,0,0,0.16) 0 2px 4px;
        border-radius:  2px;
      }

      .subscribe-list ul {
        max-height: 340px;
        overflow-y: auto;
      }
      .subscribe-list ul>li{
        height: 42px;
      }
      .subscribe-list ul>li>a{
        color: #222;
        height: 42px;
        width: 100%;
        display: inline-flex;
        flex-direction: row;
        justify-content: flex-start;
        align-items: center;
      }
      .subscribe-list>ul>li>a:hover{
        color: #00a1d6;
        background: #e5e9ef;
      }
      .subscribe-list .season-cover{
        width: 30px;
        height: auto;
        border-radius: 3px;
        margin-left: 8px;
        vertical-align: text-bottom;
      }
      .subscribe-list .season-name{
        text-overflow: ellipsis;
        overflow-x: hidden;
        white-space: nowrap;
        display: inline-block;
        max-width: 120px;
        padding-left: 10px;
      }
      .subscribe-list .season-tag{
        margin-left: auto;
        margin-right: 10px;
        background: #ff8eb3;
        color: #fff;
        padding: 0 5px;
        display: inline-block;
        height: 18px;
        border-radius: 9px;
        vertical-align: middle;
        line-height: 18px;
      }
    `;
    head.appendChild(style);
  }

  /**
   * Get cookie by name
   * @param {string} name 
   */
  function getCookie(name){
    const value = "; " + document.cookie;
    let parts = value.split("; " + name + "=");
    if (parts.length == 2) {
      return parts.pop().split(";").shift();
    }
    return '';
  }

  /**
   * print something in console with custom style
   * @param {*} stuff 
   */
  function log(stuff) {
    console.log('%cbilibili订阅+:', 'background: #f25d8e; border-radius: 3px; color: #fff; padding: 0 8px', stuff);
  }
})();