A-SOUL 时代
我在今年六月份的时候被室友安利了关注了嘉然,进而得知了 A-SOUL。起初只是觉得这个粉色小东西的声音好听,不嗲不做作;动捕也十分强大,是个资本拿钱砸出来的 Vtuber。
后来我陆陆续续刷到了很多一个魂们整的典中典。突发恶疾的视频让我笑到断气,溜完视频后又直奔评论区看发病小作文。还有很多三句剪一句的二创,弹幕也各个是人才。我也学着评论里奇怪的说话方式,发着带有特殊意义的 emoji。
卖惨的时候一口一个我要紫砂 remake,看到跳舞就刷烧、风情 + 🥵,唱日文歌就刷够罕见,不许看!就要看!我不好说,一个猜想不一定对,谢谢这对贝极星很重要,收到收到收到,给然然盖被子,大腿别着凉了捏,不对啊,我不曾拥有过然然啊!
—— 属实给我玩明白了。
我尤其喜欢看 A-SOUL 的土味短视频,后面得知这是发在 A-SOUL 成员抖音账号上的,每个视频时间短,无厘头,甚至没有个完整的剧情。但是毕竟是正儿八经拍的,动捕、收音、运镜都比直播要好些,想一次看个够。因此我在想能否写个站来汇总所有的土味短视频,实时更新,让我看个爽。
因此,asoul.video 诞生了。
在开发 asoul.video 的过程中,我遇到了很多有趣的问题,大部分问题是围绕抖音与字节跳动的风控相关,自己钻研了很久也总算找到了 bypass 的办法,其中有不少可圈可点的地方,让我们一步步展开……
抓取抖音短视频
一开始我选择了抖音网页版的接口进行抓取,谁知网页版接口请求的构造十分复杂。开局两个混淆的 JS,一个是字节通用的反爬虫,一个是混淆的乱七八糟貌似还套了个虚拟机的抖音网页版 JS。
我是没耐心去一点点逆了。上网搜了下,发现了:
https://www.iesdouyin.com/web/api/v2/aweme/post/
这样一个简单的 API,无脑返回视频元信息 + 视频播放链接。只需要无脑替换 cursor
遍历所有的视频爬取下来即可。一切来得太过于容易,让我对其产生了怀疑。这也导致我后面去逆了这个接口以前所需要的签名算法。
逆向抖音虚拟机(然并卵)
上述提到的接口,在今年年初的时候,是需要带上 _signature
参数才能正常访问,但现在不知为何不带签名也行。所以其实逆了个寂寞,就当学新东西了。
_signature
的原始 JavaScript 代码见:vm.js
网上大多都是使用 NodeJS + jsdom 运行计算。
其实这个 JavaScript 并不困难,其本质上是用 JavaScript 实现了一个基于栈的虚拟机,最下方的那一堆乱七八糟的字符串就是该虚拟机的机器码。上面有一个长长的 for,里面有多个 case,就是不同操作的 Opcode。
就拿开头的 gr$Date
这一段来说,第一个字符 g
,将其 ASCII 码减去 32 即为对应的 Opcode,即 103 – 32 = 71。
case 71:
v[x++] = n;
break;
其中 v 是我们虚拟机的栈,x 是栈顶指针,n 就是代码前面声明的 var n = this;
。所以这一个指令的意思就是将 this
PUSH 入栈。
第二个指令 r
,同样将其 ASCII 码减去 32,得到 82。
case 82:
u(v[--x][f()]);
break;
乍一看,语义上就是将 v 中栈顶元素弹出(假设这个元素是 X),然后取 X 这个东西的 f()
属性的值,再把这个东西放到 u()
函数里执行。那 f()
和 u()
是干啥的呢?
function u(e) {
v[x++] = e
}
function f() {
return g = t.charCodeAt(b++) - 32,
t.substring(b, b += g)
}
u()
就是简单的 PUSH 元素入栈操作。
f()
从底下那堆乱七八糟的机器码里面读取一个字符,获取其 ASCII 码并减 32,然后对机器码做字符串截取,长度就是刚刚读取的数值的长度。所以就很明了了,f()
就是从当前机器码中读取一个长度,然后向后截取这么长的字符串。
下一个 $
的 ASCII 码减 32 等于 4,所以向后截取 4 个字符,也就是 Date
。
所以综上,就是将 this['Date']
PUSH 入栈,也就是拿到了 JavaScript 里获取时间日期的 Date
函数。
怎么样,是不是还算容易理解?所以 gr$Date
这一段机器码的干的事情就是把 this.Date
函数 PUSH 入栈。后面的话其实还会执行这个 Date()
函数获取当前时间等等。
我们只需要在每个 case 分支下,console.log
打印一下当前执行的指令的序号,以及内容,还有上下文的变量。对着运行后的 log 慢慢分析,就能看懂上面的代码他做了什么。
逆向后的代码见 douyin_signature.js
大致原理为获取当前时间与浏览器 UA,然后对每个字符都放入那个循环里跑一遍即可。
其中用到的反爬虫手段,是过程中会调用浏览器的 Canvas API 进行作图,并在图片中写上 龘ฑภ경
这些个很复杂的文字,并将 Canvas 画出来的图作为常数用在循环中。如果你只是简单地使用 NodeJS 环境而非浏览器来运行,就会因为 Canvas API 返回 NULL 而计算出不一样的结果。
因为每次画的图片都是一样的,其得出常数也就都是一样的 311735490
,我在浏览器中运行跑过一次后,将这个常数直接放进逆向后的代码中即可。
不管怎么说,至少学到了新东西。这套过时的虚拟机也可以试着改进下,放到公司的产品中进行反爬虫的风控。届时就牵扯到如何将 JavaScript 正向编译成虚拟机的机器码了。
获取视频 + 封面图片直链
简简单单去个视频水印
通过上述接口获取到的抖音视频链接,在 play_addr.url_list
与 download_addr.url_list
这两个字段下。其中 play_addr.url_list
带有一堆密密麻麻看不懂的参数,保不齐其中哪个就是一个签名,过了一定时间后链接就失效了。
因此我选择 download_addr.url_list
中的较短的视频链接。但该视频链接的视频是加了抖音水印的,很影响观感,那么怎么去水印呢?
链接形如:
https://aweme.snssdk.com/aweme/v1/play/?video_id=v0200fg10000c6c9rq3c77u5tkbhogq0&line=0&ratio=540p&watermark=1&media_type=4&vr_type=0&improve_bitrate=0&logo_name=aweme_search_suffix&source=PackSourceEnum_DOUYIN_REFLOW
相信聪明的你应该已经看出来了,我们将 URL 中的 watermark=1
改为 watermark=0
,或者直接去掉,水印就没了。😅
去掉多余参数,整理后的视频链接为:
https://aweme.snssdk.com/aweme/v1/play/?video_id=v0200fg10000c6c9rq3c77u5tkbhogq0
video_id
即为视频元信息里的视频 ID,访问 URL 后会 302 跳转到实际的播放链接。
简简单单绕个图片签名
而对于视频封面,分为 cover
和 dynamic_cover
两种。经调研发现,有些视频的封面是动态的,此时应该优先选择 dynamic_cover
,我后面对此其实也做了处理。
封面图片的 URL 形如:
https://p3-sign.douyinpic.com/obj/tos-cn-i-dy/a2f41e36a417460ab810bca8e3b9ed6d?x-expires=1639249200&x-signature=ej7Hp%2FsixjRdAHuUo%2FQst7XDsyY%3D&from=4257465056_large
可以看到其中有 x-expires
参数来标明图片过期的时间戳,过期时间约为两周。而 signature
则是对图片 URL Query 参数的签名,签名不对则返回 403。我们无从得知签名的计算方式,也就无法修改图片过期时间。
那…… 应该怎样绕过签名拿到永久图片链接呢?
我发现该 URL 的子域为 p3-sign
,p3
肯定是相应的 CDN 机房或节点,后面的 -sign
是不是签名的意思?如果去掉呢?
我将子域中的 -sign
去掉,得到
https://p3.douyinpic.com/obj/tos-cn-i-dy/a2f41e36a417460ab810bca8e3b9ed6d
还真能直接访问,这下拿到图片永久链接了捏 😅
真的离谱,我怀疑内部有人在开摆。
Bypass 抖音视频防盗链
asoul.video 的前端使用 Vue 框架编写,并使用 vue-video-player
在前端播放视频。
但实际过程中,我发现抖音的视频链接其实是有防盗链的。当前端浏览器带上 https://asoul.video/
的 Referer 去访问时,直接就 403 被拦了。
但是……
在不带 Referer 头时,视频是可以正常访问的,也就是相当于我们直接在浏览器中访问对应的 URL。那这就简单了,在页面中加入该 meta 标签
<meta name="referrer" content="never">
直接所有请求都不发送 Referer,这样就 bypass 了抖音视频的防盗链。但这里我们其实做的有点绝,禁止了所有请求的 Referer,导致相关统计服务比如 Google Analytics 也获取不到访客来源数据了,可以设置 content="same-origin"
,来允许仅在同源请求下发送 Referer 头。
无头之殇 —— 抓取抖音短视频评论
A-SOUL 枝网小作文查重通过抓取 A-SOUL 五人 bilibili 账号下的评论来做数据分析。因此我也想能否抓取 A-SOUL 抖音视频下的评论,当然我并不会也去做个查重,只是单纯的多加点功能好看些。
可惜抖音视频的评论并没有上述那种即开即用的接口,我最终选择了通过抖音 Web 版抓取数据。文章开头提到,抖音 Web 版有着极其严苛的风控机制,逆它那个 JS 我是没这个时间,也没这个本事。
所以便投机取巧,操作无头浏览器来进行页面内容的抓取。
我使用 Go https://github.com/go-rod/rod 库,启动浏览器访问视频播放页,模拟下拉操作不断滚动刷新评论区,hook 评论 API 返回的 JSON 内容,解析后入库。
go-rod 默认是使用 Chromium 而非 Chrome 来启动,其下载的 Chromium 连视频播放控件似乎都不支持,但当时我没多想。后续发现 Chromium 经常一打开视频页,页面就自动退出了,然后程序就 panic 了,即使我加载了 rod 的反机器人检测插件 https://github.com/go-rod/stealth 也毫无作用。但是换成 Chrome 就很很稳定的访问抓取。
后来我在 GitHub 上看到有大佬基于 AST 对抖音 Web 端的 JavaScript 做了一些去混淆,这才使我能管中窥豹,看到其逆天的风控能力——除了 Canvas 以外,抖音还会检测各种浏览器 API,从普通的 localStorage 是否正常,到窗口大小,像素深度;再到一些冷门 API,比如蓝牙,定位,RTC 等功能。甚至还会去检测浏览器是否支持 ActiveX 控件。综上所有的特征得出当前运行环境是否正常。
好家伙,能想到的几乎都给他查完了,还是让 rod 窗口化起个 Chrome 吧。
让女孩们始终绽放笑颜!
asoul.video 的前端使用 Vuetify 框架,每个视频其实都是一个 v-card 控件展示视频封面。原本抖音视频的图片封面是长方形的,但这里经过裁剪变成了正方形,导致图片中 A-SOUL 小姐姐们的头经常会被截掉。(珈乐:诶,我会歪脖)
得让小姐姐们的笑脸永远处于画面的正中央!
因此,我决定想办法对大约 500 个视频封面中的动漫人物面部进行标注,将面部坐标入库,前端显示时根据面部坐标以及展示的图片大小对图片位置进行偏移即可。
500 多张图片,我当然是不可能人工去标注的。对机器学习一窍不通的我,在 GitHub 上发现了一个日本老哥的七年前的项目 https://github.com/nagadomi/lbpcascade_animeface 。作者开源了一个 OpenCV 的模型,将其加载后即可使用 OpenCV 检测获得图片中所有动漫人物的面部坐标。我拿几张嘉然的视频封面试了下,识别率还是挺准确的。
作者提供了 Python 的实现,而我用他的模型,结合 gocv 封了一个 Go 版本的,https://github.com/asoul-video/face-detection 。这里得夸一下 gocv 封装的 OpenCV API 设计的还真不错,完全不懂的我,都可以完成从 Python 版迁移到 Go 的工作,因为相关函数名和对象属性其实都是一样的。
最后将该程序封成了一个 Web 服务,Dockerfile 打包成镜像。原本是想上云做 Serverless 的,可惜打出来的镜像太大,阿里云表示不能用。只好部署在自己 Apicon 的机子上,接入 Apicon 的网关作为一个服务,也算是生态闭环了。😅
火山引擎 veImageX 模板
让女孩们的笑容始终位于舞台正中后,我发现封面图片加载的速度其实并不乐观。显示封面的 div 也才 285 x 220 的大小,可有的图片原始尺寸居然超过 1000 像素,这妥妥的没必要。如果我们能降低图片的大小,这样加载起来就能快很多。
我联想到一般的 CDN 或者对象存储,其实都可以在 URL 后拼接参数对图像进行变形、裁剪、滤镜等处理。经过一翻调研,我得知字节系所有产品线均使用 ImageX 引擎来处理图片。我们简单打开今日头条或者抖音,找到一张比较小的图片,分析其 URL:
https://p6.toutiaoimg.com/img/pgc-image/1007c7c87d564df8a356946c2dc2a5cb~tplv-tt-cs0:640:360.jpg
可以看到图片末尾的 ~tplv
后面跟的,就是图片的处理参数。这里的 640:360,其实就是图片的裁剪大小。
同时 ImageX 又作为 to B 产品,在字节火山引擎上作为产品卖,名为 veImageX。
因为是 to B 的产品,所以我到现在都还没通过申请。但我们可以阅读 veImageX 的文档 https://www.volcengine.com/docs/508/8084 来了解它支持哪些参数。比如 ~info
可以查看图片元信息。
同时,通过在 GitHub 和 Sourcegraph 上搜索 ~tplv
关键字,我们可以找到一些使用案例,从而挖掘出更多的玩法。
最后我找到了可以使用 ~tplv-crop-top:285:285.jpg
这个参数来从上向下进行裁剪图片。前端在 URL 后加上该参数将图片裁小后,加载真的变快了!
震惊!veImageX 模板居然是通用的!
火山引擎的 veImageX 有一个在线 Demo 页:https://imagexdemo.volcengine.com/ 可以来体验所有功能。
从中我们可以了解到其实裁剪还可以指定坐标,veImageX 还可以给图片加水印。当你在页面上设置图片的处理方式时,页面会将你的处理 POST 发送给 https://imagexdemo.volcengine.com/api/PreviewLiteImageTemplate/
,响应中返回处理后的图片链接:
"PreviewURL": "https://p3-imagex-lite.volcimagex.com/imagex-rc/1.png~tplv-yykgsuqxec-imagexlite-e5461252847c46d6365efa580388585e.image"
可以看到对图片的处理,其实也就是给图片使用一个临时创建处理模板。即 ~tplv-
后面那段。
而奇怪的事情就由此发生了:
在 veImageX Demo 页中创建的图片处理模板,居然可以直接用在字节全线产品生产环境的图片外链中,从而实现对图片的自定义处理。
比方说我在 veImageX Demo 页创建了一个图片处理模板,这个模板会给图片打上自定义的水印,并加上我写的字:
F12 获得这个图片的模板名称后,将其拼接在抖音封面图片 URL 的后面。
可以看到图片同样被该模板处理了。我还测试了今日头条下的图片,也是有相同的问题。
我在查 veImageX 模板时,发现掘金其实就是火山引擎的客户,他们就在用 veImageX 模板来处理图片打水印。不出意外,他们的 ~tplv-
模板在字节其他产品下的图片处理中也是通的。
按理来说你火山引擎 to B 产品,跟内部私有字节云应该是完全分离开来的。
我也不清楚这算不算漏洞,低危交了 ByteSRC,然后被忽略了。
那行,既然你字节觉得这不算洞,也不会有实际危害,那咱就公开了。😈
veImageX 的虚假 CORS 同源限制
在 veImageX Demo 的裁剪功能中,有一个「动漫人脸裁剪」,跟我上面用 OpenCV 做的效果是一样的。
而上面 OpenCV 的方案有不少图片未能识别出人脸,我便想让这些图片 fallback 到 veImageX 去进行处理。我用 veImageX Demo 创建个动漫人脸裁剪的图片处理模板,然后前端拼抖音封面图片 URL 后面即可,反正他们都是通的嘛。
然而 veImageX Demo 创建的图片模板有效期只有一小时,无法硬编码到代码内。
如果用户每次访问 asoul.video,都能临时生成一个模板就好了……
可事实就是这么幸运,veImageX Demo 通过 POST https://imagexdemo.volcengine.com/api/PreviewLiteImageTemplate/
来获取图片处理模板。该请求的响应头中有
来限制同源。而当我把 Referer 改成 https://asoul.video
时,他返回的 CORS 头居然就变成了
什么鬼?这虚假的 CORS 同源限制,还就那个自适应?😅
接下来就很简单了,每次用户访问,直接前端 axios 请求这个接口,请求拿到图片模板用就行了。就这啊?属实绷不住了。
沸腾期待
以上就是我在开发 asoul.video 这个网站背后所遇到的有意思的小故事。通篇下来很多地方都是在跟字节斗智斗勇,我相信这样的故事在今后还会不断发生。
话题回到 A-SOUL,除开逆天的发病视频外,我也很欣赏那些有才能的一个魂们,为这个团体所做的付出。他们在自己所擅长的领域之内,用爱发电做一些力所能及的事情。枝网小作文查重,支持正义原创发病;Wiki 站和枝江方言词典,让新来的一个魂能快速了解她们;A-SOUL DB 对每一期直播进行了素材的详细标注分类,让有能 man 快速做出逆天二创。
说实话,我是很喜欢当下这样一个氛围的。这是在我迄今为止时间不长的推 v 过程中没有遇见的,因此我也想着能做些什么。我入坑的时间比较晚,错过了之前很多的精彩,也没能体会到那段辛酸,但我愿意从现在开始追随着她们,在人生中的重要转折点留下她们的印记。
很庆幸当时被拉着入坑…… 被拉?……贝拉?
拉姐!拉姐你带我走吧!!!😭😭😭