.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 https://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协议(https://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仓库,各位可以自行前往下载:https://github.com/sdcb/blog-data/tree/master/2021/20191011-douyu-barrage-with-dotnet

喜欢的朋友 请关注我的微信公众号:【DotNet骚操作】

DotNet骚操作

posted @ 2021-01-17 17:35  .NET骚操作  阅读(2075)  评论(6编辑  收藏  举报