// ==UserScript==
// @name bilibili三连
// @version 0.0.13
// @include https://www.bilibili.com/video/av*
// @include https://www.bilibili.com/video/BV*
// @include https://www.bilibili.com/medialist/play/*
// @description 推荐投币收藏一键三连
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addValueChangeListener
// @run-at document-idle
// @namespace https://greasyfork.org/users/164996
// @downloadURL none
// ==/UserScript==
const click = s => {
if (!s) return
if (s instanceof HTMLElement) s.click()
else {
const n = document.querySelector(s)
if (!n) return
n.click()
}
return true
}
const waitForAll = (selectors, delay = 500, timeout = 50000) =>
new Promise(resolve => {
let max_times = 1 + timeout / delay
let times = 0
let nodes
const f = () => {
nodes = selectors.map(i => document.querySelector(i))
times = times + 1
if (Object.values(nodes).every(v => v != null)) {
resolve(nodes)
} else if (times >= max_times) {
resolve([])
} else {
setTimeout(f, delay)
}
}
f()
})
const state = {
get(k) {
return this.state[k]
},
set(k, v) {
this.state[k] = v
this.render()
GM_setValue('state', JSON.stringify(this.state))
},
toggle(k) {
this.set(k, !this.state[k])
},
state: {},
node: {},
default_state: {
like: true,
coin: 0,
collect: true,
collection: '输入收藏夹名'
},
render() {
const { like, coin, coin_value, collect, collection } = this.node
const get = this.get.bind(this)
if (get('like')) like.classList.add('sanlian_on')
else like.classList.remove('sanlian_on')
if (get('coin')) coin.classList.add('sanlian_on')
else coin.classList.remove('sanlian_on')
coin_value.innerHTML = 'x' + get('coin')
if (get('collect')) collect.classList.add('sanlian_on')
else collect.classList.remove('sanlian_on')
collection.value = get('collection')
},
load(state_str) {
try {
this.state = JSON.parse(state_str)
for (let k of Object.keys(this.default_state)) {
if (typeof this.default_state[k] != typeof this.state[k]) {
throw `${k}'s type is not same as default`
}
}
} catch (e) {
this.state = { ...this.default_state }
}
this.render()
},
addStyle() {
const css = `
#sanlian > div {
display: none;
position: absolute;
color: SlateGray;
background: white;
border: 1px solid #e5e9ef;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.14);
border-radius: 2px;
padding: 1em;
cursor: default;
z-index: 2;
}
#sanlian_like {
margin: 0 1em 0 0;
}
#sanlian_coin {
margin: 0 1em 0 0;
}
#sanlian input {
color: SlateGrey;
cursor: text;
}
#sanlian span[id^='sanlian_'] * {
color: SlateGrey;
cursor: pointer;
user-select: none;
}
#sanlian span[id^='sanlian_'].sanlian_on * {
color: SlateBlue;
}
#sanlian span[id^='sanlian_']:hover * {
color: DarkSlateBlue;
}
#sanlian > div > input {
border: 0;
border-bottom: 1px solid;
}
#sanlian span#sanlian_coin i {
margin: 0;
}
#sanlian > i.iconfont {
margin-left: -1em;
transform-origin: right;
transform: scale(0.4, 0.8);
display: inline-block;
}
.video-toolbar .ops > span {
width: 88px;
}
${this.selector.coin_dialog}, ${this.selector.collect_dialog} {
display: block;
}
`
const style = document.createElement('style')
style.type = 'text/css'
style.appendChild(document.createTextNode(css))
document.head.appendChild(style)
const rules = style.sheet.rules
this.node.dialog_style = rules[rules.length - 1].style
// remove leading space of coin text
if (!this.is_medialist) {
const coin_text = document.querySelector(this.selector.coin + ' i')
.nextSibling
if (coin_text.nodeType == Node.TEXT_NODE) {
coin_text.textContent = coin_text.textContent.trim()
}
}
},
addNode() {
const { collect } = this.node
const { selector } = this
const sanlian = collect.cloneNode(true)
const sanlian_icon = sanlian.querySelector('i')
const sanlian_text =
sanlian_icon.nextElementSibling || sanlian_icon.nextSibling
sanlian.id = 'sanlian'
sanlian.classList.remove('on')
sanlian.title = '推荐硬币收藏'
const sanlian_canvas = sanlian.querySelector('canvas')
if (sanlian_canvas) sanlian_canvas.remove()
sanlian_icon.innerText = this.is_medialist ? '' : ''
sanlian_icon.classList.remove('blue')
sanlian_icon.classList.add('van-icon-tuodong')
sanlian_text.textContent = '三连'
const sanlian_panel = document.createElement('div')
for (const name of ['like', 'coin', 'collect']) {
const wrapper = document.createElement('span')
wrapper.id = `sanlian_${name}`
const node = document.querySelector(selector[name] + ' i').cloneNode(true)
node.classList.remove('blue')
wrapper.appendChild(node)
if (name == 'coin') {
wrapper.insertAdjacentHTML('beforeend', `x${state.coin}`)
}
sanlian_panel.appendChild(wrapper)
this.node[name] = wrapper
}
sanlian_panel.insertAdjacentHTML('beforeend', ``)
sanlian.appendChild(sanlian_panel)
collect.parentNode.insertBefore(sanlian, collect.nextSibling)
Object.assign(this.node, {
coin_value: document.querySelector('#sanlian_coin span'),
collection: document.querySelector('#sanlian input'),
sanlian,
sanlian_icon,
sanlian_text,
sanlian_panel
})
},
addListener() {
const {
app,
like,
coin,
collect,
collection,
sanlian,
sanlian_icon,
sanlian_text,
sanlian_panel,
dialog_style
} = this.node
const selector = this.selector
const get = this.get.bind(this)
const set = this.set.bind(this)
const toggle = this.toggle.bind(this)
like.addEventListener('click', function() {
toggle('like')
})
coin.addEventListener('click', function() {
set('coin', (get('coin') + 1) % 3)
})
collect.addEventListener('click', function() {
toggle('collect')
})
collection.addEventListener('keyup', function() {
set('collection', collection.value)
})
sanlian.addEventListener('mouseover', () => {
sanlian_panel.style.display = 'flex'
})
sanlian.addEventListener('mouseout', () => {
sanlian_panel.style.display = 'none'
})
sanlian.addEventListener('click', async e => {
const timeout = 3500
if (![sanlian, sanlian_icon, sanlian_text].includes(e.target)) return
dialog_style.display = 'none'
const fallback = setTimeout(() => {
dialog_style.display = 'block'
}, timeout)
if (get('like')) click(selector.like_off)
if (get('coin') > 0 && click(selector.coin_off)) {
await new Promise(resolve => {
new MutationObserver(function(e) {
this.disconnect()
if (get('coin') === 1) click(selector.coin_left)
else click(selector.coin_right)
const fallback = setTimeout(() => {
click(selector.coin_close)
}, timeout)
new MutationObserver(function() {
this.disconnect()
clearTimeout(fallback)
resolve()
}).observe(app, { childList: true })
setTimeout(() => {
click(selector.coin_yes)
}, 0)
}).observe(app, { childList: true })
})
}
if (get('collect') && click(selector.collect)) {
await new Promise(resolve => {
new MutationObserver(function(e) {
if (e[0].target.nodeName !== 'UL') return
this.disconnect()
const choices = document.querySelectorAll(
'div.collection-m div.group-list input+i'
)
// match or first
const choice =
[...choices].find(
i =>
i.nextElementSibling.textContent.trim() === get('collection')
) || choices[0]
// already collect
if (
!choice ||
choice.previousElementSibling.checked ||
!click(choice)
) {
click('i.close')
return resolve()
}
const fallback = setTimeout(() => {
click('i.close')
}, timeout)
// wait for dialog close
new MutationObserver(function() {
this.disconnect()
clearTimeout(fallback)
resolve()
}).observe(app, { childList: true })
const yes = document.querySelector(selector.collect_yes)
if (yes.hasAttribute('disabled')) {
new MutationObserver(function() {
this.disconnect()
click(yes)
}).observe(yes, { attributes: true })
} else click(yes)
}).observe(app, { childList: true, subtree: true })
})
}
clearTimeout(fallback)
dialog_style.display = 'block'
})
},
selector: {
app: 'div#app>div.v-wrap',
people: 'span.bilibili-player-video-info-people-text',
like: '#arc_toolbar_report span.like',
coin: '#arc_toolbar_report span.coin',
collect: '#arc_toolbar_report span.collect',
like_off: '#arc_toolbar_report span.like:not(.on)',
coin_off: '#arc_toolbar_report span.coin:not(.on)',
collect_off: '#arc_toolbar_report span.collect:not(.on)',
coin_left: '.mc-box.left-con',
coin_right: '.mc-box.right-con',
coin_close: 'div.bili-dialog-m div.coin-operated-m i.close',
coin_yes: 'div.coin-bottom > span',
collect_yes: 'div.collection-m button.submit-move',
coin_dialog: '.bili-dialog-m',
collect_dialog: '.bili-dialog-m',
},
selector_in_medialist: {
app: 'div.container',
people: 'span.bilibili-player-video-info-people-text',
like: '#playContainer div.play-options > ul > li:nth-child(1)',
coin: '#playContainer div.play-options > ul > li:nth-child(2)',
collect: '#playContainer div.play-options > ul > li:nth-child(3)',
like_off:
'#playContainer div.play-options > ul > li:nth-child(1) > i:not(.blue)',
coin_off:
'#playContainer div.play-options > ul > li:nth-child(2) > i:not(.blue)',
collect_off:
'#playContainer div.play-options > ul > li:nth-child(3) > i:not(.blue)',
coin_left: '.play-one-coin',
coin_right: '.play-two-coin',
coin_close: '.play-coin-close',
coin_yes: '.play-coin-btn',
collect_yes: 'div.collection-m button.submit-move',
coin_dialog: '.play-coin-bg',
collect_dialog: '.collection-bg',
},
async init() {
this.is_medialist = window.location.href.includes('/medialist/')
this.selector = this.is_medialist
? this.selector_in_medialist
: this.selector
let { collect, app, people } = this.selector
;[collect, app, people] = await waitForAll([collect, app, people])
if (!collect) return
Object.assign(this.node, { collect, app })
this.addStyle()
this.addNode()
this.addListener()
this.load(GM_getValue('state'))
GM_addValueChangeListener('state', (name, old_state, new_state) => {
if (JSON.stringify(this.state) == new_state) return
this.load(new_state)
})
}
}
state.init()