From de561e52d7785597f1af2c6fd0d712d19ac5f928 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 20 Oct 2018 20:19:12 -0400 Subject: fetch game versions from the wiki (#597) --- .../Framework/Clients/Wiki/WikiClient.cs | 208 +++++++++++++++++++++ .../Clients/Wiki/WikiCompatibilityClient.cs | 198 -------------------- .../Framework/Clients/Wiki/WikiModList.cs | 18 ++ src/StardewModdingAPI.Toolkit/ModToolkit.cs | 6 +- 4 files changed, 229 insertions(+), 201 deletions(-) create mode 100644 src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs delete mode 100644 src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs create mode 100644 src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs (limited to 'src/StardewModdingAPI.Toolkit') diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs new file mode 100644 index 00000000..9be760f8 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs @@ -0,0 +1,208 @@ +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; } + } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs deleted file mode 100644 index 20436e66..00000000 --- a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs +++ /dev/null @@ -1,198 +0,0 @@ -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 compatibility list. - public class WikiCompatibilityClient : 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 WikiCompatibilityClient(string userAgent, string baseUrl = "https://stardewvalleywiki.com/mediawiki/api.php") - { - this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); - } - - /// Fetch mod compatibility entries. - public async Task FetchAsync() - { - // 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); - - // 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 - return this.ParseEntries(modNodes).ToArray(); - } - - /// 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; } - } - } -} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs new file mode 100644 index 00000000..0d614f28 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// Metadata from the wiki's mod compatibility list. + public class WikiModList + { + /********* + ** Accessors + *********/ + /// The stable game version. + public string StableVersion { get; set; } + + /// The beta game version (if any). + public string BetaVersion { get; set; } + + /// The mods on the wiki. + public WikiModEntry[] Mods { get; set; } + } +} diff --git a/src/StardewModdingAPI.Toolkit/ModToolkit.cs b/src/StardewModdingAPI.Toolkit/ModToolkit.cs index 44503b20..c55f6c70 100644 --- a/src/StardewModdingAPI.Toolkit/ModToolkit.cs +++ b/src/StardewModdingAPI.Toolkit/ModToolkit.cs @@ -47,10 +47,10 @@ namespace StardewModdingAPI.Toolkit } /// Extract mod metadata from the wiki compatibility list. - public async Task GetWikiCompatibilityListAsync() + public async Task GetWikiCompatibilityListAsync() { - var client = new WikiCompatibilityClient(this.UserAgent); - return await client.FetchAsync(); + var client = new WikiClient(this.UserAgent); + return await client.FetchModsAsync(); } /// Get SMAPI's internal mod database. -- cgit