using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using HtmlAgilityPack; using Pathoschild.Http.Client; namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { /// An HTTP client for fetching mod metadata from the wiki. public class WikiClient : IDisposable { /********* ** Properties *********/ /// The underlying HTTP client. private readonly IClient Client; /********* ** Public methods *********/ /// Construct an instance. /// The user agent for the wiki API. /// The base URL for the wiki API. public WikiClient(string userAgent, string baseUrl = "https://stardewvalleywiki.com/mediawiki/api.php") { this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); } /// Fetch mods from the compatibility list. public async Task FetchModsAsync() { // fetch HTML ResponseModel response = await this.Client .GetAsync("") .WithArguments(new { action = "parse", page = "Modding:SMAPI_compatibility", format = "json" }) .As(); string html = response.Parse.Text["*"]; // parse HTML var doc = new HtmlDocument(); doc.LoadHtml(html); // fetch game versions string stableVersion = doc.DocumentNode.SelectSingleNode("div[@class='game-stable-version']")?.InnerText; string betaVersion = doc.DocumentNode.SelectSingleNode("div[@class='game-beta-version']")?.InnerText; // find mod entries HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("table[@id='mod-list']//tr[@class='mod']"); if (modNodes == null) throw new InvalidOperationException("Can't parse wiki compatibility list, no mods found."); // parse WikiModEntry[] mods = this.ParseEntries(modNodes).ToArray(); return new WikiModList { StableVersion = stableVersion, BetaVersion = betaVersion, Mods = mods }; } /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. public void Dispose() { this.Client?.Dispose(); } /********* ** Private methods *********/ /// Parse valid mod compatibility entries. /// The HTML compatibility entries. private IEnumerable ParseEntries(IEnumerable nodes) { foreach (HtmlNode node in nodes) { // extract fields string name = this.GetMetadataField(node, "mod-name"); string alternateNames = this.GetMetadataField(node, "mod-name2"); string author = this.GetMetadataField(node, "mod-author"); string alternateAuthors = this.GetMetadataField(node, "mod-author2"); string[] ids = this.GetMetadataField(node, "mod-id")?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() ?? new string[0]; int? nexusID = this.GetNullableIntField(node, "mod-nexus-id"); int? chucklefishID = this.GetNullableIntField(node, "mod-cf-id"); string githubRepo = this.GetMetadataField(node, "mod-github"); string customSourceUrl = this.GetMetadataField(node, "mod-custom-source"); string customUrl = this.GetMetadataField(node, "mod-url"); string brokeIn = this.GetMetadataField(node, "mod-broke-in"); string anchor = this.GetMetadataField(node, "mod-anchor"); // parse stable compatibility WikiCompatibilityInfo compatibility = new WikiCompatibilityInfo { Status = this.GetStatusField(node, "mod-status") ?? WikiCompatibilityStatus.Ok, UnofficialVersion = this.GetSemanticVersionField(node, "mod-unofficial-version"), UnofficialUrl = this.GetMetadataField(node, "mod-unofficial-url"), Summary = this.GetMetadataField(node, "mod-summary")?.Trim() }; // parse beta compatibility WikiCompatibilityInfo betaCompatibility = null; { WikiCompatibilityStatus? betaStatus = this.GetStatusField(node, "mod-beta-status"); if (betaStatus.HasValue) { betaCompatibility = new WikiCompatibilityInfo { Status = betaStatus.Value, UnofficialVersion = this.GetSemanticVersionField(node, "mod-beta-unofficial-version"), UnofficialUrl = this.GetMetadataField(node, "mod-beta-unofficial-url"), Summary = this.GetMetadataField(node, "mod-beta-summary") }; } } // yield model yield return new WikiModEntry { ID = ids, Name = name, AlternateNames = alternateNames, Author = author, AlternateAuthors = alternateAuthors, NexusID = nexusID, ChucklefishID = chucklefishID, GitHubRepo = githubRepo, CustomSourceUrl = customSourceUrl, CustomUrl = customUrl, BrokeIn = brokeIn, Compatibility = compatibility, BetaCompatibility = betaCompatibility, Anchor = anchor }; } } /// Get the value of a metadata field. /// The metadata container. /// The field name. private string GetMetadataField(HtmlNode container, string name) { return container.Descendants().FirstOrDefault(p => p.HasClass(name))?.InnerHtml; } /// Get the value of a metadata field as a compatibility status. /// The metadata container. /// The field name. private WikiCompatibilityStatus? GetStatusField(HtmlNode container, string name) { string raw = this.GetMetadataField(container, name); if (raw == null) return null; if (!Enum.TryParse(raw, true, out WikiCompatibilityStatus status)) throw new InvalidOperationException($"Unknown status '{raw}' when parsing compatibility list."); return status; } /// Get the value of a metadata field as a semantic version. /// The metadata container. /// The field name. private ISemanticVersion GetSemanticVersionField(HtmlNode container, string name) { string raw = this.GetMetadataField(container, name); return SemanticVersion.TryParse(raw, out ISemanticVersion version) ? version : null; } /// Get the value of a metadata field as a nullable integer. /// The metadata container. /// The field name. private int? GetNullableIntField(HtmlNode container, string name) { string raw = this.GetMetadataField(container, name); if (raw != null && int.TryParse(raw, out int value)) return value; return null; } /// The response model for the MediaWiki parse API. [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] private class ResponseModel { /// The parse API results. public ResponseParseModel Parse { get; set; } } /// The inner response model for the MediaWiki parse API. [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] [SuppressMessage("ReSharper", "CollectionNeverUpdated.Local")] [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] private class ResponseParseModel { /// The parsed text. public IDictionary Text { get; set; } } } }