218 lines
9.1 KiB
C#
218 lines
9.1 KiB
C#
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<List<DouyinFollowCandidate>> 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<SearchResponse>(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<SearchUserEntry>())
|
||
.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<KeyValuePair<string, string>> BuildParameters(string douyinNo, string cookie)
|
||
{
|
||
var cookies = ParseCookie(cookie);
|
||
return new List<KeyValuePair<string, string>>
|
||
{
|
||
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<string, string> ParseCookie(string cookie)
|
||
{
|
||
var values = new Dictionary<string, string>(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<string, string> 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<KeyValuePair<string, string>> 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<SearchUserEntry> 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<string> Urls { get; set; }
|
||
}
|
||
}
|
||
}
|