using System.Globalization; using System.Net; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using System.Xml; using System.Xml.Linq; namespace XFEEDToolBox.Core.News; public sealed record GalnetArticle(string Id, string Title, string Body, string GameDate, string Link, DateTimeOffset? PublishedAt) { [JsonIgnore] public Uri? SourceUri => GalnetRss.OfficialUri(Link); } public sealed record GalnetNewsSnapshot(DateTimeOffset RetrievedAt, IReadOnlyList Articles); public sealed record GalnetRefreshResult(GalnetNewsSnapshot? Snapshot, bool Updated, string Message); /// Frontier's public, in-universe Galnet feed. No game login or interaction is required. public static class GalnetRss { public const string FeedUrl = "https://www.elitedangerous.com/en-GB/rss/galnet.xml"; public const int MaximumBytes = 2_000_000; private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); public static IReadOnlyList Parse(string xml) { if (xml.Length > MaximumBytes) throw new InvalidDataException("新闻内容超出读取上限。"); using var text = new StringReader(xml); using var reader = XmlReader.Create(text, new() { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = MaximumBytes }); var channel = XDocument.Load(reader).Root?.Element("channel") ?? throw new InvalidDataException("新闻源格式无效。"); return channel.Elements("item").Take(100).Select(item => { var title = PlainText(item.Element("title")?.Value ?? ""); var body = PlainText(item.Element("description")?.Value ?? ""); var link = OfficialUri(item.Element("link")?.Value ?? "")?.AbsoluteUri ?? ""; var rawDate = item.Element("pubDate")?.Value.Trim() ?? ""; // RSS dates use the game's year (e.g. 3312). Do not convert them to the current Earth year. var dateValue = rawDate.Contains(',') ? rawDate[(rawDate.IndexOf(',') + 1)..].Trim() : rawDate; DateTimeOffset? published = DateTimeOffset.TryParse(dateValue, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.AssumeUniversal, out var date) ? date : null; var gameDate = published?.ToString("dd MMM yyyy", CultureInfo.InvariantCulture).ToUpperInvariant() ?? rawDate; var id = item.Element("guid")?.Value.Trim(); return new GalnetArticle(string.IsNullOrWhiteSpace(id) ? link + title : id, title, body, gameDate, link, published); }).Where(ValidArticle).DistinctBy(article => article.Id) .OrderByDescending(article => article.PublishedAt ?? DateTimeOffset.MinValue).Take(20).ToArray(); } public static Uri? OfficialUri(string? value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == "https" && uri.IsDefaultPort && uri.UserInfo.Length == 0 && (uri.Host.Equals("www.elitedangerous.com", StringComparison.OrdinalIgnoreCase) || uri.Host.Equals("community.elitedangerous.com", StringComparison.OrdinalIgnoreCase)) ? uri : null; internal static bool ValidArticle(GalnetArticle? article) => article is not null && !string.IsNullOrWhiteSpace(article.Id) && article.Id.Length <= 2048 && !string.IsNullOrWhiteSpace(article.Title) && article.Title.Length <= 1000 && !string.IsNullOrWhiteSpace(article.Body) && article.Body.Length <= 100_000 && article.GameDate is { Length: <= 100 } && article.Link is { Length: <= 2048 } && (article.Link.Length == 0 || OfficialUri(article.Link) is not null); private static string PlainText(string value) { value = Regex.Replace(value, @"<(script|style)\b[^>]*>.*?", "", RegexOptions.IgnoreCase | RegexOptions.Singleline, RegexTimeout); value = Regex.Replace(value, @"|", "\n", RegexOptions.IgnoreCase, RegexTimeout); value = Regex.Replace(value, @"<[^>]*>", "", RegexOptions.None, RegexTimeout); value = WebUtility.HtmlDecode(value).Replace("\r\n", "\n").Replace('\r', '\n'); value = new string(value.Where(character => !char.IsControl(character) || character is '\n' or '\t').ToArray()); return Regex.Replace(value, @"\n[\t ]*\n(?:[\t ]*\n)+", "\n\n", RegexOptions.None, RegexTimeout).Trim(); } } /// Keeps the last usable edition on disk until another usable edition arrives. public sealed class GalnetNewsService(string cachePath, HttpClient? client = null) { private static readonly HttpClient SharedClient = new() { Timeout = TimeSpan.FromSeconds(20) }; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly SemaphoreSlim gate = new(1, 1); private readonly HttpClient http = client ?? SharedClient; private bool cacheLoaded; public GalnetNewsSnapshot? Current { get; private set; } public async Task LoadCachedAsync(CancellationToken token = default) { await gate.WaitAsync(token).ConfigureAwait(false); try { await LoadCacheCoreAsync(token).ConfigureAwait(false); return Current; } finally { gate.Release(); } } public async Task RefreshAsync(CancellationToken token = default) { await gate.WaitAsync(token).ConfigureAwait(false); try { await LoadCacheCoreAsync(token).ConfigureAwait(false); try { using var deadline = CancellationTokenSource.CreateLinkedTokenSource(token); deadline.CancelAfter(TimeSpan.FromSeconds(20)); using var request = new HttpRequestMessage(HttpMethod.Get, GalnetRss.FeedUrl); request.Headers.UserAgent.ParseAdd("XFEEDToolBox-Galnet/1.0"); request.Headers.Accept.ParseAdd("application/rss+xml, application/xml;q=0.9"); using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token).ConfigureAwait(false); response.EnsureSuccessStatusCode(); if (response.Content.Headers.ContentLength > GalnetRss.MaximumBytes) throw new InvalidDataException("新闻内容超出读取上限。"); await using var stream = await response.Content.ReadAsStreamAsync(deadline.Token).ConfigureAwait(false); var bytes = await ReadBoundedAsync(stream, deadline.Token).ConfigureAwait(false); var articles = GalnetRss.Parse(Encoding.UTF8.GetString(bytes)); if (articles.Count == 0) throw new InvalidDataException("新闻源暂未返回有效内容。"); token.ThrowIfCancellationRequested(); if (Current is not null && Current.Articles.SequenceEqual(articles)) return new(Current, false, "已是最新新闻"); // An out-of-date upstream response must not replace a newer saved edition. if (Current is not null && articles.Max(article => article.PublishedAt) is { } latest && Current.Articles.Max(article => article.PublishedAt) is { } savedLatest && latest < savedLatest) return new(Current, false, "已保留较新的新闻"); var next = new GalnetNewsSnapshot(DateTimeOffset.UtcNow, articles); try { await SaveAsync(next, token).ConfigureAwait(false); Current = next; return new(next, true, "新闻已更新"); } catch (Exception error) when (error is IOException or UnauthorizedAccessException) { Current = next; return new(next, true, "新闻已更新,本次未能保存到本地"); } } catch (OperationCanceledException) when (!token.IsCancellationRequested) { return Unavailable(); } catch (Exception error) when (error is HttpRequestException or IOException or InvalidDataException or XmlException or RegexMatchTimeoutException) { return Unavailable(); } } finally { gate.Release(); } } private GalnetRefreshResult Unavailable() => new(Current, false, Current is null ? "暂时无法获取银河新闻,稍后会自动重试。" : "暂时无法获取新新闻,继续显示已保存的内容。"); private async Task LoadCacheCoreAsync(CancellationToken token) { if (cacheLoaded) return; try { if (File.Exists(cachePath)) { await using var stream = File.OpenRead(cachePath); var bytes = await ReadBoundedAsync(stream, token).ConfigureAwait(false); var snapshot = JsonSerializer.Deserialize(bytes, JsonOptions); if (snapshot is { Articles.Count: > 0 and <= 20 } && snapshot.Articles.All(GalnetRss.ValidArticle)) Current = snapshot; } } catch (Exception error) when (error is IOException or InvalidDataException or UnauthorizedAccessException or JsonException) { } cacheLoaded = true; } private async Task SaveAsync(GalnetNewsSnapshot snapshot, CancellationToken token) { var directory = Path.GetDirectoryName(Path.GetFullPath(cachePath))!; Directory.CreateDirectory(directory); var temporary = Path.Combine(directory, $".galnet-{Guid.NewGuid():N}.tmp"); try { await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(snapshot, JsonOptions), token).ConfigureAwait(false); token.ThrowIfCancellationRequested(); File.Move(temporary, cachePath, true); } finally { if (File.Exists(temporary)) File.Delete(temporary); } } private static async Task ReadBoundedAsync(Stream stream, CancellationToken token) { using var output = new MemoryStream(); var buffer = new byte[8192]; int read; while ((read = await stream.ReadAsync(buffer, token).ConfigureAwait(false)) != 0) { if (output.Length + read > GalnetRss.MaximumBytes) throw new InvalidDataException("新闻内容超出读取上限。"); output.Write(buffer, 0, read); } return output.ToArray(); } }