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) { // parse status WikiCompatibilityStatus status; { string rawStatus = node.GetAttributeValue("data-status", null); if (rawStatus == null) continue; // not a mod node? if (!Enum.TryParse(rawStatus, true, out status)) throw new InvalidOperationException($"Unknown status '{rawStatus}' when parsing compatibility list."); } // parse unofficial version ISemanticVersion unofficialVersion = null; { string rawUnofficialVersion = node.GetAttributeValue("data-unofficial-version", null); SemanticVersion.TryParse(rawUnofficialVersion, out unofficialVersion); } // parse other fields string name = node.Descendants("td").FirstOrDefault()?.InnerText?.Trim(); string summary = node.Descendants("td").FirstOrDefault(p => p.GetAttributeValue("class", null) == "summary")?.InnerText.Trim(); string[] ids = this.GetAttribute(node, "data-id")?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() ?? new string[0]; int? nexusID = this.GetNullableIntAttribute(node, "data-nexus-id"); int? chucklefishID = this.GetNullableIntAttribute(node, "data-chucklefish-id"); string githubRepo = this.GetAttribute(node, "data-github"); string customSourceUrl = this.GetAttribute(node, "data-custom-source"); string customUrl = this.GetAttribute(node, "data-custom-url"); // yield model yield return new WikiCompatibilityEntry { ID = ids, Name = name, Status = status, NexusID = nexusID, ChucklefishID = chucklefishID, GitHubRepo = githubRepo, CustomSourceUrl = customSourceUrl, CustomUrl = customUrl, UnofficialVersion = unofficialVersion, Summary = summary }; } } /// Get a nullable integer attribute value. /// The HTML node. /// The attribute name. private int? GetNullableIntAttribute(HtmlNode node, string attributeName) { string raw = this.GetAttribute(node, attributeName); if (raw != null && int.TryParse(raw, out int value)) return value; return null; } /// Get a strings attribute value. /// The HTML node. /// The attribute name. private string GetAttribute(HtmlNode node, string attributeName) { string raw = node.GetAttributeValue(attributeName, null); if (raw != null) raw = HtmlEntity.DeEntitize(raw); return raw; } /// 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; } } } }