feat: add fnOS packaging, storage workflows and release pipeline
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user