# 斗鱼弹幕助手

# 0. 前言

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

# 1. 分析阶段

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

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

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

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

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

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

用户在终端执行命令


回想一下抓取网站的方法

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

很显然,只要解决了请求网页,其他的也就无非解析和 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,抓一下流量。终于看到弹幕的样子了。

是这样的。

每条消息的内容

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

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

省略若干消息分析过程。

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

猜测网站架构图

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

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

Ta 的房间链接分为两种

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

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

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

# 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 消息格式以及发送一条消息

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

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

每条消息的内容

并遵循下面的格式:

  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,直接发送这条消息就好。返回的两条消息也没有什么价值。

    send_message(:qrl,@danmu_auth_socket,"")
    str = @danmu_auth_socket.recv(4000)
    str = @danmu_auth_socket.recv(4000)

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

发送的消息内容为

“type@=keeplive/tick@=” + timestamp + “/vbw@=0/k@=19beba41da8ac2b4c7895a66cab81e23/”

直接发送。无太大意义。

    send_message(:keeplive,@danmu_auth_socket,"")
    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。

    send_message(:join_group,@danmu_socket,"")

# 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 获取弹幕的步骤。

于是,通过这些步骤,就可以完成了简单的 douutv 的和新代码,接下来的步骤就是完善,重构这些代码了。

# 总结

# 1. 痛点一,头疼的过度封装

我们知道,在编写 Ruby 的 Socket Server 和 Client 的时候,非常方便,特别是传输的 socket 消息内容为字符串的时候。

但是,当处理的消息内容不可打印的字符串的时候,必须要转化成字节数组的时候,让我着实混乱了一阵,直到使用了 pack(”c*“) 和 unpack(“c*”), 并且通过 wireshark 抓包验证了自己的发送的数据包和接受的数据包才安心使用 pack 与 unpack。

# 2. 痛点二,至今还没有解决 rtmp 地址的获取

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

路径如下

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

这一处的请求不是 XHR,也就是不是 JS 脚本通过 XMLHttpRequest 异步加载;那么,八成是 flash 通过 http 协议获取的。我估计八成执行逻辑应该是在 flash 之中。

# 代码

核心代码的地址为:

重构版本即将出炉。

还请轻拍。

# 参考链接

PS: 如果有问题可以在下方留言或者发送 email 到 twocucao@gmail.com 给我。

# ChangeLog

2016-02-09 09:01:00 - 重写部分内容。增加 Ruby Socket 部分。