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; using StardewModdingAPI.Toolkit.Framework.UpdateData; 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; // parse mod data overrides Dictionary overrides = new Dictionary(StringComparer.OrdinalIgnoreCase); { HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("//table[@id='mod-overrides-list']//tr[@class='mod']"); if (modNodes == null) throw new InvalidOperationException("Can't parse wiki compatibility list, no mod data overrides section found."); foreach (var entry in this.ParseOverrideEntries(modNodes)) { if (entry.Ids?.Any() != true || !entry.HasChanges) continue; foreach (string id in entry.Ids) overrides[id] = entry; } } // parse mod entries WikiModEntry[] mods; { 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."); mods = this.ParseModEntries(modNodes, overrides).ToArray(); } // build model 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. /// The mod data overrides to apply, if any. private IEnumerable ParseModEntries(IEnumerable nodes, IDictionary overridesById) { 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"); // 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") }; } } // find data overrides WikiDataOverrideEntry overrides = ids .Select(id => overridesById.TryGetValue(id, out overrides) ? overrides : null) .FirstOrDefault(p => p != null); // 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, Overrides = overrides, Anchor = anchor }; } } /// Parse valid mod data override entries. /// The HTML mod data override entries. private IEnumerable ParseOverrideEntries(IEnumerable nodes) { foreach (HtmlNode node in nodes) { yield return new WikiDataOverrideEntry { Ids = this.GetAttributeAsCsv(node, "data-id"), ChangeLocalVersions = this.GetAttributeAsChangeDescriptor(node, "data-local-version", raw => SemanticVersion.TryParse(raw, out ISemanticVersion version) ? version.ToString() : raw ), ChangeRemoteVersions = this.GetAttributeAsChangeDescriptor(node, "data-remote-version", raw => SemanticVersion.TryParse(raw, out ISemanticVersion version) ? version.ToString() : raw ), ChangeUpdateKeys = this.GetAttributeAsChangeDescriptor(node, "data-update-keys", raw => UpdateKey.TryParse(raw, out UpdateKey key) ? key.ToString() : raw ) }; } } /// 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 change descriptor. /// The element whose attributes to read. /// The attribute name. /// Format an raw entry value when applying changes. private ChangeDescriptor GetAttributeAsChangeDescriptor(HtmlNode element, string name, Func formatValue) { string raw = this.GetAttribute(element, name); return raw != null ? ChangeDescriptor.Parse(raw, out _, formatValue) : null; } /// 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() : Array.Empty(); } /// 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; } } } }