前几天 (寒假前咯) 闲着无聊,看到舍友们都在看斗鱼 TV,虽然我对那些网络游戏都不是非常感兴趣,但是我突然间想到,如果我可以获取上面的弹幕内容,不就有点意思了么?
如果我想要抓取网页上面的东西,无非就是两种方法
第一种方法是万能的,但显然是不行的,原因如下:
第二种方法显然是一个正常的程序员的做法。
语言选用 Ruby
写一个客户端,也就是写一个小爬虫,使用的场景:
用户在终端执行命令
gem install danmu
danmu douyu [room_id/url]
#比如
danmu douyu qiuri
danmu douyu http://www.douyutv.com/13861
然后就可以在终端欣赏弹幕咯。
回想一下抓取网站的方法
四步走:请求网页(原始数据) - 提取数据(提纯数据) - 保存数据 - 分析数据
很显然,只要解决了请求网页,其他的也就无非解析和 SQL 语句什么的。
如果是像我上面说的那么简单,也就不必再写一篇文章。毕竟,网页小爬虫没有什么技术含量。分布式爬虫才有。
通常情况下的网页小爬虫无非要解决如下问题:
请求,如果对方有一定策略的反爬虫,那需要反反爬虫。比如,
并且,由于请求响应机制的存在,通常情况下,每一个请求对应一个响应,如果出错了,要么超时,要么有状态码,所以普通的 web 爬虫也相对而言比较容易些。
那么,斗鱼 TV 的站点是不是这样子的就能够容易爬取呢?
你猜到了,答案是“不是”。
由于弹幕具有实时性,就决定了斗鱼 TV 的弹幕无法通过保存完整指定时间端弹幕的 XML(比如 BILIBILI 的一个视频弹幕是存在一段 xml 中的)或者 Json 数据来显示弹幕。要不然的话,那主播操作很出色的时候,观众的弹幕岂不是无法实时显示了么?
那么,肯定就是 WebSocket 了,于是,我一如既往的打开 F12,查看网络流量。
正如你想到的那样,没有任何的弹幕流量来往。一个 WebSocket 的消息都没有。
那么,消息肯定是有的,但是消息并不是通过 HTTP 协议或者 WebSocket 协议传输的,那么问题会出在哪呢?
分析前端的代码,找出获取弹幕的 JS 代码,苦于代码太多,找了很久没有找到。那也就是执行逻辑可能在 flash 里面。
于是祭出大杀器 WireShark,抓一下流量。终于看到弹幕的样子了。
是这样的。
原来使用的是 Flash 的 Socket 功能。
那么,我们只需要模拟 Socket 的每一条消息就好了。
多分析几组数据,但还是对发送消息内容缺乏把握,特别是在用户认证,用户接收弹幕这一块。在搜索引擎上搜索了一阵,发现知乎上有个帖子,读完终于解了我的疑惑。
地址为: https://www.zhihu.com/question/29027665
在此基础上,省略若干消息分析过程。
总结后得出斗鱼 TV 网站的服务器分布。
首先我们拿随便一个主播房间来说,比如,qiuri
Ta 的房间链接分为两种
对这个主播房间页面请求,正常,所有的有用信息都不是放在 HTML 中渲染出来,而是有一条放在 HTML 中内置的 JS 脚本中,这是为了减少服务器渲染 HTML 的压力?可是渲染放在 JS 里面不也一样需要渲染?(不明白)总之,就是程序先加载没有具体数据填充页面,然后 JS 更新数据。
内置的两段 JS 脚本,JS 脚本中有两个变量,该变量很容易转换成 JSON 数据,也就是两段 JSON 数据,一个是关于主播的个人信息,另一个是关于弹幕认证服务器的列表(该列表中的任意一个服务器均可以认证,但每一次请求主播页面得到的认证服务器列表都不一样)
通过这步,我们就拿到了主播的信息以及弹幕服务器的认证地址,端口。
我们通过抓包,分析那一大坨数据包,可以确定以下通过以下的流程便可以获取弹幕消息。(分析过程比较繁琐)
首先建立两个 Socket。一个用于认证 (@danmu_auth_socket),另一个用户获取弹幕 (@danmu_client)。
后面会详细解释
既然是发消息,那么每条消息总是有些格式的。
斗鱼的消息格式大致如下:
每一条消息并遵循下面的格式:
1.通信协议长度,后四个部分的长度,四个字节 2.第二部分与第一部分一样 3.请求代码,发送给斗鱼的话,内容为 0xb1,0x02, 斗鱼返回的代码为 0xb2,0x02 4.发送内容 5.末尾字节
# -*- encoding : utf-8 -*-
class Message
# 向斗鱼发送的消息
# 1.通信协议长度,后四个部分的长度,四个字节
# 2.第二部分与第一部分一样
# 3.请求代码,发送给斗鱼的话,内容为0xb1,0x02, 斗鱼返回的代码为0xb2,0x02
# 4.发送内容
# 5.末尾字节
#pack('c*')是字节数组转字符串的一种诡异的转化方式
def initialize(content)
@length = [content.size + 9,0x00,0x00,0x00].pack('c*')
@code = @length.dup
@magic = [0xb1,0x02,0x00,0x00].pack('c*')
@content = content
@end = [0x00].pack('c*')
end
def to_s
@length + @code + @magic + @content + @end
end
end
经过封装,我们仅仅关注那些可见的字符串,也就是 Content 部分就可以了。 content 部分,也就是发送消息的内容,在文章后面将会详解。
开启两个 Socket,一个用户认证,另一个用于弹幕的获取。
用于用户弹幕认证的,是 2.1 中所说的认证服务器列表中任意一个。挑选出来一组 ip 和端口
@danmu_auth_socket = TCPSocket.new @auth_dst_ip,@auth_dst_port
用户获取弹幕的只要为
danmu.douyutv.com:8601
danmu.douyutv.com:8602
danmu.douyutv.com:12601
danmu.douyutv.com:12602
四组域名:端口均可以作为如下的 DANMU_SERVER 和 PORT
@danmu_socket = TCPSocket.new DANMU_SERVER,DANMU_PORT
发送一条消息只需如此
data = "type@=loginreq/username@="+@username+"/password@=1234567890123456/roomid@=" + @room_id.to_s + "/"
all_data = message(data)
@danmu_socket.write all_data
把需要传输的字符串放进去就好了。
接下来,我们需处理上面说的六个步骤
发送消息内容为:
type@=loginreq/username@=/ct@=0/password@=/roomid@=156277/devid@=DF9E4515E0EE766B39F8D8A2E928BB7C/rt@=1453795822/vk@=4fc6e613fc650a058757331ed6c8a619/ver@=20150929/
我们需要注意的内容如下:
type 表示消息的类型登陆消息为loginreq
username 不需要,请求登陆以后系统会自动的返回对应的游客账号。
ct 不清楚什么意思,默认为0并无影响
password 不需要
roomid 房间的id
devid 为设备标识,无所谓,所以我们使用随机的UUID生成
rt 应该是runtime吧,时间戳
vk 为时间戳+"7oE9nPEG9xXV69phU31FYCLUagKeYtsF"+devid的字符串拼接结果的MD5值(这个是参考了一篇文章,关于这一处我也不大明白怎么探究出来的)
ver 默认
通过这一步,我们可以获取两条消息,并从消息中使用正则表达式获取对应的用户名以及 gid
str = @danmu_auth_socket.recv(4000)
@username= str[/\/username@=(.+)\/nickname/,1]
str = @danmu_auth_socket.recv(4000)
@gid = str[/\/gid@=(\d+)\//,1]
发送的消息内容为
"type@=qrl/rid@=" + @room_id.to_s + "/"
无需多说,类型为 qrl,rid 为 roomid,直接发送这条消息就好。返回的两条消息也没有什么价值。
data = "type@=qrl/rid@=" + @room_id.to_s + "/"
msg = message(data)
@danmu_auth_socket.write msg
str = @danmu_auth_socket.recv(4000)
str = @danmu_auth_socket.recv(4000)
发送的消息内容为
"type@=keeplive/tick@=" + timestamp + "/vbw@=0/k@=19beba41da8ac2b4c7895a66cab81e23/"
直接发送。无太大意义。
data = "type@=keeplive/tick@=" + timestamp + "/vbw@=0/k@=19beba41da8ac2b4c7895a66cab81e23/"
msg = message(data)
@danmu_auth_socket.write msg
str = @danmu_auth_socket.recv(4000)
前三步,也就是 2.2-2.3-2.4 三步骤,也就是使用@danmu_auth_socket 完成获取 username 和 gid 的重要步骤。获取这两个字段以后,也就完成了它存在的使命。
接下来的就是@danmu_socket获取弹幕的时候了!
消息内容为:"type@=loginreq/username@="+@username+"/password@=1234567890123456/roomid@=" + @room_id.to_s + "/"
和上面 2.2 中略有不同。但是,需要注意的是
username 为2.2中所得到的username
password 的值得变化
data = "type@=loginreq/username@="+@username+"/password@=1234567890123456/roomid@=" + @room_id.to_s + "/"
all_data = message(data)
@danmu_socket.write all_data
str = @danmu_socket.recv(4000)
接下来就是完成认证的最后一步了,join_group 的消息内容为
"type@=joingroup/rid@=" + @room_id.to_s + "/gid@="+@gid+"/"
gid 为 2.2 中所得到的 gid。
data = "type@=joingroup/rid@=" + @room_id.to_s + "/gid@="+@gid+"/"
msg = message(data)
@danmu_socket.write msg
获取弹幕,并且打印出来。
danmu_data = @danmu_socket.recv(4000)
type = danmu_data[danmu_data.index("type@=")..-3]
puts type.gsub('sui','').gsub('@S','/').gsub('@A=',':').gsub('@=',':').split('/')
后三步,则是@danmu_socket 获取弹幕的步骤。
于是,通过这些步骤,就可以完成了简单的 danmu 的核心代码,接下来的步骤就是完善,重构这些代码了。
找了很久没有办法解决 rtmp 地址的自动获取:
路径如下
这一处的请求不是 XHR,也就是不是 JS 脚本通过 XMLHttpRequest 异步加载;那么,八成是 flash 通过 http 协议获取的。我估计八成执行逻辑应该是在 flash 之中。也就不方便获取其中的 sign 值。故,暂时无法解析 rtmp 视频流地址了
效果图:
代码的地址为:
https://github.com/twocucao/danmu
技术浅薄,还请轻拍。
PS:如果有问题可以在下方留言或者发送 email 到 [email protected] 给我。