XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
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();
    }
}