feat(core): connect to danmaku server with buvid3 from cookie & refactor HttpApiClient (#507)

* feat(core): connect to danmaku server with buvid3

* make buvid3 nullable & add json serializer setting

* fix test

* fix regex & make regex static

* remove anonHttpClient

* remove MainHttpClient

* split FetchAsTextAsync

* GetAnonymousCookieAsync

* use template string in TestCookieAsync

* fix test

* background get anonymous cookie

* make jsonSerializerSettings static

* fix uid parse logic

* fix buvid match

* remove GetAnonCookie

* restore merge typo

* fix comment

* rename ICookieTester
This commit is contained in:
进栈检票 2023-07-16 16:01:50 +08:00 committed by GitHub
parent a073e5fe4d
commit adc91cc4f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 72 additions and 79 deletions

View File

@ -33,6 +33,8 @@ namespace BililiveRecorder.Core.Api.Danmaku
public Func<string, string?>? BeforeHandshake { get; set; } = null; public Func<string, string?>? BeforeHandshake { get; set; } = null;
private static readonly JsonSerializerSettings jsonSerializerSettings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore };
public DanmakuClient(IDanmakuServerApiClient apiClient, ILogger logger) public DanmakuClient(IDanmakuServerApiClient apiClient, ILogger logger)
{ {
this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
@ -98,7 +100,7 @@ namespace BililiveRecorder.Core.Api.Danmaku
this.danmakuTransport = transport; this.danmakuTransport = transport;
await this.SendHelloAsync(roomid, this.apiClient.GetUid(), danmakuServerInfo.Token ?? string.Empty).ConfigureAwait(false); await this.SendHelloAsync(roomid, this.apiClient.GetUid(), this.apiClient.GetBuvid3(), danmakuServerInfo.Token ?? string.Empty).ConfigureAwait(false);
await this.SendPingAsync().ConfigureAwait(false); await this.SendPingAsync().ConfigureAwait(false);
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
@ -213,17 +215,18 @@ namespace BililiveRecorder.Core.Api.Danmaku
#region Send #region Send
private Task SendHelloAsync(int roomid, long uid, string token) private Task SendHelloAsync(int roomid, long uid, string? buvid, string token)
{ {
var body = JsonConvert.SerializeObject(new var body = JsonConvert.SerializeObject(new
{ {
uid = uid, uid,
roomid = roomid, roomid,
protover = 0, protover = 0,
buvid,
platform = "web", platform = "web",
type = 2, type = 2,
key = token, key = token,
}, Formatting.None); }, Formatting.None, jsonSerializerSettings);
if (this.BeforeHandshake is { } func) if (this.BeforeHandshake is { } func)
{ {

View File

@ -12,40 +12,29 @@ using Newtonsoft.Json.Linq;
namespace BililiveRecorder.Core.Api.Http namespace BililiveRecorder.Core.Api.Http
{ {
internal class HttpApiClient : IApiClient, IDanmakuServerApiClient, IHttpClientAccessor internal class HttpApiClient : IApiClient, IDanmakuServerApiClient, ICookieTester
{ {
internal const string HttpHeaderAccept = "application/json, text/javascript, */*; q=0.01"; internal const string HttpHeaderAccept = "application/json, text/javascript, */*; q=0.01";
internal const string HttpHeaderReferer = "https://live.bilibili.com/"; internal const string HttpHeaderReferer = "https://live.bilibili.com/";
internal const string HttpHeaderOrigin = "https://live.bilibili.com"; internal const string HttpHeaderOrigin = "https://live.bilibili.com";
internal const string HttpHeaderUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"; internal const string HttpHeaderUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36";
private readonly Regex matchCookieUidRegex = new Regex(@"DedeUserID=(\d+?);", RegexOptions.Compiled); private static readonly Regex matchCookieUidRegex = new Regex(@"DedeUserID=(\d+?);", RegexOptions.Compiled);
private static readonly Regex matchCookieBuvid3Regex = new Regex(@"buvid3=(.+?);", RegexOptions.Compiled);
private long uid; private long uid;
private string? buvid3;
private readonly GlobalConfig config; private readonly GlobalConfig config;
private readonly HttpClient anonClient; private HttpClient client;
private HttpClient mainClient;
private bool disposedValue; private bool disposedValue;
public HttpClient MainHttpClient => this.mainClient;
public HttpApiClient(GlobalConfig config) public HttpApiClient(GlobalConfig config)
{ {
this.config = config ?? throw new ArgumentNullException(nameof(config)); this.config = config ?? throw new ArgumentNullException(nameof(config));
config.PropertyChanged += this.Config_PropertyChanged; config.PropertyChanged += this.Config_PropertyChanged;
this.mainClient = null!; this.client = null!;
this.UpdateHttpClient(); this.UpdateHttpClient();
this.anonClient = new HttpClient
{
Timeout = TimeSpan.FromMilliseconds(config.TimingApiTimeout)
};
var headers = this.anonClient.DefaultRequestHeaders;
headers.Add("Accept", HttpHeaderAccept);
headers.Add("Origin", HttpHeaderOrigin);
headers.Add("Referer", HttpHeaderReferer);
headers.Add("User-Agent", HttpHeaderUserAgent);
} }
private void UpdateHttpClient() private void UpdateHttpClient()
@ -68,16 +57,20 @@ namespace BililiveRecorder.Core.Api.Http
if (!string.IsNullOrWhiteSpace(cookie_string)) if (!string.IsNullOrWhiteSpace(cookie_string))
{ {
headers.Add("Cookie", cookie_string); headers.Add("Cookie", cookie_string);
long.TryParse(matchCookieUidRegex.Match(cookie_string).Groups[1].Value, out var uid);
long.TryParse(this.matchCookieUidRegex.Match(cookie_string).Groups[1].Value, out var uid);
this.uid = uid; this.uid = uid;
string buvid3 = matchCookieBuvid3Regex.Match(cookie_string).Groups[1].Value;
if (!string.IsNullOrWhiteSpace(buvid3))
this.buvid3 = buvid3;
else
this.buvid3 = null;
} }
else else
{ {
this.uid = 0; this.uid = 0;
} }
var old = Interlocked.Exchange(ref this.mainClient, client); var old = Interlocked.Exchange(ref this.client, client);
old?.Dispose(); old?.Dispose();
} }
@ -87,19 +80,21 @@ namespace BililiveRecorder.Core.Api.Http
this.UpdateHttpClient(); this.UpdateHttpClient();
} }
private static async Task<BilibiliApiResponse<T>> FetchAsync<T>(HttpClient client, string url) where T : class private async Task<string> FetchAsTextAsync(string url)
{ {
// 记得 GetRoomInfoAsync 里复制了一份这里的代码,以后修改记得一起改了 var resp = await this.client.GetAsync(url).ConfigureAwait(false);
var resp = await client.GetAsync(url).ConfigureAwait(false);
if (resp.StatusCode == (HttpStatusCode)412) if (resp.StatusCode == (HttpStatusCode)412)
throw new Http412Exception("Got HTTP Status 412 when requesting " + url); throw new Http412Exception("Got HTTP Status 412 when requesting " + url);
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); return await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
}
private async Task<BilibiliApiResponse<T>> FetchAsync<T>(string url) where T : class
{
var text = await this.FetchAsTextAsync(url).ConfigureAwait(false);
var obj = JsonConvert.DeserializeObject<BilibiliApiResponse<T>>(text); var obj = JsonConvert.DeserializeObject<BilibiliApiResponse<T>>(text);
return obj?.Code != 0 ? throw new BilibiliApiResponseCodeNotZeroException(obj?.Code, text) : obj; return obj?.Code != 0 ? throw new BilibiliApiResponseCodeNotZeroException(obj?.Code, text) : obj;
} }
@ -111,18 +106,7 @@ namespace BililiveRecorder.Core.Api.Http
var url = $@"{this.config.LiveApiHost}/xlive/web-room/v1/index/getInfoByRoom?room_id={roomid}"; var url = $@"{this.config.LiveApiHost}/xlive/web-room/v1/index/getInfoByRoom?room_id={roomid}";
// return FetchAsync<RoomInfo>(this.mainClient, url); var text = await this.FetchAsTextAsync(url).ConfigureAwait(false);
// 下面的代码是从 FetchAsync 里复制修改的
// 以后如果修改 FetchAsync 记得把这里也跟着改了
var resp = await this.mainClient.GetAsync(url).ConfigureAwait(false);
if (resp.StatusCode == (HttpStatusCode)412)
throw new Http412Exception("Got HTTP Status 412 when requesting " + url);
resp.EnsureSuccessStatusCode();
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
var jobject = JObject.Parse(text); var jobject = JObject.Parse(text);
@ -141,18 +125,35 @@ namespace BililiveRecorder.Core.Api.Http
throw new ObjectDisposedException(nameof(HttpApiClient)); throw new ObjectDisposedException(nameof(HttpApiClient));
var url = $@"{this.config.LiveApiHost}/xlive/web-room/v2/index/getRoomPlayInfo?room_id={roomid}&protocol=0,1&format=0,1,2&codec=0,1&qn={qn}&platform=web&ptype=8&dolby=5&panorama=1"; var url = $@"{this.config.LiveApiHost}/xlive/web-room/v2/index/getRoomPlayInfo?room_id={roomid}&protocol=0,1&format=0,1,2&codec=0,1&qn={qn}&platform=web&ptype=8&dolby=5&panorama=1";
return FetchAsync<RoomPlayInfo>(this.mainClient, url); return this.FetchAsync<RoomPlayInfo>(url);
}
public async Task<(bool, string)> TestCookieAsync()
{
// 需要测试 cookie 的情况不需要风控和失败检测
var resp = await this.client.GetStringAsync("https://api.live.bilibili.com/xlive/web-ucenter/user/get_user_info").ConfigureAwait(false);
var jo = JObject.Parse(resp);
if (jo["code"]?.ToObject<int>() != 0)
return (false, $"Response:\n{resp}");
string message = $@"User: {jo["data"]?["uname"]?.ToObject<string>()}
UID (from API response): {jo["data"]?["uid"]?.ToObject<string>()}
UID (from Cookie): {this.GetUid()}
BUVID3 (from Cookie): {this.GetBuvid3()}";
return (true, message);
} }
public long GetUid() => this.uid; public long GetUid() => this.uid;
public string? GetBuvid3() => this.buvid3;
public Task<BilibiliApiResponse<DanmuInfo>> GetDanmakuServerAsync(int roomid) public Task<BilibiliApiResponse<DanmuInfo>> GetDanmakuServerAsync(int roomid)
{ {
if (this.disposedValue) if (this.disposedValue)
throw new ObjectDisposedException(nameof(HttpApiClient)); throw new ObjectDisposedException(nameof(HttpApiClient));
var url = $@"{this.config.LiveApiHost}/xlive/web-room/v1/index/getDanmuInfo?id={roomid}&type=0"; var url = $@"{this.config.LiveApiHost}/xlive/web-room/v1/index/getDanmuInfo?id={roomid}&type=0";
return FetchAsync<DanmuInfo>(this.mainClient, url); return this.FetchAsync<DanmuInfo>(url);
} }
protected virtual void Dispose(bool disposing) protected virtual void Dispose(bool disposing)
@ -163,8 +164,7 @@ namespace BililiveRecorder.Core.Api.Http
{ {
// dispose managed state (managed objects) // dispose managed state (managed objects)
this.config.PropertyChanged -= this.Config_PropertyChanged; this.config.PropertyChanged -= this.Config_PropertyChanged;
this.mainClient.Dispose(); this.client.Dispose();
this.anonClient.Dispose();
} }
// free unmanaged resources (unmanaged objects) and override finalizer // free unmanaged resources (unmanaged objects) and override finalizer

View File

@ -7,6 +7,7 @@ namespace BililiveRecorder.Core.Api
internal interface IDanmakuServerApiClient : IDisposable internal interface IDanmakuServerApiClient : IDisposable
{ {
long GetUid(); long GetUid();
string? GetBuvid3();
Task<BilibiliApiResponse<DanmuInfo>> GetDanmakuServerAsync(int roomid); Task<BilibiliApiResponse<DanmuInfo>> GetDanmakuServerAsync(int roomid);
} }
} }

View File

@ -1,10 +1,10 @@
using System.Net.Http; using System.Net.Http;
using System.Threading.Tasks;
namespace BililiveRecorder.Core.Api namespace BililiveRecorder.Core.Api
{ {
public interface IHttpClientAccessor public interface ICookieTester
{ {
HttpClient MainHttpClient { get; } Task<(bool, string)> TestCookieAsync();
long GetUid();
} }
} }

View File

@ -18,6 +18,7 @@ namespace BililiveRecorder.Core.Api
} }
public long GetUid() => this.client.GetUid(); public long GetUid() => this.client.GetUid();
public string? GetBuvid3() => this.client.GetBuvid3();
public async Task<BilibiliApiResponse<DanmuInfo>> GetDanmakuServerAsync(int roomid) => await this.policies public async Task<BilibiliApiResponse<DanmuInfo>> GetDanmakuServerAsync(int roomid) => await this.policies
.Get<IAsyncPolicy>(PolicyNames.PolicyDanmakuApiRequestAsync) .Get<IAsyncPolicy>(PolicyNames.PolicyDanmakuApiRequestAsync)

View File

@ -37,7 +37,7 @@ namespace BililiveRecorder.DependencyInjection
public static IServiceCollection AddRecorderApiClients(this IServiceCollection services) => services public static IServiceCollection AddRecorderApiClients(this IServiceCollection services) => services
.AddSingleton<HttpApiClient>() .AddSingleton<HttpApiClient>()
.AddSingleton<IHttpClientAccessor>(sp => sp.GetRequiredService<HttpApiClient>()) .AddSingleton<ICookieTester>(sp => sp.GetRequiredService<HttpApiClient>())
.AddSingleton<PolicyWrappedApiClient<HttpApiClient>>() .AddSingleton<PolicyWrappedApiClient<HttpApiClient>>()
.AddSingleton<IApiClient>(sp => sp.GetRequiredService<PolicyWrappedApiClient<HttpApiClient>>()) .AddSingleton<IApiClient>(sp => sp.GetRequiredService<PolicyWrappedApiClient<HttpApiClient>>())
.AddSingleton<IDanmakuServerApiClient>(sp => sp.GetRequiredService<PolicyWrappedApiClient<HttpApiClient>>()) .AddSingleton<IDanmakuServerApiClient>(sp => sp.GetRequiredService<PolicyWrappedApiClient<HttpApiClient>>())

View File

@ -40,10 +40,10 @@
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock TextWrapping="Wrap"> <TextBlock TextWrapping="Wrap">
建议使用小号;软件开发者不对账号发生的任何事情负责。<LineBreak/> 建议使用小号;软件开发者不对账号发生的任何事情负责。<LineBreak/>
账号 UID 是直接从 Cookie 中的 DedeUserID 读取的,连接弹幕服务器时会用。<LineBreak/> 账号 UID 和 BUVID3 是直接从 Cookie 中的 DedeUserID 和 buvid3 读取的,连接弹幕服务器时会用。<LineBreak/>
录播姬没有主动断开重连弹幕服务器的功能,如果你设置 Cookie 的目的是以登录状态连接弹幕服务器,建议修改设置后重启录播姬。<LineBreak/> 录播姬没有主动断开重连弹幕服务器的功能,如果你设置 Cookie 的目的是以登录状态连接弹幕服务器,建议修改设置后重启录播姬。<LineBreak/>
Alt account highly recommended; developers are not responsible for anything happened to your account.<LineBreak/> Alt account highly recommended; developers are not responsible for anything happened to your account.<LineBreak/>
Account UID is read directly from DedeUserID in Cookie, and will be used when connecting to the danmaku server.<LineBreak/> Account UID and BUVID3 is read directly from DedeUserID and buvid3 in Cookie, and will be used when connecting to the danmaku server.<LineBreak/>
BililiveRecorder does not have the ability to reconnect to the danmaku server. If you set Cookie to connect to the danmaku server in logged-in state, it is recommended to restart BililiveRecorder after changing the setting. BililiveRecorder does not have the ability to reconnect to the danmaku server. If you set Cookie to connect to the danmaku server in logged-in state, it is recommended to restart BililiveRecorder after changing the setting.
</TextBlock> </TextBlock>
<c:SettingWithDefault IsSettingNotUsingDefault="{Binding HasCookie}"> <c:SettingWithDefault IsSettingNotUsingDefault="{Binding HasCookie}">

View File

@ -17,10 +17,10 @@ namespace BililiveRecorder.WPF.Pages
public partial class AdvancedSettingsPage public partial class AdvancedSettingsPage
{ {
private static readonly ILogger logger = Log.ForContext<AdvancedSettingsPage>(); private static readonly ILogger logger = Log.ForContext<AdvancedSettingsPage>();
private readonly IHttpClientAccessor? httpApiClient; private readonly ICookieTester? httpApiClient;
private readonly UserScriptRunner? userScriptRunner; private readonly UserScriptRunner? userScriptRunner;
public AdvancedSettingsPage(IHttpClientAccessor? httpApiClient, UserScriptRunner? userScriptRunner) public AdvancedSettingsPage(ICookieTester? httpApiClient, UserScriptRunner? userScriptRunner)
{ {
this.InitializeComponent(); this.InitializeComponent();
this.httpApiClient = httpApiClient; this.httpApiClient = httpApiClient;
@ -29,7 +29,7 @@ namespace BililiveRecorder.WPF.Pages
public AdvancedSettingsPage() public AdvancedSettingsPage()
: this( : this(
(IHttpClientAccessor?)(RootPage.ServiceProvider?.GetService(typeof(IHttpClientAccessor))), (ICookieTester?)(RootPage.ServiceProvider?.GetService(typeof(ICookieTester))),
(UserScriptRunner?)(RootPage.ServiceProvider?.GetService(typeof(UserScriptRunner))) (UserScriptRunner?)(RootPage.ServiceProvider?.GetService(typeof(UserScriptRunner)))
) )
{ } { }
@ -66,29 +66,18 @@ namespace BililiveRecorder.WPF.Pages
private async Task TestCookieAsync() private async Task TestCookieAsync()
{ {
bool succeed;
string message;
if (this.httpApiClient is null) if (this.httpApiClient is null)
{ (succeed, message) = (false, "No Http Client Available");
MessageBox.Show("No Http Client Available", "Cookie Test - Failed", MessageBoxButton.OK, MessageBoxImage.Warning); else
return; (succeed, message) = await this.httpApiClient.TestCookieAsync().ConfigureAwait(false);
}
var resp = await this.httpApiClient.MainHttpClient.GetStringAsync("https://api.live.bilibili.com/xlive/web-ucenter/user/get_user_info").ConfigureAwait(false); if (succeed)
var jo = JObject.Parse(resp); MessageBox.Show(message, "Cookie Test - Succeed", MessageBoxButton.OK, MessageBoxImage.Information);
if (jo["code"]?.ToObject<int>() != 0) else
{ MessageBox.Show(message, "Cookie Test - Failed", MessageBoxButton.OK, MessageBoxImage.Warning);
MessageBox.Show("Response:\n" + resp, "Cookie Test - Failed", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var b = new StringBuilder();
b.Append("User: ");
b.Append(jo["data"]?["uname"]?.ToObject<string>());
b.Append("\nUID (from API response): ");
b.Append(jo["data"]?["uid"]?.ToObject<string>());
b.Append("\nUID (from Cookie): ");
b.Append(this.httpApiClient.GetUid());
MessageBox.Show(b.ToString(), "Cookie Test - Successed", MessageBoxButton.OK, MessageBoxImage.Information);
} }
private void TestScript_Click(object sender, RoutedEventArgs e) private void TestScript_Click(object sender, RoutedEventArgs e)

View File

@ -1,10 +1,9 @@
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("BililiveRecorder.Core.UnitTests")] [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("BililiveRecorder.Core.UnitTests")]
namespace BililiveRecorder.Core.Api namespace BililiveRecorder.Core.Api
{ {
public interface IHttpClientAccessor public interface ICookieTester
{ {
System.Net.Http.HttpClient MainHttpClient { get; } System.Threading.Tasks.Task<System.ValueTuple<bool, string>> TestCookieAsync();
long GetUid();
} }
} }
namespace BililiveRecorder.Core.Config namespace BililiveRecorder.Core.Config