.NET鬥魚直播彈幕客戶端(2021)

.NET鬥魚直播彈幕客戶端(2021)

離之前更新的兩篇《.NET鬥魚直播彈幕客戶端》已經有一段時間,近期有許多客戶向我回饋剛好有這方面的需求,但之前的程式碼不能用了——但網上許多流傳的Node.jsPython腳本卻可以用,這豈能忍?(剛好我終於找回了我的發布密碼😂)因此我有動力重新對此進行好(xie)好(xie)研(bo)究(ke)。

為何之前的不能用了

重新運行之前的C#腳本,發現是在這一行報錯的:

using var client = new TcpClient();
await client.ConnectAsync("openbarrage.douyutv.com", 8601); // 這裡報錯

網上查了查,發現鬥魚確實已經停止使用openbarrage.douyutv.com:8601了。進一步查資料顯示,新url改成了danmuproxy.douyu.com,鬥魚已經統一使用WebSocket協議(之前為TCP協議),經過進一步對比新協議程式碼示例,發現協議過程沒有任何區別,序列化也依然用的STT演算法。

私貨時間:
我認為鬥魚這樣做合理,因為WebSocket性能不差,且不需再為瀏覽器和第三方介面各自維護兩套不同的程式碼。

具體過程如下:

  • 建立WebSocket連接
  • 發送登錄請求(可匿名)
  • 加入指定的房間號
  • 每隔45秒,響應一次心跳包
  • (此時,即可)正常接收彈幕數據

新程式碼實現

.NET中有許多提供WebSocket功能的庫和第三方包,之前我經常用websocket-sharp,這是第三方包。現在我們盡量不用第三方包,官方提供的WebSocket客戶端叫System.Net.WebSockets.ClientWebSocket,同時支援.NET 4.5.NET Core

按正常的思路,我們會這樣寫:

return Observable.Create<string>(async (roomId, cancellationToken) =>
{
    using var ws = new ClientWebSocket();
    await ws.ConnectAsync(new Uri("wss://danmuproxy.douyu.com:8506/"), cancellationToken);
    await MsgTool.LoginAsync(ws, roomId, cancellationToken);
    // other codes
});

但實際運行卻不行,會報這個錯:

WebSocketException:
The 'Sec-WebSocket-Accept' header value 'Kfh9QIsMVZcl6xEPYxPHzW8SZ8w=' is invalid.

相信我,如果你仔細對比Node/Python.NET程式碼,整個程式碼中沒任何區別,但打開Fiddler仔細分析協議,發現事情沒這麼簡單,這是一個無法成功連上伺服器的包:

請求:
GET //danmuproxy.douyu.com:8506/ HTTP/1.1
Host: danmuproxy.douyu.com:8506
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: VsPg1/SSskKrbYouGm3ROQ==

響應:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: Kfh9QIsMVZcl6xEPYxPHzW8SZ8w=
Sec-WebSocket-Version: 13
EndTime: 09:37:44.958
ReceivedBytes: 0
SentBytes: 0

研究原因

其中請注意看請求中的Sec-WebSocket-Key項,和響應中的Sec-WebSocket-Accept項。

按照WebSocket協議(//tools.ietf.org/html/rfc6455#section-11.3.3),伺服器響應頭Sec-WebSocket-Accept項的值,應該為請求頭Sec-WebSocket-Key項字元串追加固定值"258EAFA5-E914-47DA-95CA-C5AB0DC85B11",然後計算其SHA1哈希值,再求Base64,用C#程式碼說,這一過程如下:

string WebSocketComputeAccept(string key)
{
	using var sha = SHA1.Create();
	byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"));
	return Convert.ToBase64String(hash);
}

如上的VsPg1/SSskKrbYouGm3ROQ==按這個計算過程,它應該返回VrPdUdxpPeBXDi1ttGN607h8ct0=,但實際卻是Kfh9QIsMVZcl6xEPYxPHzW8SZ8w=,這就是為何C#會報錯,因此服務端返回了錯誤值。

進一步研究原因

我嘗試了許多次,C#用客戶端連接時,總是會生成隨機的Sec-WebSocket-Key值,但不管值如何,服務端總是會返回相同的值——但一旦切換為Node.js,返回的值就完全正常。

我仔細分析了其它語言的WebSocket頭與.NET的區別,發現一個重要因素:.NET客戶端請求中的Sec-WebSocket-Key項,一定是最後一條,但其它語言中不是最後一條。

如果我們使用Fiddler手動發送握手請求,將Sec-WebSocket-KeySec-WebSocket-Version順序對調一下,發現響應值如下(伺服器響應匹配):

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: VrPdUdxpPeBXDi1ttGN607h8ct0=
Sec-WebSocket-Version: 13

然而用ClientWebSocket是無法控制請求頭順序的,這一點可以在源程式碼中找到。

最終答案

雖然無法控制請求頭順序,但可以控制Sec-WebSocket-Key不是最後一個,只需添加一個子協議頭,值無所謂:ws.Options.AddSubProtocol("-");,因此重點程式碼如下(完整程式碼請見LINQPad腳本——douyu-2020.linq):

using var ws = new ClientWebSocket();
ws.Options.AddSubProtocol("-");
await ws.ConnectAsync(new Uri("wss://danmuproxy.douyu.com:8506"), QueryCancelToken);
await ws.SendAsync(SerializeDouyu($"type@=loginreq/roomid@=74751/ver@=20190610/"), WebSocketMessageType.Binary, false, QueryCancelToken);
await ws.SendAsync(SerializeDouyu($"type@=joingroup/rid@=74751/gid@=-9999/"), WebSocketMessageType.Binary, false, QueryCancelToken);
_ = Task.Run(async () =>
{
	while (!QueryCancelToken.IsCancellationRequested)
	{
		await Task.Delay(45000, QueryCancelToken);
		await ws.SendAsync(SerializeDouyu($"type@=mrkl/"), WebSocketMessageType.Binary, false, QueryCancelToken);
	}
});

while (!QueryCancelToken.IsCancellationRequested)
{
	var buffer = new byte[4096];
	WebSocketReceiveResult r = await ws.ReceiveAsync(buffer, QueryCancelToken);
	string result = DeserializeDouyu(new Memory<byte>(buffer, 0, r.Count), QueryCancelToken);
	DecodeStringToJObject(result).Dump();
}

運行效果:

封裝優化

之前我是基於System.Reactive庫做的封裝,但C# 9.0已經發布許久,這次我重新基於IAsyncEnumerable寫了一版,這個以C# 9.0作為非同步流的基礎,擴展可以用System.Linq.Async,從而獲得與正常的LINQ完全一致的體驗,核心程式碼如下:

public class DouyuBarrage
{
    static HttpClient http = new HttpClient();

    public static async IAsyncEnumerable<string> RawFromUrl(string url, [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        HttpResponseMessage html = await http.GetAsync(url, cancellationToken);
        var roomId = Regex.Match(await html.Content.ReadAsStringAsync(), @"\$ROOM.room_id[ ]?=[ ]?(\d+);").Groups[1].Value;

        using var ws = new ClientWebSocket();
		ws.Options.AddSubProtocol("-");
        await ws.ConnectAsync(new Uri("wss://danmuproxy.douyu.com:8506/"), cancellationToken);
        await MsgTool.LoginAsync(ws, roomId, cancellationToken);
        await MsgTool.JoinGroupAsync(ws, roomId, cancellationToken);

        var task = Task.Run(async () =>
        {
            while (!cancellationToken.IsCancellationRequested)
            {
                await MsgTool.SendTick(ws, cancellationToken);
                await Task.Delay(45000, cancellationToken);
            }
        }, cancellationToken);

        while (ws.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
        {
            yield return await MsgTool.RecieveAsync(ws, cancellationToken);
        }

        GC.KeepAlive(task);
        await MsgTool.Logout(ws, cancellationToken);
    }
	
	public static IAsyncEnumerable<JToken> JObjectFromUrl(string url) => RawFromUrl(url)
		.Select(MsgTool.DecodeStringToJObject);
	
	public static IAsyncEnumerable<Barrage> ChatMessageFromUrl(string url) => JObjectFromUrl(url)
		.Where(x => x["type"].Value<string>() == "chatmsg")
		.Select(Barrage.FromJToken);
}

見最後兩個方法JObjectFromUrlChatMessageFromUrl,基於IAsyncEnumerable,可以獲得與LINQSystem.Reactive完全一致的開發體驗,一行程式碼即可完成非同步流的篩選、數據轉換。

說在最後

以上所有的完整程式碼和示例,都已經上傳到我的部落格專用Github倉庫,各位可以自行前往下載://github.com/sdcb/blog-data/tree/master/2021/20191011-douyu-barrage-with-dotnet

喜歡的朋友 請關注我的微信公眾號:【DotNet騷操作】

DotNet騷操作