記一次斗魚(yú)TV彈幕爬蟲(chóng)經(jīng)歷(Ruby版本)

斗魚(yú)彈幕助手

0.前言

前幾天(寒假前咯)閑著無(wú)聊,看到舍友們都在看斗魚(yú)TV,雖然我對(duì)那些網(wǎng)絡(luò)游戲都不是非常感興趣,但是我突然間想到,如果我可以獲取上面的彈幕內(nèi)容,不就有點(diǎn)意思了么?

1.分析階段

如果我想要抓取網(wǎng)頁(yè)上面的東西,無(wú)非就是兩種方法

  1. 使用瀏覽器,手工(自己點(diǎn)擊)或者非手工(使用JS腳本),存取我想要的東西。
  2. 編寫(xiě)HTTP客戶端(斗魚(yú)無(wú)HTTPS通訊)

第一種方法是萬(wàn)能的,但顯然是不行的, 原因如下:

  • 手動(dòng)保存實(shí)在是不可行,程序員不為也。
  • 瀏覽器與本地交互有限,換而言之,也就是即使我抓取了對(duì)應(yīng)的彈幕,我也沒(méi)有辦法解決持久化的問(wèn)題。
  • 假設(shè)你選擇的是Chrome或者firefox瀏覽器,也不是不能實(shí)現(xiàn)持久化,但這需要寫(xiě)擴(kuò)展,Chrome擴(kuò)展沒(méi)有寫(xiě)過(guò),也不是很感興趣。

第二種方法顯然是一個(gè)正常的程序員的做法。

語(yǔ)言選用Ruby

寫(xiě)一個(gè)客戶端,也就是寫(xiě)一個(gè)小爬蟲(chóng),使用的場(chǎng)景:

用戶在終端執(zhí)行命令

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

回想一下抓取網(wǎng)站的方法

四步走:請(qǐng)求網(wǎng)頁(yè)(原始數(shù)據(jù)) - 提取數(shù)據(jù)(提純數(shù)據(jù)) - 保存數(shù)據(jù) - 分析數(shù)據(jù)

很顯然,只要解決了請(qǐng)求網(wǎng)頁(yè),其他的也就無(wú)非解析和SQL語(yǔ)句什么的。

1.1.斗魚(yú)TV彈幕抓取的思路確定

如果是像我上面說(shuō)的那么簡(jiǎn)單,也就不必再寫(xiě)一篇文章。畢竟,網(wǎng)頁(yè)小爬蟲(chóng)沒(méi)有什么技術(shù)含量。分布式爬蟲(chóng)才有。

通常情況下的網(wǎng)頁(yè)小爬蟲(chóng)無(wú)非要解決如下問(wèn)題:

請(qǐng)求,如果對(duì)方有一定策略的反爬蟲(chóng),那需要反反爬蟲(chóng)。比如,

  • header帶上host,帶上refer,帶上其他
  • 需要驗(yàn)證,那就申請(qǐng)用戶名和密碼,然后登陸
  • 如果在登錄時(shí)期有防跨站機(jī)制,那就先獲取一次登錄頁(yè)面,然后解析出token,帶上對(duì)應(yīng)的token然后登陸。
  • 在程序中加入Log,并且存到本地。防止出現(xiàn)各種各樣的反爬蟲(chóng)機(jī)制ban掉了程序,從而方便進(jìn)行下一步防反爬蟲(chóng)對(duì)策。

并且,由于請(qǐng)求響應(yīng)機(jī)制的存在,通常情況下,每一個(gè)請(qǐng)求對(duì)應(yīng)一個(gè)響應(yīng),如果出錯(cuò)了,要么超時(shí),要么有狀態(tài)碼,所以普通的web爬蟲(chóng)也相對(duì)而言比較容易些。

那么,斗魚(yú)TV的站點(diǎn)是不是這樣子的就能夠容易爬取呢?

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

由于彈幕具有實(shí)時(shí)性,就決定了斗魚(yú)TV的彈幕無(wú)法通過(guò)保存完整指定時(shí)間端彈幕的XML(比如BILIBILI的一個(gè)視頻彈幕是存在一段xml中的)或者Json數(shù)據(jù)來(lái)顯示彈幕。要不然的話,那主播操作很出色的時(shí)候,觀眾的彈幕豈不是無(wú)法實(shí)時(shí)顯示了么?

那么,肯定就是WebSocket了,于是,我一如既往的打開(kāi)F12,查看網(wǎng)絡(luò)流量。

正如你想到的那樣,沒(méi)有任何的彈幕流量來(lái)往。一個(gè)WebSocket的消息都沒(méi)有。

那么,消息肯定是有的,但是消息并不是通過(guò)HTTP協(xié)議或者WebSocket協(xié)議傳輸?shù)?,那么?wèn)題會(huì)出在哪呢?

分析前端的代碼,找出獲取彈幕的JS代碼,苦于代碼太多,找了很久沒(méi)有找到。那也就是執(zhí)行邏輯可能在flash里面。

于是祭出大殺器WireShark,抓一下流量。終于看到彈幕的樣子了。

是這樣的。

douyutveachmsg.png

原來(lái)使用的是Flash的Socket功能。

那么,我們只需要模擬Socket的每一條消息就好了.

多分析幾組數(shù)據(jù),但還是對(duì)發(fā)送消息內(nèi)容缺乏把握,特別是在用戶認(rèn)證,用戶接收彈幕這一塊。在搜索引擎上搜索了一陣,發(fā)現(xiàn)知乎上有個(gè)帖子,讀完終于解了我的疑惑。

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

在此基礎(chǔ)上,省略若干消息分析過(guò)程。

總結(jié)后得出斗魚(yú)TV網(wǎng)站的服務(wù)器分布。

douyutvinfo.jpg

1.2.房間信息和彈幕認(rèn)證服務(wù)器獲取

首先我們拿隨便一個(gè)主播房間來(lái)說(shuō),比如,qiuri

Ta的房間鏈接分為兩種

對(duì)這個(gè)主播房間頁(yè)面請(qǐng)求,正常,所有的有用信息都不是放在HTML中渲染出來(lái),而是有一條放在HTML中內(nèi)置的JS腳本中,這是為了減少服務(wù)器渲染HTML的壓力?可是渲染放在JS里面不也一樣需要渲染?(不明白)總之,就是程序先加載沒(méi)有具體數(shù)據(jù)填充頁(yè)面,然后JS更新數(shù)據(jù)。

內(nèi)置的兩段JS腳本,JS腳本中有兩個(gè)變量,該變量很容易轉(zhuǎn)換成JSON數(shù)據(jù),也就是兩段JSON數(shù)據(jù),一個(gè)是關(guān)于主播的個(gè)人信息,另一個(gè)是關(guān)于彈幕認(rèn)證服務(wù)器的列表(該列表中的任意一個(gè)服務(wù)器均可以認(rèn)證,但每一次請(qǐng)求主播頁(yè)面得到的認(rèn)證服務(wù)器列表都不一樣)

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

通過(guò)這步,我們就拿到了主播的信息以及彈幕服務(wù)器的認(rèn)證地址,端口。

1.3.發(fā)送Socket消息的流程簡(jiǎn)介

我們通過(guò)抓包,分析那一大坨數(shù)據(jù)包,可以確定以下通過(guò)以下的流程便可以獲取彈幕消息。(分析過(guò)程比較繁瑣)

首先建立兩個(gè)Socket。一個(gè)用于認(rèn)證(@danmu_auth_socket),另一個(gè)用戶獲取彈幕(@danmu_client)。

  • 步驟1: @danmu_auth_socket 發(fā)送消息登陸,獲取消息1解析出匿名用戶的用戶名,再獲取消息2解析出gid
  • 步驟2: @danmu_auth_socket 發(fā)送qrl消息,獲取兩個(gè)沒(méi)有什么用的消息
  • 步驟3: @danmu_auth_socket 發(fā)送keeplive消息
  • 步驟4: @danmu_socket 發(fā)送偽登陸消息(所有匿名用戶都一樣只需要輸入步驟一中用戶名就行了,因?yàn)檎J(rèn)證已經(jīng)在上面做過(guò)了)
  • 步驟5: @danmu_socket 發(fā)送join_group消息需要步驟一中國(guó)的gid
  • 步驟6: @danmu_socket 不斷的recv消息就可以獲取彈幕消息了

后面會(huì)詳細(xì)解釋

2.1.消息Socket消息格式以及發(fā)送一條消息

既然是發(fā)消息,那么每條消息總是有些格式的。

斗魚(yú)的消息格式大致如下:

douyutveachmsg.png

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

1.通信協(xié)議長(zhǎng)度,后四個(gè)部分的長(zhǎng)度,四個(gè)字節(jié)
2.第二部分與第一部分一樣
3.請(qǐng)求代碼,發(fā)送給斗魚(yú)的話,內(nèi)容為0xb1,0x02, 斗魚(yú)返回的代碼為0xb2,0x02
4.發(fā)送內(nèi)容
5.末尾字節(jié)

# -*- encoding : utf-8 -*-
class Message
  # 向斗魚(yú)發(fā)送的消息
  # 1.通信協(xié)議長(zhǎng)度,后四個(gè)部分的長(zhǎng)度,四個(gè)字節(jié)
  # 2.第二部分與第一部分一樣
  # 3.請(qǐng)求代碼,發(fā)送給斗魚(yú)的話,內(nèi)容為0xb1,0x02, 斗魚(yú)返回的代碼為0xb2,0x02
  # 4.發(fā)送內(nèi)容
  # 5.末尾字節(jié)
  #pack('c*')是字節(jié)數(shù)組轉(zhuǎn)字符串的一種詭異的轉(zhuǎn)化方式
  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

經(jīng)過(guò)封裝,我們僅僅關(guān)注那些可見(jiàn)的字符串,也就是Content部分就可以了。
content部分,也就是發(fā)送消息的內(nèi)容,在文章后面將會(huì)詳解。

開(kāi)啟兩個(gè)Socket,一個(gè)用戶認(rèn)證,另一個(gè)用于彈幕的獲取。

用于用戶彈幕認(rèn)證的,是2.1中所說(shuō)的認(rèn)證服務(wù)器列表中任意一個(gè)。挑選出來(lái)一組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

發(fā)送一條消息只需如此

    data = "type@=loginreq/username@="+@username+"/password@=1234567890123456/roomid@=" + @room_id.to_s + "/"
    all_data = message(data)
    @danmu_socket.write all_data

把需要傳輸?shù)淖址胚M(jìn)去就好了.

接下來(lái),我們需處理上面說(shuō)的六個(gè)步驟

2.2.發(fā)送消息詳細(xì)流程之步驟一

發(fā)送消息內(nèi)容為:

type@=loginreq/username@=/ct@=0/password@=/roomid@=156277/devid@=DF9E4515E0EE766B39F8D8A2E928BB7C/rt@=1453795822/vk@=4fc6e613fc650a058757331ed6c8a619/ver@=20150929/

我們需要注意的內(nèi)容如下:

type 表示消息的類(lèi)型登陸消息為loginreq
username 不需要,請(qǐng)求登陸以后系統(tǒng)會(huì)自動(dòng)的返回對(duì)應(yīng)的游客賬號(hào)。
ct 不清楚什么意思,默認(rèn)為0并無(wú)影響
password 不需要
roomid 房間的id
devid 為設(shè)備標(biāo)識(shí),無(wú)所謂,所以我們使用隨機(jī)的UUID生成
rt 應(yīng)該是runtime吧,時(shí)間戳
vk 為時(shí)間戳+"7oE9nPEG9xXV69phU31FYCLUagKeYtsF"+devid的字符串拼接結(jié)果的MD5值(這個(gè)是參考了一篇文章,關(guān)于這一處我也不大明白怎么探究出來(lái)的)
ver 默認(rèn)

通過(guò)這一步,我們可以獲取兩條消息,并從消息中使用正則表達(dá)式獲取對(duì)應(yīng)的用戶名以及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.發(fā)送消息詳細(xì)流程之步驟二

發(fā)送的消息內(nèi)容為

"type@=qrl/rid@=" + @room_id.to_s + "/"

無(wú)需多說(shuō),類(lèi)型為qrl,rid為roomid,直接發(fā)送這條消息就好。返回的兩條消息也沒(méi)有什么價(jià)值。

    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.發(fā)送消息詳細(xì)流程之步驟三

發(fā)送的消息內(nèi)容為

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

直接發(fā)送。無(wú)太大意義。

    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的重要步驟。獲取這兩個(gè)字段以后,也就完成了它存在的使命。

接下來(lái)的就是@danmu_socket獲取彈幕的時(shí)候了!

2.5.發(fā)送消息詳細(xì)流程之步驟四

消息內(nèi)容為:"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.發(fā)送消息詳細(xì)流程之步驟五

接下來(lái)就是完成認(rèn)證的最后一步了,join_group的消息內(nèi)容為

"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.發(fā)送消息詳細(xì)流程之步驟六

獲取彈幕,并且打印出來(lái)。

    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 獲取彈幕的步驟。

于是,通過(guò)這些步驟,就可以完成了簡(jiǎn)單的danmu的核心代碼,接下來(lái)的步驟就是完善,重構(gòu)這些代碼了。

總結(jié)

痛點(diǎn)一,至今還沒(méi)有解決rtmp地址的獲取

找了很久沒(méi)有辦法解決rtmp地址的自動(dòng)獲?。?/p>

路徑如下

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

這一處的請(qǐng)求不是XHR,也就是不是JS腳本通過(guò)XMLHttpRequest異步加載;那么,八成是flash通過(guò)http協(xié)議獲取的。我估計(jì)八成執(zhí)行邏輯應(yīng)該是在flash之中。也就不方便獲取其中的sign值.故,暫時(shí)無(wú)法解析rtmp視頻流地址了

效果圖和代碼

效果圖:

test.gif

代碼的地址為:

https://github.com/twocucao/danmu

技術(shù)淺薄,還請(qǐng)輕拍。

參考鏈接

PS:如果有問(wèn)題可以在下方留言或者發(fā)送email到twocucao@gmail.com給我。

ChangeLog

2016-02-09 09:01:00 - 重寫(xiě)部分內(nèi)容.首發(fā)簡(jiǎn)書(shū).

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容