前言
终于把毕设的第一部分完成了,过一段时间再去看老师的批注来修改吧。
做毕设的时候用的框架晚点再写。
因为最近对乱七八糟的东西感兴趣,所以一边看文章一边动手顺便一边写一写自己的学习过程。
BiliBili 直播
F12 审查元素可以看到弹幕的实现方式是 div 的重叠,不过我完全不懂前端,就不提了。
根据 F12 看到的 websocket、无限加载的 flv 文件以及网上的资料,BiliBili 的直播实现方式应该是 rtmp 推流 + httpflv 拉流,websocket 可能用来获取弹幕。貌似轮询加载的 data.bilibili.com/log/web 上面带了直播间相关的信息,不过比较复杂我看不懂作用。
这时候如果观察 websocket 中的数据,就会发现基本都是一些开头类似,内容不同的数据,猜测前面是数据包的头,主要数据经过了加密处理,有人好像已经做过了解析:https://github.com/yangjunlong/barrager.js 。
不过他的 player.js 的版本跟现在网站上使用的已经不同了,说不准合不合用,所以我打算自行研究一番。
一开始我从 websocket 相关的 onmessage 找起,结果一直找不到地方。后来我从弹幕处理的尾部(就是弹幕显示到我们屏幕上的时候)往前推,从 addDanmaku 到 _messageReply ,根据 onReceivedMessage 到 onMessageReply,就能找到 e.prototype.onMessage 了,最后的解析函数就是 convertToObject 这个函数。这函数链是真的复杂。
后面想分析一下具体的处理的过程,但是他的不知名函数套娃分析起来实在是太麻烦了……
不研究了,要研究光看这个文件还不够,这个文件里面的代码只是运行了函数,相关代码只是放在对象里面还没有运行。要解包可以下断点把它的对象拿来用。
后来我观察了一下 Network 的流量,觉得可以尝试断点调试一下,但是它的 JS 文件没有经过美化的,直接下断点会把自己的浏览器卡死。所以我就打算把某个直播间的 HTML 文件和几个需要用到的 JS 文件下载下来,在本地搭个环境。
接着我就发现,它的 JS 执行是一环一环的,前面的 XHR 没有成功后面的 WebSocket 就不会建立了。
没办法,我就在本地用 Python 简单写了个后端,然后把本地的 DNS 解析指向了自己( WebSocket 那个域名我没改成功,不知道为什么,最后是直接改了 player 那个 JS 文件里面的域名)。如果不需要本地调试,也可以尝试覆写直接调试网站上的 JS,参考这篇文章:https://cloud.tencent.com/developer/article/1563717 。
按照这个思路,我就可以修改 JS 代码,实现弹幕的分离了(?),比如只看同传 man 的弹幕,或者只把同传 man 的弹幕放到直播界面上。
按照 addDanmaku 函数跟下去,可以发现弹幕的收集是使用队列来实现的,WebSocket 收到信息之后按长度分段入队:
void 0 === t && (t = !1);
var i = this._player.config.user;
if (e.text && e.text.length > 30 && a.default.DANMAKU_MODE_TOP === e.mode) {
for (var n = e.text.split(""), o = Math.floor(n.length / 30) + 1, r = 1; r < o; r++)
n.splice(30 * r, 0, "\n");
e.text = n.join(""),
e.textAlign = "center"
}
i.isSuperUser && (e.color = i.superUserForceColor),
i.isSuperUser && (e.mode = a.default.DANMAKU_MODE_REVERSE),
t ? this._danmaku && this._danmaku.add(e) : this._WAITING_QUEUE.push(e)
我好像没见过 t 为 true 的时候。接下来按照队列来寻找,就会发现弹幕的显示使用 setTimeout 套娃每 2 秒调用 _render 函数做的。
然后就是 _render 调用 _add 来显示弹幕啦:
e.prototype._add = function(e, t) {
var i = this;
if (void 0 === t && (t = this._RENDER_INTERVAL_TIME),
e.length) {
var n = e.splice(0, 1)[0];
this._checkVideoIsPaused() && this._danmaku && this._danmaku.add(n),
n = null,
window.clearTimeout(this._TIMEOUT_ADD_DANMAKU),
this._TIMEOUT_ADD_DANMAKU = window.setTimeout(function() {
i._add(e, t)
}, t)
}
}
这里还是套娃,间隔时间在 _render 函数中按照弹幕数量来计算。
其实仔细一看就会发现,弹幕是分成两部分来处理的,上面所说的部分有个 _checkVideoIsPaused() 函数的处理,换句话说就是这部分是显示在直播界面上的弹幕的部分。Block 几个加载的 JS 也可以发现,直播界面上的弹幕只需要 player 和 jq 两个 JS。
那右边的滚动弹幕是怎么处理的呢?要看几十万行代码就太复杂了,所以我 block 了几个 JS,确定了最后生成滚动弹幕 div 的代码在 3.xxxx.js 文件中,关键代码如下:
var F = Object(i.a)("span", "user-name v-middle " + (g ? "my-self" : "pointer open-menu"), (g ? "[自己]" : t.userInfo.username) + " : ", g ? null : t.activityInfo.usernameColor && [["style", "color:" + t.activityInfo.usernameColor]]);
R.appendChild(F);
var G = Object(i.a)("span", "danmaku-content v-middle pointer ts-dot-2" + (g ? "" : " open-menu"), w);
return R.appendChild(G),
然后根据调用链往上找就看到这部分弹幕的处理方法了,在 _messageReply 函数的最后一行,会根据是否屏蔽弹幕来调用 _addServerCallbackSmoothly 函数将弹幕放入另一个队列 _SERVER_CALLBACK_QUEUE 中,然后再通过类似的 _renderServerCallback 套娃 _callServerCallback 再调用 3.xxxx.js 中的 receiveMessage,即绑定在 receiveMessage 上面的 ae 函数处理到屏幕上。
这前端代码真的是复杂……流程复杂函数名也复杂。
脑洞一开想到了弹幕姬,思考一下如果要自己整一个要怎么做,WebSocket 是没有跨域也没有 Cookie 表示身份的,所以加入某个直播间肯定是通过客户端发送直播间相关信息,给 WebSocket 数据包加密函数加个 console.log,可以打印出身份验证包:
{"uid":xxxxxxxxx,"roomid":21641569,"protover":2,"platform":"web","clientver":"1.10.3","type":2,"key":"xxxxxxxxxxxxxxxxxxxxxx"}
uid 就是自己的 id,key 就是访问 getConf 接口拿到的 token。除此之外还有很多个空的 heartbeat 数据包,不提。那么理论上只要带上 cookie 访问这个接口,拿到 token 之后进行 WebSocket 身份验证,就可以在自己的客户端上看到弹幕了。
接下来开始仔细研究一下 player.js 里面的代码,探究一下功能的具体实现方式,按三个 !function 的执行顺序来看,学习前端开发的同时顺便琢磨一下反代码审计。
通过断点观察调用链,直播间的实现原理就差不多清晰了,player.js 将用来初始化直播间的 EmbedPlayer 函数挂到 window 上,然后在 HTML 里面加载调用。
第一个函数的函数体:
"object" == typeof exports && "object" == typeof module ? module.exports = t() : "function" == typeof define && define.amd ? define([], t) : "object" == typeof exports ? exports.EmbedPlayer = t() : e.EmbedPlayer = t()
这里似乎是对几种 JS 规范的判断,还是一个套娃三木判断,第一个部分似乎是 Node.JS 使用的 CommonJS 规范,第二个部分是浏览器的 AMD 规范,最后似乎还是 Node.JS,最后在浏览器上运行的就是最后一种:e.EmbedPlayer = t(),将第二个参数作为函数执行,然后将结果挂在第一个参数对象中的 EmbedPlayer 上。
接下来看函数的参数部分,第一个参数是 window,第二个部分则是一个函数。该函数执行之后会套娃进行里面的函数,然后返回里面函数的执行结果,里面函数的参数部分是一个函数数组,函数体将参数和各种对象、函数放进 n 里面组装了一个对象 n,然后返回调用 n 函数后的结果:
if (t[r])
return t[r].exports;
var a = t[r] = {
i: r,
l: !1,
exports: {}
};
return e[r].call(a.exports, a, a.exports, n),
a.l = !0,
a.exports
n 函数会调用第四个函数,然后返回一个 a.exports 对象,函数大概是这样:
function(e, t, n) {
var r = n(5).default;
e.exports = r,
window.EmbedPlayer = r
}
这里又执行了一遍 n 函数,这次是调用第五个函数,函数太长我就不放了,主要逻辑就是执行了几个之前的函数数组里的函数,把结果存在变量里面,然后将各种函数和属性塞进一个对象 c 里面,并将 c 赋值给前面的 a.exports.default,所以第一个大函数执行完之后,window.EmbedPlayer 其实就是这个 c ,调用的时候执行的就是 541 行初的 e 函数。
说一个题外话,前端程序员在代码里夹带了好多私货啊,我合理怀疑他是个海王星厨,还是个炮姐厨。
其他几个大函数都跟上面讲的里面函数差不多,组合了一堆属性和函数,再把相关的入口函数挂到 window 上。
接下来我继续断点调试,尝试理清整个流程,首先是事件的绑定:HTML new 一个 window.EmbedPlayer,也就是运行了 541 行的 e 函数 -> initialize -> new window.Player -> 14742行的匿名函数 -> 调用 addFirstPlayCallback 入队 _firstPlayingCallbackQueue 回调 -> 绑定 loadedmetadata 事件 -> 加载成功后调用 triggerFirstPlayCallback 处理回调。
正常是这个流程,如果直播加载不出来呢?这时候会通过设定好定时的 triggerFirstPlayCallback 函数处理 _firstPlayingCallbackQueue,从队列里面取出回调函数,执行 i.components.socket.init 来建立 WebSocket 连接,除了触发回调的那一步,后面的步骤都是一样的。
后来我花了点时间,把 player.js 里面的关于 WebSocket 数据包加解密的代码提取了出来,不过精简的不够彻底,还剩下1000+行的代码,看起来都跟加解密有关,我也就懒得继续删改了,能用就行。
下一步的学习应该是用 JS 写一个自己的 B 站弹幕姬软件来玩玩。
我在看别人写过的第三方 B 站弹幕软件的时候,抓包发现他的 WebSocket 数据包竟然是没有加密的,这是为什么呢?不清楚 B 站是什么样的机制,不管了。
筛选出的加解密代码大概是这个样子:
!function(e) {
...
}
({
22: function(e, t, n) {
...
},
25: function(e, t) {
var n, i;
n = "undefined" != typeof self ? self : this,
i = function() {
return function(e) {
...
}([
...
])
}
,
e.exports = i()
}
})
Orz