简介
之前看到一个UP主因为一些原因在视频底下大规模控评,我注意到在控评的评论区下,原本网页端应该每次加载返回20条评论,但实际上要少很多。我就好奇是评论本身后端没有返回还是怎么回事,就打开F12抓了下请求看看,结果发现被标记为隐藏的评论后端也在正常返回数据,只是前端隐藏了。不仅如此,我还发现评论的API还有返回评论者评论时的IP属地,只是前端没有进行渲染。于是我就打算写个脚本来把这些数据显示出来
脚本目前已经发布,详见这里
以下功能已集成至Bilibili Evolved,请搜索IP属地显示
、关注时间显示
、相簿发布时间显示
等组件
此文就来讲解该脚本的原理
实现原理
以下是该脚本的实现原理讲解
前期准备
API分析
B站网页端每次会请求20条评论,后端的评论API会返回评论的相关信息。评论的详细JSON格式可以参考这个。
以下是一条评论数据的示例
1 | { |
根据我和网页上实际显示的评论和后端实际返回的评论比较,我发现前端会检测评论条目对象里的invisible
属性,若属性为空时则不会渲染评论本身。除此之外,可以发现reply_control.location
属性正是App端显示的IP属地,而网页端并没有对这个属性进行解析。因此,只要能实现对这两个属性进行单独处理,就能实现显示隐藏评论以及在网页端显示IP属地
技术选择
为了实现这些功能多半得直接修改B站的前端JS脚本源码,因为众所周知这年头的打包工具全都会把JS脚本压缩成IIFE运行,变量全都给整成闭包了,根本没法用JS脚本在外部进行操作,所以用TamperMonkey这种在网页上执行用户JS脚本的插件肯定没辙,得想别的办法
幸好我以前凑巧就用过一个能实现这种功能的插件,Header Editor
Header Editor除了修改Header和请求重定向等功能之外,在Firefox里也能使用自定义脚本直接修改响应体,这样就能实现修改源码的功能
不过我经过研究发现Chrome等浏览器貌似不支持这么做,找了几个同类型的插件都说Chrome里无法修改响应体,估计是浏览器本身的限制
幸好这类插件的重定向请求等功能在Chrome里也可以用,可以在修改完B站前端的源码后把修改后的文件放到别的CDN上,然后把B站原先的脚本重定向到CDN那里存储的修改后的脚本,这样也能实现一样的效果
最终我选择在GitHub新建一个仓库,并且使用jsDelivr作为CDN直接获取GitHub仓库里的文件,这样就能在非Firefox浏览器里也实现同样的效果
旧版评论区逆向
我是先从网页端旧版界面开始逆向的,先看看网页端的评论元素长什么样
1 | <div class="con "> |
要添加自定义元素,那肯定得先找到是哪段代码创建的这个评论元素。这里的class非常明显,我们可以直接在浏览器的开发者工具里全局搜索time-location
、reply-time
等样式。以下是我在Firefox开发者工具里搜索的结果
可知脚本来自comment.min.js 这个.min.js后面根本不min啊(恼) 。注意这里搜到了两个片段,分别是处理评论主楼和楼中楼回复的,处理的时候两个地方都得改一遍。跳转过去查看代码,我们就会发现这是在创建评论区对应的DOM元素
很明显这是在用字符串拼接的方式直接创建了HTML元素并直接插入到<body>
里。但是等等,有没有注意到什么
1 | // item?.reply_control?.location ? `<span class="reply-location">IP属地:${item?.reply_control?.location || ''}</span>` : '', |
注释?
是的,你没看错,他们已经把IP属地显示都写好了,仅仅是去掉这行注释就可以直接启用网页端IP属地显示。而且注释里写的样式reply-location
也是个确实存在的可用的CSS样式
这说明B站早就做好了对应的功能,只是由于某种未知的原因没有把这个功能在网页端上线
那这样的话只需要去掉这行注释就能实现IP属地显示了
但我要做的可不止是这个,我要做的还有显示被隐藏评论。另外有人觉得只显示IP属地不够明显,所以我还顺便改了下IP属地的样式(
简单检查下代码可以发现这一段行大致是这样的
1 | var con = [ |
因此我们可以猜测con
数组就是原始HTML片段,只要我们在后面继续加就可以实现添加自己的元素
同时可以注意到这其中用的参数都是item.xxx
,比如评论时间是取的item.ctime
,评论属地用的是item?.reply_control?.location
因此我们可以猜测item
就是评论元素本身,那要检测评论是否被隐藏的话,只要取item.invisible
即可判断
所以最终我的处理方法是把
1 | // item?.reply_control?.location ? `<span class="reply-location">IP属地:${item?.reply_control?.location || ''}</span>` : '', |
替换成
1 | item?.reply_control?.location ? `<span style="margin-left:5px; color: #0CC;">${item?.reply_control?.location || ''}</span>` : '', |
注意IP属地:
这几个字在返回的JSON数据里已经加上了,所以这里应该把HTML里的IP属地:
这几个字删掉,不然就会重复显示了
除了恢复IP属地显示之外,我加了一条判断,如果item.invisible
为true就添加一个<span>
用来说明评论被隐藏,否则就什么也不添加
这样就能在显示评论属地的时候也能标记出隐藏评论了
但是别忘了,前文刚说了
我发现前端会检测评论条目对象里的
invisible
属性,若属性为空时则不会渲染评论本身
如果元素直接不被创建,在这光拼接HTML也没有用,所以还得把这个判断给整掉
但有了之前的经验,要整掉这个判断就很简单了
在该脚本里直接搜索invisible
就能发现5段相同的判断
1 | if (item.invisible) { |
明显是在评论被隐藏时不渲染评论
那想干掉这个判断就很简单了,直接把判断条件替换成if (false)
或者干脆删掉这段代码都行
所以最后在Header Editor里实现个替换脚本就很简单了
1 | return val |
这就是网页端旧版评论区逆向的原理了
新版评论区逆向
就在我整完旧版评论区逆向发布脚本后,有群友跟我说不能用,F12抓网络也只能找到个comment-pc-vue.next.js
的东西。经我测试发现B站网页端的旧版和新版界面竟然用的不是同一套处理方案,新版的是以vue为基础整的,并且这个comment-pc-vue.next.js
虽然名字里没有min但确实是给压缩完了。幸好有前面逆向旧版评论区的经验,我猜测新版评论区也保留了reply-location
等一系列和IP属地相关的样式等,所以可以按类似的方法分析
获取js源码
首先要做的自然是获取相关脚本的源码了
B站新版界面前端的源码似乎还处于开发中,大概每隔几天就会重新打包更新一次,这里以2023/3/5的版本为例
从https://s1.hdslb.com/bfs/seed/jinkela/commentpc/comment-pc-vue.next.js把脚本下载下来看看
彳亍,这可是真的是.min.js了。不过问题不大,先用Firefox对代码进行格式化并保存后再来找找reply-location
看看
1 | // 第44279行 |
搜索出来能搜出好几个,这里明显能看出这段代码是把reply-location
样式给整进变量os
里了,那我们来看看os
在哪里被引用了
1 | // 44714行 |
找是找出来了,可是这坨代码是nm啥啊(
先别急,一步步来分析
源码分析
首先我们可以注意到,这段代码里有很多字符串常量,如div
、span
、reply-like
等。再加上我们刚刚找到的os
变量,可以合理猜测这里就是在创建评论区对应的DOM元素
再注意一下每行代码的开头
1 | (0, P._) |
不熟悉JavaScript特性的话可能就不知道这段是在整啥,这个其实是在使用JavaScript的逗号表达式,会返回最后一个表达式的值,详见这里
所以这里其实就是等于直接取后面的值,也就是P._
和z.SU
。在这几个后面直接就跟了括号,所以这里其实就是取P._
和z.SU
,并把它们当作函数进行调用
为什么是P._
、z.SU
这种鬼畜的变量名?这是打包工具对源码进行压缩的结果,就当作是混淆后的变量名得了。因为是经过打包压缩的代码,所以我们这里就不指望能通过删注释就复原IP属地显示了,我们得自己写一个出来
至于为什么不直接写P._()
进行调用,而要写(0, P._) ()
这种鬼畜的形式?这是为了改变函数里的this
指向,可以看看隔壁的讲解,这里不再赘述
所以这里摆明了就是函数调用,包括后面的(0, P.wg)
、(0, P.iD)
、(0, kt.toDisplayString)
、(0, z.SU)
、(0, P.kq)
全都是一个意思,都是在进行函数调用而已
接下来尝试分析其中的一行
1 | (0, P._) ('span', rs, (0, kt.toDisplayString) ((null === (a = (0, z.SU) (m)) || void 0 === a ? void 0 : a.last_mtime_text) || (0, z.SU) (lt) ((0, z.SU) (v))), 1), |
先别两眼一黑,一步步来
我们知道P._
就是一个函数,先不管这个函数具体是啥,一个个来看后面的参数
第一个参数'span'
很明显就是指的<span>
标签
第二个参数rs
我们可以查找一下引用,不难发现
1 | // 44276行 |
好,这个明显就是评论时间标签的样式。注意rs
赋值的方法是上述代码,而不是rs = 'reply-time',
,所以我们可以猜测rs
和上文提到的os
都不止是class
,而是这个元素的所有属性,包括class
继续看下一个参数
1 | (0, kt.toDisplayString) ((null === (a = (0, z.SU) (m)) || void 0 === a ? void 0 : a.last_mtime_text) || (0, z.SU) (lt) ((0, z.SU) (v))) |
???
别急,还是一步步来
首先,kt.toDisplayString
很明显就是个把后面的东西转换成字符串的函数,那我们直接看后面的参数…
1 | (null === (a = (0, z.SU) (m)) || void 0 === a ? void 0 : a.last_mtime_text) || (0, z.SU) (lt) ((0, z.SU) (v)) |
这nm是啥?
说实话,我逆向到这的时候我也不知道这是个锤子,但后面能看到几个有意义的参数,如(0, z.SU) (m)
、a.last_mtime_text
、(0, z.SU) (lt) ((0, z.SU) (v))
等
前面两个提供不了什么特别有用的信息,来看看第三个(0, z.SU) (lt) ((0, z.SU) (v))
查看一下lt
的引用,可以找到它的定义
1 | // 37575行 |
一眼顶针,鉴定为根据评论时间计算该显示的字符串
所以这个函数的参数e
就是后面的(0, z.SU) (v)
了
我们来看看v
的定义
1 | // 44345行 |
这名字怎么看都像是评论的具体时间
因此,这里我们可以简单猜测一下这行代码的作用
P._
是创建HTML元素用的函数,它接收三个参数,第一个是字符串类型的元素名称,第二个是元素的各种属性,第三个估计不是innerText
就是innerHTML
z.SU
是某种取值函数,像是这里的lt
和v
就是和评论时间相关的函数/变量
(这里其实可以猜测P._
就是vue的渲染函数h()
,详见这里,可以看到参数类型跟这里的一模一样)
既然得知了这行代码的大致作用,我们可以修改源码输出一下v
试试,将(0, z.SU) (lt) ((0, z.SU) (v))
直接整成console.log(v)
,会发现v
就是vue的响应式对象
那(0, z.SU)
的作用就很明显了,就是对响应式对象进行.value
取值
接下来我们可以看看下一行代码
1 | (0, z.SU) (f) ? ((0, P.wg) (), (0, P.iD) ('span', os, (0, kt.toDisplayString) ((0, z.SU) (f)), 1)) : (0, P.kq) ('v-if', !0), |
好,这nm又是什么(
别急,开头的f
我们可以先查找下引用,不难发现
1 | // 44346行 |
这很有可能就是没有被显示出来的IP属地
后面跟的是一个三元表达式,既然当f
为false
时显示(0, P.kq) ('v-if', !0)
,那就当这段代码是v-if
判断得了,结果应该就是不创建元素
所以我们把重点放到前面来,可以看到关键代码
1 | (0, P.iD) ('span', os, (0, kt.toDisplayString) ((0, z.SU) (f)), 1) |
(0, P.iD)
和前文类似,一眼顶针鉴定为创建元素
后面的三个参数一样,os
就是刚刚的reply-location
,即IP属地的显示样式。后面的(0, z.SU)
就是前面说的对响应式对象取值
我当时逆向时是在这里把前面的(f)
改成(true)
强制让它输出f
的内容,结果发现f
恒为false
上文提到f = p.replyLocation
,那我们看看这个p
是怎么回事
1 | // 44343行 |
1 | // 37905行 |
很明显xt
就是把评论数据解析成对象的函数,来看其中一个
1 | var n = (0, P.Fl) ((function () { |
这里就是在解析一个属性了。注意看n.rpid
,这个是后端返回的评论原始JSON数据里的一个属性P.Fl
应该就是vue的ref
了,因为返回的值是vue的响应式对象,需要我们使用.value
才能获取具体的值
前面那一段可以猜测是打包工具针对不支持ES6的浏览器写的polyfill,猜测源码应该是使用链判断运算符写的空值判断
1 | let n = ref(e.value?.rpid ?? 0); |
因此可以猜测后面每一个x = (0, P.Fl) ...
都是在读取一个属性,最后的return就是把这些读取的单个属性拼接成一个对象
那我们就来看看最后返回的replyLocation
到底是怎么回事
1 | // 38172行 |
1 | // 37926行 |
这里要提一下js的自动类型转换,!1
是打包工具对于false
的简写,在比较前会自动转换为false
,所以!1 === false
所以我们可以合理猜测源码是
1 | let l = ref(false); |
合着location是tm固定返回false啊(恼)
我们还有一个功能是标记隐藏评论,这个就在刚刚p = xt(t)
的下面,不难发现C = p.invisible
,创建新元素的时候判断一下这个C
,并查找下C
的引用把原先的判断条件全改成false
就行了(
现在我们把需要了解的源码都基本上浏览了一遍,可以开始魔改了
源码修改
我们从44716行开始魔改
1 | (0, z.SU) (f) ? ((0, P.wg) (), (0, P.iD) ('span', os, (0, kt.toDisplayString) ((0, z.SU) (f)), 1)) : (0, P.kq) ('v-if', !0), |
这一行的作用是创建IP属地显示元素,若存在就显示,不存在就不创建
按照惯例我们先改一下它的样式,把os
改成我们自己的{class:"reply-location",style:{color:"#0CC"}}
,这样就能在保持原有样式的同时加上我们自己的字体颜色显示了
源码里已经把创建元素都写好了,改的是收到的IP属地的数据,所以我们这里不需要动创建元素这里,直接取改原数据处理那一段
接下来我们就需要改f
,即replyLocation
了
按照上文的分析,replyLocation
本身就被设计成固定返回false
,我们得直接修改它的值
不过按照上文的分析也不难实现,只要把37927行的return !1
改成return e.value?.reply_control?.location
即可,这样就可以正确处理IP属地了
下一行我们得创建自己的隐藏评论标记元素,已知C
是ref(评论是否被隐藏),P.iD
是创建元素,直接照抄上面创建元素的方法即可
1 | (C.value)?((0,P.iD)("span",{style:{marginLeft:"-10px",marginRight:"10px",color:"red"}},(0,kt.toDisplayString)("评论被隐藏"),1)):(0,P.iD)("span",{style:{display:"none"}}), |
这里的逻辑是,先取C.value
判断评论是否被隐藏,如果是就在IP属地元素后创建个自己的span
,设置好样式,内容就是"评论被隐藏"
,否则就创建个不显示的空元素(我不知道怎么在不创建元素的情况下还不报错,只能这么弄了)
最后别忘了查找对于C
的引用并把判断条件改成false
1 | // 44556行 |
直接把(C)
改成(false)
即可
这样就完成主楼回复内容替换了
但别忘了上文说的,除了主楼之外还有楼中楼也得进行相同的处理,楼中楼对应的样式是sub-reply-location
,按和上文完全相同的流程再走一遍即可
通用替换脚本
按以上方法确实可以实现所需的功能,但是没法整出通用替换脚本,因为B站前端新版界面似乎一直在开发,每几天就会重新打包一次,很多的变量名都会改
所以得用一些方法在变量名被修改时也能匹配对应的代码
我用的方法是正则表达式配合捕获组
虽然匹配的方法有亿点复杂吧(
1 | //主楼invisible变量 |
正则表达式的相关内容这里不再叙述,这边匹配的原理就是匹配各段代码之间的不同处理逻辑,而不是变量名,变量名会因为打包工具随便更改,所以我直接用的\w+\.\w+
进行匹配,但基础的处理逻辑是基本不会改的
提取指定变量我是直接取的捕获组,用括号区分各个捕获组之后再用match
进行匹配,可以直接以数组下标的形式把匹配到的内容提取出来
用正则表达式替换的时候也可以使用捕获组,在替换的内容里写$n
就可以使用第n
个捕获组里的文本内容
所以这里为了提取各种变量最多甚至用到了多达7个捕获组
我是在这里测试这些超级复杂的正则表达式的,这网站甚至会帮你把捕获组标出来,还能测试替换后的结果
个人空间关注/粉丝数查看上限突破+关注时间显示
讲真这里的逆向比刚刚评论区逆向简单得多,我就不(lan)用(de)放图演示了,直接口述吧
首先F12抓包粉丝/关注显示可以发现,get参数里有个ps=20
,这个参数就是控制每页显示多少个人的。经测试可以发现最大可以设置为50。由于访问页数还是只有5页,因此针对其他用户,可查看的粉丝/关注数就由原先的5 * 20 = 100
个提升为5 * 50 = 250
个
既然参数里有ps,那可以合理猜测发起请求时使用了一个包含ps
属性的js对象作为参数,简单搜索ps:20
即可发现相应的源码,直接查找替换成ps:50
即可
至于页数计算,查看源码可知是由Math.ceil(this.curTag.count/50)
写死的……
那就直接把/20
替换成/50
即可
关注时间显示这块,照例直接找关注界面的css class属性,可以很快就找到这部分源码。查看源码可知变量i
很可能是粉丝对象本身,配合抓包内容可以猜测i.mtime
就是关注时间。模仿前面的创建元素的代码,使用t._v(t._s())
即可创建元素。注意对于部分较早期关注的粉丝,关注时间可能是不存在的,因此记得做空值判断
相簿界面发布时间显示
一样开局根据css的class属性直接找相关代码,比如可以直接搜查看次数的图标的classalbum-card__count-icon--view
即可直接找到对应代码。同空间相关代码,可以猜测t.item
就是每个相簿元素本身,根据抓包结果可以猜测t.item.ctime
就是发布时间。模仿上文代码,使用e()
即可创建元素。这里要注意,根据前文代码的staticClass
,搜索一下可知这是vue的创建元素的参数。如果我们想添加内嵌样式,得使用staticStyle
,而这个参数需要传递一个对象。最后,第三个数组就是元素的内部内容,同样仿造前文使用t._v(t._s())
传入一个字符串即可创建对应元素,我这直接使用了new Date(t.item.ctime * 1000).toLocaleString()
总结
以上就是本插件的主要原理讲解。由于该插件的原理是直接修改b站的前端源码,因此我能想到的通用方案就只有使用能重定向请求的插件配合CDN实现了。希望以上原理讲解能给你的开发带来或多或少的帮助