一般来说,在现在普遍使用前后端分离开发策略的大环境下,网站的 Token 之类的数据都是通过独立的 HTTP 请求获取并存储在 Cookie 中的,大部分情况下只需要定位并解析这些请求的具体参数规则就可以实现编程方式的信息爬取。但很显然斗鱼似乎并不一般。
前期准备
首先,在解析直播源之前,先要对直播协议有一些基础的认识。现代网络直播平台通常会采用 RTMP、HLS 或 HTTP-FLV 协议来实现流媒体传输,而其中应属 HLS 应用最为广泛,它的直播源就是我们常见的 m3u
或 m3u8
文件。
RTMP 协议会将流媒体分割成大小相同的块(Chunk),并通过传输协议(大部分情况下是 TCP)进行传输,支持多路复用和负载均衡(所以经常被用于转播场景)。但是它的发送效率取决于块(Chunk)的分割大小,Chunk 越大,对带宽要求越高;Chunk 越小,对 CPU 的要求越高。所以如今比起网络平台的分发源,它更多用于直播用户上传的情景,就比如 OBS 的源路径就是 RTMP 协议的路径,这也是为什么直播设备需要强强 CPU 的原因。
HLS 协议会将流媒体切成若干份小片段(即 ts
视频片段),然后逐段进行传输,而 m3u
或 m3u8
就是它的索引文件,用来记录这些片段的先后顺序和具体地址。浏览器在打开直播时,会从服务器获取 m3u
或 m3u8
文件,然后根据其索引内容逐个下载这些视频分片,从而实现直播的效果。
m3u
和m3u8
的区别在于m3u8
支持UTF-8
的编码。
HTTP-FLV 是当年随着 Flash 红极一时的网络流媒体传输协议,但是随着 Flash 的退役,其处境也愈发困窘。如今使用 HTTP-FLV 协议必须保证用户浏览器引入 flv.js
,否则无法正常播放,因此也在逐渐被淘汰,但仍有部分网络平台支持这种协议。
正式解析
平台直播业务分析
使用浏览器随意打开任意一个直播间,按下 F12
打开开发者工具,切换到 网络
/ Network
选项卡,先尝试搜索一下有没有对 m3u
和 m3u8
的请求。
OK,没有。
那接下来找一下有没有响应中包含 live
、url
之类信息的请求。果不其然,很快就找到了。
我在一个 URL 为 https://www.douyu.com/lapi/live/getH5Play/<ROOM ID>
的请求中发现了直播源的信息,请求方式为 POST
,携带数据类型为 application/json
,通过尝试 x-www-form-urlencoded
也可以正常访问。
其响应结构如下:
{
"error": 0,
"msg": "OK",
"data": {
"room_id": XXXXXX,
"is_mixed": false,
"mixed_live": "",
"mixed_url": "",
"rtmp_cdn": "scdncmccnemg",
"rtmp_url": "https://stream-neimenggu-cmcc-117-161-177-66.edgesrv.com:443/live",
"rtmp_live": "XXXXXXrmHNB7Jis.flv?wsAuth=bff943f5c6adb2ff831578b5e04e8963\u0026token=web-h5-0-XXXXXX-9f0ddd5f76fbdc65fde96313f6950c47523744948b9f9b55\u0026logo=0\u0026expire=0\u0026did=cb20c7af9f768f257103c96c00031701\u0026ver=Douyu_225010705\u0026pt=2\u0026st=0\u0026sid=406985992\u0026mcid2=0\u0026vhost=play1\u0026origin=tct\u0026fcdn=scdn\u0026fo=0\u0026mix=0\u0026isp=scdncmccnemg",
"client_ip": "XXXXXX",
"inNA": 0,
"rateSwitch": 1,
"rate": 0,
"cdnsWithName": [
{
"name": "线路1",
"cdn": "scdncmccnemg",
"isH265": false,
"re-weight": 99999
},
{
"name": "线路5",
"cdn": "tct-h5",
"isH265": false,
"re-weight": 10000
},
{
"name": "线路7",
"cdn": "hw-h5",
"isH265": false,
"re-weight": 10009
}
],
"multirates": [
{
"name": "原画1080P60",
"rate": 0,
"highBit": 1,
"bit": 10158,
"diamondFan": 0
},
{
"name": "高清",
"rate": 2,
"highBit": 0,
"bit": 900,
"diamondFan": 0
}
],
...
}
}
不难看出,该直播间的直播源就是上面结构中 rtmp_url
+ rtmp_live
的组合。至此,斗鱼的直播业务流就很明确了:
- 用户访问直播间,获取直播间 ID
- 调用
getH5Play
这个 API 获取直播源 - 将直播源注入 H5 播放器中进行播放
很简单的一种业务流程,当然过程中还涉及 CDN 的选取之类的业务,但有没有其实也无伤大雅。
参数解析
从请求数据可以看出,getH5Play
这个 API 在请求时需要包含若干参数,通过尝试,得出以下参数是必须的:
字段 | 用途 |
---|---|
rate | 画面质量,就是上面 JSON 中 multirates 数组的 rate 字段,默认值为 0 ,即画质最优 |
cdn | CDN 地址 |
did | 意义不明,大概率是用户 ID |
v | 意义不明,似乎是 2201<Date> 的格式 |
sign | 意义不明,看起来像是某种校验用途的参数,而且每个新请求都会产生新的 sign 值 |
tt | 时间戳 |
rate
、cdn
、v
和 tt
都有明确的定值标准,那么我们只需要知道 did
和 sign
两个字段是如何生成即可。现代的前端技术都会用到 Webpack 之类的打包工具,JS 脚本中的参数名都是一堆莫名其妙的乱码,可读性极差,但是网站要获取直播源那肯定要通过 HTTP 请求,我们只需要去排查 JS 中涉及异步请求的语句即可。
通常情况下,Web 的异步请求分为两个类型:Fetch
和 XHR
。其中前者可以通过 HTTP API 提供的 fetch
函数实现;而后者通常是由第三方库提供,如 ajax
、axios
或者某些框架提供的 HTTP 请求函数,但其底层是 XMLHttpRequest
。
通过搜索请求方式 POST
可以找到相关的请求语句,在排查过后发现了一个 g.httpClient.post(String, z + "/" + a, window.ub98484234(a, u, c) + "&cdn=" + e + "&rate=" + t + "&iar=" + o + "&ver=" + V)
最有可能是该请求的原始语句,而 ub98484234
这个函数很明显就是突破口。在进行全局检索,发现这个 ub98484234
函数居然定义在 HTML 文件中(前后端分离了个寂寞)。
尝试把这段 JS 复制下来用 Node.js 运行以下,得到以下结果:
(function (xx0,xx1,xx2){var cb=xx0+xx1+xx2+"220120240705";var rb=CryptoJS.MD5(cb).toString();var re=[];for(var i=0;i<rb.length/8;i++)re[i]=(parseInt(rb.substr(i*8,2),16)&0xff)|((parseInt(rb.substr(i*8+2,2),16)<<8)&0xff00)|((parseInt(rb.substr(i*8+4,2),16)<<24)>>>8)|(parseInt(rb.substr(i*8+6,2),16)<<24);var k2=[0x1610a4c2,0x4a066935,0x184ec0,0x37b4408b];for(var I=0;I<2;I++){var v0=re[I*2],v1=re[I*2+1],sum=0,i=0;var delta=0x9e3779b9;for(i=0;i<32;i++){sum+=delta;v0+=((v1<<4)+k2[0])^(v1+sum)^((v1>>>5)+k2[1]);v1+=((v0<<4)+k2[2])^(v0+sum)^((v0>>>5)+k2[3]);}re[I*2]=v0;re[I*2+1]=v1;}re[0]=(re[0]<<(k2[0]%16))|(re[0]>>>(32-(k2[0]%16)));re[0]^=k2[2];re[0]^=k2[0];re[0]=(re[0]>>>(k2[2]%16))|(re[0]<<(32-(k2[2]%16)));re[1]+=k2[1];re[1]=(re[1]<<(k2[3]%16))|(re[1]>>>(32-(k2[3]%16)));re[1]=(re[1]<<(k2[3]%16))|(re[1]>>>(32-(k2[3]%16)));re[2]-=k2[0];re[2]=(re[2]<<(k2[2]%16))|(re[2]>>>(32-(k2[2]%16)));re[2]=(re[2]<<(k2[0]%16))|(re[2]>>>(32-(k2[0]%16)));re[2]=(re[2]<<(k2[2]%16))|(re[2]>>>(32-(k2[2]%16)));re[3]-=k2[1];re[3]^=k2[3];re[3]=(re[3]>>>(k2[3]%16))|(re[3]<<(32-(k2[3]%16)));re[0]^=k2[0];re[0]=(re[0]<<(k2[2]%16))|(re[0]>>>(32-(k2[2]%16)));re[0]^=k2[2];re[1]-=k2[1];re[1]+=k2[3];re[1]-=k2[3];re[1]-=k2[3];re[2]-=k2[0];re[2]-=k2[2];re[2]+=k2[2];re[3]+=k2[1];re[3]=(re[3]<<(k2[3]%16))|(re[3]>>>(32-(k2[3]%16)));re[3]=(re[3]<<(k2[3]%16))|(re[3]>>>(32-(k2[3]%16)));{var hc='0123456789abcdef'.split('');for(var i=0;i<re.length;i++){var j=0,s='';for(;j<4;j++)s+=hc[(re[i]>>(j*8+4))&15]+hc[(re[i]>>(j*8))&15];re[i]=s;}re=re.join('');}var rt="v=220120240705"+"&did="+xx1+"&tt="+xx2+"&sign="+re;return rt;});
它的结果居然还是一段 JS 代码,但是这段代码中包含了很多关键的参数,比如 did
和 sign
。也就是说我们得输出这段 JS 代码中 xx1
、xx2
和 re
三个参数。老办法上 Node.js,把 function
和 return
去掉加上 console.log
:
var cb = xx0 + xx1 + xx2 + "220120240705";
var rb = CryptoJS.MD5(cb).toString();
var re = [];
for (var i = 0; i < rb.length / 8; i++) re[i] = (parseInt(rb.substr(i * 8, 2), 16) & 0xff) | ((parseInt(rb.substr(i * 8 + 2, 2), 16) << 8) & 0xff00) | ((parseInt(rb.substr(i * 8 + 4, 2), 16) << 24) >>> 8) | (parseInt(rb.substr(i * 8 + 6, 2), 16) << 24);
var k2 = [0x1610a4c2, 0x4a066935, 0x184ec0, 0x37b4408b];
for (var I = 0; I < 2; I++) {
var v0 = re[I * 2], v1 = re[I * 2 + 1], sum = 0, i = 0;
var delta = 0x9e3779b9;
for (i = 0; i < 32; i++) {
sum += delta;
v0 += ((v1 << 4) + k2[0]) ^ (v1 + sum) ^ ((v1 >>> 5) + k2[1]);
v1 += ((v0 << 4) + k2[2]) ^ (v0 + sum) ^ ((v0 >>> 5) + k2[3]);
}
re[I * 2] = v0;
re[I * 2 + 1] = v1;
}
re[0] = (re[0] << (k2[0] % 16)) | (re[0] >>> (32 - (k2[0] % 16)));
re[0] ^= k2[2]; re[0] ^= k2[0];
re[0] = (re[0] >>> (k2[2] % 16)) | (re[0] << (32 - (k2[2] % 16)));
re[1] += k2[1];
re[1] = (re[1] << (k2[3] % 16)) | (re[1] >>> (32 - (k2[3] % 16)));
re[1] = (re[1] << (k2[3] % 16)) | (re[1] >>> (32 - (k2[3] % 16)));
re[2] -= k2[0];
re[2] = (re[2] << (k2[2] % 16)) | (re[2] >>> (32 - (k2[2] % 16)));
re[2] = (re[2] << (k2[0] % 16)) | (re[2] >>> (32 - (k2[0] % 16)));
re[2] = (re[2] << (k2[2] % 16)) | (re[2] >>> (32 - (k2[2] % 16)));
re[3] -= k2[1];
re[3] ^= k2[3];
re[3] = (re[3] >>> (k2[3] % 16)) | (re[3] << (32 - (k2[3] % 16)));
re[0] ^= k2[0];
re[0] = (re[0] << (k2[2] % 16)) | (re[0] >>> (32 - (k2[2] % 16)));
re[0] ^= k2[2];
re[1] -= k2[1];
re[1] += k2[3];
re[1] -= k2[3];
re[1] -= k2[3];
re[2] -= k2[0];
re[2] -= k2[2];
re[2] += k2[2];
re[3] += k2[1];
re[3] = (re[3] << (k2[3] % 16)) | (re[3] >>> (32 - (k2[3] % 16)));
re[3] = (re[3] << (k2[3] % 16)) | (re[3] >>> (32 - (k2[3] % 16)));
{
var hc = '0123456789abcdef'.split('');
for (var i = 0; i < re.length; i++) {
var j = 0, s = '';
for (; j < 4; j++)
s += hc[(re[i] >> (j * 8 + 4)) & 15] + hc[(re[i] >> (j * 8)) & 15];
re[i] = s;
}
re = re.join('');
}
var rt = "v=220120240705" + "&did=" + xx1 + "&tt=" + xx2 + "&sign=" + re;
console.log(rt)
如果直接运行这段 JS 的话还有个问题:初始的参数 xx0
、xx1
和 xx2
我们是不知道的,但是根据倒数第二行我们可以得出 xx1
就是 did
,xx2
是 tt
,xx0
未知,然后把这三个参数和 v
的值接在一起后用 MD5 进行加密,然后经过一系列的算法得到 re
,也就是 sign
的值。通过查询可以确定网站的源码中并没有涉及 xx0
的变量,那就只能一个一个试了。
先截取浏览器访问直播间时的请求,把 tt
、did
和 sign
的值都记录下来。xx1
和 xx2
以及 v
都是不涉及直播间的信息,按照正常的开发思路,一定要有一个参数是用来区分直播间地址的,所以我大胆猜测 xx0
就是直播间 ID,把直播间 ID 直接替代 xx0
尝试运行,果不其然输出结果与请求中的 sign
相同。
接下来就是 did
的问题,前面猜测 did
应该是用户 ID,是一种 Token,Token 的生成理应在服务端而非客户端,用来保证用户信息的安全性。既然如此想要获取 Token 就必须要向服务器发送登录请求,但现在国内网站登录都需要手机验证,麻烦得很。但与此同时,即便是未登录的用户也能观看直播的这一现象引起了我的注意,浏览器开发者工具中显示,在未登录状态下的 did
默认值为 10000000000000000000000000001501
,试着把该值直接替代 JS 中的 xx1
,依然可以通过 API 获取到所有画质直播源的信息。
其输出为以下格式:
v=220120250109&did=10000000000000000000000000001501&tt=1736392000&sign=447735281af245ae1fb3c41f0cd17dc5
至此 API 请求的所有参数规则均已解析完毕。
调试
为了方便我们直接通过 cURL
进行调试,写一个单元测试脚本 test.sh
:
#!/bin/bash
room_id=$1
rate=0
cdn=
v=220120240705
did=10000000000000000000000000001501
sign=
tt=$(date '+%s')
# get sign JS
html=$(curl -s -X GET "https://www.douyu.com/$room_id")
js="$(echo $html | grep -o 'vdwdae325w_64we.*function ub98484234.*function' | sed -r 's/(.*)function/\1/g' | sed 's/function/\ function/g' | sed -r 's/eval[A-Za-z0-9\(\),;\}]*/strc;}/g') console.log(ub98484234());"
res=$(node -e "$js")
rb=$(echo -n "$room_id$did$tt$v" | openssl md5 | awk '{print $2}')
js=$(echo $res | sed -r 's/\(funtion\ \((.*)return\ rt;\}\)/\1console.log(rt)/g' | sed "s/CryptoJS.MD5(cb).toString()/\"$rb\"/g")
sign=$(node -e "$js")
params="$sign&cdn=$cdn&rate=$rate"
curl -s -X POST "https://www.douyu.com/lapi/live/getH5Play/$room_id" -H 'content-type: application/x-www-form-urlencoded' --data-raw "$params"
$ chmod u+x test.sh
$ ./test.sh <ROOM ID>
可以使用 jq
对调试结果进行格式化和筛选,从而获取 rtmp_url
和 rtmp_live
两个值。