抓取虎牙直播弹幕流(WireShark&前端源码)

(来自老VPS写的博文,第一次没有经验被种木马了55~,图没救回来)

网上搜索的时候发现,很多文档和博文都是过时的,目前各大直播网站都已经升级到使用HTML5中的WebSocket协议,以前的方法很明显是行不通的。

# 前言

(上个星期面试了一下百度,总共过了二面,但是发现被面试官微信拉黑了,那应该是凉凉了。不过说来倒是,自己的第一次面试太紧张了,面试官出了一道很简单的题都紧张的写错了几次,然而自己懂的东西也展现不出来。确实非常难受,知道自己是这样的人,遇到了陌生人就非常的不习惯,自己总是最慢热的那一个)

最近有点烦躁,但是该玩的还是要玩的,本文内容是通过Wireshark结合虎牙前端源码抓取直播弹幕流,是综合设计课程的内容中我自己的实现,还是非常有趣的。

# 寻找WebSocket地址

我使用的是Chrome浏览器,打开开发者工具中的NetWork标签栏,并选中WS(Websocket)过滤

图已丢失(请脑补)

可以看得到虎牙直播同时打开了数条WebSocket连接,我们选中其中一个,并查看其Frame流。

图已丢失(请脑补)

全部都是二进制帧,看来虎牙对消息进行了加密(不太可能)或者编码。而我从帧流和弹幕流对比中基本确认弹幕流用的是哪一条websocket流,然后使用WireShark抓包分析锁定了就是这一条。(后来看源码发现弹幕流有四条websocket,虎牙前端会选择其中一条)

图已丢失(请脑补)

抓包发现还可以看到有意义的ASCII字符,说明这不是加密,而是某一种通信结构体的编码。弹幕流这种消息如果加密的话将要消耗大量的客户端和服务端资源,而如果使用常用的RPC通信框架,比如Netty,则可以对消息结构体进行压缩(二进制流和字符流的区别)和跨平台通信(类比JSON)。

# 分析源码

分析源码的步骤需要一点技巧,而我一开始没有什么经验。

还是开发者工具,点开Performance标签页并开始记录。记录几秒后停止然后查看事件日志。

图已丢失(请脑补)

发现一个在vplayerUI.jsp()函数频繁被调用,于是进入函数内部查看。

            function p(t) {
                f++;
                m++;
                I += t.data.size;
                var e = new FileReader;
                e.onload = r;
                e.readAsArrayBuffer(t.data)
            }

该函数使用FileReader读取了一个二进制流,读取完毕后调用r函数,那我们在看r是什么函数。

function r() {
                var t = this.result;
                if (localStorage.__wup > 1) {
                    Taf.Util.jcestream(t, 32)
                }
                var e = new Taf.JceInputStream(t);
                var i = new HUYA.WebSocketCommand;
                i.readFrom(e);
                switch (i.iCmdType) {
                case HUYA.EWebSocketCommandType.EWSCmd_RegisterRsp:
                    e = new Taf.JceInputStream(i.vData.buffer);
                    var r = new HUYA.WSRegisterRsp;
                    r.readFrom(e);
                    if (g) {
                        console.log("%c<<<<<<< %crspRegister", st("#0000E3"), st("#D9006C"), r)
                    }
                    v.dispatch("WSRegisterRsp", r);
                    break;
                case HUYA.EWebSocketCommandType.EWSCmdS2C_RegisterGroupRsp:
                    e = new Taf.JceInputStream(i.vData.buffer);
                    var n = new HUYA.WSRegisterGroupRsp;
                    n.readFrom(e);
                    if (g) {
                        console.log("%c<<<<<<< %crspregisterGroup", st("#0000E3"), st("#D9006C"), n)
                    }
                    v.dispatch("WSRegisterGroupRsp", n);
                    break;
                case HUYA.EWebSocketCommandType.EWSCmdS2C_UnRegisterGroupRsp:
                    e = new Taf.JceInputStream(i.vData.buffer);
                    var s = new HUYA.WSUnRegisterGroupRsp;
                    s.readFrom(e);
                    if (g) {
                        console.log("%c<<<<<<< %crspunRegisterGroup", st("#0000E3"), st("#D9006C"), s)
                    }
                    v.dispatch("WSUnRegisterGroupRsp", s);
                    break;
                case HUYA.EWebSocketCommandType.EWSCmd_WupRsp:
                    var a = new Taf.Wup;
                    a.decode(i.vData.buffer);
                    var o = TafMx.WupMapping[a.sFuncName];
                    if (o) {
                        var l = new o;
                        var u = a.newdata.get("tRsp") ? "tRsp" : "tResp";
                        a.readStruct(u, l, o);
                        K(l);
                        if (g && (a.sServantName.toLowerCase() != "videogateway" || S) && !TafMx.NoLog[a.sFuncName]) {
                            console.log("%c<<<<<<< %crspWup:%c " + a.sFuncName, st("#0000E3"), st("black"), st("#0000E3"), a.sServantName, l)
                        }
                        if (a.iRequestId > 0) {
                            l.iRequestId = a.iRequestId
                        }
                        v.dispatch(a.sFuncName, l)
                    } else {
                        v.dispatch(a.sFuncName);
                        if (a.sFuncName != "OnUserHeartBeat") {
                            console.info("收到未映射的 WupRsp,sFuncName=" + a.sFuncName)
                        }
                    }
                    break;
                case HUYA.EWebSocketCommandType.EWSCmdS2C_MsgPushReq:
                    e = new Taf.JceInputStream(i.vData.buffer);
                    var d = new HUYA.WSPushMessage;
                    d.readFrom(e);
                    var c = d.iUri;
                    logUtils.addUri(c);
                    e = new Taf.JceInputStream(d.sMsg.buffer);
                    var h = TafMx.UriMapping[d.iUri];
                    if (h) {
                        var p = new h;
                        p.readFrom(e);
                        K(p);
                        if (g && !TafMx.NoLog[c.toString()]) {
                            console.log("%c<<<<<<< %crspMsgPush, %curi=" + c, st("#0000E3"), st("black"), st("#8600FF"), p)
                        }
                        v.dispatch(c, p)
                    } else if (U) {
                        console.info("收到未映射的 WSPushMessage uri=" + pushMsg.iUri)
                    }
                    break;
                case HUYA.EWebSocketCommandType.EWSCmdS2C_HeartBeatAck:
                    console.log("%c<<<<<<< rspHeartBeat: " + Date.now(), st("#0000E3"));
                    break;
                case HUYA.EWebSocketCommandType.EWSCmdS2C_VerifyCookieRsp:
                    e = new Taf.JceInputStream(i.vData.buffer);
                    var f = new HUYA.WSVerifyCookieRsp;
                    f.readFrom(e);
                    var m = f.iValidate == 0;
                    if (!m) {
                        vplayer.trigger("verifyCookieFail")
                    }
                    logUtils.addLog("VerifyCookie校验" + (m ? "通过!" : "失败!"));
                    if (g) {
                        console.log("%c<<<<<<< %cVerifyCookie", st("#0000E3"), st("#D9006C"), "校验" + (m ? "通过!" : "失败!"), f)
                    }
                    break;
                case HUYA.EWebSocketCommandType.EWSCmdS2C_MsgPushReq_V2:
                    e = new Taf.JceInputStream(i.vData.buffer);
                    var d = new HUYA.WSPushMessage_V2;
                    d.readFrom(e);
                    for (var I = 0, w = d.vMsgItem.value.length; I < w; I++) {
                        var y = d.vMsgItem.value[I];
                        var c = y.iUri;
                        var h = TafMx.UriMapping[c];
                        if (h) {
                            var p = new h;
                            var e = new Taf.JceInputStream(y.sMsg);
                            p.readFrom(e);
                            K(p);
                            if (g && !TafMx.NoLog[c.toString()]) {
                                console.log("%c<<<<<<< %crspMsgPushV2, %curi=" + c, st("#0000E3"), st("black"), st("#8600FF"), p)
                            }
                            v.dispatch(c, p)
                        } else if (U) {
                            console.info("收到未映射的 WSPushMessage_V2 uri=" + c)
                        }
                    }
                    break;
                default:
                    console.log("%c<<<<<<< Not matched CmdType: " + i.iCmdType, st("#red"))
                }
            }

这个函数已经很明显了,就是解析消息体的函数,可以看到解析过程为

  1. 得到arrayBuffer
  2. 新建new Taf.JceInputStream
  3. 根据EWebSocketCommandType的值选择使用哪一种消息体解析
  4. 调用该消息体的readFrom从流中读取数据并转换为对象。
  5. 广播,渲染。。。

通过debugger,发现弹幕流队形的消息体和EWebSocketCommandType是如下关系

  • EWSCmdS2C_MsgPushReq_V2WSPushMessage_V2
  • EWSCmdS2C_MsgPushReqWSPushMessage

# Taf/Tars

一开始的我没有意识到这是一个通信框架,于是用Java的转写这段解析代码,我成功的转写了解析WSPushMessage的函数,然而发现太麻烦了,同事转写的过程中发现该结构体非常规律,有readStringreadInt64readInt32readStruct以及更加复杂的结构体函数。

我意识到了这是很可能是一个框架,于是开始百度搜索Taf,然后发现没有什么结果,然后在在Github搜索,终于发现了一个叫TARS (opens new window)的RPC框架,他是腾讯的一套RPC框架,前身就是TAF。我克隆了该仓库中的流处理库Tars.Node.Stream (opens new window)

不过发现其结构体函数和旧版的Taf有很大的不同,虽然可以成功解析消息,但是不能直接复制虎牙的源码的,需要全部转写。于是我又复制了Huya上旧版的TAF源码,并且包装成库。

const Taf = {};
// ...TAF 源码
export default Taf

然后把虎牙的消息结构体也包装成库。

import Taf from "./Taf"
const Huya = {};
export default Huya

// 其他结构体
Huya.MessageNotice.prototype._clone = function () {
    return new Huya.MessageNotice
    }

Huya.MessageNotice.prototype._write = function (t, e, i) {
    t.writeStruct(e, i)
    }

Huya.MessageNotice.prototype._read = function (t, e, i) {
    return t.readStruct(e, true, i)
    }

Huya.MessageNotice.prototype.writeTo = function (t) {
    t.writeStruct(0, this.tUserInfo)
    t.writeInt64(1, this.lTid)
    t.writeInt64(2, this.lSid)
    t.writeString(3, this.sContent)
    t.writeInt32(4, this.iShowMode)
    t.writeStruct(5, this.tFormat)
    t.writeStruct(6, this.tBulletFormat)
    t.writeInt32(7, this.iTermType)
    t.writeVector(8, this.vDecorationPrefix)
    t.writeVector(9, this.vDecorationSuffix)
    t.writeVector(10, this.vAtSomeone)
    t.writeInt64(11, this.lPid)
    t.writeVector(12, this.vBulletPrefix)
    t.writeString(13, this.sIconUrl)
    t.writeInt32(14, this.iType)
    t.writeVector(15, this.vBulletSuffix)
    }

Huya.MessageNotice.prototype.readFrom = function (t) {
    this.tUserInfo = t.readStruct(0, false, this.tUserInfo)
    this.lTid = t.readInt64(1, false, this.lTid)
    this.lSid = t.readInt64(2, false, this.lSid)
    this.sContent = t.readString(3, false, this.sContent)
    this.iShowMode = t.readInt32(4, false, this.iShowMode)
    this.tFormat = t.readStruct(5, false, this.tFormat)
    this.tBulletFormat = t.readStruct(6, false, this.tBulletFormat)
    this.iTermType = t.readInt32(7, false, this.iTermType)
    this.vDecorationPrefix = t.readVector(8, false, this.vDecorationPrefix)
    this.vDecorationSuffix = t.readVector(9, false, this.vDecorationSuffix)
    this.vAtSomeone = t.readVector(10, false, this.vAtSomeone)
    this.lPid = t.readInt64(11, false, this.lPid)
    this.vBulletPrefix = t.readVector(12, false, this.vBulletPrefix)
    this.sIconUrl = t.readString(13, false, this.sIconUrl)
    this.iType = t.readInt32(14, false, this.iType)
    this.vBulletSuffix = t.readVector(15, false, this.vBulletSuffix)
    }

// 其他结构体

# 尝试解析

开始使用TAF对消息体进行解析,只需要改造虎牙的源码即可,保留解析弹幕流部分,其他全部跳过。

我这里测试的时候,截取了开发者工具中收到的一条ArrayBuffer进行测试,解析成功了。

import Taf from "../pojo/Taf"
import Huya from "../pojo/Huya"

// 此处t为socket响应,t.data为socket的ArrayBuffer Body
export default (t) => {
    const tarsInputStream = new Taf.JceInputStream(t.data);
    const webSocketCommand = new Huya.WebSocketCommand();

    webSocketCommand.readFrom(tarsInputStream);
    switch (webSocketCommand.iCmdType) {
        case Huya.EWebSocketCommandType.EWSCmdS2C_MsgPushReq: {
            const vData = new Taf.JceInputStream(webSocketCommand.vData.buffer);
            const wsPushMessage = new Huya.WSPushMessage();
            wsPushMessage.readFrom(vData);

            const sMsg = new Taf.JceInputStream(wsPushMessage.sMsg.buffer);
            if (wsPushMessage.iUri !== 1400)
                console.debug("not a message");

            const messageNotice = new Huya.MessageNotice();
            try {
                messageNotice.readFrom(sMsg);
            }catch (e) {
                console.error(e.message)
            }
            printf(messageNotice)
            break;
        }
        case Huya.EWebSocketCommandType.EWSCmdS2C_MsgPushReq_V2: {
            const vData = new Taf.JceInputStream(webSocketCommand.vData);
            const wsPushMessage = new Huya.WSPushMessage_V2();
            wsPushMessage.readFrom(vData);
            for (let i = 0; i < wsPushMessage.vMsgItem.value.length; i++) {
                const wsMsgItem = wsPushMessage.vMsgItem.value[i];

                if (1400 !== wsMsgItem.iUri)
                    console.debug("not a message");

                const messageNotice = new Huya.MessageNotice();
                const sMsg = new Taf.JceInputStream(wsMsgItem.sMsg);
                try {
                    messageNotice.readFrom(sMsg);
                }catch (e) {
                    console.error(e.message)
                }
                printf(messageNotice)
            }
            break;
        }
        default:
            console.debug("%c<<<<<<< Not matched CmdType: " + webSocketCommand.iCmdType)
    }
    }

// 打印
function printf(messageNotice) {
    console.log(messageNotice.tUserInfo.sNickName + ": " + messageNotice.sContent)
    }

// LOL骚男直播间弹幕
// 我xx怂: 骚哥加油我要发家致富了

# 尝试连接websocket

继续寻找源码,在刚开始载入页面的时候,我截到了一个相关的e()函数。(省略大部分费时费力的查找和检验步骤,这就是弹幕流的websocket连接函数)

                function e() {
                    if (G.useHttps) {
                        var t = utils.getDns();
                        delete localStorage.wssdns
                    } else {
                        var t = utils.getIps();
                        delete localStorage.wssips
                    }
                    var i = t.length;
                    if (i == 0)
                        d();
                    var r = false;
                    var n = [];
                    var e = function(t) {
                        var e = t.currentTarget;
                        var i = e.ip;
                        e.onopen = e.onclose = e.onerror = undefined;
                        if (!r) {
                            r = true;
                            u = (G.useHttps ? "wss://" : "ws://") + i;
                            d(e);
                            c()
                        } else {
                            G.wsIps.push(i);
                            e.close()
                        }
                    };
                    var s = function(t) {
                        var e = t.currentTarget.ip;
                        if (n.indexOf(e) == -1) {
                            n.push(e)
                        }
                        if (n.length == i && !r) {
                            d()
                        }
                    };
                    for (var a = 0; a < i; a++) {
                        var o = t[a];
                        if (ISDEBUG && !ENV.online && G.useHttps) {
                            o += ":4434"
                        }
                        var l = new WebSocket((G.useHttps ? "wss://" : "ws://") + o);
                        l.ip = o;
                        l.onopen = e;
                        l.onclose = s;
                        l.onerror = s
                    }
                }

通过打断点发现里面的t变量储存了websocket连接的地址,至于怎么获得的我就懒得找了。然后各个函数重新看一遍,发现虎牙会对这个数组的地址只选择一条连接,我们再查看onopen时候调用的函数。

                    var e = function(t) {
                        var e = t.currentTarget;
                        var i = e.ip;
                        e.onopen = e.onclose = e.onerror = undefined;
                        if (!r) {
                            r = true;
                            u = (G.useHttps ? "wss://" : "ws://") + i;
                            d(e);
                            c()
                        } else {
                            G.wsIps.push(i);
                            e.close()
                        }
                    };

这里没有什么意义,就是选择一条地址进行连接,注意到一条函数c,下一步我们会继续追踪这条函数c

我们先开始转写连接的代码,由于Node端和Browser端的环境不一样,Node需要引入另外的WebSocket库。

for (let url of URLS) {
    if(hadOne)
        break;
    const client = new WebSocketClient(`ws://${url}`, "", "https://www.huya.com", {
        "Pragma": "no-cache",
        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/70.0.3538.77 Chrome/70.0.3538.77 Safari/537.36",
        "Cache-Control": "no-cache",
        "Cookie": "xxx"
    });

    client.onerror = (error) => {
        console.log('Connect Error: ' + error.toString());
    };

    client.onopen = () => {
        console.log('WebSocket Client Connected');
        // client._connection.extensions.push("permessage-deflate")
    };

    client.onclose = a => {
        console.log('Connection Closed ' + a.reason);
    };

    client.onmessage = receive;
    }

// WebSocket Client Connected

这里引入了一个第三方WebSocket库,可以自由选择。转写代码的同时模拟了浏览器请求,防止被封。这里注意注释的代码,是必须要加上的,这里用注释强调一下,是一个坑。

# 追踪初始化

接上之前追踪到的c函数,

                function c() {
                    console.log("=== WebSocket Connected ===");
                    logUtils.addLog("websocket连接成功");
                    G.loginRegister = false;
                    G.wsurl = u.replace(G.useHttps ? "wss://" : "ws://", "");
                    if (!G.wsconnected && G.reConnectTimes == 0) {
                        G.wsConnectTime = Date.now() - G.wsConnectTime
                    }
                    v.connected = true;
                    G.wsconnected = true;
                    P();
                    v.dispatch("WEBSOCKET_CONNECTED");
                    H = Date.now();
                    clearInterval(t);
                    t = setInterval(b, 1e3)
                }

该函数检验连接状态并执行一条关键函数v.dispatch("WEBSOCKET_CONNECTED");,继续追踪。

                this.dispatch = function(t, e) {
                // t = "WEBSOCKET_CONNECTED", e = undefined
                    C = t;
                    var i = k[t];
                    if (i instanceof Array) {
                        for (var r = 0, n = i.length; r < n; r++) {
                            var s = i[r];
                            if (typeof s === "function") {
                                s(e)
                            }
                        }
                        if (i.length == 0) {}
                    } else {}
                    C = null;
                    i = N[t];
                    if (i instanceof Array) {
                        for (var r = 0, n = i.length; r < n; r++) {
                            v.removeListener(t, i[r])
                        }
                        delete N[t]
                    }
                    return this
                }

这里可以看到会用t的值访问k对象,然后对里面的函数逐个执行。这就是一个状态广播函数了。我们要继续找到k对象中t键对应了什么值。这里不能直接寻找声明位置了,因为声明函数的时候k = {}。我们需要找到这个键值对生成的地方。

u.addListener("WEBSOCKET_CONNECTED", o);

经过开发者工具的debug,找到了该条命令,其中u就是上面this.dispatchthis类型的实例,那我们直接追踪o。

            function o() {
                if (G.isReplay) {
                    G.hasVideo = true;
                    v()
                } else {
                    _();
                    c()
                }
                if (!o.isInited) {
                    logUtils.addLog("ws初始化完成");
                    o.isInited = true;
                    window.connectWebSocketEnd && connectWebSocketEnd();
                    __vTime("WebSocket连接成功:" + (Date.now() - G.beginTime));
                    if (!G.isReplay) {
                        G.vplayer.setTafHandler(u)
                    }
                    Event.fireEvent(Event.WEBSOCKET_INITED)
                }
            }

可以见到此函数为WebSocket初始化后的函数,注意_()c()。这两个函数为具体的初始化函数。

            function _() {
                var t = _.lastTime || 0;
                var e = Date.now();
                if (e - t < 1e3)
                    return;
                _.lastTime = e;
                G.livingInfoTime = e;
                var i = new HUYA.GetLivingInfoReq;
                i.tId = G.userId;
                i.lTopSid = G.topsid;
                i.lSubSid = G.subsid;
                i.lPresenterUid = G.presenterUid;
                i.sTraceSource = ENV.ref;
                u.sendWup("liveui", "getLivingInfo", i);
                logUtils.addLog("getLivingInfoReq")
            }
            function c() {
                if (!G.wsconnected || !d.isInited)
                    return;
                var t = Date.now();
                if (t - s < 1e3) {
                    return
                }
                s = t;
                if (!c.isInited) {
                    c.isInited = true
                }
                var e = new HUYA.LiveLaunchReq;
                e.tId = G.userId;
                e.tLiveUB.eSource = HUYA.ELiveSource.WEB_HUYA;
                if (G.useHttps) {
                    e.bSupportDomain = 1
                }
                logUtils.addLog("send doLaunch");
                u.sendWup("liveui", "doLaunch", e)
            }

我们转写该初始化函数并尝试通信。注意上面有些提前生成的变量,通过打断点获取到需要的变量内容并储存下来,在转写的时候会用得到

// 地址数组
let URLS = ["b6831c13-ws.va.huya.com", "b6831c14-ws.va.huya.com", "3d809126-ws.va.huya.com", "3d809124-ws.va.huya.com"];
// 用户信息(未登录的),注意是一个消息结构体,所以需要不能直接把储存下载的JSON直接用上,需要转回虎牙的结构体
let userId = Object.assign(new Huya.UserId(), JSON.parse('{"lUid":0,"sGuid":"3adxxxx7c5f6b","sToken":"","sHuYaUA":"webh5&1xxxx&websocket","sCookie":"vpxxxx11","iTokenType":0}'))
// 以下为直播房间相关id
const topsid = 95431869;
const subsid = 2516077608;
const presenterUid = 900821317;

// _
const buffer1 = (() => {
    let getLivingInfoReq = new Huya.GetLivingInfoReq();
    getLivingInfoReq.tId = userId;
    getLivingInfoReq.lTopSid = topsid;
    getLivingInfoReq.lSubSid = subsid;
    getLivingInfoReq.lPresenterUid = presenterUid;
    getLivingInfoReq.sTraceSource = "lol/0/1/1";

    let wup = new Taf.Wup();
    wup.setServant("liveui");
    wup.setFunc("getLivingInfo");
    wup.setRequestId(-1);
    wup.writeStruct("tReq", getLivingInfoReq);

    const webSocketCommand = new Huya.WebSocketCommand();
    webSocketCommand.iCmdType = Huya.EWebSocketCommandType.EWSCmd_WupReq;
    webSocketCommand.vData = wup.encode();

    const o = new Taf.JceOutputStream;
    webSocketCommand.writeTo(o);
    return o.getBuffer();
    })();

// c
const buffer2 = (() => {
    let liveLaunchReq = new Huya.LiveLaunchReq;
    liveLaunchReq.tId = userId;
    liveLaunchReq.tLiveUB = new Huya.LiveUserbase()
    liveLaunchReq.tLiveUB.eSource = Huya.ELiveSource.WEB_HUYA;
    liveLaunchReq.bSupportDomain = 1

    let wup = new Taf.Wup();
    wup.setServant("liveui");
    wup.setFunc("doLaunch");
    wup.setRequestId(-1);
    wup.writeStruct("tReq", liveLaunchReq);

    const webSocketCommand = new Huya.WebSocketCommand();
    webSocketCommand.iCmdType = Huya.EWebSocketCommandType.EWSCmd_WupReq;
    webSocketCommand.vData = wup.encode();

    const o = new Taf.JceOutputStream;
    webSocketCommand.writeTo(o);
    return o.getBuffer();
    })();

然后改写socket的开启函数

    client.onopen = () => {
        console.log('WebSocket Client Connected');
        client._connection.extensions.push("permessage-deflate")
        client.send(buffer1)
        client.send(buffer2)
    };

    // 1002
// 1010

这里出现过两个错误,

  • 1002

    RSV标志位错误,这里的RSV是WebSocket帧中的标志位有3位,报错可以通过Wireshark抓包把发出去的帧改成合适的值。另一种情况是,检查请求头部和帧头部其他值有没有出错。

    • 1010

    这里协商了请求头的extensions值没有对应。检查了一下请求,我加上了client._connection.extensions.push("permessage-deflate")还有一个client_max_window_bits加不加好像都可以。

    最后再尝试连接,就成功了。同时收到了对应的结构体响应,我们按照上方解析步骤解析就行了。

但是这里出现了问题,初始化步骤初始化完后,websocket没有推送弹幕流。说明还有申请包需要推送,但是源码太乱了,以及它通过广播来触发事件,让我追踪很不方便。另外,Chrome还经常卡死,我感觉优化没做好,我的机子配置很好了。

那我们通过WireShark来抓哪一个是申请包。通过弹幕流推送的开始网上找本机的发包,我找到了一条可疑的包。

图已丢失(请脑补)

我把该包原封不动发出去,下面为十六进制流转换成ArrayBuffer的代码

import Taf from "../pojo/Taf"
import Huya from "../pojo/Huya"
import Tars from "@tars/stream";

// 改包内容
const str = "00101d000025090002060e6c6976653a393030383231333137060e636861743a39303038323133313716002c3600"

// 转换成ArrayBuffer
const bytes = parseHexString(str);
const arrayBuffer = byteToUint8Array(bytes).buffer

function parseHexString(str) {
    var result = [];

    for (let i = 0; i < str.length; i += 2) {
        result.push(parseInt(str[i] + str[i + 1], 16))
    }

    return result;
    }

function byteToUint8Array(byteArray) {
    var uint8Array = new Uint8Array(byteArray.length);
    for (var i = 0; i < uint8Array.length; i++) {
        uint8Array[i] = byteArray[i];
    }

    return uint8Array;
    }

buffer3即是,我们发送出去

    client.onopen = () => {
        console.log('WebSocket Client Connected');
        client._connection.extensions.push("permessage-deflate")
        client.send(buffer1)
        client.send(buffer2)
        client.send(buffer3)
    };

    // 4

同时补充一下websocket.onmessagereceive函数

(t) => {
    const tarsInputStream = new Taf.JceInputStream(t.data);
    const webSocketCommand = new Huya.WebSocketCommand();

    webSocketCommand.readFrom(tarsInputStream);
    console.log(webSocketCommand.iCmdType);
    }

// 22
// 22
// 22
// 22
// 22
// 22

这里一直受到22的推送,22就是弹幕流的结构体,说明这个包就是发送包。我们把这个包解析出来,代码和上面一样的。

    const tarsInputStream = new Taf.JceInputStream(arrayBuffer);
    const webSocketCommand = new Huya.WebSocketCommand();

    webSocketCommand.readFrom(tarsInputStream);
    console.log(webSocketCommand.iCmdType);
    // 16

我们找到16对应的结构体就是wsRegisterGroupReq了,我们把该结构体解析后重新封装一下,关键代码是wsRegisterGroupReq.vGroupId这个数组里面的值,我们往里面添加"chat:" + presenterUid一个字符串即可。

// sendRegisterGroup
const buffer3 = (() => {
    // presenterUid 为上面的部分房间信息
    const chat = "chat:" + presenterUid;
    const wsRegisterGroupReq = new Huya.WSRegisterGroupReq;
    wsRegisterGroupReq.vGroupId.value.push(chat);

    const jceOutputStream = new Taf.JceOutputStream;
    wsRegisterGroupReq.writeTo(jceOutputStream);
    const webSocketCommand = new Huya.WebSocketCommand;
    webSocketCommand.iCmdType = Huya.EWebSocketCommandType.EWSCmdC2S_RegisterGroupReq;
    webSocketCommand.vData = jceOutputStream.getBinBuffer();

    const jceOutputStream2 = new Taf.JceOutputStream;
    webSocketCommand.writeTo(jceOutputStream2);
    return jceOutputStream2.getBuffer();
    })();

这里return一个和之前截获到的包一样的ArrayBuffer,在连接开始之后发送,既可以不断收到房间推送的弹幕。

# 结语

这里说的虽然很简单,但是看源码和抓包都非常费神费力,收发的包非常多,前端源码也很庞大。本文只是从走过的多条岔路中整理的一条直路。但是还是非常有趣的,学到了WebSocket以及RPC框架的不少知识。


# DEMO Github 仓库

GITHUB huya-danmu (opens new window)