斗魚(yú)彈幕助手
0.前言
前幾天(寒假前咯)閑著無(wú)聊,看到舍友們都在看斗魚(yú)TV,雖然我對(duì)那些網(wǎng)絡(luò)游戲都不是非常感興趣,但是我突然間想到,如果我可以獲取上面的彈幕內(nèi)容,不就有點(diǎn)意思了么?
1.分析階段
如果我想要抓取網(wǎng)頁(yè)上面的東西,無(wú)非就是兩種方法
- 使用瀏覽器,手工(自己點(diǎn)擊)或者非手工(使用JS腳本),存取我想要的東西。
- 編寫(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
然后就可以在終端欣賞彈幕咯.

回想一下抓取網(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,抓一下流量。終于看到彈幕的樣子了。
是這樣的。

原來(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ù)器分布。

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ù)器列表都不一樣)



通過(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ú)的消息格式大致如下:

每一條消息并遵循下面的格式:
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>
路徑如下
這一處的請(qǐng)求不是XHR,也就是不是JS腳本通過(guò)XMLHttpRequest異步加載;那么,八成是flash通過(guò)http協(xié)議獲取的。我估計(jì)八成執(zhí)行邏輯應(yīng)該是在flash之中。也就不方便獲取其中的sign值.故,暫時(shí)無(wú)法解析rtmp視頻流地址了
效果圖和代碼
效果圖:

代碼的地址為:
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ū).