Bilibili Watchlater Plus : 让 B 站的稍后再看更好用

B 站的稍后再看功能明显是一个半成品,有许多不完善的地方,其中最致命的就是无法加入和播放番剧。最近我终于忍受不了它的诸多问题,着手开发了一个油猴脚本 Bilibili Watchlater Plus,对稍后再看功能进行了一番改造。开源地址:bilibili-watchlater-plus

1. 问题

B 站的稍后再看功能主要有三点让我觉得不能忍受:

  1. 无法将番剧加入稍后再看。

    image-20190125195221887

  2. 即使通过一些特别的方式将番剧加入了稍后再看,也无法播放。

    image-20190125203812434

  3. 稍后再看按钮没有初始状态。

    一句话总结理想状态就是,已加入的视频初始显示勾选,没加入的视频初始不勾选;点击未勾选的按钮,将视频加入稍后再看;点击已勾选的按钮,将视频移出稍后再看

    然后 B 站目前无论一个视频有没有被加入稍后再看,它的按钮都是同一个初始状态,也就是说下面的第一张图。

    没加入稍后再看的视频的默认初始状态:

    image-20190125210925839

    已被加入稍后再看的视频的理想初始状态

    image-20190125211007688

2. 初级阶段

在开发这个正式版本之前,我曾写过一个临时用用的版本,主要作用于首页右上角的动态悬浮窗,因为那是我用的最多的地方。

这个版本可以从这里获取:Bilibili Watchlater Plus 0.0.1

当时解决了在动态悬浮窗上已经可以将番剧加入稍后再看,稍后再看按钮有初始状态。对于在稍后再看中的番剧视频,则是采用直接跳转到播放链接的方式解决。

上述的临时版本让我在一段时间内有了比较舒适的使用体验,但是近段时间 B 站更改了一些 UI 和 API 接口,原来的脚本失效了。正巧假期时间比较充裕,我就借此机会开发了一个比较完整的版本。

3. 实现

3.1. 如何实现

首先考虑各种需求该如何实现。

  1. 让番剧也可以加入稍后再看。

    首先是可行性。B 站的稍后再看 API 需要 aid (即视频 ID)作为参数,而番剧的单集 ID 是 epid。只需要通过番剧播放页就可以获取 epid 对应的 aid,再调用 API 就可以将番剧加入稍后再看。

    然后是如何实现。目前常规视频在封面图右下角都会有一个稍后再看按钮,点击就可以添加或删除稍后再看,然后番剧的封面却没有。因此实现番剧加入稍后再看非常简单,只需要给番剧的封面也添加一个稍后再看按钮就可以了。

  2. 稍后再看按钮的初始状态。

    在之前提到的初级版本里,我解决这个问题的方法非常粗暴:首先通过 API 确定每个视频是否已经加入了稍后再看,对于已经加入稍后再看的视频,给它的按钮添加一个 class 改变样式。

    这样的好处是非常简单粗暴,几行就可以写完所有逻辑。但是坏处也显而易见,按钮仅仅是样式改变了,功能却没有,导致点击一个已勾选的按钮结果却又是将视频加入稍后再看,而不是勾选按钮应该做的”将视频移出稍后再看“。简单的来说就是按钮功能和样式的不统一。

    要实现按钮功能和样式的统一,只能抛弃原有的按钮,自己从头开始为每个视频添加稍后再看按钮。这样按钮的样式、功能都可以自己控制,唯一的缺点就是需要一定的工作量。

  3. 可以在稍后再看播放番剧。

    这个是最让我头疼的地方就是如何在稍后再看中播放番剧。最后我发现 Hack B 站的视频播放器使之能播放番剧太麻烦了,不如我自己实现一个稍后再看的播放逻辑。

    原本的播放逻辑是 B 站在一个专为稍后再看编写的单页面应用中,逐个播放视频,当遇到番剧时就无法解析并弹窗。为了简化开发,我设定的新逻辑是,点击稍后再看的视频直接跳转到常规视频播放页面,并在页面左边添加一个汉堡菜单,可以看到并跳转到其他稍后再看。

3.2. 监听页面变化

上面提到,我们需要替换、添加按钮,但这会遇到几个问题:

  • 由于油猴脚本可能在任何时候插入页面,此时页面的状态不确定
  • 页面可能在任何时候更新,比如加载中的页面或者用户点击导致页面变化

最理想的实现是,监听页面的变化。这里提出一个需求,我需要寻找页面中所有的旧按钮,替换成新的自定义的按钮。利用 HTML 提供的 MutationObserver API,我们可以订阅某个根节点下的所有变化,我们只需要在变化的节点下寻找有没有旧按钮就行了。


但是现在出现了另一个需求:添加按钮。

添加按钮的逻辑是,找到一个父元素,这个父元素原本应该有稍后再看按钮,而现在却没有,那么我们就向父元素里添加自定义的按钮。也就是说我们搜索的检查点有两个,父元素和是否有子元素,只有”找到父元素“和”按钮子元素不存在“同时满足时,才添加自定义按钮。

让我们回想刚刚提到的方法,首先 MutationObserver 会提供一个有更改的节点,如果目标父元素和子元素都位于这个节点下,那么没有问题,我们可以很轻松的搜索到。但如果 MutationObserver 回调的节点位于父元素和子元素之间,搜索就会变得略微复杂,因为我们需要通知向两个方向搜索。


最终妥协了上述两种情况,使用的解决方案是,使用 MutationObserver 监听这个 document.body 的变化。在每次变化发生之后,遍历整个文档寻找目标节点。

这样的做法好处是,遍历的逻辑非常简单,直接使用 JQuery 就可以做到。坏处是更加耗时,因为每次整个文档的任意一处发生变化时,哪怕变化的地方与我们的目标毫无关系,都需要遍历整个文档树,而且通常整个 HTML 文档会在短时间内频繁地更新。为了解决性能问题,使用 Lodash 的 debounce 函数对回调去抖动,这样短时间内的频繁更新只会触发一次搜索。

大致代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
new MutationObserver((mutationList) = {
for (const mutation of mutationList){
// 只关心添加节点的变化
if (mutation.addedNodes.length) {
// 200 毫秒内的频繁调用只会触发一次
_.debounce(() => {
$('...').each((index, ele) => {
// 找到的目标
});
}, 200);
}
}
}).observe(document.body, {
childList: true,
subtree: true
});

3.3. 异步更新状态

在开发中还遇到一个小情景:现在所有的稍后再看按钮都成功替换成了自定义的按钮,然而因为稍后再看列表是异步获取的,没办法同步地给这些按钮设置是否勾选的状态。

那么如何异步地给按钮们更新状态呢?

一个最简单粗暴的思路就是:按钮默认都是不勾选的状态。此时去获取稍后再看列表,待数据返回后,再从页面中找回所有的按钮,依次给他们分配状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
for (const oldButton of oldButtons) {
const button = document.createElement('div');
button.aid = '...';
// 默认不勾选
button.checked = false;
button.className = 'watch-later-plus-button';
oldButton.replaceWith(button);
}

const watchlaterList = await getWatchlaterList();
$('.watch-later-plus-button')
.filter('...') // 根据 watchlaterList 过滤出需要勾选的按钮
.each((_, ele) => ele.checked = true);

上面的想法可以通过保存按钮引用的方式减少一次遍历:

1
2
3
4
5
6
7
8
9
10
11
12
13
const newButtons = [];
for (const oldButton of oldButtons) {
const button = document.createElement('div');
button.aid = '...';
// 默认不勾选
button.checked = false;
newButtons.push(buttons);
oldButton.replaceWith(button);
}

const watchlaterList = await getWatchlaterList();
buttons.filter('...') // 根据 watchlaterList 过滤出需要勾选的按钮
.forEach((ele) => ele.checked = true);

上面两个方法本质是一样的,在创建按钮之后单独维护了一系列用于更新状态的语句。

我们可以利用闭包以及 Promise 的特性,写出这样的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const watchLaterList = (() => {
let promise;
return () => {
if (!promise) {
promise = getWatchLaterList();
}
return promise;
};
})();

for (const oldButton of oldButtons) {
const button = document.createElement('div');
button.aid = '...';
watchLaterList().then(list => {
button.checked = list.includes(button.aid);
});
buttons.push(buttons);
oldButton.replaceWith(button);
}

这样更新状态的逻辑就不需要单独维护了。

4. 现状和吐槽

使用脚本之后,上面提到的 B站稍后再看的问题都得到了解决,特别是番剧也支持稍后再看之后,使用体验非常棒。

image-20190125224055885

不要嫌弃我的 UI,又不是不能用。之后 B 站再改 UI 或 API 的时候再更新吧。

吐槽一下 B 站的前端,写个新版本的 UI 还只有播放界面才有,还是常规视频的播放界面有新 UI 而番剧的播放界面没有;其他地方都是普通的多页应用,但是到了稍后再看和个人空间确实单页面应用;就一个稍后再看的按钮的逻辑,在首页、动态悬浮窗、动态首页、空间页面的实现居然都是不一样的。