1. 程式人生 > >Python爬取鬥魚的彈幕,看看奇葩網友都說了些什麽

Python爬取鬥魚的彈幕,看看奇葩網友都說了些什麽

run 重要 技術 直接 執行 number encoding noop 一段

0.前言

前幾天(寒假前咯)閑著無聊,看到舍友們都在看鬥魚TV,雖然我對那些網絡遊戲都不是非常感興趣,但是我突然間想到,如果我可以獲取上面的彈幕內容,不就有點意思了麽?

1.分析階段

如果我想要抓取網頁上面的東西,無非就是兩種方法

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

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

學習Python中有不明白推薦加入交流裙
號:653466668
群裏有誌同道合的小夥伴,互幫互助,
群裏有免費的視頻學習教程和PDF!

  • 手動保存實在是不可行,程序員不為也。
  • 瀏覽器與本地交互有限,換而言之,也就是即使我抓取了對應的彈幕,我也沒有辦法解決持久化的問題。
  • 假設你選擇的是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的每一條消息就好了.

多分析幾組數據,但還是對發送消息內容缺乏把握,特別是在用戶認證,用戶接收彈幕這一塊。在搜索引擎上搜索了一陣,發現知乎上有個帖子,讀完終於解了我的疑惑。

地址為: www.zhihu.com/question/29…

在此基礎上,省略若幹消息分析過程。

總結後得出鬥魚TV網站的服務器分布。

技術分享圖片
douyutvinfo.jpg

1.2.房間信息和彈幕認證服務器獲取

首先我們拿隨便一個主播房間來說,比如,qiuri

Ta的房間鏈接分為兩種

  • www.douyutv.com/qiuri
  • 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
  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地址的自動獲取:

路徑如下

www.douyutv.com/swf_api/roo…

這一處的請求不是XHR,也就是不是JS腳本通過XMLHttpRequest異步加載;那麽,八成是flash通過http協議獲取的。我估計八成執行邏輯應該是在flash之中。也就不方便獲取其中的sign值.故,暫時無法解析rtmp視頻流地址了

Python爬取鬥魚的彈幕,看看奇葩網友都說了些什麽