// ==UserScript==
// @name bilibili订阅+
// @namespace https://github.com/YanxinTang/Tampermonkey
// @version 0.7.3
// @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() && !isInNewVersionBlacklist()) {
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: `
`
};
const UserItem = {
props: ['item'],
computed: {
spaceURL() {
return `https://space.bilibili.com/${this.item.mid}`;
},
avatarURL() {
return this.item.face.replace('http:', '');
}
},
template: `
`
}
new Vue({
el: subscribeMenuEl,
components: { VideoItem, UserItem },
data() {
return {
isPanelVisible: false,
loading: false,
inLeaveAnimation: 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(){
if (!this.inLeaveAnimation) {
this.isPanelVisible = true;
}
},
onMouseleaveHandler(){
this.isPanelVisible = false;
},
onContentBeforeLeaveHandler() {
this.inLeaveAnimation = true;
},
onContentAfterLeaveHandler() {
this.inLeaveAnimation = 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: `
订阅
`,
});
} 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';
}
function isInNewVersionBlacklist() {
const blacklists = [
/https:\/\/space.bilibili.com\/.*/,
/https:\/\/www.bilibili.com\/bangumi\/play\/.*/,
/https:\/\/www.bilibili.com\/anime\/.*/
];
return blacklists.some(patten => patten.test(location.href));
}
/**
* 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);
}
})();