Greasy Fork is available in English.

bilibili三连

推荐投币收藏一键三连

  1. // ==UserScript==
  2. // @name bilibili三连
  3. // @version 0.0.22
  4. // @include https://www.bilibili.com/video/av*
  5. // @include https://www.bilibili.com/video/BV*
  6. // @include https://www.bilibili.com/medialist/play/*
  7. // @description 推荐投币收藏一键三连
  8. // @grant GM_getValue
  9. // @grant GM_setValue
  10. // @grant GM_addValueChangeListener
  11. // @run-at document-idle
  12. // @namespace https://greasyfork.org/users/164996
  13. // ==/UserScript==
  14. const find = (selector) => {
  15. return document.querySelector(selector)
  16. }
  17. const click = (s) => {
  18. if (!s) return
  19. if (s instanceof HTMLElement) s.click()
  20. else {
  21. const n = document.querySelector(s)
  22. if (!n) return
  23. n.click()
  24. }
  25. return true
  26. }
  27. const waitForAllByObserver = (
  28. selectors,
  29. {
  30. app = document.documentElement,
  31. timeout = 3000,
  32. childList = true,
  33. subtree = true,
  34. attributes = true,
  35. disappear = false,
  36. } = {}
  37. ) => {
  38. return new Promise((resolve) => {
  39. let observer_id
  40. let timer_id
  41. const check = () => {
  42. const nodes = selectors.map((i) => document.querySelector(i))
  43. if (Object.values(nodes).every((v) => (disappear ? !v : v))) {
  44. if (observer_id != undefined) observer_id.disconnect()
  45. if (timer_id != undefined) clearTimeout(timer_id)
  46. resolve(nodes)
  47. }
  48. }
  49. if (check()) return
  50. observer_id = new MutationObserver(check)
  51. if (timeout != Infinity) {
  52. timer_id = setTimeout(() => {
  53. observer_id.disconnect()
  54. clearTimeout(timer_id)
  55. resolve()
  56. }, timeout)
  57. }
  58. observer_id.observe(app, { childList, subtree, attributes })
  59. })
  60. }
  61. const sleep = (timeout) =>
  62. new Promise((resolve) => {
  63. setTimeout(resolve, timeout)
  64. })
  65. const state = {
  66. get(k) {
  67. return this.state[k]
  68. },
  69. set(k, v) {
  70. this.state[k] = v
  71. this.render()
  72. GM_setValue('state', JSON.stringify(this.state))
  73. },
  74. toggle(k) {
  75. this.set(k, !this.state[k])
  76. },
  77. state: {},
  78. node: {},
  79. default_state: {
  80. like: true,
  81. coin: 0,
  82. collect: true,
  83. collection: '输入收藏夹名',
  84. },
  85. render() {
  86. const { like, coin, coin_value, collect, collection } = this.node
  87. const get = this.get.bind(this)
  88. if (get('like')) like.classList.add('sanlian_on')
  89. else like.classList.remove('sanlian_on')
  90. if (get('coin')) coin.classList.add('sanlian_on')
  91. else coin.classList.remove('sanlian_on')
  92. coin_value.innerHTML = 'x' + get('coin')
  93. if (get('collect')) collect.classList.add('sanlian_on')
  94. else collect.classList.remove('sanlian_on')
  95. collection.value = get('collection')
  96. },
  97. load(state_str) {
  98. try {
  99. this.state = JSON.parse(state_str)
  100. for (let k of Object.keys(this.default_state)) {
  101. if (typeof this.default_state[k] != typeof this.state[k]) {
  102. throw `${k}'s type is not same as default`
  103. }
  104. }
  105. } catch (e) {
  106. this.state = { ...this.default_state }
  107. }
  108. this.render()
  109. },
  110. remove_coin_leading_space() {
  111. const trim = () => {
  112. const coin_text = document.querySelector(this.selector.coin + ' i')
  113. .nextSibling
  114. if (
  115. coin_text.nodeType == Node.TEXT_NODE &&
  116. coin_text.textContent != coin_text.textContent.trim()
  117. ) {
  118. coin_text.textContent = coin_text.textContent.trim()
  119. }
  120. }
  121. new MutationObserver(trim).observe(
  122. document.querySelector(this.selector.coin),
  123. { characterData: true, subtree: true }
  124. )
  125. trim()
  126. },
  127. addStyle() {
  128. const css = `
  129. #sanlian > div {
  130. display: none;
  131. position: absolute;
  132. color: SlateGray;
  133. background: white;
  134. border: 1px solid #e5e9ef;
  135. box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.14);
  136. border-radius: 2px;
  137. padding: 1em;
  138. cursor: default;
  139. z-index: 2;
  140. }
  141. #sanlian_like {
  142. margin: 0 1em 0 0;
  143. }
  144. #sanlian_coin {
  145. margin: 0 1em 0 0;
  146. }
  147. #sanlian input {
  148. color: SlateGrey;
  149. cursor: text;
  150. }
  151. #sanlian span[id^='sanlian_'] * {
  152. color: SlateGrey;
  153. cursor: pointer;
  154. user-select: none;
  155. }
  156. #sanlian span[id^='sanlian_'].sanlian_on * {
  157. color: SlateBlue;
  158. }
  159. #sanlian span[id^='sanlian_']:hover * {
  160. color: DarkSlateBlue;
  161. }
  162. #sanlian > div > input {
  163. border: 0;
  164. border-bottom: 1px solid;
  165. }
  166. #sanlian span#sanlian_coin i {
  167. margin: 0;
  168. }
  169. #sanlian > i.iconfont {
  170. margin-left: -1em;
  171. transform-origin: right;
  172. transform: scale(0.4, 0.8);
  173. display: inline-block;
  174. }
  175. .video-toolbar .ops > span {
  176. width: 88px;
  177. }
  178. ${this.selector.coin_dialog}, ${this.selector.collect_dialog} {
  179. display: block;
  180. }
  181. `
  182. const style = document.createElement('style')
  183. style.type = 'text/css'
  184. style.appendChild(document.createTextNode(css))
  185. document.head.appendChild(style)
  186.  
  187. const rules = style.sheet.rules
  188. this.node.dialog_style = rules[rules.length - 1].style
  189. this.remove_coin_leading_space()
  190. },
  191. addNode() {
  192. const { collect } = this.node
  193. const { selector } = this
  194. const sanlian = collect.cloneNode(true)
  195. const sanlian_icon = sanlian.querySelector('i')
  196. const sanlian_text =
  197. sanlian_icon.nextElementSibling || sanlian_icon.nextSibling
  198. sanlian.id = 'sanlian'
  199. sanlian.classList.remove('on')
  200. sanlian.title = '推荐硬币收藏'
  201. const sanlian_canvas = sanlian.querySelector('canvas')
  202. if (sanlian_canvas) sanlian_canvas.remove()
  203. sanlian_icon.innerText = ''
  204. sanlian_icon.classList.remove('blue')
  205. sanlian_icon.classList.add('van-icon-tuodong')
  206. sanlian_text.textContent = '三连'
  207. const sanlian_panel = document.createElement('div')
  208. for (const name of ['like', 'coin', 'collect']) {
  209. const wrapper = document.createElement('span')
  210. wrapper.id = `sanlian_${name}`
  211. const node = document.querySelector(selector[name] + ' i').cloneNode(true)
  212. node.classList.remove('blue')
  213. wrapper.appendChild(node)
  214. if (name == 'coin') {
  215. wrapper.insertAdjacentHTML('beforeend', `<span>x${state.coin}</span>`)
  216. }
  217. sanlian_panel.appendChild(wrapper)
  218. this.node[name] = wrapper
  219. }
  220. sanlian_panel.insertAdjacentHTML('beforeend', `<input type="text">`)
  221. sanlian.appendChild(sanlian_panel)
  222. collect.parentNode.insertBefore(sanlian, collect.nextSibling)
  223. Object.assign(this.node, {
  224. coin_value: document.querySelector('#sanlian_coin span'),
  225. collection: document.querySelector('#sanlian input'),
  226. sanlian,
  227. sanlian_icon,
  228. sanlian_text,
  229. sanlian_panel,
  230. })
  231. },
  232. addListener() {
  233. const {
  234. app,
  235. coin,
  236. collect,
  237. collection,
  238. dialog_style,
  239. like,
  240. sanlian,
  241. sanlian_icon,
  242. sanlian_panel,
  243. sanlian_text,
  244. } = this.node
  245.  
  246. const {
  247. coin_close,
  248. coin_dialog,
  249. coin_left,
  250. coin_off,
  251. coin_right,
  252. coin_yes,
  253. collect_choice,
  254. collect_close,
  255. collect_dialog,
  256. collect_yes,
  257. like_off,
  258. } = this.selector
  259. const selector = this.selector
  260. const get = this.get.bind(this)
  261. const set = this.set.bind(this)
  262. const toggle = this.toggle.bind(this)
  263. like.addEventListener('click', function () {
  264. toggle('like')
  265. })
  266. coin.addEventListener('click', function () {
  267. set('coin', (get('coin') + 1) % 3)
  268. })
  269. collect.addEventListener('click', function () {
  270. toggle('collect')
  271. })
  272. like.addEventListener('contextmenu', function () {
  273. toggle('like')
  274. })
  275. coin.addEventListener('contextmenu', function () {
  276. set('coin', (get('coin') + 2) % 3)
  277. })
  278. collect.addEventListener('contextmenu', function () {
  279. toggle('collect')
  280. })
  281. collection.addEventListener('keyup', function () {
  282. set('collection', collection.value)
  283. })
  284. sanlian.addEventListener('mouseover', () => {
  285. sanlian_panel.style.display = 'flex'
  286. })
  287. sanlian.addEventListener('mouseout', () => {
  288. sanlian_panel.style.display = 'none'
  289. })
  290. const like_handler = async () => {
  291. if (get('like')) click(like_off)
  292. }
  293. const coin_handler = async () => {
  294. if (!get('coin') > 0 || !click(coin_off)) return
  295. if (!(await waitForAllByObserver([coin_left]))) return
  296. if (get('coin') === 1) click(coin_left)
  297. else click(coin_right)
  298. await sleep(0) // only for visual updating
  299. click(coin_yes)
  300. await Promise.race([
  301. waitForAllByObserver([coin_dialog], { disappear: true }),
  302. waitForAllByObserver(['.error']),
  303. ])
  304. click(coin_close)
  305. }
  306. const collect_handler = async () => {
  307. if (
  308. !get('collect') ||
  309. !click(selector.collect) ||
  310. !(await waitForAllByObserver([collect_choice]))
  311. ) {
  312. click('i.close')
  313. return
  314. }
  315. const choices = document.querySelectorAll(selector.collect_choice)
  316. const choice =
  317. [...choices].find(
  318. (i) => i.nextElementSibling.textContent.trim() === get('collection')
  319. ) || choices[0]
  320. // already collect
  321. if (
  322. !choice ||
  323. choice.previousElementSibling.checked ||
  324. !click(choice) ||
  325. !(await waitForAllByObserver([collect_yes]))
  326. ) {
  327. click('i.close')
  328. return
  329. }
  330. click(collect_yes)
  331. await waitForAllByObserver([collect_dialog], { disappear: true })
  332. }
  333. sanlian.addEventListener('click', async (e) => {
  334. if (![sanlian, sanlian_icon, sanlian_text].includes(e.target)) return
  335. dialog_style.display = 'none'
  336. const fallback = setTimeout(() => {
  337. dialog_style.display = 'block'
  338. }, 3500)
  339. await like_handler()
  340. await coin_handler()
  341. await collect_handler()
  342. clearTimeout(fallback)
  343. dialog_style.display = 'block'
  344. })
  345. },
  346. selector: {
  347. app: 'div#app',
  348. coin: '#arc_toolbar_report span.coin',
  349. coin_close: 'div.bili-dialog-m div.coin-operated-m i.close',
  350. collect_close: 'div.bili-dialog-m div.collection-m i.close',
  351. coin_dialog: '.bili-dialog-m',
  352. coin_left: '.mc-box.left-con',
  353. coin_off: '#arc_toolbar_report span.coin:not(.on)',
  354. coin_right: '.mc-box.right-con',
  355. coin_yes: 'div.coin-bottom > span',
  356. collect: '#arc_toolbar_report span.collect',
  357. collect_choice: 'div.collection-m div.group-list input+i',
  358. collect_dialog: '.bili-dialog-m',
  359. collect_off: '#arc_toolbar_report span.collect:not(.on)',
  360. collect_yes: 'div.collection-m button.submit-move:not([disable])',
  361. like: '#arc_toolbar_report span.like',
  362. like_off: '#arc_toolbar_report span.like:not(.on)',
  363. people: 'div.bilibili-player-video-info-people-number',
  364. },
  365. async init() {
  366. let { collect, app, people } = this.selector
  367. ;[collect, app, people] = await waitForAllByObserver(
  368. [collect, app, people],
  369. { timeout: Infinity }
  370. )
  371. if (!collect) return
  372. Object.assign(this.node, { collect, app })
  373. this.addStyle()
  374. this.addNode()
  375. this.addListener()
  376. this.load(GM_getValue('state'))
  377. GM_addValueChangeListener('state', (name, old_state, new_state) => {
  378. if (JSON.stringify(this.state) == new_state) return
  379. this.load(new_state)
  380. })
  381. },
  382. }
  383. state.init()