feat: add fnOS packaging, storage workflows and release pipeline

This commit is contained in:
2026-08-11 18:05:49 +08:00
parent c5922f9b08
commit 95932f0199
181 changed files with 24024 additions and 1164 deletions
+217
View File
@@ -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; }
}
}
}