// ==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/* // @downloadURL none // ==/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: `
{{item.new_ep.index_show}}
{{item.title}}
{{item.time}}
{{item?.new_ep?.long_title ?? '' }}
` }; const UserItem = { props: ['item'], computed: { spaceURL() { return `https://space.bilibili.com/${this.item.mid}`; }, avatarURL() { return this.item.face.replace('http:', ''); } }, template: `
{{item.uname}}
{{item.sign }}
` } 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: `
  • 订阅
    {{tab.name}}
  • `, }); } 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: `
  • {{ title }} {{ tag }}
  • `, } const List = { name: 'List', components: { ListItem }, props: { list: { type: Array, default: () => [], } }, template: ` `, } 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: `
    订阅
    `, }) } /** * 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 ''; } 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); } })();