using System.Net; using dy.net.model.dto; using dy.net.utils; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace dy.net.service { public interface IDouyinLiveStatusClient { Task ProbeAsync( string secUid, string cookie, CancellationToken cancellationToken = default); } public sealed class DouyinLiveStatusRequestException : InvalidOperationException { public DouyinLiveStatusRequestException( string message, HttpStatusCode? statusCode = null, bool requiresAccountCooldown = false, Exception innerException = null) : base(message, innerException) { StatusCode = statusCode; RequiresAccountCooldown = requiresAccountCooldown; } public HttpStatusCode? StatusCode { get; } public bool RequiresAccountCooldown { get; } } public sealed class DouyinLiveStatusClient : IDouyinLiveStatusClient { private const string ProfilePath = "/aweme/v1/web/user/profile/other/"; private readonly IHttpClientFactory _clientFactory; private readonly DouyinABogusSigner _signer = new(); public DouyinLiveStatusClient(IHttpClientFactory clientFactory) => _clientFactory = clientFactory; public async Task ProbeAsync( string secUid, string cookie, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(secUid)) throw new InvalidOperationException("博主 SecUid 为空,无法检查直播状态"); if (string.IsNullOrWhiteSpace(cookie)) throw new InvalidOperationException("授权 Cookie 为空,无法检查直播状态"); var parameters = BuildParameters(secUid.Trim(), cookie); var unsignedQuery = BuildQueryString(parameters); var requestUri = ProfilePath + "?" + unsignedQuery + "&a_bogus=" + Encode(_signer.Sign(unsignedQuery)); using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); request.Headers.Referrer = new Uri("https://www.douyin.com/user/" + Encode(secUid.Trim())); request.Headers.TryAddWithoutValidation("Accept", "application/json, text/plain, */*"); request.Headers.TryAddWithoutValidation("Cookie", cookie); var client = _clientFactory.CreateClient(DouyinRequestParamManager.DY_HTTP_CLIENT); try { using var response = await client.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (!response.IsSuccessStatusCode) { var shouldCooldown = response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.TooManyRequests or HttpStatusCode.Unauthorized; throw new DouyinLiveStatusRequestException( $"抖音直播状态请求失败(HTTP {(int)response.StatusCode})", response.StatusCode, shouldCooldown); } var content = await response.Content.ReadAsStringAsync(cancellationToken); UserProfileResponse payload; try { payload = JsonConvert.DeserializeObject(content); } catch (JsonException ex) { throw new DouyinLiveStatusRequestException( "抖音返回了无法识别的直播状态响应,可能需要验证授权", requiresAccountCooldown: true, innerException: ex); } if (payload?.User == null) throw new DouyinLiveStatusRequestException( "抖音没有返回博主资料,可能需要验证授权", requiresAccountCooldown: true); if (payload.StatusCode != 0) throw new DouyinLiveStatusRequestException( $"抖音拒绝了直播状态查询(状态码 {payload.StatusCode})", requiresAccountCooldown: true); return DouyinLiveStatusParser.Parse( payload.User.LiveStatus, payload.User.RoomId, payload.User.RoomIdStr, payload.User.RoomData); } catch (DouyinLiveStatusRequestException) { throw; } catch (HttpRequestException ex) { throw new DouyinLiveStatusRequestException("抖音直播状态网络请求失败,请稍后重试", innerException: ex); } catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) { throw new DouyinLiveStatusRequestException("抖音直播状态查询超时,请稍后重试", innerException: ex); } } private static List> BuildParameters(string secUid, string cookie) { var cookies = ParseCookie(cookie); return new List> { new("device_platform", "webapp"), new("aid", "6383"), new("channel", "channel_pc_web"), new("update_version_code", "170400"), new("pc_client_type", "1"), new("pc_libra_divert", "Windows"), new("support_h265", "1"), new("support_dash", "1"), new("version_code", "170400"), new("version_name", "17.4.0"), new("cookie_enabled", "true"), new("screen_width", "1536"), new("screen_height", "864"), new("browser_language", "zh-CN"), new("browser_platform", "Win32"), new("browser_name", "Chrome"), new("browser_version", "119.0.0.0"), new("browser_online", "true"), new("engine_name", "Blink"), new("engine_version", "119.0.0.0"), new("os_name", "Windows"), new("os_version", "10"), new("cpu_core_num", "16"), new("device_memory", "8"), new("platform", "PC"), new("downlink", "10"), new("effective_type", "4g"), new("round_trip_time", "200"), new("uifid", CookieValue(cookies, "UIFID", "UIFID_TEMP")), new("msToken", CookieValue(cookies, "msToken")), new("sec_user_id", secUid), new("publish_video_strategy_type", "2"), new("personal_center_strategy", "1") }; } private static Dictionary ParseCookie(string cookie) { var values = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var part in (cookie ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries)) { var separator = part.IndexOf('='); if (separator <= 0) continue; values[part[..separator].Trim()] = part[(separator + 1)..].Trim(); } return values; } private static string CookieValue(Dictionary cookies, params string[] names) { foreach (var name in names) if (cookies.TryGetValue(name, out var value)) return value; return string.Empty; } private static string BuildQueryString(IEnumerable> parameters) => string.Join("&", parameters.Select(x => Encode(x.Key) + "=" + Encode(x.Value ?? string.Empty))); private static string Encode(string value) => Uri.EscapeDataString(value ?? string.Empty); private sealed class UserProfileResponse { [JsonProperty("status_code")] public int StatusCode { get; set; } [JsonProperty("user")] public UserProfile User { get; set; } } private sealed class UserProfile { [JsonProperty("live_status")] public int? LiveStatus { get; set; } [JsonProperty("room_id")] public string RoomId { get; set; } [JsonProperty("room_id_str")] public string RoomIdStr { get; set; } [JsonProperty("room_data")] public JToken RoomData { get; set; } } } }