修复
This commit is contained in:
lincube
2026-03-05 12:34:39 +08:00
parent 00694e715f
commit 469f7e1132
46 changed files with 1535 additions and 344 deletions

View File

@@ -12,6 +12,8 @@ namespace LanMountainDesktop.Services;
public sealed class RecommendationDataService : IRecommendationInfoService, IDisposable
{
private const string UserAgent = "Mozilla/5.0";
private sealed record DailyArtworkCacheEntry(DailyArtworkSnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record DailyPoetryCacheEntry(DailyPoetrySnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record ArtworkCandidate(
@@ -19,13 +21,16 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
string? Artist,
string? Year,
string? ArtworkUrl,
string? ImageId);
string? ImageId,
string? ThumbnailDataUrl);
private readonly RecommendationApiOptions _options;
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
private readonly AppSettingsService _appSettingsService = new();
private readonly object _cacheGate = new();
private DailyArtworkCacheEntry? _dailyArtworkCache;
private readonly Dictionary<string, DailyArtworkCacheEntry> _dailyArtworkCacheBySource =
new(StringComparer.OrdinalIgnoreCase);
private DailyPoetryCacheEntry? _dailyPoetryCache;
public RecommendationDataService(
@@ -60,7 +65,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
{
lock (_cacheGate)
{
_dailyArtworkCache = null;
_dailyArtworkCacheBySource.Clear();
_dailyPoetryCache = null;
}
}
@@ -79,7 +84,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, _options.JinriShiciPoetryUrl);
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
using var response = await _httpClient.SendAsync(request, cancellationToken);
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
@@ -132,45 +137,25 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new DailyArtworkQuery();
if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(out var cached))
var mirrorSource = ResolveArtworkMirrorSource(normalizedQuery);
if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(mirrorSource, out var cached))
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(cached);
}
var candidateCount = Math.Clamp(_options.DefaultArtworkCandidateCount, 10, 100);
return string.Equals(mirrorSource, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase)
? await GetDailyArtworkFromDomesticSourceAsync(mirrorSource, cancellationToken)
: await GetDailyArtworkFromOverseasSourceAsync(mirrorSource, cancellationToken);
}
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkFromOverseasSourceAsync(
string mirrorSource,
CancellationToken cancellationToken)
{
var localDate = GetChinaLocalDate();
var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100);
var requestUrl = string.Format(
CultureInfo.InvariantCulture,
_options.ArtInstituteArtworkApiTemplate,
page,
candidateCount);
string responseText;
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
using var response = await _httpClient.SendAsync(request, cancellationToken);
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail(
"upstream_http_error",
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_network_error", ex.Message);
}
try
{
var responseText = await FetchOverseasArtworkPayloadAsync(localDate, cancellationToken);
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
if (!root.TryGetProperty("data", out var dataArray) || dataArray.ValueKind != JsonValueKind.Array)
@@ -183,7 +168,9 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
{
var title = ReadString(item, "title");
var imageId = ReadString(item, "image_id");
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(imageId))
var thumbnailDataUrl = ReadString(item, "thumbnail", "lqip");
if (string.IsNullOrWhiteSpace(title) ||
(string.IsNullOrWhiteSpace(imageId) && string.IsNullOrWhiteSpace(thumbnailDataUrl)))
{
continue;
}
@@ -199,7 +186,8 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
artist,
ReadString(item, "date_display"),
ReadString(item, "api_link"),
imageId.Trim()));
string.IsNullOrWhiteSpace(imageId) ? null : imageId.Trim(),
string.IsNullOrWhiteSpace(thumbnailDataUrl) ? null : thumbnailDataUrl.Trim()));
}
if (candidates.Count == 0)
@@ -217,24 +205,121 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
Museum: "The Art Institute of Chicago",
ArtworkUrl: selected.ArtworkUrl,
ImageUrl: BuildArtworkImageUrl(selected.ImageId),
ThumbnailDataUrl: selected.ThumbnailDataUrl,
FetchedAt: DateTimeOffset.UtcNow);
SetDailyArtworkCache(snapshot);
SetDailyArtworkCache(mirrorSource, snapshot);
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (HttpRequestException ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_network_error", ex.Message);
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", ex.Message);
}
}
private bool TryGetDailyArtworkFromCache(out DailyArtworkSnapshot snapshot)
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkFromDomesticSourceAsync(
string mirrorSource,
CancellationToken cancellationToken)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, _options.DomesticArtworkApiUrl);
request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
using var response = await _httpClient.SendAsync(request, cancellationToken);
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail(
"upstream_http_error",
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
}
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
if (!root.TryGetProperty("images", out var images) || images.ValueKind != JsonValueKind.Array)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", "Daily image list is missing.");
}
var candidates = images.EnumerateArray().ToArray();
if (candidates.Length == 0)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_empty_result", "No daily image candidates were returned.");
}
var localDate = GetChinaLocalDate();
var indexSeed = localDate.Year * 1000 + localDate.DayOfYear;
var selected = candidates[Math.Abs(indexSeed) % candidates.Length];
var imageUrl = BuildDomesticImageUrl(
ReadString(selected, "url"),
_options.DomesticArtworkHost);
if (string.IsNullOrWhiteSpace(imageUrl))
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", "Daily image URL is missing.");
}
var title = ReadString(selected, "title");
if (string.IsNullOrWhiteSpace(title))
{
title = ExtractDomesticTitle(ReadString(selected, "copyright"));
}
if (string.IsNullOrWhiteSpace(title))
{
title = "Bing Daily Image";
}
var dateText = ParseDomesticDateText(ReadString(selected, "startdate"));
var artworkUrl = BuildDomesticImageUrl(
ReadString(selected, "copyrightlink"),
_options.DomesticArtworkHost);
if (string.IsNullOrWhiteSpace(artworkUrl) ||
artworkUrl.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase))
{
artworkUrl = null;
}
var snapshot = new DailyArtworkSnapshot(
Provider: "BingCN",
Title: title.Trim(),
Artist: "Bing China",
Year: dateText,
Museum: "Bing China",
ArtworkUrl: artworkUrl,
ImageUrl: imageUrl,
ThumbnailDataUrl: null,
FetchedAt: DateTimeOffset.UtcNow);
SetDailyArtworkCache(mirrorSource, snapshot);
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_network_error", ex.Message);
}
}
private bool TryGetDailyArtworkFromCache(string mirrorSource, out DailyArtworkSnapshot snapshot)
{
lock (_cacheGate)
{
if (_dailyArtworkCache is not null && _dailyArtworkCache.ExpireAt > DateTimeOffset.UtcNow)
if (_dailyArtworkCacheBySource.TryGetValue(mirrorSource, out var cacheEntry) &&
cacheEntry.ExpireAt > DateTimeOffset.UtcNow)
{
snapshot = _dailyArtworkCache.Snapshot;
snapshot = cacheEntry.Snapshot;
return true;
}
}
@@ -243,11 +328,11 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
return false;
}
private void SetDailyArtworkCache(DailyArtworkSnapshot snapshot)
private void SetDailyArtworkCache(string mirrorSource, DailyArtworkSnapshot snapshot)
{
lock (_cacheGate)
{
_dailyArtworkCache = new DailyArtworkCacheEntry(
_dailyArtworkCacheBySource[mirrorSource] = new DailyArtworkCacheEntry(
snapshot,
DateTimeOffset.UtcNow.Add(_options.CacheDuration));
}
@@ -325,6 +410,105 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
imageId.Trim());
}
private string ResolveArtworkMirrorSource(DailyArtworkQuery query)
{
if (!string.IsNullOrWhiteSpace(query.MirrorSource))
{
return DailyArtworkMirrorSources.Normalize(query.MirrorSource);
}
try
{
var snapshot = _appSettingsService.Load();
return DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
}
catch
{
return DailyArtworkMirrorSources.Overseas;
}
}
private async Task<string> 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))