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:Mod_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? curseForgeID = this.GetAttributeAsNullableInt(node, "data-curseforge-id"); string curseForgeKey = this.GetAttribute(node, "data-curseforge-key"); 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"); string contentPackFor = this.GetAttribute(node, "data-content-pack-for"); string devNote = this.GetAttribute(node, "data-dev-note"); string pullRequestUrl = this.GetAttribute(node, "data-pr"); IDictionary mapLocalVersions = this.GetAttributeAsVersionMapping(node, "data-map-local-versions"); IDictionary mapRemoteVersions = this.GetAttributeAsVersionMapping(node, "data-map-remote-versions"); // 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") }; } } // yield model yield return new WikiModEntry { ID = ids, Name = names, Author = authors, NexusID = nexusID, ChucklefishID = chucklefishID, CurseForgeID = curseForgeID, CurseForgeKey = curseForgeKey, ModDropID = modDropID, GitHubRepo = githubRepo, CustomSourceUrl = customSourceUrl, CustomUrl = customUrl, ContentPackFor = contentPackFor, Compatibility = compatibility, BetaCompatibility = betaCompatibility, Warnings = warnings, PullRequestUrl = pullRequestUrl, DevNote = devNote, MapLocalVersions = mapLocalVersions, MapRemoteVersions = mapRemoteVersions, 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 an attribute value and parse it as a version mapping. /// The element whose attributes to read. /// The attribute name. private IDictionary GetAttributeAsVersionMapping(HtmlNode element, string name) { // get raw value string raw = this.GetAttribute(element, name); if (raw?.Contains("→") != true) return null; // parse // Specified on the wiki in the form "remote version → mapped version; another remote version → mapped version" IDictionary map = new Dictionary(StringComparer.InvariantCultureIgnoreCase); foreach (string pair in raw.Split(';')) { string[] versions = pair.Split('→'); if (versions.Length == 2 && !string.IsNullOrWhiteSpace(versions[0]) && !string.IsNullOrWhiteSpace(versions[1])) map[versions[0].Trim()] = versions[1].Trim(); } return map; } /// 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; } } } }