B站挖掘弹幕彩蛋分析记录
致不灭的你:第12话 弹幕彩蛋分析
官方在评论区置顶了一条评论:
“#致不灭的你#12集已更新,在本集21分20秒之后,输入弹幕指令“少年与少女的永恒之爱”或者“花朵”将有弹幕彩蛋出现,一起见证铃和古古最后的爱恋!”
具体效果如下图所示:
详情看评论接口:https://api.bilibili.com/x/v2/reply/main?next=0&type=1&oid=248907536 返回的json数据
.data.top.upper.content.message
//此处省略其它节点数据
{
"data":{
"top":{
"upper":{
"content":{
"message":"#致不灭的你#12集已更新,在本集21分20秒之后,输入弹幕指令“少年与少女的永恒之爱”或者“花朵”将有弹幕彩蛋出现,一起见证铃和古古最后的爱恋!"
}
}
}
}
}
接下来分析一下输入弹幕指令“少年与少女的永恒之爱”或者“花朵”是如何转换成弹幕彩蛋!alt)
我这里使用的是Chrome DevTools,也就是谷歌浏览器开发者工具来调试播放页面。
HTML代码里打断点
我这里还不知道弹幕是放在哪一个元素节点里,所以用了比较繁琐(笨)的方法,以下把存放弹幕的Dom节点简称弹幕容器。
-
Chrome打开视频播放页,键盘输入Ctrl+Shift+C打开开发者工具并启用元素定位功能。接着是光标选中视频播放器,定位到Dom节点,这里我本来是希望能直接选中弹幕的,但发现只能定位到视频播放器。
HTML <div class="bilibili-player-video"> <video crossorigin="anonymous" preload="auto" src="blob:https://www.bilibili.com/6bccdf52-dc1a-4aa3-938e-618d58f2f6d8"> </video> </div>
-
我就想直接把整个视频Dom节点删掉,或许就能选中弹幕节点了,结果还是不行。最后我尝试用排除法,选中视频节点的兄弟节点按下DEL一一删除,直到页面上的弹幕消失,那就说明被删除的节点就是弹幕容器,按下Ctrl+Z还原弹幕容器这个Dom节点。
<div class="bilibili-player-video-danmaku" aria-live="polite"> <!--省略弹幕子节点--> </div>
-
我当时看到的视频里的桔梗花几乎刷满半屏,所以很轻易就在弹幕容器的子节点里找到桔梗花的Dom节点。
<div class="bilibili-player-video-danmaku" aria-live="polite"> <!--省略其它节点--> <div class="b-danmaku" style="width: 42.1875px; height: 28.125px; top: 233px; left: 1001.75px; transform: translateX(-410.823px) translateY(0px) translateZ(0px); transition: -webkit-transform 0s linear 0s;">;<img src="//i0.hdslb.com/bfs/feed-admin/9c05fff7d25348445a5c905e12985d61a7cc0cf1.png" style="width:42.1875px;height:28.125px;"></div> </div>
这里打开img标签的src属性指向的图片url,发现就是一朵桔梗花。 如果子节点里没发现桔梗花的Dom节点,那就回到视频拖动进度条到21分20秒之后,尽可能找到桔梗花较多的一帧,就很容易在弹幕容器里找到桔梗花的Dom节点。 我确定桔梗花就是在弹幕容器以后,选中弹幕容器Dom节点,右击Break on…>;subtree modifications打上一个Dom断点,让桔梗花弹幕渲染到弹幕播放器里时,自动定位jsc-player.996b9ac5.js的相关代码行里,代码经过压缩混淆看起来密密麻麻的,点一下Pretty-print格式化一下代码。 > 注意这个js代码文件名可能会随着版本更新后,发生变化,不过jsc-player应该是短期内不变的。 > 这里我还是感到比较幸运的,因为之前调试其它接口的时候,总是定位到我完全看不懂的代码行里。
Javascript调用栈分析
以下代码行数会随着版本更新后,发生变化,不过短期内应该不变的。
上面Dom断点后,自动定位到jsc-player.996b9ac5.js:formatted:46735,这行代码是一个函数声明。
function zl(i) { //i对象里包含了弹幕Dom节点、桔梗花原图url等 var e = i.dom , t = i.textData , a = i.divCssText , r = i.fontSize , o = Sl[t.picture] , n = 0 , l = 0; //... //这里构造桔梗花img标签 e.innerHTML = '<img src="' + t.picture + '" style="width:' + n + "px;height:" + l + 'px;">;', //... /**调用另外一个函数,参数依然是i对象**/Fl(i)), }
看了一下大概逻辑,可以发现这就是构造桔梗花弹幕的函数。主要是拼接img标签,缩放桔梗花原图,计算图片位置等。。。。。。可以看到函数最后还调用了另外一个函数jsc-player.996b9ac5.js:formatted:46720
function Fl(i) { var e = i.append , t = i.parent , a = i.dom , r = i.className , o = i.divCssText; //... /**把包含桔梗花图片的弹幕Dom添加到弹幕容器里**/e && t.appendChild(a), //... }
zl函数已经是确定把文本弹幕渲染成桔梗花的逻辑,并不是把少年与少女的永恒之爱或者花朵判断为关键词,转换成桔梗花的逻辑,所以查看面板右侧的Call Stack调用栈,找到zl函数的调用栈。从调用栈中找到调用zl函数的b函数。 jsc-player.996b9ac5.js:formatted:46959
var b = function(t) { //这个createDom对象有一个dmPicture属性指向zl函数,t就是一个字符串参数dmPicture。 var o = e.createDom[t]({ text: r.text, textData: i, className: d, divCssText: c, fontSize: s, prefix: p, dom: a, parent: r.parent, append: l })
接着往下看调用b函数的textRender函数
//这里判断i对象存在**picture**属性就会调用zl函数 if (i.picture) { if (Sl[i.picture]) return i.attr = -1, //调用b函数 b("dmPicture"); i.picture = "" }
这里把i对象称为弹幕对象,避免跟下面的i对象混淆。 接着找一下弹幕对象的picture属性究竟是在哪里赋值的。继续往调用栈下方看,一直找到一个i对象定义的onTime函数jsc-player.996b9ac5.js:formatted:47224,具体分析看下面的函数注释
i.prototype.onTime = function(i, e) { //... /** * 这个r是一个数组,里面存储的就是一个个弹幕对象。 * timeLine也是一个数组,包含了视频播放器右侧的弹幕池里的所有弹幕。 * 因为这个timeLine数组大小=弹幕池的弹幕数量,r数组就是通过这个timeLine数组定义的getItemsByRange函数截取出来的。 * 这个getItemsByRange函数传入了两个实参,都是一种只包含stime属性的对象,这里推测getItemsByRange的作用是根据当前视频播放的时间点,从弹幕池中截取属于当前时间点的弹幕组成r数组。 **/ , r = this.timeLine.getItemsByRange({ stime: e ? e - a / 512 * this.config.duration / this.config.speedPlus / this.config.videoSpeed : this.lastTime - .001 }, { stime: e || t }); e && (this.config.danmakuNumber = 1e3), this.insert(r, i, e || i), e && (this.config.danmakuNumber = 300) } this.lastTime = t }
接着找一下i.timeLine里的弹幕对象从哪里来的。在jsc-player.996b9ac5.js:formatted:47232打下断点,调用栈的栈顶右击Restart frame。调用栈刷新代码运行到新断点位置,再从调用栈里往下看有个nextFrame函数
i.prototype.nextFrame = function(i, e) { //在这里断点发现timeLine早已赋值,结合下面的递归调用判断timeLine应该是在开始执行nextFrame前赋值的。 var t = this; //... //函数体里给i对象的一堆属性赋值,然而也没有timeLine。 this.fps = 0, this.fpsStart = !0, this.manager.onTime(this.time / 1e3, i), this.animateTime = a), this.manager.render(this.time / 1e3, this.paused, i)), this.paused ? this.animateRange = a - this.animateTime : (this.fps++, this.animateRange = 0, //... this.animate = window.requestAnimationFrame((function(i) { //递归调用 t.nextFrame(null, i) } )), //... } }
接着调用栈里往下找,定位到一个i.play函数方法体里调用i.nextFrame的位置,这行代码是在jsc-player.996b9ac5.js:formatted:47558。而i.play有如下定义:
i.prototype.play = function(i) { //断点2 var e = this; this.paused = !1, this.timeZero = this.pauseTime, this.pauseTime = 0, this.animate && window.cancelAnimationFrame(this.animate), this.animate = window.requestAnimationFrame((function() { e.startTime = Date.now(), e.animateTime = e.startTime - e.animateRange, //这里开始调用i.nextFrame,直接这行代码再打一个断点 e.nextFrame(i) } )), this.fpsTimer && clearTimeout(this.fpsTimer), this.setFps() }
断点后Restart frame发现这里也已经赋值timeLine数组,函数体第一行再断点Restart frame后确定play函数运行前,i.timeLine里的弹幕对象已存在,接着在调用栈里往下找。定位到jsc-player.996b9ac5.js:formatted:48853,这里又发现代码行是在另外一个i对象的play函数函数体里,避免混淆把之前的i对象称为danmaku对象。 这个i.play有如下定义:
i.prototype.play = function() { var i; //调用栈定位 this.danmaku.play(), this.player.advDanmaku && this.player.advDanmaku.play(), this.player.basDanmaku && this.player.basDanmaku.play(), this.player.danmakuSetting && this.player.danmakuSetting.maskStart(), null === (i = this.player.allPlugins) || void 0 === i || i.play() }
接着跟踪i.danmaku.timeLine定义的位置,调用栈往下看定位到 jsc-player.996b9ac5.js:formatted:38770,这是在一个i._onVideoPlay函数体里
i.prototype._onVideoPlay = function(i, e, t) { //打断点 var a, r, o, n = this, l = this.player; //... //这里的i对象又是另外一个i对象,为了避免混淆我这里直接把链式调用写完整,上面把this.player赋值给l,而l.danmaku就是上面的i对象,也就是timeLine数组等于i.player.danmaku.danmaku.timeLine l.danmaku && l.danmaku.play(), l.subtitle && l.subtitle.play() }
_onVideoPlay函数体首行打上断点Restart frame后,timeLine数组也已经存在弹幕对象。接着还是看调用栈,找到另外一个play函数,这是一个属于视频播放器video dom定义的play事件触发的函数。浏览一下play函数体,没有发现timeLine相关定义。
$(i).on("play", (function() { (null === performance || void 0 === performance ? void 0 : performance.timing) && !performance.timing. //...
这里在调用栈上方的Watch添加两个变量 a.danmaku.danmaku.timeLine a.controller.player.danmaku.danmaku.timeLine 接着首行打上断点,并且视频播放器点击一下暂停再播放,重新执行了play函数,确定了timeLine对象并不会在视频播放器播放前就已经初始化好了,所以只能查找video dom对象的其它事件继续分析。
开发者工具切换到Elements,选中video dom查看右侧的Event Listeners,刷新了视频播放器绑定的所有事件。这里我推测loadstart事件初始化timeLine可能性较大,于是展开loadstart查看绑定的事件函数只有一个,就是在 jsc-player.996b9ac5.js:formatted:64977
$(i).on("loadstart", (function() { a.initialized || a.loadingpanel.ready(3), a.sourceSwitching || clearInterval(a.timerChecker), //这行设置状态的代码在play函数也出现多次 a.controller.setState(x.V_IDEL) } ))
函数体里首行断点发现并没有执行,但是留意到这里也有一行熟悉的代码,于是我回到play事件函数,在jsc-player.996b9ac5.js:formatted:64977发现类似的设置装填的代码
a.controller.setState(x.V_PLAY)
我推测x状态对象可能存在弹幕加载完毕的状态常量,所以直接找到x状态对象定义的位置jsc-player.996b9ac5.js:formatted:11868。发现x并不是一个对象而是一个函数。
, x = function() { function i() {} return i.TAB_WATCHLATER = 2, i.TAB_PLAYLIST = 3, i.TAB_DANMAKULIST = 4, i.TAB_BLOCKLIST = 5, i.UI_NORMAL = 0, i.UI_WIDE = 1, //... //jsc-player.996b9ac5.js:formatted:12045声明了弹幕加载完成的变量 VIDEO_DANMAKU_LOADED: "video_danmaku_loaded", //... }
接着把状态VIDEO_DANMAKU_LOADED作为关键词,在js里全局搜索VIDEO_DANMAKU_LOADED出现的位置,试图找到弹幕加载完后执行弹幕池初始化相关逻辑。定位了几个代码行,在jsc-player.996b9ac5.js:formatted:51188找到了弹幕加载完的事件函数。
t.bind(x.EVENT.VIDEO_DANMAKU_LOADED, (function(i, t, a) { if (!t) return e.trackInfoPush("web_load_danmuku_fail", a), !1; e.trackInfoPush("web_load_danmuku", a) } )),
这里函数体首行断点,查看回调函数,原以为三个实参中应该有一个是包含了timeLine数组,然而并没有。不过观察了函数体,觉得e.trackInfoPush有可能是初始化timeLine,但是没有在函数体里找到关键逻辑。
经反复调试,在jsc-player.996b9ac5.js:formatted:43091找到获取弹幕并解码的逻辑。
return i.prototype.loadDmPb = function(i, e, t) { var a = this; return void 0 === e && (e = !0), void 0 === t && (t = "DmSegMobileReply"), this.nativeXHR(i, e).then((function(i) { if (!a.protoMessage[t]) { var e = Vn.Root.fromJSON(Bn); a.protoMessage[t] = e.lookupType("bilibili.community.service.dm.v1." + t) } var r = a.protoMessage[t].decode(new Uint8Array(i.data)) , o = a.protoMessage[t].toObject(r); return Promise.resolve({ data: o, loadTime: i.loadTime }) } )).catch((function(i) { return Promise.reject(i) } )) }
发现picture来源就是直接从接口 Protocol Buffers弹幕解码后的每一个弹幕对象中包含的action属性解析出来的
{ "id": 51423415803838470, "progress": 1449368, "mode": 1, "fontsize": 25, "color": 16777215, "midHash": "36e0d151", "content": "少年与少女永恒的爱", "ctime": 1625224556, "weight": 10, "action": "picture:i0.hdslb.com/bfs/feed-admin/9c05fff7d25348445a5c905e12985d61a7cc0cf1.png?scale=1.00", "idStr": "51423415803838469" }
结合jsc-player.996b9ac5.js:formatted:44354声明的发送弹幕方法,尝试在视频进度条21分20秒以后模拟发送弹幕,发现接口携带的表单参数并不包含action,因此推断action应该是在服务端那边生成的。
i.prototype.send = function(i) { var e = this , t = this.player , a = this; if (i && t.config.cid && this.status === x.SEND_STATUS_TYPING && !this.SENDING_DISABELD) { var r = t.currentTime() , o = new Date , n = { type: 1, oid: t.config.cid, msg: i, aid: t.config.aid, bvid: t.config.bvid, progress: Math.ceil(1e3 * r), color: this.config.color, fontsize: this.config.fontsize, pool: this.config.pool, mode: this.config.mode, rnd: this.config.rnd, plat: 1 };
断点调试记录
[
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 46727,
"columnNumber": 8,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 46748,
"columnNumber": 8,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 47558,
"columnNumber": 12,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 47566,
"columnNumber": 16,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 47843,
"columnNumber": 20,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 46231,
"columnNumber": 12,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 47231,
"columnNumber": 18,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 47822,
"columnNumber": 12,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 48852,
"columnNumber": 12,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 38727,
"columnNumber": 12,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 38709,
"columnNumber": 12,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 64836,
"columnNumber": 16,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 51188,
"columnNumber": 20,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 43793,
"columnNumber": 20,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 43124,
"columnNumber": 24,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 43791,
"columnNumber": 16,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 47616,
"columnNumber": 16,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 43501,
"columnNumber": 12,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 43533,
"columnNumber": 16,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 43458,
"columnNumber": 12,
"condition": "",
"enabled": false
},
{
"url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
"lineNumber": 43101,
"columnNumber": 16,
"condition": "",
"enabled": true
}
]