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 (WikiDataOverrideEntry 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( 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", Justification = "Used via JSON deserialization.")] [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used via JSON deserialization.")] private class ResponseModel { /********* ** Accessors *********/ /// The parse API results. public ResponseParseModel Parse { get; } /********* ** Public methods *********/ /// Construct an instance. /// The parse API results. public ResponseModel(ResponseParseModel parse) { this.Parse = parse; } } /// The inner response model for the MediaWiki parse API. [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", Justification = "Used via JSON deserialization.")] [SuppressMessage("ReSharper", "CollectionNeverUpdated.Local", Justification = "Used via JSON deserialization.")] [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used via JSON deserialization.")] private class ResponseParseModel { /********* ** Accessors *********/ /// The parsed text. public IDictionary Text { get; } = new Dictionary(); } } }