using dy.net.model.dto; using dy.net.utils; using Newtonsoft.Json; namespace dy.net.service { public sealed class DouyinUserSearchClient { private const string SearchPath = "/aweme/v1/web/discover/search/"; private readonly IHttpClientFactory _clientFactory; private readonly DouyinABogusSigner _signer = new(); public DouyinUserSearchClient(IHttpClientFactory clientFactory) => _clientFactory = clientFactory; public async Task> SearchAsync( string douyinNo, string cookie, CancellationToken cancellationToken = default) { var parameters = BuildParameters(douyinNo, cookie); var unsignedQuery = BuildQueryString(parameters); var signature = _signer.Sign(unsignedQuery); var requestUri = SearchPath + "?" + unsignedQuery + "&a_bogus=" + Encode(signature); using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); request.Headers.Referrer = new Uri("https://www.douyin.com/root/search/" + Encode(douyinNo) + "?type=user"); request.Headers.TryAddWithoutValidation("Accept", "*/*"); 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) throw new InvalidOperationException($"抖音用户搜索请求失败(HTTP {(int)response.StatusCode})"); var content = await response.Content.ReadAsStringAsync(cancellationToken); SearchResponse payload; try { payload = JsonConvert.DeserializeObject(content); } catch (JsonException) { throw new InvalidOperationException("抖音返回了风控验证页面,请稍后重试或使用手动添加"); } if (payload == null) throw new InvalidOperationException("抖音用户搜索没有返回有效数据"); if (payload.StatusCode != 0) throw new InvalidOperationException("抖音用户搜索被拒绝,请确认 Cookie 有效后重试"); return (payload.Users ?? new List()) .Select(x => x.User) .Where(x => x != null && !string.IsNullOrWhiteSpace(x.SecUid) && !string.IsNullOrWhiteSpace(x.Uid) && !string.IsNullOrWhiteSpace(x.Nickname)) .Select(x => new DouyinFollowCandidate { SecUid = x.SecUid, UperId = x.Uid, UniqueId = x.UniqueId, ShortId = x.ShortId, DouyinNo = !string.IsNullOrWhiteSpace(x.UniqueId) ? x.UniqueId : x.ShortId, UperName = x.Nickname, UperAvatar = x.Avatar?.Urls?.FirstOrDefault() ?? string.Empty, Signature = x.Signature, Enterprise = !string.IsNullOrWhiteSpace(x.Enterprise) ? x.Enterprise : x.CustomVerify, FollowerCount = x.FollowerCount }) .GroupBy(x => x.SecUid, StringComparer.Ordinal) .Select(x => x.First()) .Take(10) .ToList(); } catch (HttpRequestException) { // 不向上抛出包含查询 URI 的原始异常,避免 msToken 出现在全局日志中。 throw new InvalidOperationException("抖音用户搜索网络请求失败,请稍后重试"); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { throw new InvalidOperationException("抖音用户搜索超时,请稍后重试"); } } private static List> BuildParameters(string douyinNo, 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("pc_search_top_1_params", "{\"enable_ai_search_top_1\":1}"), new("search_channel", "aweme_user_web"), new("keyword", douyinNo), new("search_source", "switch_tab"), new("query_correct_type", "1"), new("is_filter_search", "0"), new("from_group_id", string.Empty), new("disable_rs", "0"), new("offset", "0"), new("count", "10"), new("need_filter_settings", "0"), new("list_type", "single") }; } 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 SearchResponse { [JsonProperty("status_code")] public int StatusCode { get; set; } [JsonProperty("user_list")] public List Users { get; set; } } private sealed class SearchUserEntry { [JsonProperty("user_info")] public SearchUser User { get; set; } } private sealed class SearchUser { [JsonProperty("sec_uid")] public string SecUid { get; set; } [JsonProperty("uid")] public string Uid { get; set; } [JsonProperty("unique_id")] public string UniqueId { get; set; } [JsonProperty("short_id")] public string ShortId { get; set; } [JsonProperty("nickname")] public string Nickname { get; set; } [JsonProperty("signature")] public string Signature { get; set; } [JsonProperty("enterprise_verify_reason")] public string Enterprise { get; set; } [JsonProperty("custom_verify")] public string CustomVerify { get; set; } [JsonProperty("follower_count")] public long FollowerCount { get; set; } [JsonProperty("avatar_thumb")] public SearchAvatar Avatar { get; set; } } private sealed class SearchAvatar { [JsonProperty("url_list")] public List Urls { get; set; } } } }