(For English readers) This post is my notes for reverse engineering the Bilibili frontend to remove the newly added 1-minute auto pause when playing a video without logging in.
最近在用PipePipe(原AnimePipe)刷B站的时候偶然发现音乐区某翻唱up主,遂沉迷其中。在实验室工(mo)作(yu)的时候总想放此up主的歌作为背景音乐,但是B站2023年新加了一个非常讨厌的限制:如果不登录,视频开播之后一分钟左右会自动暂停并跳出登录提示窗。我无法忍受时不时切换回B站点继续,于是打算尝试浅浅逆向一下B站前端,找到破解这个限制的方法。
我的web基础不是很扎实,让各位看官老爷见笑了。
前辈研究
首先我们查一下相关的研究,可以找到这个脚本:B站(bilibili)自动续播因未登录而暂停的视频和对应的博文:关于B站(bilibili)对未登录用户视频观看进行暂停和弹窗的分析与简单解决方案。里面提到:
既然很可能是网页加载时引入的JS实现的,于是通过edge禁止部分JS加载的debug模式,排查出所属的文件。经验证,该功能存在于来自s1.hdslb.com
的stardust-video.****
的js文件。当禁止该js加载时,不再出现自动暂停和弹窗的情况。然而,虽然视频可以继续播放,弹幕也没有问题,但是评论区、头像、及右侧推送区的加载出现了异常。这也是可以预见的,很显然是一个压缩后的综合性JS文件,并不可能单独实现这一功能。也因此,禁止该文件的引入虽然能解决所述的问题,但仍然影响着用户的正常使用和体验。
于是想到换一下思路,如通过重写同名函数覆盖该功能。遗憾的是该文件是经过压缩混淆的生产环境文件,内容太多太杂,实现的功能较多,难以定位(例如,搜索setTimeout和setInterval可以得到成百上千的结果)。尝试过简单的反混淆工具,也未能得到易于解读的效果。
原来博主的解决思路是写一个脚本检测暂停然后自动续播,但是我作为完美主义患者不能接受这种治标不治本的workaround。博主提到的setTimeout
函数为我们提供了初始的逆向思路,我们就从这里入手。
开始摸索
和延时相关的功能最后总要落到两个函数上:setTimeout
和setInterval
。我个人感觉setTimeout
会更接近这里的场景,所以我们先研究它。我最开始的思路是打出来B站对这个函数的全部调用,然后寻找定时和一分钟接近的。想要列出所有对setTimeout
的调用,最直接的方法就是override掉这个函数,在每次调用的时候嵌入一个console.log
。简单搓一段代码:
window.oldSetTimeout = window.setTimeout;
window.setTimeout = function(callback, delay, ...args) {
console.log("Set timeout " + delay);
return window.oldSetTimeout(function() {
console.log("Hit timeout " + delay);
callback(...args);
}, delay);
};
这样我们就可以获知什么时候setTimeout
被调用了,延迟设置了多少,什么时候触发了回调函数。我们要想个办法让这段代码在所有其它脚本之前运行,这样才能保证B站的脚本会运行我们的这个版本。我用了个插件叫JS Injector。
细化思路
我注入了这段js之后刷新页面,果不其然console炸了……数不清的log刷满了屏幕。显然我们需要一些更具体的思路。我第一个想到的是按照延迟时长过滤,只看延迟大于等于50000的调用(留一点margin),这样下来输出少了很多,剩下的delay值有这么几个:50000,85000,100000. 问题是没有一个足够接近一分钟,暂停事件发生的时候console也没跳出来Hit timeout
的提示。
那还能说明什么呢,说明这个思路不太对呗。我想到的另外一种可能就是B站通过反复调用setTimeout
来修改某些内部状态值,并且在达到一定条件的时候触发暂停。虽然这种情况更应该用setInterval
,但是鉴于我之前看到了大量500、1000之类的小delay不断刷屏,这个猜想也是有合理性的。
我通过在特定的delay值出现的时候直接return
进行了排查,发现在禁用500毫秒delay的时候出现了值得注意的现象:视频播放之后不到10秒就会卡住,并且console报错WebSocket连接中断。有趣的是自动暂停并且弹登录窗的事情也没有发生。这让我怀疑500毫秒delay具有某种心跳的作用,不断刷新一个和后端的WebSocket连接来获取视频流,同时这个心跳也起到计时暂停的作用。这种瞎猜虽然有那么一点道理,但并没有什么用,小delay出现得太多了,排查起来很麻烦。
不过观察暂停事件发生时间附近console的输出给我带来了一些线索。这些输出大概长这样:
miniLogin.umd.min.js
显然是管登录弹窗的脚本,而它第一次出现在console log里就是这里。结合我对500毫秒delay的怀疑,我继续推断是在这一次碰到500毫秒延迟到期的时候前段判断到了应该暂停弹窗的时间。这里一个401毫秒的延迟引起了我的注意,因为这个数字非常独特,只出现过这一次。
深挖线索
这个401毫秒的延迟会不会就是突破点呢?为了搞清楚在周围究竟发生了什么,我决定在这个地方打一个call stack出来看一看。代码很简单:
if (delay == 401) console.trace();
然后这个stack trace大概长这个样子:
jinkela是……金坷垃?B站开发好调皮
大概可以看到,第一次调用stardust-video
里的函数是c
,然后它又去调用了p
和o
,最后来到openQuickLogin
。结合禁用stardust-video
的时候不会发生暂停事件的观察,可以合理猜测负责暂停的代码就在这么几个函数调用中间。我们看看这里的代码:(用Firefox直接打开会看到缩成一团的压缩版脚本,可以点击左下角图标为{}
的按钮来prettify)
function mo(t, e, n, r) {
var i,
o = !1,
a = 0;
function s() {
i && clearTimeout(i)
}
function c() {
for (var c = arguments.length, l = new Array(c), u = 0; u < c; u++) l[u] = arguments[u];
var f = this,
d = Date.now() - a;
function p() {
a = Date.now(),
n.apply(f, l)
}
function h() {
i = void 0
}
o || (r && !i && p(), s(), void 0 === r && d > t ? p() : !0 !== e && (i = setTimeout(r ? h : p, void 0 === r ? t - d : t)))
}
return 'boolean' != typeof e && (r = n, n = e, e = void 0),
c.cancel = function () {
s(),
o = !0
},
c
}
浏览器停在了o || (r && !i ...
这一行。显然这个代码是混淆过的,里面有不少故意为了增加阅读难度而搞出来的迷惑操作,比如!0
,直接看不容易看懂。于是我把这段代码贴给ChatGPT,并且提了这样一个要求:
出人意料的是,ChatGPT不仅还原了这些迷惑操作,还推断出了混淆之前的变量名,而且看起来很合理。
function mo(delay, immediate, callback, debounce) {
var timeoutId,
canceled = false,
lastExecutionTime = 0;
function clearTimeout() {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
function executeCallback() {
var args = Array.from(arguments);
var context = this;
var timeSinceLastExecution = Date.now() - lastExecutionTime;
function scheduleNextExecution() {
lastExecutionTime = Date.now();
callback.apply(context, args);
}
function clearTimer() {
timeoutId = undefined;
}
if (!canceled) {
if (debounce && !timeoutId) {
scheduleNextExecution();
}
clearTimeout();
if (debounce === undefined && timeSinceLastExecution > delay) {
scheduleNextExecution();
} else if (immediate !== true) {
timeoutId = setTimeout(debounce ? clearTimer : scheduleNextExecution, debounce === undefined ? delay - timeSinceLastExecution : delay);
}
}
}
if (typeof immediate !== "boolean") {
debounce = callback;
callback = immediate;
immediate = undefined;
}
executeCallback.cancel = function () {
clearTimeout();
canceled = true;
};
return executeCallback;
}
柳暗花明
虽然这个函数形迹可疑,我们还是没有确凿的证据证明它就是罪魁祸首。逆向的时候只看代码是非常痛苦的,如果结合观察运行时行为有一些事情会变得简单很多,于是我决定打个断点跟踪一下。在几次step in后,我来到了一个函数loginVersionEveryPlayInterval
:
这个名字和我的猜想高度重合,让我们看看这个函数怎么写的:
loginVersionEveryPlayInternval: function (t) {
var e = this,
n = 0,
r = 0,
i = 0,
o = mo(1500, !0, (function () {
if (!e.userInfo.isLogin && + i != + e.currentCid) {
var o = e.player.getMediaInfo().absolutePlayTime;
o - n >= t && (i = e.currentCid, e.player.pause(), e.openQuickLogin(), n = o),
0 !== o && (r = o)
}
}));
this.player.on(nano.EventType.Player_TimeUpdate, o),
this.player.on(nano.EventType.Player_LoadStart, (function () {
var t,
o;
null !== (t = e.videoData) && void 0 !== t && null !== (o = t.rights) && void 0 !== o && o.is_stein_gate && + i == + e.currentCid ? n -= r : n = 0,
r = 0
}))
},
e.player.pause(), e.openQuickLogin()
把这段代码的意图写在了脸上……我们可以看到o
,也就是mo
函数的返回值c
(ChatGPT还原:executeCallback
)被注册成了Player_TimeUpdate
事件的一个回调函数. ChatGPT的判断正确。Player_TimeUpdate
我没有继续深挖,但是顾名思义应该是一个会被周期性调用的函数。而mo
被调用的时候,n
(ChatGPT还原:callback
)被赋值为这里调用了e.player.pause()
的那个匿名函数。因此,c
调用p
的时候,p
就会去调用这个匿名函数检测是否到了应该暂停的时间。另一方面,如果我们在这里打印t
,会发现它的值为60,正好是暂停发生的一分钟时长,也印证了这段代码就是我们要寻找的关键。
采取行动
mo
里其余的代码看起来像是非常扭曲的控制延迟的逻辑,推测应该是故意为了混淆视听而写成这样。既然核心逻辑已经明确,我们就可以开始着手干掉这个讨厌的暂停了。方法有很多,我这里采取的方法是让mo
函数什么都不做,直接返回。实现也非常简单粗暴:在mo
函数的开头加一句return;
。
为了测试这样做的效果,我把整个stardust-video
脚本的代码粘贴进了JS Injector。(注:虽然Chromium可以直接编辑js,但这个文件太大,编辑后浏览器会卡死。)然后在mo
函数里添加了return
语句,最后在Dev Tools里屏蔽了原来的stardust-video
脚本。这个测试成功了,直到整个视频播放完都没有出现暂停现象。
但是用JS Injector修改脚本的方法还是笨重了一些,而且适应性不强。为了让解决方案更轻量化,我决定搓个TamperMonkey脚本。我并没有开发TamperMonkey脚本的经验,js水平也仅仅停留在初学者,所以就直接从网上抄了一小段代码魔改了一下:
function addScript(text) {
text = text.replace("function mo(t,e,n,r){", "function mo(t,e,n,r){return;");
var newScript = document.createElement('script');
newScript.type = "text/javascript";
newScript.textContent = text;
var body = document.getElementsByTagName('body')[0];
body.appendChild(newScript);
}
window.addEventListener('beforescriptexecute', function(e) {
var src = e.target.src;
if (src.search(/stardust-video\.[\w\d]+\.js/) != -1) {
e.preventDefault();
e.stopPropagation();
var xmlHttp = new XMLHttpRequest();
xmlHttp.onreadystatechange = function() {
if (xmlHttp.readyState == 4 && xmlHttp.status == 200) {
addScript(xmlHttp.responseText);
}
}
xmlHttp.open("GET", src, true);
xmlHttp.send(null);
}
});
脚本的实现思路是通过监听beforescriptexecute
事件拦截stardust-video
脚本的加载,并且重新获取脚本内容,经过添加return
处理后插入到页面中。这应该不是最优雅的实现方法,如果有更好的方法请评论告诉我。经过实测,这个脚本有效,并且没有带来任何可见的副作用。
太好了,问题解决了,可以听歌去了。
斩草除根
结果就在本篇博文发布之后不久,某日我在听歌的时候突然又被暂停了。经简单调查发现mo
被改了个名字,变成了go
。推测是B站更新小版本的时候重新混淆过。好消息是loginVersionEveryPlayInternval
(对没错,有个拼写错误)的名字并没有变。因此我稍微修改了一下脚本,把这个函数的名字给改掉了。这样不管以后怎么更新,只要官方不去动这个函数,我们的脚本都会有效。
当Bilibili出现登录模态Modal时, 在HTML里面可以找到Div (bili-mini-mask) ClassName名称, 用Chrome DevTools打开網路, 点击搜尋, 输入 (bili-mini-mask) 就能找到stardust-video.js, 复制后格式化, 再用SublimeText搜索一下就能找到checkHasLoginDialog, 进一步搜索checkHasLoginDialog就能找到openLoginDialogCheck, loginVersionWechat, loginVersionBackBlock, loginVersionEveryPlayInternval, 两分钟就能找到根源, 很简单的, 对函式上下文调用修補一下就好了
在Google檢索<<不登录Bilibili查看评论区的方法>>时, 偶然发现你这篇和那篇CSDN不相关的文章
今天花了点时间用Tamper-monkey写了个解決Bili-bili手机网页端牛皮癣的小脚本 (问题: 1. 安装下载App Dialog, 2. 点击复制垃圾文本, 3. 点击全屏自动下载安装包, 4. 观看更多视频需要安装App的问题), 1.简单JSPath点击, 2.exec-Command替换为空闭包, 3. 窗口Player-Agent替换为空闭包, 4. 通过循环DOM 取得Vue options propsData bvid, 克隆替换DOM树解决事件侦听器无法移除的问题 (onclick location设置combined bvid URL), 然后又想再研究研究评论区只有两条内容的问题, Chrome DevTools打开網路, 点击搜尋字串, 发现API拉取了全部的评论, 只是js本地设置了limit, 研究了好一会后来解决了, 也是通过Vue user is-Login 设置Boolean true, 然后再init-Comment就行了, 完美解决免登录评论区的问题
最后一条留言, 我把刚才提到的去处Bili-bili牛皮癣小脚本上传到Pastebin上了, 导入Tamper-Monkey 就能用 (((((另外我测试时候发现, 这个脚本顺带着也解决了你提到的不登录自动暂停的问题)))))) 有需要的话任何人都可以下载, pastebin点com/apWLNVMP
好用,射射兄弟
试了下,貌似又失效了? 添加类名"bili-mini-mask"的div,或者给此类名添加display:none属性,可以限制弹窗,但是还是会暂停视频要手动点一次播放,后续就不会再触发; 遗憾没找到触发暂停的代码,不然就完美了
重新尝试了下,取巧使用覆写settimeout,找到源代码延迟时间,就等于能取消源代码主动触发的事件执行,可以实现不再暂停和免登录1080P, 我的代码
请问可以分享一下代码吗?
github搜xzz2021/beautifyPage, 代码还不太完善,核心替换在inject.js文件里,业务逻辑在bilibili/index.vue下,触发暂停事件还没找到,自己加了个1分钟的定时器,会自动点击继续播放
xzz2022: 链接http://xzz2022.top:2023/share/srT9spq2似乎已经断裂,无法下载文件(美国)。请考虑更新。
使用Tampermonkey插件,然后写个脚本,使用新特性MutationObserver
使用着还可以,看这篇文章
https://blog.csdn.net/weixin_43331420/article/details/129659049