= 0 ? html[startIndex..] : html;
var maxCount = Math.Max(1, maxItems);
var seenUrls = new HashSet
(StringComparer.OrdinalIgnoreCase);
foreach (Match match in CnrListAnchorRegex.Matches(scope))
{
var normalizedUrl = ResolveAbsoluteUrl(match.Groups["url"].Value, listPageUri);
if (string.IsNullOrWhiteSpace(normalizedUrl) || !seenUrls.Add(normalizedUrl))
{
continue;
}
var inner = match.Groups["inner"].Value;
var title = ExtractTagInnerText(inner, "strong");
if (string.IsNullOrWhiteSpace(title))
{
continue;
}
var summary = ExtractTagInnerText(inner, "em");
var publishTime = ExtractTagInnerTextByClass(inner, "span", "publishTime");
var imageUrl = ExtractFirstImageUrl(inner, listPageUri);
yield return new DailyNewsItemSnapshot(
Title: title,
Summary: summary,
Url: normalizedUrl,
ImageUrl: imageUrl,
PublishTime: publishTime);
if (seenUrls.Count >= maxCount)
{
yield break;
}
}
}
private static string? ExtractTagInnerText(string htmlFragment, string tagName)
{
if (string.IsNullOrWhiteSpace(htmlFragment) || string.IsNullOrWhiteSpace(tagName))
{
return null;
}
var match = Regex.Match(
htmlFragment,
$"<{tagName}[^>]*>(?.*?){tagName}>",
RegexOptions.IgnoreCase | RegexOptions.Singleline);
if (!match.Success)
{
return null;
}
return NormalizeInlineText(match.Groups["value"].Value);
}
private static string? ExtractTagInnerTextByClass(string htmlFragment, string tagName, string className)
{
if (string.IsNullOrWhiteSpace(htmlFragment) ||
string.IsNullOrWhiteSpace(tagName) ||
string.IsNullOrWhiteSpace(className))
{
return null;
}
var match = Regex.Match(
htmlFragment,
$"<{tagName}[^>]*class=\"[^\"]*{Regex.Escape(className)}[^\"]*\"[^>]*>(?.*?){tagName}>",
RegexOptions.IgnoreCase | RegexOptions.Singleline);
if (!match.Success)
{
return null;
}
return NormalizeInlineText(match.Groups["value"].Value);
}
private static string? ExtractFirstImageUrl(string htmlFragment, Uri pageUri)
{
if (string.IsNullOrWhiteSpace(htmlFragment))
{
return null;
}
var matches = HtmlImageTagRegex.Matches(htmlFragment);
foreach (Match match in matches)
{
var normalized = ResolveAbsoluteUrl(match.Groups["url"].Value, pageUri);
if (IsLikelyContentImageUrl(normalized))
{
return normalized;
}
}
return null;
}
private async Task TryFetchArticleCoverImageAsync(string articleUrl, CancellationToken cancellationToken)
{
if (!Uri.TryCreate(articleUrl, UriKind.Absolute, out var articleUri))
{
return null;
}
var html = await FetchHtmlWithCnrEncodingAsync(articleUrl, cancellationToken);
var metaMatches = new[]
{
Regex.Match(
html,
"]+property=\"og:image\"[^>]+content=\"(?[^\"]+)\"",
RegexOptions.IgnoreCase),
Regex.Match(
html,
"]+name=\"image\"[^>]+content=\"(?[^\"]+)\"",
RegexOptions.IgnoreCase)
};
foreach (var metaMatch in metaMatches)
{
if (!metaMatch.Success)
{
continue;
}
var metaUrl = ResolveAbsoluteUrl(metaMatch.Groups["url"].Value, articleUri);
if (IsLikelyContentImageUrl(metaUrl))
{
return metaUrl;
}
}
var imageMatches = Regex.Matches(
html,
"
]+src=\"(?[^\"]+)\"",
RegexOptions.IgnoreCase | RegexOptions.Singleline);
foreach (Match imageMatch in imageMatches)
{
var normalized = ResolveAbsoluteUrl(imageMatch.Groups["url"].Value, articleUri);
if (IsLikelyContentImageUrl(normalized))
{
return normalized;
}
}
return null;
}
private static bool IsLikelyContentImageUrl(string? imageUrl)
{
if (string.IsNullOrWhiteSpace(imageUrl))
{
return false;
}
var value = imageUrl.Trim();
if (!(value.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
value.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ||
value.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ||
value.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) ||
value.EndsWith(".avif", StringComparison.OrdinalIgnoreCase)))
{
return false;
}
return !(value.Contains("share", StringComparison.OrdinalIgnoreCase) ||
value.Contains("logo", StringComparison.OrdinalIgnoreCase) ||
value.Contains("code.png", StringComparison.OrdinalIgnoreCase));
}
private static string? ResolveAbsoluteUrl(string? rawUrl, Uri baseUri)
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
return null;
}
var candidate = rawUrl.Trim();
if (candidate.Contains("'+", StringComparison.Ordinal) ||
candidate.Contains("+'", StringComparison.Ordinal))
{
return null;
}
if (candidate.StartsWith("//", StringComparison.Ordinal))
{
return $"{baseUri.Scheme}:{candidate}";
}
if (Uri.TryCreate(candidate, UriKind.Absolute, out var absoluteUri))
{
if (!string.Equals(absoluteUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(absoluteUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return absoluteUri.ToString();
}
return Uri.TryCreate(baseUri, candidate, out var relativeUri)
? relativeUri.ToString()
: null;
}
private static bool IsSmartTeachPinnedDiscussion(JsonElement discussionNode)
{
return ReadBoolean(discussionNode, "attributes", "isStickiest") ||
ReadBoolean(discussionNode, "attributes", "isSticky") ||
ReadBoolean(discussionNode, "attributes", "isTagSticky") ||
ReadBoolean(discussionNode, "attributes", "front") ||
ReadBoolean(discussionNode, "attributes", "frontpage");
}
private static string? ResolveSmartTeachForumUrl(string? rawUrl, string? baseUrl)
{
var normalizedAbsolute = NormalizeHttpUrl(rawUrl);
if (!string.IsNullOrWhiteSpace(normalizedAbsolute))
{
return normalizedAbsolute;
}
if (string.IsNullOrWhiteSpace(rawUrl) || string.IsNullOrWhiteSpace(baseUrl))
{
return null;
}
if (!Uri.TryCreate(baseUrl.Trim(), UriKind.Absolute, out var baseUri))
{
return null;
}
var normalized = ResolveAbsoluteUrl(rawUrl, baseUri);
return NormalizeHttpUrl(normalized);
}
private static string? BuildSmartTeachDiscussionUrl(string? baseUrl, string discussionId, string slug)
{
if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(discussionId))
{
return null;
}
if (!Uri.TryCreate(baseUrl.Trim(), UriKind.Absolute, out var baseUri))
{
return null;
}
var normalizedId = discussionId.Trim();
var normalizedSlug = slug.Trim();
string path;
if (string.IsNullOrWhiteSpace(normalizedSlug))
{
path = $"/d/{normalizedId}";
}
else if (normalizedSlug.StartsWith($"{normalizedId}-", StringComparison.OrdinalIgnoreCase))
{
path = $"/d/{normalizedSlug}";
}
else
{
path = $"/d/{normalizedId}-{normalizedSlug}";
}
return new Uri(baseUri, path).ToString();
}
private static DateTimeOffset? TryParseDateTimeOffset(string? rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return null;
}
return DateTimeOffset.TryParse(
rawValue,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var value)
? value
: null;
}
private static long? TryParseSmartTeachDiscussionId(string? rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return null;
}
return long.TryParse(rawValue.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
? value
: null;
}
private static (string Title, long? HeatScore) ParseBaiduHotSearchTitle(string? rawTitle)
{
var normalized = NormalizeInlineText(rawTitle);
if (string.IsNullOrWhiteSpace(normalized))
{
return (string.Empty, null);
}
var match = BaiduHotSearchHeatRegex.Match(normalized);
if (!match.Success)
{
return (normalized, null);
}
var title = NormalizeInlineText(match.Groups["keyword"].Value);
if (string.IsNullOrWhiteSpace(title))
{
title = normalized;
}
var heatScoreText = match.Groups["heat"].Value;
if (long.TryParse(heatScoreText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var heatScore))
{
return (title, heatScore);
}
return (title, null);
}
private static string NormalizeInlineText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
var decoded = WebUtility.HtmlDecode(text);
var withoutTags = HtmlTagRegex.Replace(decoded ?? string.Empty, " ");
return Regex.Replace(withoutTags, "\\s+", " ").Trim();
}
private static string? ReadString(JsonElement node, params string[] path)
{
var target = TryGetNode(node, path);
if (!target.HasValue)
{
return null;
}
return target.Value.ValueKind switch
{
JsonValueKind.String => target.Value.GetString(),
JsonValueKind.Number => target.Value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null
};
}
private static bool ReadBoolean(JsonElement node, params string[] path)
{
var target = TryGetNode(node, path);
if (!target.HasValue)
{
return false;
}
var value = target.Value;
switch (value.ValueKind)
{
case JsonValueKind.True:
return true;
case JsonValueKind.False:
return false;
case JsonValueKind.String:
return bool.TryParse(value.GetString(), out var boolValue) && boolValue;
case JsonValueKind.Number:
return value.TryGetInt32(out var intValue) && intValue != 0;
default:
return false;
}
}
private static JsonElement? TryGetNode(JsonElement node, params string[] path)
{
var current = node;
foreach (var segment in path)
{
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next))
{
return null;
}
current = next;
}
return current;
}
private string? BuildArtworkImageUrl(string? imageId)
{
if (string.IsNullOrWhiteSpace(imageId))
{
return null;
}
return string.Format(
CultureInfo.InvariantCulture,
_options.ArtInstituteImageUrlTemplate,
imageId.Trim());
}
private string ResolveArtworkMirrorSource(DailyArtworkQuery query)
{
if (!string.IsNullOrWhiteSpace(query.MirrorSource))
{
return DailyArtworkMirrorSources.Normalize(query.MirrorSource);
}
try
{
var snapshot = _componentSettingsService.Load();
return DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
}
catch
{
return DailyArtworkMirrorSources.Overseas;
}
}
private async Task FetchOverseasArtworkPayloadAsync(DateOnly localDate, CancellationToken cancellationToken)
{
var candidateCount = Math.Clamp(_options.DefaultArtworkCandidateCount, 10, 100);
var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100);
var requestUrl = string.Format(
CultureInfo.InvariantCulture,
_options.ArtInstituteArtworkApiTemplate,
page,
candidateCount);
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
using var response = await _httpClient.SendAsync(request, cancellationToken);
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
}
return responseText;
}
private static string? BuildDomesticImageUrl(string? rawValue, string fallbackHost)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return null;
}
var candidate = rawValue.Trim();
if (Uri.TryCreate(candidate, UriKind.Absolute, out var absoluteUri))
{
return absoluteUri.ToString();
}
if (!Uri.TryCreate(fallbackHost, UriKind.Absolute, out var hostUri))
{
return null;
}
var normalizedPath = candidate.StartsWith("/", StringComparison.Ordinal) ? candidate : $"/{candidate}";
return new Uri(hostUri, normalizedPath).ToString();
}
private static string ExtractDomesticTitle(string? copyrightText)
{
if (string.IsNullOrWhiteSpace(copyrightText))
{
return string.Empty;
}
var compact = copyrightText.Trim();
var bracketIndex = compact.IndexOf('(');
if (bracketIndex <= 0)
{
return compact;
}
return compact[..bracketIndex].Trim();
}
private static string? ParseDomesticDateText(string? rawDate)
{
if (string.IsNullOrWhiteSpace(rawDate) || rawDate.Length < 8)
{
return null;
}
if (DateTime.TryParseExact(
rawDate[..8],
"yyyyMMdd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var date))
{
return date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
return null;
}
private static string? ReadFirstNonEmptyLine(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return text
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim())
.FirstOrDefault(line => !string.IsNullOrWhiteSpace(line));
}
private static DateOnly GetChinaLocalDate()
{
var now = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8));
return DateOnly.FromDateTime(now.Date);
}
private static string? NormalizeHttpUrl(string? rawUrl)
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
return null;
}
var candidate = rawUrl.Trim();
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
{
return null;
}
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return uri.ToString();
}
private static string Truncate(string? text, int maxLength)
{
if (string.IsNullOrEmpty(text))
{
return string.Empty;
}
return text.Length <= maxLength
? text
: $"{text[..maxLength]}...";
}
// 智教Hub相关方法
public async Task> GetZhiJiaoHubImagesAsync(
ZhiJiaoHubQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new ZhiJiaoHubQuery();
var source = ZhiJiaoHubSources.Normalize(normalizedQuery.Source);
var mirrorSource = ZhiJiaoHubMirrorSources.Normalize(normalizedQuery.MirrorSource);
var cacheKey = $"{source}|{mirrorSource}";
if (!normalizedQuery.ForceRefresh && TryGetZhiJiaoHubFromCache(cacheKey, out var cached))
{
return RecommendationQueryResult.Ok(cached);
}
try
{
var snapshot = await FetchZhiJiaoHubSnapshotAsync(source, mirrorSource, cancellationToken);
SetZhiJiaoHubCache(cacheKey, snapshot);
return RecommendationQueryResult.Ok(snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (HttpRequestException ex)
{
return RecommendationQueryResult.Fail("upstream_network_error", ex.Message);
}
catch (Exception ex)
{
return RecommendationQueryResult.Fail("upstream_parse_error", ex.Message);
}
}
private async Task FetchZhiJiaoHubSnapshotAsync(string source, string mirrorSource, CancellationToken cancellationToken)
{
var config = ZhiJiaoHubSourceConfig.GetConfig(source);
try
{
List images;
// 如果使用JSON索引模式(Rin's Hub)
if (config.UseJsonIndex && !string.IsNullOrEmpty(config.JsonIndexUrl))
{
images = await FetchImagesFromJsonIndex(config, mirrorSource, cancellationToken);
}
else
{
// 标准模式(ClassIsland/SECTL)
var contentsUrl = config.ApiUrl;
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
{
contentsUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + contentsUrl;
}
images = await FetchImagesFromContentsApi(config, contentsUrl, mirrorSource, cancellationToken);
}
if (images.Count == 0)
{
throw new InvalidOperationException($"在 {config.DisplayName} 中未找到图片文件");
}
var random = new Random();
var shuffled = images.OrderBy(_ => random.Next()).ToList();
for (int i = 0; i < shuffled.Count; i++)
{
var item = shuffled[i];
shuffled[i] = item with { Index = i };
}
return new ZhiJiaoHubSnapshot(shuffled, 0, source);
}
catch (HttpRequestException ex) when (ex.Message.Contains("403") || ex.Message.Contains("rate limit"))
{
throw new HttpRequestException("GitHub API 速率限制,请稍后重试");
}
catch (Exception ex)
{
throw new HttpRequestException($"从 {config.DisplayName} 获取图片列表失败: {ex.Message}");
}
}
private async Task> FetchImagesFromContentsApi(
ZhiJiaoHubSourceConfig config,
string contentsUrl,
string mirrorSource,
CancellationToken cancellationToken)
{
var images = new List();
using var request = new HttpRequestMessage(HttpMethod.Get, contentsUrl);
request.Headers.TryAddWithoutValidation("User-Agent", "LanMountainDesktop/1.0");
request.Headers.TryAddWithoutValidation("Accept", "application/vnd.github+json");
request.Headers.TryAddWithoutValidation("X-GitHub-Api-Version", "2022-11-28");
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorText = await response.Content.ReadAsStringAsync(cancellationToken);
if ((int)response.StatusCode == 403)
{
throw new HttpRequestException("GitHub API 速率限制,请稍后重试");
}
if ((int)response.StatusCode == 404)
{
throw new HttpRequestException(
$"在 {config.DisplayName} 中找不到图片目录。请检查仓库结构和路径配置。\n" +
$"仓库: {config.Owner}/{config.Repo}\n" +
$"路径: {config.Path}");
}
throw new HttpRequestException(
$"从 {config.DisplayName} 获取数据失败: {(int)response.StatusCode} - {Truncate(errorText, 200)}");
}
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Array)
{
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("message", out var messageNode))
{
var errorMessage = messageNode.GetString();
throw new InvalidOperationException($"GitHub API 错误 ({config.DisplayName}): {errorMessage}");
}
throw new InvalidOperationException($"从 {config.DisplayName} 返回的数据格式无效");
}
int index = 0;
foreach (var item in root.EnumerateArray())
{
var type = ReadString(item, "type");
if (type != "file")
{
continue;
}
var name = ReadString(item, "name");
var downloadUrl = ReadString(item, "download_url");
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
var extension = Path.GetExtension(name).ToLowerInvariant();
if (extension != ".png" && extension != ".jpg" && extension != ".jpeg" && extension != ".gif" && extension != ".webp")
{
continue;
}
var decodedName = Uri.UnescapeDataString(name);
decodedName = Path.GetFileNameWithoutExtension(decodedName);
string imageUrl;
if (!string.IsNullOrWhiteSpace(downloadUrl))
{
imageUrl = downloadUrl;
}
else
{
imageUrl = string.Format(
CultureInfo.InvariantCulture,
config.RawUrlTemplate,
Uri.EscapeDataString(name));
}
imageUrl = ZhiJiaoHubMirrorSources.ApplyMirror(imageUrl, mirrorSource);
images.Add(new ZhiJiaoHubImageItem(decodedName, imageUrl, index));
index++;
}
return images;
}
///
/// 从JSON索引文件获取图片列表(Rin's Hub专用)
///
private async Task> FetchImagesFromJsonIndex(
ZhiJiaoHubSourceConfig config,
string mirrorSource,
CancellationToken cancellationToken)
{
var images = new List();
// 下载JSON索引文件
var jsonUrl = config.JsonIndexUrl!;
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
{
jsonUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + jsonUrl;
}
using var request = new HttpRequestMessage(HttpMethod.Get, jsonUrl);
request.Headers.TryAddWithoutValidation("User-Agent", "LanMountainDesktop/1.0");
using var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
var jsonText = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(jsonText);
var root = document.RootElement;
// 解析 hub_items 数组
if (!root.TryGetProperty("hub_items", out var hubItems) || hubItems.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException($"JSON索引文件格式无效:缺少 hub_items 数组");
}
int index = 0;
foreach (var item in hubItems.EnumerateArray())
{
// 获取图片路径
if (!item.TryGetProperty("image", out var imageProp))
{
continue;
}
var imagePath = imageProp.GetString();
if (string.IsNullOrWhiteSpace(imagePath))
{
continue;
}
// 获取标题(用于显示名称)
string title = string.Empty;
if (item.TryGetProperty("title", out var titleProp))
{
title = titleProp.GetString() ?? string.Empty;
}
// 如果没有标题,使用文件名
if (string.IsNullOrWhiteSpace(title))
{
title = Path.GetFileNameWithoutExtension(imagePath);
}
// 构建完整的图片URL
// imagePath 格式如: "Discord/姐姐好香.png"
// 需要拼接为: https://raw.githubusercontent.com/.../updates/images/Discord/姐姐好香.png
// 并对路径中的每个部分进行URL编码
var pathParts = imagePath.Split('/');
var encodedPath = string.Join("/", pathParts.Select(part => Uri.EscapeDataString(part)));
var imageUrl = $"https://raw.githubusercontent.com/{config.Owner}/{config.Repo}/main/{config.Path}/{encodedPath}";
// 应用镜像加速
imageUrl = ZhiJiaoHubMirrorSources.ApplyMirror(imageUrl, mirrorSource);
images.Add(new ZhiJiaoHubImageItem(title, imageUrl, index));
index++;
}
return images;
}
private bool TryGetZhiJiaoHubFromCache(string cacheKey, out ZhiJiaoHubSnapshot snapshot)
{
lock (_cacheGate)
{
if (_zhiJiaoHubCacheBySource.TryGetValue(cacheKey, out var cacheEntry) &&
cacheEntry.ExpireAt > DateTimeOffset.UtcNow)
{
snapshot = cacheEntry.Snapshot;
return true;
}
}
snapshot = null!;
return false;
}
private void SetZhiJiaoHubCache(string cacheKey, ZhiJiaoHubSnapshot snapshot)
{
lock (_cacheGate)
{
// 使用较长的缓存时间(1小时),因为图片列表不常变化
_zhiJiaoHubCacheBySource[cacheKey] = new ZhiJiaoHubCacheEntry(
snapshot,
DateTimeOffset.UtcNow.Add(TimeSpan.FromHours(1)));
}
}
private readonly ZhiJiaoHubCacheService _zhiJiaoHubCacheService = new();
public async Task SyncZhiJiaoHubImagesAsync(
string source,
string mirrorSource,
IProgress<(int Current, int Total, string Status)>? progress = null,
CancellationToken cancellationToken = default)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource);
try
{
var query = new ZhiJiaoHubQuery(normalizedSource, ForceRefresh: true, MirrorSource: normalizedMirror);
var result = await GetZhiJiaoHubImagesAsync(query, cancellationToken);
if (!result.Success || result.Data == null)
{
return new ZhiJiaoHubSyncResult(
false,
null,
0,
0,
0,
result.ErrorMessage ?? "Failed to fetch image list");
}
return await _zhiJiaoHubCacheService.SyncImagesAsync(
normalizedSource,
result.Data.Images,
normalizedMirror,
progress,
cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new ZhiJiaoHubSyncResult(false, null, 0, 0, 0, ex.Message);
}
}
public ZhiJiaoHubLocalSnapshot? LoadZhiJiaoHubLocalSnapshot(string source)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
return _zhiJiaoHubCacheService.LoadLocalSnapshot(normalizedSource);
}
public bool HasZhiJiaoHubLocalCache(string source)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
return _zhiJiaoHubCacheService.HasLocalCache(normalizedSource);
}
public async Task> GetZhiJiaoHubHybridImagesAsync(
string source,
string mirrorSource,
CancellationToken cancellationToken = default)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource);
var localPathMap = _zhiJiaoHubCacheService.LoadLocalPathMap(normalizedSource);
try
{
var query = new ZhiJiaoHubQuery(normalizedSource, ForceRefresh: true, MirrorSource: normalizedMirror);
var result = await GetZhiJiaoHubImagesAsync(query, cancellationToken);
if (!result.Success || result.Data == null)
{
return RecommendationQueryResult.Fail(
result.ErrorCode ?? "upstream_error",
result.ErrorMessage ?? "Failed to fetch image list");
}
var hybridImages = result.Data.Images.Select((img, idx) =>
{
var hasLocal = localPathMap.TryGetValue(img.Url, out var localPath);
return new ZhiJiaoHubHybridImageItem(
img.Name,
img.Url,
hasLocal ? localPath : null,
idx,
hasLocal);
}).ToList();
var snapshot = new ZhiJiaoHubHybridSnapshot(
hybridImages,
normalizedSource,
hybridImages.Count(i => i.IsCached),
hybridImages.Count);
return RecommendationQueryResult.Ok(snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult.Fail("upstream_network_error", ex.Message);
}
}
public async Task DownloadAndCacheImageAsync(
string source,
ZhiJiaoHubImageItem image,
string mirrorSource,
CancellationToken cancellationToken = default)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource);
return await _zhiJiaoHubCacheService.DownloadAndSaveImageAsync(
normalizedSource,
image.Name,
image.Url,
normalizedMirror,
cancellationToken);
}
public Task StartBackgroundDownloadAsync(
string source,
IReadOnlyList images,
string mirrorSource,
Action? onProgress = null,
CancellationToken cancellationToken = default)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource);
return Task.Run(async () =>
{
var uncachedImages = images.Where(i => !i.IsCached).ToList();
var total = uncachedImages.Count;
var downloaded = 0;
foreach (var image in uncachedImages)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
try
{
var localPath = await _zhiJiaoHubCacheService.DownloadAndSaveImageAsync(
normalizedSource,
image.Name,
image.RemoteUrl,
normalizedMirror,
cancellationToken);
if (localPath != null)
{
downloaded++;
}
onProgress?.Invoke(downloaded, total, image.Name);
}
catch
{
}
}
}, cancellationToken);
}
}