Ruby 斗鱼弹幕助手 (Ruby 版本)

twocucao · 2016年02月09日 · 最后由 Jackie 回复于 2017年02月13日 · 14602 次阅读
本帖已被管理员设置为精华贴

斗鱼弹幕助手

0.前言

前几天 (寒假前咯) 闲着无聊,看到舍友们都在看斗鱼 TV,虽然我对那些网络游戏都不是非常感兴趣,但是我突然间想到,如果我可以获取上面的弹幕内容,不就有点意思了么?

1.分析阶段

如果我想要抓取网页上面的东西,无非就是两种方法

  1. 使用浏览器,手工(自己点击)或者非手工(使用 JS 脚本),存取我想要的东西。
  2. 编写 HTTP 客户端(斗鱼无 HTTPS 通讯)

第一种方法是万能的,但显然是不行的,原因如下:

  • 手动保存实在是不可行,程序员不为也。
  • 浏览器与本地交互有限,换而言之,也就是即使我抓取了对应的弹幕,我也没有办法解决持久化的问题。
  • 假设你选择的是 Chrome 或者 firefox 浏览器,也不是不能实现持久化,但这需要写扩展,Chrome 扩展没有写过,也不是很感兴趣。

第二种方法显然是一个正常的程序员的做法。

语言选用 Ruby

写一个客户端,也就是写一个小爬虫,使用的场景:

用户在终端执行命令

gem install danmu
danmu douyu [room_id/url]
#比如
danmu douyu qiuri
danmu douyu http://www.douyutv.com/13861

然后就可以在终端欣赏弹幕咯。

Screen Shot 2016-02-09 at 12.23.15 PM.png

回想一下抓取网站的方法

四步走:请求网页(原始数据) - 提取数据(提纯数据) - 保存数据 - 分析数据

很显然,只要解决了请求网页,其他的也就无非解析和 SQL 语句什么的。

1.1.斗鱼 TV 弹幕抓取的思路确定

如果是像我上面说的那么简单,也就不必再写一篇文章。毕竟,网页小爬虫没有什么技术含量。分布式爬虫才有。

通常情况下的网页小爬虫无非要解决如下问题:

请求,如果对方有一定策略的反爬虫,那需要反反爬虫。比如,

  • header 带上 host,带上 refer,带上其他
  • 需要验证,那就申请用户名和密码,然后登陆
  • 如果在登录时期有防跨站机制,那就先获取一次登录页面,然后解析出 token,带上对应的 token 然后登陆。
  • 在程序中加入 Log,并且存到本地。防止出现各种各样的反爬虫机制 ban 掉了程序,从而方便进行下一步防反爬虫对策。

并且,由于请求响应机制的存在,通常情况下,每一个请求对应一个响应,如果出错了,要么超时,要么有状态码,所以普通的 web 爬虫也相对而言比较容易些。

那么,斗鱼 TV 的站点是不是这样子的就能够容易爬取呢?

你猜到了,答案是“不是”。

由于弹幕具有实时性,就决定了斗鱼 TV 的弹幕无法通过保存完整指定时间端弹幕的 XML(比如 BILIBILI 的一个视频弹幕是存在一段 xml 中的)或者 Json 数据来显示弹幕。要不然的话,那主播操作很出色的时候,观众的弹幕岂不是无法实时显示了么?

那么,肯定就是 WebSocket 了,于是,我一如既往的打开 F12,查看网络流量。

正如你想到的那样,没有任何的弹幕流量来往。一个 WebSocket 的消息都没有。

那么,消息肯定是有的,但是消息并不是通过 HTTP 协议或者 WebSocket 协议传输的,那么问题会出在哪呢?

分析前端的代码,找出获取弹幕的 JS 代码,苦于代码太多,找了很久没有找到。那也就是执行逻辑可能在 flash 里面。

于是祭出大杀器 WireShark,抓一下流量。终于看到弹幕的样子了。

是这样的。

douyutveachmsg.png

原来使用的是 Flash 的 Socket 功能。

那么,我们只需要模拟 Socket 的每一条消息就好了。

多分析几组数据,但还是对发送消息内容缺乏把握,特别是在用户认证,用户接收弹幕这一块。在搜索引擎上搜索了一阵,发现知乎上有个帖子,读完终于解了我的疑惑。

地址为: https://www.zhihu.com/question/29027665

在此基础上,省略若干消息分析过程。

总结后得出斗鱼 TV 网站的服务器分布。

douyutvinfo.jpg

1.2.房间信息和弹幕认证服务器获取

首先我们拿随便一个主播房间来说,比如,qiuri

Ta 的房间链接分为两种

对这个主播房间页面请求,正常,所有的有用信息都不是放在 HTML 中渲染出来,而是有一条放在 HTML 中内置的 JS 脚本中,这是为了减少服务器渲染 HTML 的压力?可是渲染放在 JS 里面不也一样需要渲染?(不明白)总之,就是程序先加载没有具体数据填充页面,然后 JS 更新数据。

内置的两段 JS 脚本,JS 脚本中有两个变量,该变量很容易转换成 JSON 数据,也就是两段 JSON 数据,一个是关于主播的个人信息,另一个是关于弹幕认证服务器的列表(该列表中的任意一个服务器均可以认证,但每一次请求主播页面得到的认证服务器列表都不一样)

Sc

Screen Shot 2016-02-09 at 1.01.51 PM.png

Screen Shot 2016-02-09 at 12.44.01 PM.png

通过这步,我们就拿到了主播的信息以及弹幕服务器的认证地址,端口。

1.3.发送 Socket 消息的流程简介

我们通过抓包,分析那一大坨数据包,可以确定以下通过以下的流程便可以获取弹幕消息。(分析过程比较繁琐)

首先建立两个 Socket。一个用于认证 (@danmu_auth_socket),另一个用户获取弹幕 (@danmu_client)。

  • 步骤 1: @danmu_auth_socket 发送消息登陆,获取消息 1 解析出匿名用户的用户名,再获取消息 2 解析出 gid
  • 步骤 2: @danmu_auth_socket 发送 qrl 消息,获取两个没有什么用的消息
  • 步骤 3: @danmu_auth_socket 发送 keeplive 消息
  • 步骤 4: @danmu_socket 发送伪登陆消息(所有匿名用户都一样只需要输入步骤一中用户名就行了,因为认证已经在上面做过了)
  • 步骤 5: @danmu_socket 发送 join_group 消息需要步骤一中国的 gid
  • 步骤 6: @danmu_socket 不断的 recv 消息就可以获取弹幕消息了

后面会详细解释

2.1.消息 Socket 消息格式以及发送一条消息

既然是发消息,那么每条消息总是有些格式的。

斗鱼的消息格式大致如下:

douyutveachmsg.png

每一条消息并遵循下面的格式:

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

把需要传输的字符串放进去就好了。

接下来,我们需处理上面说的六个步骤

2.2.发送消息详细流程之步骤一

发送消息内容为:

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]

2.3.发送消息详细流程之步骤二

发送的消息内容为

"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)

2.4.发送消息详细流程之步骤三

发送的消息内容为

"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获取弹幕的时候了!

2.5.发送消息详细流程之步骤四

消息内容为:"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)

2.6.发送消息详细流程之步骤五

接下来就是完成认证的最后一步了,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

2.7.发送消息详细流程之步骤六

获取弹幕,并且打印出来。

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 地址的获取

找了很久没有办法解决 rtmp 地址的自动获取:

路径如下

http://www.douyutv.com/swf_api/room/301712?cdn=&nofan=yes&_t=24243097&sign=3b2efb130cb25a85e621f477f95c7341

这一处的请求不是 XHR,也就是不是 JS 脚本通过 XMLHttpRequest 异步加载;那么,八成是 flash 通过 http 协议获取的。我估计八成执行逻辑应该是在 flash 之中。也就不方便获取其中的 sign 值。故,暂时无法解析 rtmp 视频流地址了

效果图和代码

效果图:

test.gif

代码的地址为:

https://github.com/twocucao/danmu

技术浅薄,还请轻拍。

参考链接

PS:如果有问题可以在下方留言或者发送 email 到 [email protected] 给我。

感谢分享!

楼主可以尝试破解一下斗鱼的安卓客户端,貌似里面有全套的 api,不过密钥放在核心的一个 so 文件库里面,可能需要一些比较高级的技巧才能拿到

很赞的实战贴 👍


另外,楼主说:

浏览器与本地交互有限,换而言之,也就是即使我抓取了对应的弹幕,我也没有办法解决持久化的问题。

其实,这个也有解,把 Rails 应用跑在本地,绑定一个端口。浏览器里面拿到数据就往本地应用的端口 POST 数据。

#2 楼 @assyer 即使破解了也不敢分享哈.(逃...) #3 楼 @kgen 好思路,虽然有点 Geek,浏览器里面拿到数据使用 js 脚本 post 过来吗?可能还需要把 Rails 的防跨站攻击给关掉吧。

干得漂亮

#4 楼 @twocucao 嗯,需要关掉防跨站攻击。

rtmp 地址的签名算法可以试试反编译他们的 android 客户端

第一种方法是万能的,但显然是不行的

第一种方法其实也是可以的,使用像 watir-webdriver 这样的 webdriver ruby 封装库可以直接使用 ruby 开启浏览器打开网页抓取数据,数据抓取到之后想怎么保存就怎么保存。

这个必须得点赞!

楼主很棒,值得学习啊

好赞的干货文章

I, [2016-02-18T10:17:05.975042 #13481]  INFO -- : 准备登陆认证
I, [2016-02-18T10:17:06.482470 #13481]  INFO -- : 初始化DAMMU_SOCKET和DANMU_AUTH_SOCKET

卡在这里了

土不三:宝宝心里苦

好帖留名!

有点意思

干得漂亮

可以看看这个,我觉得挺好用的,https://www.pa1pa.com/

需要 登录 后方可回复, 如果你还没有账号请 注册新账号