Greasy Fork

来自缓存

Greasy Fork is available in English.

bilibili订阅+

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

当前为 2022-01-19 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         bilibili订阅+
// @namespace    https://github.com/YanxinTang/Tampermonkey
// @version      0.7.1
// @description  bilibili导航添加订阅按钮以及订阅列表
// @author       tyx1703
// @license      MIT
// @noframes
// @require     https://cdn.jsdelivr.net/npm/vue@2/dist/vue.min.js
// @include      *.bilibili.com/*
// @exclude     *://live.bilibili.com/*
// @exclude     *://manga.bilibili.com/*
// @exclude     *://bw.bilibili.com/*
// @exclude     *://show.bilibili.com/*
// ==/UserScript==

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

  if (isNewVersion()) {
    const PER_PAGE = 15;
    try {
      newStyle();
      const lastPopoverButton = await getLastPopoverButton();
      const subscribeMenuEl = document.createElement('li');
      subscribeMenuEl.setAttribute('id', 'subscribe');
      lastPopoverButton.after(subscribeMenuEl);;

      const getBangumis = (page) => {
        return fetch(`//api.bilibili.com/x/space/bangumi/follow/list?type=1&follow_status=0&pn=${page}&ps=${PER_PAGE}&vmid=${DedeUserID}`)
          .then(response => response.json())
          .then(response => response.data)
          .then(({ list, ...rest}) => {
            return {
              list: list.map(item => ({ ...item, id: item.media_id })),
              ...rest
            }
          });
      }

      const getCinemas = (page) => {
        return fetch(`//api.bilibili.com/x/space/bangumi/follow/list?type=2&follow_status=0&pn=${page}&ps=${PER_PAGE}&vmid=${DedeUserID}`)
          .then(response => response.json())
          .then(response => response.data)
          .then(({ list, ...rest}) => {
            return {
              list: list.map(item => ({ ...item, id: item.media_id })),
              ...rest
            }
          });
      }

      const getFloowings = (page) => {
        return fetch(`//api.bilibili.com/x/relation/followings?&pn=${page}&ps=${PER_PAGE}&vmid=${DedeUserID}&order=desc`)
          .then(response => response.json())
          .then(response => {
            return {
              list: response.data.list.map(item => ({ ...item, id: item.mid })),
              total: response.data.total,
              pn: page,
            }
          });
      }

      const VideoItem = {
        props: ['item'],
        computed: {
          coverURL() {
            return this.item.cover.replace('http:', '');
          }
        },
        template: `
          <a
            target="_blank"
            class="header-history-card header-history-video"
            :href="item.url"
          >
            <div class="header-history-video__image">
              <picture class="v-img">
                <source :srcset="coverURL + '@256w_144h_1c.webp'" type="image/webp" />
                <img :src="coverURL + '@256w_144h_1c'" />
              </picture>
              <div
                class="header-history-live__tag header-history-live__tag--red"
                v-if="item?.new_ep?.index_show ?? false"
              >
                <span class="header-history-live__tag--text">
                  {{item.new_ep.index_show}}
                </span>
              </div>
            </div>
            <div class="header-history-card__info">
              <div :title="item.title" class="header-history-card__info--title">
                {{item.title}}
              </div>
              <div class="header-history-card__info--date">
                <span>{{item.time}}</span>
              </div>
              <div class="header-history-card__info--name">
                <span>{{item?.new_ep?.long_title ?? '' }}</span>
              </div>
            </div>
          </a>
        `
      };
      
      const UserItem = {
        props: ['item'],
        computed: {
          spaceURL() {
            return `https://space.bilibili.com/${this.item.mid}`;
          },
          avatarURL() {
            return this.item.face.replace('http:', '');
          }
        },
        template: `
          <a
            target="_blank"
            class="header-history-card header-history-video"
            :href="spaceURL"
          >
            <div class="header-history-video__image">
              <picture class="v-img"">
                <source :srcset="avatarURL + '@256w_144h_1c.webp'" type="image/webp" />
                <img :src="avatarURL + '@256w_144h_1c'" />
              </picture>
            </div>
            <div class="header-history-card__info">
              <div :title="item.title" class="header-history-card__info--title">
                {{item.uname}}
              </div>
              <div class="header-history-card__info--name">
                <span>{{item.sign }}</span>
              </div>
            </div>
          </a>
        `
      }

      new Vue({
        el: subscribeMenuEl,
        components: { VideoItem, UserItem },
        data() {
          return {
            isPanelVisible: false,
            loading: false,
            activeTab: 'bangumis',
            tabs: [
              { key: 'bangumis', name: '追番' },
              { key: 'cinemas', name: '追剧' },
              { key: 'floowings', name: '关注' },
            ],
            dataset: {
              bangumis: {
                list: [],
                total: 0,
                page: 0,
                component: 'VideoItem',
              },
              cinemas: {
                list: [],
                total: 0,
                page: 0,
                component: 'VideoItem',
              },
              floowings: {
                list: [],
                total: 0,
                page: 0,
                component: 'UserItem',
              },
            }
          }
        },
        created() {
          this.load()
        },
        computed: {
          list() {
            return this.dataset[this.activeTab].list
          },
          total() {
            return this.dataset[this.activeTab].total;
          },
          page() {
            return this.dataset[this.activeTab].page;
          },
          tabComponent() {
            return this.dataset[this.activeTab].component;
          }
        },
        methods: {
          async load() {
            const tab = this.activeTab;
            let request;
            if (tab === 'bangumis') {
              request = getBangumis;
            };
            if (tab === 'cinemas') {
              request = getCinemas; 
            };
            if (tab === 'floowings') {
              request = getFloowings;
            }
            try {
              this.loading = true;
              const { list, total, pn } = await request(this.page + 1);
              this.dataset[tab].list = [...this.dataset[tab].list, ...list];
              this.dataset[tab].total = total;
              this.dataset[tab].page = pn;
            } catch (error) {
              throw error;
            } finally {
              this.loading = false;
            }
          },
          changeTabHandler(tab) {
            this.activeTab = tab.key;
            if (this.list.length <= 0) {
              this.load();
            }
          },
          onMouseoverHandler(){
            this.isPanelVisible = true;
          },
          onMouseleaveHandler(){
            this.isPanelVisible = false;
          },
          onScrollHandler() {
            const panelContent = this.$refs.panelContent;
            if (
              !this.loading
              && this.list.length < this.total
              && panelContent.scrollHeight - panelContent.scrollTop - 50 <= panelContent.clientHeight
            ) {
              this.load();
            }
          },
        },
        template: `
        <li
          class="v-popover-wrap"
          @mouseover="onMouseoverHandler"
          @mouseleave="onMouseleaveHandler"
        >
          <a
            href="//www.bilibili.com/account/history"
            target="_blank" class="right-entry__outside"
          >
            <svg class="right-entry-icon" viewBox="0 0 1182 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2974" xmlns:xlink="http://www.w3.org/1999/xlink" width="20" height="21"><path d="M1088.792893 96.259987A330.610168 330.610168 0 0 0 622.44343 96.259987l-31.600682 31.600683L560.199662 96.259987A330.370769 330.370769 0 0 0 93.132002 96.259987c-128.557321 128.557321-121.854146 345.692312 6.703175 474.249634l23.939911 23.939911 401.472304 402.429901a92.886854 92.886854 0 0 0 131.190712 0L1058.149807 595.167729l23.939911-23.939911c128.317922-128.317922 135.260496-345.452913 6.703175-474.967831z m-23.939911 247.299279a220.486579 220.486579 0 0 1-66.313553 140.527277l-25.136906 26.333902-383.038573 383.038573-383.038573-383.038573-24.41871-25.376306a220.725978 220.725978 0 0 1-66.552952-140.527276A210.671215 210.671215 0 0 1 340.191882 120.199898a219.528982 219.528982 0 0 1 140.527276 67.031751l25.615705 25.376305L550.623698 256.896789l63.201364 63.201365a62.483167 62.483167 0 1 0 88.338271-88.57767l-22.742915-23.939911 26.8127-25.615705a210.671215 210.671215 0 0 1 359.098662 162.551995z" fill="currentColor" p-id="2975"></path><path d="M249.030112 413.615829m42.320183-42.320184l0 0q42.320183-42.320183 84.640366 0l107.323985 107.323985q42.320183 42.320183 0 84.640367l0 0q-42.320183 42.320183-84.640367 0l-107.323984-107.323985q-42.320183-42.320183 0-84.640367Z" fill="currentColor" p-id="2976"></path></svg>
            <span class="right-entry-text">订阅</span>
          </a>
          <transition
            name="v-popover_bottom"
            enter-active-class="v-popover_bottom-enter-from"
            leave-active-class="v-popover_bottom-leave-from"
          >
            <div
              v-show="isPanelVisible"
              class="v-popover is-bottom"
              style="padding-top: 15px; margin-left: -50px;"
            >
              <div class="v-popover-content">
                <div class="history-panel-popover">
                  <div class="header-tabs-panel">
                    <div
                      v-for="tab in tabs"
                      :key="tab.key"
                      class="header-tabs-panel__item"
                      :class="{'header-tabs-panel__item--active': activeTab === tab.key }"
                      @click="changeTabHandler(tab)"
                    >{{tab.name}}</div>
                  </div>
                  <div class="header-tabs-panel__content" ref="panelContent" @scroll="onScrollHandler">
                    <component
                      :is="tabComponent"
                      v-for="item in list"
                      :item="item"
                      :key="item.id"
                    />
                  </div>
                </div>
              </div>
            </div>
          </transition>
        </li>
        `,
      });
    } catch (error) {
      log(error)
    }
  } else {
    getNavList().then(navList => main(navList));
    style();
  }

  function getLastPopoverButton(count = 1) {
    if (count >= 30) {
      return Promise.reject("获取顶部按列表超时")
    }
    return new Promise((resolve) => {
      const popoverButtons = document.body.querySelectorAll('.bili-header .bili-header__bar .right-entry>.v-popover-wrap');
      if (popoverButtons.length) {
        resolve(popoverButtons[popoverButtons.length - 1]);
        return;
      }
      setTimeout(() => {
        resolve(getLastPopoverButton(count++));
      }, 100);
    });
  }

  function newStyle() {
    let head = document.head || document.getElementsByTagName('head')[0];
    let style = document.createElement('style');

    style.textContent += `
      
    `;
    head.appendChild(style);
  }

  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 http://greasyfork.icu/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 '';
  }

  function isNewVersion() {
    const iWannaGoBack = getCookie('i-wanna-go-back');
    return iWannaGoBack === '-1';
  }

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