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<GalnetArticle> Articles);
public sealed record GalnetRefreshResult(GalnetNewsSnapshot? Snapshot, bool Updated, string Message);
/// <summary>Frontier's public, in-universe Galnet feed. No game login or interaction is required.</summary>
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<GalnetArticle> 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[^>]*>.*?</\1\s*>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline, RegexTimeout);
value = Regex.Replace(value, @"<br\s*/?>|</(?:p|div|li)\s*>", "\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();
}
}
/// <summary>Keeps the last usable edition on disk until another usable edition arrives.</summary>
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<GalnetNewsSnapshot?> LoadCachedAsync(CancellationToken token = default)
{
await gate.WaitAsync(token).ConfigureAwait(false);
try { await LoadCacheCoreAsync(token).ConfigureAwait(false); return Current; }
finally { gate.Release(); }
}
public async Task<GalnetRefreshResult> 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<GalnetNewsSnapshot>(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<byte[]> 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();
}
}
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<GalnetArticle> Articles);
public sealed record GalnetRefreshResult(GalnetNewsSnapshot? Snapshot, bool Updated, string Message);
/// <summary>Frontier's public, in-universe Galnet feed. No game login or interaction is required.</summary>
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<GalnetArticle> 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[^>]*>.*?</\1\s*>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline, RegexTimeout);
value = Regex.Replace(value, @"<br\s*/?>|</(?:p|div|li)\s*>", "\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();
}
}
/// <summary>Keeps the last usable edition on disk until another usable edition arrives.</summary>
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<GalnetNewsSnapshot?> LoadCachedAsync(CancellationToken token = default)
{
await gate.WaitAsync(token).ConfigureAwait(false);
try { await LoadCacheCoreAsync(token).ConfigureAwait(false); return Current; }
finally { gate.Release(); }
}
public async Task<GalnetRefreshResult> 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<GalnetNewsSnapshot>(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<byte[]> 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();
}
}