淘寶直播彈幕爬蟲

背景說明

公司有通過淘寶直播間短鏈接來爬取直播彈幕的需求, 奈何即便google上面也僅找到一個(gè)相關(guān)的話題, 還沒有答案. 所以只能自食其力了.
爬蟲的github倉庫地址在文末, 我們先看一下爬蟲的最終效果:


overview.png

下面我們來抽絲剝繭, 重現(xiàn)一下調(diào)研過程.

頁面分析

直播間地址在分享直播時(shí)可以拿到:


address.png

彈幕一般不是websocket就是socket. 我們打開dev tools過濾ws的請(qǐng)求即可看到websocket地址:


wsurl.png

提一下斗魚: 它走的是flash的socket, 我們就算打開dev tools也是懵逼, 好在斗魚官方直接開放了socket的API.

我們繼續(xù)查看收到的消息, 發(fā)現(xiàn)消息的壓縮類型compressType有兩種: COMMON和GZIP. data的值肯定就是目標(biāo)消息了, 看起來像經(jīng)過了base64編碼, 解密過程后面再說.


frames.png

現(xiàn)在我們首先要解決的問題是如何拿到websocket地址. 分析一下html source, 發(fā)現(xiàn)可以通過其中不變的部分查找到腳本:


source.png

然鵝, 拿到這塊整個(gè)的腳本格式化之后發(fā)現(xiàn), 原始代碼明顯是模塊化開發(fā)的, 經(jīng)過了打包壓縮. 所以我們只能分析模塊內(nèi)一小塊代碼, 這是沒有意義的.

但是我們可以觀察到不同的直播間websocket地址唯一不同的只有token, 所以我們可以想辦法拿到token. 當(dāng)然這是很惡心的環(huán)節(jié), 完全沒有頭緒, 想到的各種可能性都失敗了. 后面像無頭蒼蠅一樣看頁面發(fā)起的請(qǐng)求, 竟然給找到了...
token是通過api請(qǐng)求獲取的, api地址是:

http://h5api.m.taobao.com/h5/mtop.mediaplatform.live.encryption/1.0/
api.png

好了那websocket地址的問題解決了, 我們開始寫爬蟲吧.

編寫爬蟲

看看api的query string那一堆動(dòng)態(tài)參數(shù), 普通爬蟲就別想了, 我們祭出神器: puppeteer.

puppeteer是谷歌推出的開放Node API的無頭瀏覽器, 理論上可以可編程化地控制瀏覽器的各種行為, 對(duì)于我們的場景來說就是:
直播頁面加載完之后, 攔截獲取websocket token的api請(qǐng)求, 解析結(jié)果拿到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`)

這里有個(gè)性能優(yōu)化的小技巧. puppeteer官方示例中獲取page實(shí)例會(huì)打開一個(gè)新頁面: const page = await browser.newPage(), 實(shí)際上瀏覽器啟動(dòng)本來就默認(rèn)有個(gè)about:blank頁面打開, 我們的代碼中直接是獲取這個(gè)打開的實(shí)例來跳轉(zhuǎn)直播頁面, 這樣就可以少一個(gè)進(jìn)程.
可以ps ax|grep puppeteer觀察啟動(dòng)的進(jìn)程數(shù)來進(jìn)行對(duì)比, 默認(rèn)有兩個(gè)主進(jìn)程, 剩余的都是頁面進(jìn)程.

獲取到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)
    })
rawmsgs.png

消息解密

現(xiàn)在我們能持續(xù)拉取消息了, 這樣會(huì)方便分析. 前面我們分析頁面的時(shí)候發(fā)現(xiàn)compressType有兩種: COMMON和GZIP. 經(jīng)過嘗試, COMMON的可以直接得到明文, 而GZIP的需要再經(jīng)過一次gunzip解碼. 解碼結(jié)果大致如下, 里面已經(jīng)可以看到昵稱和彈幕內(nèi)容了:


plainmsg.png

然鵝, 一切才剛剛開始...內(nèi)容里面是有亂碼的, 基于這樣的內(nèi)容做正則匹配無果. 如果嘗試直接保存buffer或者buffer.toString()到文件會(huì)發(fā)現(xiàn)文件根本打不開, 內(nèi)容是無法解析的:

invalid.png

沒辦法, 我們只能分析原始buffer array的utf8編碼了. 這里開了腦洞, 直接將buffer array做join得到的string拿來分析其規(guī)律 (分析代碼見analyze.js文件):


analyze.png

幾個(gè)樣本的分析結(jié)果如下, 其中不變的部分做了高亮:


rule.png

這些值可能是由有效字符編碼按一定規(guī)則換算過來, 但誰又能猜得到呢, 也沒必要.

這樣我們就可以通過一個(gè)正則表達(dá)式解析出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/

當(dāng)然這個(gè)pattern同樣能匹配到關(guān)注主播的彈幕, 這不是我們想要的. 我們可以通過一串確定的buffer字符串提前過濾掉這種消息:

const followedPattern = '226,129,130,226,136,176,226,143,135,102,111,108,108,111,119'

至此我們已經(jīng)可以解析出干干凈凈的昵稱+彈幕了. 完整解密代碼如下:

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}`)
    }
}

當(dāng)然可能還存在一個(gè)問題, 是關(guān)于上面分析結(jié)果表里的barrage前, 有連續(xù)的5位固定不變, 實(shí)際上剛開始是連同前面一位共6位不變的, 結(jié)果過了一天之后前面那位從130變到了131, 而再往前的幾位變化頻率則特別高. 所以我懷疑這些值有可能是跟當(dāng)前時(shí)間有關(guān).
可能不確定的一段時(shí)間之后這5位固定值也會(huì)變掉吧, 到時(shí)正則就得調(diào)整了, 但應(yīng)該可以正常運(yùn)行很久了. 如有哪些同仁感興趣, 可以找找規(guī)律.

進(jìn)程維護(hù)

實(shí)際使用時(shí)流程大致應(yīng)該是這樣的: 收到請(qǐng)求之后主進(jìn)程fork一個(gè)爬蟲子進(jìn)程來獲取websocket url, 子進(jìn)程返回結(jié)果給主進(jìn)程, 在使用方建立websocket連接(搶過連接)之后, 子進(jìn)程便可自殺釋放資源, 自殺的同時(shí)browser.close()殺死puppeteer相關(guān)進(jìn)程.
之所以這樣做是因?yàn)闇y試下來: websocket斷開連接不久token會(huì)失效.

Github倉庫

記得star啊??
https://github.com/xiaozhongliu/taobao-live-crawler

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • jHipster - 微服務(wù)搭建 CC_簡書[http://www.lxweimin.com/u/be0d56c4...
    quanjj閱讀 830評(píng)論 0 2
  • adasd a uploadFileFromNativesduploadFileFromNativeuploadF...
    betterTry閱讀 631評(píng)論 0 1
  • 中秋節(jié)
    微cai閱讀 153評(píng)論 0 0
  • 我一直以為自己是怎么吃也不會(huì)胖的那種人,于是天整日里胡吃海喝,終于不得不面對(duì)現(xiàn)實(shí):買衣服號(hào)大一點(diǎn)不說,上身還不好看...
    葳蕤時(shí)光閱讀 384評(píng)論 2 0
  • 百合花語是指百合具有百年好合美好家庭、偉大的愛之含意,有深深祝福的意義。收到這種花的祝福的人具有清純天真的性格,集...
    鶴壁訥閱讀 839評(píng)論 1 2