鬥魚彈幕助手
0.前言
前幾天(寒假前咯)閑著無聊,看到舍友們都在看鬥魚TV,雖然我對那些網路遊戲都不是非常感興趣,但是我突然間想到,如果我可以擷取上面的彈幕內容,不就有點意思了麼。 1.分析階段
如果我想要抓取網頁上面的東西,無非就是兩種方法 使用瀏覽器,手工(自己點擊)或者非手工(使用JS指令碼),存取我想要的東西。 編寫HTTP用戶端(鬥魚無HTTPS通訊)
第一種方法是萬能的,但顯然是不行的, 原因如下: 手動儲存實在是不可行,程式員不為也。 瀏覽器與本地互動有限,換而言之,也就是即使我抓取了對應的彈幕,我也沒有辦法解決持久化的問題。 假設你選擇的是Chrome或者firefox瀏覽器,也不是不能實現持久化,但這需要寫擴充,Chrome擴充沒有寫過,也不是高度興趣。
第二種方法顯然是一個正常的程式員的做法。
語言選用Ruby
寫一個用戶端,也就是寫一個小爬蟲,使用的情境:
使用者在終端執行命令
gem install danmudanmu douyu [room_id/url]#比如danmu douyu qiuridanmu 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的房間連結分為兩種 http://www.douyutv.com/qiuri http://www.douyutv.com/[房間id]
對這個主播房間頁面請求,正常,所有的有用資訊都不是放在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 endend
經過封裝,我們僅僅關注那些可見的字串,也就是Content部分就可以了。
content部分,也就是發送訊息的內容,在文章後面將會詳解。
開啟兩個Socket,一個使用者認證,另一個用於彈幕的擷取。
用於使用者彈幕認證的,是2.1中所說的證明伺服器列表中任意一個。挑選出來一組ip和連接埠
@danmu_auth_socket = TCPSocket.new @auth_dst_ip,@auth_dst_port
使用者擷取彈幕的只要為
danmu.douyutv.com:8601danmu.douyutv.com:8602danmu.douyutv.com:12601danmu.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 表示訊息的類型登陸訊息為loginrequsername 不需要,請求登陸以後系統會自動的返回對應的遊客帳號。ct 不清楚什麼意思,預設為0並無影響password 不需要roomid 房間的iddevid 為裝置標識,無所謂,所以我們使用隨機的UUID產生rt 應該是runtime吧,時間戳記vk 為時間戳記+"7oE9nPEG9xXV69phU31FYCLUagKeYtsF"+devid的字串拼接結果的MD5值(這個是參考了一篇文章,關於這一處我也不大明白怎麼探究出來的)ver 預設
通過這一步,我們可以擷取兩條訊息,並從訊息中使用Regex擷取對應的使用者名稱以及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。