using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Net; 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 { /********* ** Fields *********/ /// 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; if (betaVersion == stableVersion) betaVersion = null; // 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[] names = this.GetAttributeAsCsv(node, "data-name"); string[] authors = this.GetAttributeAsCsv(node, "data-author"); string[] ids = this.GetAttributeAsCsv(node, "data-id"); string[] warnings = this.GetAttributeAsCsv(node, "data-warnings"); int? nexusID = this.GetAttributeAsNullableInt(node, "data-nexus-id"); int? chucklefishID = this.GetAttributeAsNullableInt(node, "data-cf-id"); int? modDropID = this.GetAttributeAsNullableInt(node, "data-moddrop-id"); string githubRepo = this.GetAttribute(node, "data-github"); string customSourceUrl = this.GetAttribute(node, "data-custom-source"); string customUrl = this.GetAttribute(node, "data-url"); string anchor = this.GetAttribute(node, "id"); // parse stable compatibility WikiCompatibilityInfo compatibility = new WikiCompatibilityInfo { Status = this.GetAttributeAsEnum(node, "data-status") ?? WikiCompatibilityStatus.Ok, BrokeIn = this.GetAttribute(node, "data-broke-in"), UnofficialVersion = this.GetAttributeAsSemanticVersion(node, "data-unofficial-version"), UnofficialUrl = this.GetAttribute(node, "data-unofficial-url"), Summary = this.GetInnerHtml(node, "mod-summary")?.Trim() }; // parse beta compatibility WikiCompatibilityInfo betaCompatibility = null; { WikiCompatibilityStatus? betaStatus = this.GetAttributeAsEnum(node, "data-beta-status"); if (betaStatus.HasValue) { betaCompatibility = new WikiCompatibilityInfo { Status = betaStatus.Value, BrokeIn = this.GetAttribute(node, "data-beta-broke-in"), UnofficialVersion = this.GetAttributeAsSemanticVersion(node, "data-beta-unofficial-version"), UnofficialUrl = this.GetAttribute(node, "data-beta-unofficial-url"), Summary = this.GetInnerHtml(node, "mod-beta-summary") }; } } // parse SMAPI 3.0 readiness status WikiSmapi3Status smapi3Status = this.GetAttributeAsEnum(node, "data-smapi-3-status") ?? WikiSmapi3Status.Unknown; string smapi3Url = this.GetAttribute(node, "data-smapi-3-url"); // yield model yield return new WikiModEntry { ID = ids, Name = names, Author = authors, NexusID = nexusID, ChucklefishID = chucklefishID, ModDropID = modDropID, GitHubRepo = githubRepo, CustomSourceUrl = customSourceUrl, CustomUrl = customUrl, Compatibility = compatibility, BetaCompatibility = betaCompatibility, Smapi3Status = smapi3Status, Smapi3Url = smapi3Url, Warnings = warnings, Anchor = anchor }; } } /// Get an attribute value. /// The element whose attributes to read. /// The attribute name. private string GetAttribute(HtmlNode element, string name) { string value = element.GetAttributeValue(name, null); if (string.IsNullOrWhiteSpace(value)) return null; return WebUtility.HtmlDecode(value); } /// Get an attribute value and parse it as a comma-delimited list of strings. /// The element whose attributes to read. /// The attribute name. private string[] GetAttributeAsCsv(HtmlNode element, string name) { string raw = this.GetAttribute(element, name); return !string.IsNullOrWhiteSpace(raw) ? raw.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() : new string[0]; } /// Get an attribute value and parse it as an enum value. /// The enum type. /// The element whose attributes to read. /// The attribute name. private TEnum? GetAttributeAsEnum(HtmlNode element, string name) where TEnum : struct { string raw = this.GetAttribute(element, name); if (raw == null) return null; if (!Enum.TryParse(raw, true, out TEnum value) && Enum.IsDefined(typeof(TEnum), value)) throw new InvalidOperationException($"Unknown {typeof(TEnum).Name} value '{raw}' when parsing compatibility list."); return value; } /// Get an attribute value and parse it as a semantic version. /// The element whose attributes to read. /// The attribute name. private ISemanticVersion GetAttributeAsSemanticVersion(HtmlNode element, string name) { string raw = this.GetAttribute(element, name); return SemanticVersion.TryParse(raw, out ISemanticVersion version) ? version : null; } /// Get an attribute value and parse it as a nullable int. /// The element whose attributes to read. /// The attribute name. private int? GetAttributeAsNullableInt(HtmlNode element, string name) { string raw = this.GetAttribute(element, name); if (raw != null && int.TryParse(raw, out int value)) return value; return null; } /// Get the text of an element with the given class name. /// The metadata container. /// The field name. private string GetInnerHtml(HtmlNode container, string className) { return container.Descendants().FirstOrDefault(p => p.HasClass(className))?.InnerHtml; } /// 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; } } } }