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; }
}
}
}