公司有經過淘寶直播間短連接來爬取直播彈幕的需求, 奈何即使google上面也僅找到一個相關的話題, 尚未答案. 因此只能自食其力了.
爬蟲的github倉庫地址在文末, 咱們先看一下爬蟲的最終效果:
html
下面咱們來抽絲剝繭, 重現一下調研過程.git
直播間地址在分享直播時能夠拿到:
github
彈幕通常不是websocket就是socket. 咱們打開dev tools過濾ws的請求便可看到websocket地址:
web
提一下鬥魚: 它走的是flash的socket, 咱們就算打開dev tools也是懵逼, 好在鬥魚官方直接開放了socket的API.正則表達式
咱們繼續查看收到的消息, 發現消息的壓縮類型compressType有兩種: COMMON和GZIP. data的值確定就是目標消息了, 看起來像通過了base64編碼, 解密過程後面再說.
編程
如今咱們首先要解決的問題是如何拿到websocket地址. 分析一下html source, 發現能夠經過其中不變的部分查找到腳本:
然鵝, 拿到這塊整個的腳本格式化以後發現, 原始代碼明顯是模塊化開發的, 通過了打包壓縮. 因此咱們只能分析模塊內一小塊代碼, 這是沒有意義的.api
可是咱們能夠觀察到不一樣的直播間websocket地址惟一不一樣的只有token, 因此咱們能夠想辦法拿到token. 固然這是很噁心的環節, 徹底沒有頭緒, 想到的各類可能性都失敗了. 後面像無頭蒼蠅同樣看頁面發起的請求, 居然給找到了...
token是經過api請求獲取的, api地址是:瀏覽器
http://h5api.m.taobao.com/h5/mtop.mediaplatform.live.encryption/1.0/
好了那websocket地址的問題解決了, 咱們開始寫爬蟲吧.性能優化
看看api的query string那一堆動態參數, 普通爬蟲就別想了, 咱們祭出神器: puppeteer.websocket
puppeteer是谷歌推出的開放Node API的無頭瀏覽器, 理論上能夠可編程化地控制瀏覽器的各類行爲, 對於咱們的場景來講就是:
直播頁面加載完以後, 攔截獲取websocket token的api請求, 解析結果拿到token. 這部分的代碼以下:
const browser = await puppeteer.launch() const page = (await browser.pages())[0] await page.setRequestInterception(true) const api = 'http://h5api.m.taobao.com/h5/mtop.mediaplatform.live.encryption/1.0/' const { url } = message // intercept request obtaining the web socket token page.on('request', req => { if (req.url.includes(api)) { console.log(`[${url}] getting token`) } req.continue() }) page.on('response', async res => { if (!res.url.includes(api)) return const data = await res.text() const token = data.match(/"result":"(.*?)"/)[1] const url = `ws://acs.m.taobao.com/accs/auth?token=${token}` }) // open the taobao live page await page.goto(url, { timeout: 0 }) console.log(`[${url}] page loaded`)
這裏有個性能優化的小技巧. puppeteer官方示例中獲取page實例會打開一個新頁面:
const page = await browser.newPage()
, 實際上瀏覽器啓動原本就默認有個about:blank頁面打開, 咱們的代碼中直接是獲取這個打開的實例來跳轉直播頁面, 這樣就能夠少一個進程.
能夠ps ax|grep puppeteer觀察啓動的進程數來進行對比, 默認有兩個主進程, 剩餘的都是頁面進程.
獲取到websocket地址就能夠創建鏈接拉取消息了:
const url = `ws://acs.m.taobao.com/accs/auth?token=${token}` const ws = new WebSocket(url) ws.on('open', () => { console.log(`\nOPEN: ${url}\n`) }) ws.on('close', () => { console.log('DISCONN') }) ws.on('message', msg => { console.log(msg) })
如今咱們能持續拉取消息了, 這樣會方便分析. 前面咱們分析頁面的時候發現compressType有兩種: COMMON和GZIP. 通過嘗試, COMMON的能夠直接獲得明文, 而GZIP的須要再通過一次gunzip解碼. 解碼結果大體以下, 裏面已經能夠看到暱稱和彈幕內容了:
然鵝, 一切纔剛剛開始...內容裏面是有亂碼的, 基於這樣的內容作正則匹配無果. 若是嘗試直接保存buffer
或者buffer.toString()
到文件會發現文件根本打不開, 內容是沒法解析的:
沒辦法, 咱們只能分析原始buffer array的utf8編碼了. 這裏開了腦洞, 直接將buffer array作join獲得的string拿來分析其規律 (分析代碼見analyze.js文件):
幾個樣本的分析結果以下, 其中不變的部分作了高亮:
這些值多是由有效字符編碼按必定規則換算過來, 但誰又能猜獲得呢, 也不必.
這樣咱們就能夠經過一個正則表達式解析出nick和barrage了:
/.*,[0-9]+,0,18,[0-9]+,(.*?),32,[0-9]+,[0-9]+,[0-9]+,[0-9]+,[0-9]+,44,50,2,116,98,[0-9]+,0,10,[0-9]+,(.*?),18,20,10,12/
固然這個pattern一樣能匹配到關注主播的彈幕, 這不是咱們想要的. 咱們能夠經過一串肯定的buffer字符串提早過濾掉這種消息:
const followedPattern = '226,129,130,226,136,176,226,143,135,102,111,108,108,111,119'
至此咱們已經能夠解析出乾乾淨淨的暱稱+彈幕了. 完整解密代碼以下:
function decode(msg) { // base64 decode let buffer = Buffer.from(msg.data, 'base64') if (msg.compressType === 'GZIP') { // gzip decode buffer = zlib.gunzipSync(buffer) } const bufferStr = buffer.join(',') // [followed] notifications are ignored const followedPattern = '226,129,130,226,136,176,226,143,135,102,111,108,108,111,119' if (bufferStr.includes(followedPattern)) { return } // // print for debugging // console.log(bufferStr) // console.log(buffer.toString()) // first match is nick name and second match is barrage content const barragePattern = /.*,[0-9]+,0,18,[0-9]+,(.*?),32,[0-9]+,[0-9]+,[0-9]+,[0-9]+,[0-9]+,44,50,2,116,98,[0-9]+,0,10,[0-9]+,(.*?),18,20,10,12/ const matched = bufferStr.match(barragePattern) if (matched) { const nick = parseStr(matched[1]) const barrage = parseStr(matched[2]) console.log(`${nick}: ${barrage}`) } }
固然可能還存在一個問題, 是關於上面分析結果表裏的barrage前
, 有連續的5位固定不變, 實際上剛開始是連同前面一位共6位不變的, 結果過了一天以後前面那位從130變到了131, 而再往前的幾位變化頻率則特別高. 因此我懷疑這些值有多是跟當前時間有關.
可能不肯定的一段時間以後這5位固定值也會變掉吧, 到時正則就得調整了, 但應該能夠正常運行好久了. 若有哪些同仁感興趣, 能夠找找規律.
實際使用時流程大體應該是這樣的: 收到請求以後主進程fork一個爬蟲子進程來獲取websocket url, 子進程返回結果給主進程, 在使用方創建websocket鏈接(搶過鏈接)以後, 子進程即可自殺釋放資源, 自殺的同時browser.close()
殺死puppeteer相關進程.
之因此這樣作是由於測試下來: websocket斷開鏈接不久token會失效.
記得star啊?
https://github.com/xiaozhongliu/taobao-live-crawler