diff --git a/SimpleLogger b/SimpleLogger index c1c14d9..f275ff3 160000 --- a/SimpleLogger +++ b/SimpleLogger @@ -1 +1 @@ -Subproject commit c1c14d96ea5ab91a45acced9bb342ed228347eab +Subproject commit f275ff330db936d7eabc6dc435952ec0752edbc9 diff --git a/src/AliExpressShop/LRUCache.cs b/src/AliExpressShop/LRUCache.cs new file mode 100644 index 0000000..fcfa02b --- /dev/null +++ b/src/AliExpressShop/LRUCache.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace GameServiceWarden.Core.Collection +{ + public class LRUCache : IEnumerable + { + private class Node + { + public K key; + public V value; + public Node front; + public Node back; + } + + public int Size {get { return size; } } + public int Count {get { return valueDictionary.Count; } } + private readonly int size; + private Node top; + private Node bottom; + private Dictionary valueDictionary; + private Action cleanupAction; + + public LRUCache(int size = 100, Action cleanup = null) + { + this.size = size; + valueDictionary = new Dictionary(size); + this.cleanupAction = cleanup; + } + + private void MoveToTop(K key) { + Node node = valueDictionary[key]; + if (node != null && top != node) { + node.front.back = node.back; + node.back = top; + node.front = null; + top = node; + } + } + + private Node AddToTop(K key, V value) { + Node node = new Node(); + node.front = null; + node.back = top; + node.value = value; + node.key = key; + top = node; + if (bottom == null) { + bottom = node; + } else if (valueDictionary.Count == Size) { + valueDictionary.Remove(bottom.key); + cleanupAction?.Invoke(bottom.value); + bottom = bottom.front; + } + valueDictionary[key] = node; + return node; + } + + public V Use(K key, Func generate) { + if (generate == null) throw new ArgumentNullException("generate"); + Node value = null; + if (valueDictionary.ContainsKey(key)) { + value = valueDictionary[key]; + MoveToTop(key); + } else { + value = AddToTop(key, generate()); + } + return value.value; + } + + public async Task UseAsync(K key, Func> generate) { + if (generate == null) throw new ArgumentNullException("generate"); + Node value = null; + if (valueDictionary.ContainsKey(key)) { + value = valueDictionary[key]; + MoveToTop(key); + } else { + value = AddToTop(key, await generate()); + } + return value.value; + } + + public bool IsCached(K key) { + return valueDictionary.ContainsKey(key); + } + + public void Clear() { + top = null; + bottom = null; + valueDictionary.Clear(); + } + + public IEnumerator GetEnumerator() + { + foreach (Node node in valueDictionary.Values) + { + yield return node.value; + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/src/AliExpressShop/Shop.cs b/src/AliExpressShop/Shop.cs index 8d98108..fc24017 100644 --- a/src/AliExpressShop/Shop.cs +++ b/src/AliExpressShop/Shop.cs @@ -1,10 +1,15 @@ using System; +using System.Collections; using System.Collections.Generic; using System.IO; using System.Net; using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; +using GameServiceWarden.Core.Collection; using MultiShop.ShopFramework; using SimpleLogger; @@ -12,229 +17,24 @@ namespace AliExpressShop { public class Shop : IShop { - private const string ALIEXPRESS_BASE_URL = "https://www.aliexpress.com"; - private const string ALIEXPRESS_QUERY_FORMAT = "/wholesale?trafficChannel=main&d=y&CatId=0&SearchText={0}<ype=wholesale&SortType=default&page={1}"; - private const char SPACE_CHAR = '+'; - private const int DELAY = 500; - - //Regex - private Regex dataLineRegex = new Regex("^ +window.runParams = .+\"items\":.+;$"); - private Regex pageCountRegex = new Regex("\"maxPage\":(\\d+)"); - private Regex itemRatingRegex = new Regex("\"starRating\":\"(\\d*.\\d*)\""); - private Regex itemsSoldRegex = new Regex("\"tradeDesc\":\"(\\d+) sold\""); - private const string SHIPPING_REGEX_FORMAT = "Shipping: {0} ?\\$ (\\d*.\\d*)"; - private Regex shippingPriceRegex; - private readonly string freeShippingStr = "\"logisticsDesc\":\"Free Shipping\""; - private const string PRICE_REGEX_FORMAT = "\"price\":\"{0} ?\\$ ?(\\d*.\\d*)( - (\\d+.\\d+))?\","; - private Regex itemPriceRegex; - - //Sequences - private const string ITEM_LIST_SEQ = "\"items\":"; - private const string TITLE_SEQ = "\"title\":"; - private const string IMAGE_URL_SEQ = "\"imageUrl\":"; - private const string PRODUCT_URL_SEQ = "\"productDetailUrl\":"; - private HttpClientHandler handler; - private HttpClient client; - private bool disposedValue; - public string ShopName => "AliExpress"; public string ShopDescription => "A China based online store."; - public string ShopModuleAuthor => null; + public string ShopModuleAuthor => "Reslate"; - public void Initiate(Currency currency) + public bool UseProxy { get; set; } = true; + + private HttpClient http; + private string query; + private Currency currency; + private bool disposedValue; + + + public void Initialize() { - if (disposedValue) throw new ObjectDisposedException("Shop"); - if (client != null) throw new InvalidOperationException("Already initiated."); - Logger.AddLogListener(new ConsoleLogReceiver()); - itemPriceRegex = new Regex(string.Format(PRICE_REGEX_FORMAT, CurrencyToDisplayStr(currency))); - shippingPriceRegex = new Regex(string.Format(SHIPPING_REGEX_FORMAT, CurrencyToDisplayStr(currency))); - - Uri baseAddress = new Uri(ALIEXPRESS_BASE_URL); - CookieContainer container = new CookieContainer(); - handler = new HttpClientHandler(); - handler.CookieContainer = container; - client = new HttpClient(handler); - client.BaseAddress = baseAddress; - client.Send(new HttpRequestMessage()); - container.Add(baseAddress, new Cookie("aep_usuc_f", string.Format("site=glo&c_tp={0}®ion=CA&b_locale=en_US", currency))); - } - - public async Task> Search(string query, int maxPage = -1) - { - if (disposedValue) throw new ObjectDisposedException("Shop"); - if (client == null) throw new InvalidOperationException("HTTP client is not initiated."); - List listings = new List(); - - string modifiedQuery = query.Replace(' ', SPACE_CHAR); - Logger.Log($"Searching {ShopName} with query \"{query}\".", LogLevel.INFO); - - int? length = null; - for (int i = 1; i <= (length != null ? length : 1); i++) - { - if (maxPage != -1 && i > maxPage) break; - HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, string.Format(ALIEXPRESS_QUERY_FORMAT, modifiedQuery, i)); - Logger.Log($"Sending GET request with uri: {request.RequestUri}", LogLevel.DEBUG); - HttpResponseMessage response = await client.SendAsync(request); - - string data = null; - using (StreamReader reader = new StreamReader(await response.Content.ReadAsStreamAsync())) - { - string line = null; - while ((line = await reader.ReadLineAsync()) != null && (data == null || length == null)) - { - if (dataLineRegex.IsMatch(line)) { - data = line.Trim(); - Logger.Log($"Found line with listing data.", LogLevel.DEBUG); - } else if (length == null && pageCountRegex.IsMatch(line)) { - Match match = pageCountRegex.Match(line); - length = int.Parse(match.Groups[1].Captures[0].Value); - Logger.Log($"Found {length} pages.", LogLevel.DEBUG); - } - } - } - if (data == null) return listings; - string itemsString = GetBracketSet(data, data.IndexOf(ITEM_LIST_SEQ) + ITEM_LIST_SEQ.Length, '[', ']'); - IEnumerable listingsStrs = GetItemsFromString(itemsString); - foreach (string listingStr in listingsStrs) - { - listings.Add(GenerateListingFromString(listingStr)); - } - Logger.Log($"Delaying next page by {DELAY}ms.", LogLevel.DEBUG); - await Task.Delay(DELAY); - } - return listings; - } - - private ProductListing GenerateListingFromString(string str) { - ProductListing listing = new ProductListing(); - string name = GetQuoteSet(str, str.IndexOf(TITLE_SEQ) + TITLE_SEQ.Length); - if (name != null) { - Logger.Log($"Found name: {name}", LogLevel.DEBUG); - listing.Name = name; - } else { - Logger.Log($"Unable to get listing name from: \n {str}", LogLevel.WARNING); - } - Match ratingMatch = itemRatingRegex.Match(str); - if (ratingMatch.Success) { - Logger.Log($"Found rating: {ratingMatch.Groups[1].Value}", LogLevel.DEBUG); - listing.Rating = float.Parse(ratingMatch.Groups[1].Value); - } - Match numberSoldMatch = itemsSoldRegex.Match(str); - if (numberSoldMatch.Success) { - Logger.Log($"Found quantity sold: {numberSoldMatch.Groups[1].Value}", LogLevel.DEBUG); - listing.PurchaseCount = int.Parse(numberSoldMatch.Groups[1].Value); - } - - Match priceMatch = itemPriceRegex.Match(str); - if (priceMatch.Success) { - Logger.Log($"Found price: {priceMatch.Groups[1].Value}", LogLevel.DEBUG); - listing.LowerPrice = float.Parse(priceMatch.Groups[1].Value); - if (priceMatch.Groups[3].Success) { - Logger.Log($"Found a price range: {priceMatch.Groups[3].Value}", LogLevel.DEBUG); - listing.UpperPrice = float.Parse(priceMatch.Groups[3].Value); - } else { - listing.UpperPrice = listing.LowerPrice.Value; - } - } else { - Logger.Log($"Unable to get listing price from: \n {str}", LogLevel.WARNING); - } - - string prodUrl = GetQuoteSet(str, str.IndexOf(PRODUCT_URL_SEQ) + PRODUCT_URL_SEQ.Length).Substring(2); - if (prodUrl != null) { - Logger.Log($"Found URL: {prodUrl}", LogLevel.DEBUG); - listing.URL = prodUrl; - } else { - Logger.Log($"Unable to get item URL from: \n {str}", LogLevel.WARNING); - } - string imageUrl = GetQuoteSet(str, str.IndexOf(IMAGE_URL_SEQ) + IMAGE_URL_SEQ.Length).Substring(2); - if (imageUrl != null) { - Logger.Log($"Found image URL: {imageUrl}", LogLevel.DEBUG); - listing.ImageURL = imageUrl; - } - Match shippingMatch = shippingPriceRegex.Match(str); - if (shippingMatch.Success) { - Logger.Log($"Found shipping price: {shippingMatch.Groups[1].Value}", LogLevel.DEBUG); - listing.Shipping = float.Parse(shippingMatch.Groups[1].Value); - } else if (str.Contains(freeShippingStr)) { - listing.Shipping = 0; - } else { - listing.Shipping = null; - } - return listing; - } - - private string GetQuoteSet(string str, int start) { - char[] cs = str.ToCharArray(); - int quoteCount = 0; - int a = -1; - if (start < 0) return null; - for (int b = start; b < cs.Length; b++) - { - if (cs[b] == '"' && !(b >= 1 && cs[b - 1] == '\\')) { - if (a == -1) { - a = b + 1; - } - quoteCount += 1; - - if (quoteCount >= 2) { - return str.Substring(a, b - a); - } - } - } - return null; - } - - private string GetBracketSet(string str, int start, char open = '{', char close = '}') { - if (start < 0) return null; - - char[] cs = str.ToCharArray(); - int bracketDepth = 0; - int a = -1; - for (int i = start; i < cs.Length; i++) - { - char c = cs[i]; - if (c == open) { - if (a < 0) { - a = i; - } - bracketDepth += 1; - } else if (c == close) { - bracketDepth -= 1; - if (bracketDepth == 0) { - if (i + 1 >= cs.Length) { - return str.Substring(a); - } - return str.Substring(a, i - a + 1); - } else if (bracketDepth < 0) { - return null; - } - } - } - return null; - } - - private IEnumerable GetItemsFromString(string str) { - int startPos = 0; - string itemString = null; - while ((itemString = GetBracketSet(str, startPos)) != null) - { - startPos += itemString.Length + 1; - yield return itemString; - } - } - - private string CurrencyToDisplayStr(Currency currency) { - switch (currency) - { - case Currency.CAD: - return "C"; - case Currency.USD: - return "US"; - default: - throw new InvalidOperationException($"Currency \"{currency}\" is not supported."); - } + if (http != null) throw new InvalidOperationException("HttpClient already instantiated."); + this.http = new HttpClient(); } protected virtual void Dispose(bool disposing) @@ -243,8 +43,8 @@ namespace AliExpressShop { if (disposing) { - client.Dispose(); - handler.Dispose(); + if (http == null) throw new InvalidOperationException("HttpClient not instantiated."); + http.Dispose(); } disposedValue = true; } @@ -255,5 +55,16 @@ namespace AliExpressShop Dispose(disposing: true); GC.SuppressFinalize(this); } + + public void SetupSession(string query, Currency currency) + { + this.query = query; + this.currency = currency; + } + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + return new ShopEnumerator(query, currency, http, UseProxy); + } } } diff --git a/src/AliExpressShop/ShopEnumerator.cs b/src/AliExpressShop/ShopEnumerator.cs new file mode 100644 index 0000000..ee9fb0a --- /dev/null +++ b/src/AliExpressShop/ShopEnumerator.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using GameServiceWarden.Core.Collection; +using MultiShop.ShopFramework; +using SimpleLogger; + +namespace AliExpressShop +{ + class ShopEnumerator : IAsyncEnumerator + { + private LRUCache<(string, Currency), float> conversionCache = new LRUCache<(string, Currency), float>(); + private string query; + private Currency currency; + private HttpClient http; + bool useProxy; + int currentPage = 0; + IEnumerator pageListings; + private bool disposedValue; + + public ProductListing Current {get; private set;} + + public ShopEnumerator(string query, Currency currency, HttpClient http, bool useProxy = true) + { + this.query = query; + this.currency = currency; + this.http = http; + this.useProxy = useProxy; + } + + + private async Task> ScrapePage(int page) + { + const string ALIEXPRESS_QUERY_FORMAT = "https://www.aliexpress.com/wholesale?trafficChannel=main&d=y&CatId=0&SearchText={0}<ype=wholesale&SortType=default&page={1}"; + const char SPACE_CHAR = '+'; + const string PROXY_FORMAT = "https://cors.bridged.cc/{0}"; + const int DELAY = 1000/5; + Regex dataLineRegex = new Regex("^ +window.runParams = .+\"items\":.+;$"); + Regex pageCountRegex = new Regex("\"maxPage\":(\\d+)"); + const string ITEM_LIST_SEQ = "\"items\":"; + + + if (http == null) throw new InvalidOperationException("HttpClient is not initiated."); + List listings = new List(); + + string modifiedQuery = query.Replace(' ', SPACE_CHAR); + Logger.Log($"Searching with query \"{query}\".", LogLevel.Info); + + DateTime start = DateTime.Now; + //Set up request. We need to use the Cors Proxy. + string url = string.Format(ALIEXPRESS_QUERY_FORMAT, modifiedQuery, page); + HttpRequestMessage request = null; + if (useProxy) { + request = new HttpRequestMessage(HttpMethod.Get, string.Format(PROXY_FORMAT, url)); + } else { + request = new HttpRequestMessage(HttpMethod.Get, url); + } + + //Delay for Cors proxy. + double waitTime = DELAY - (DateTime.Now - start).TotalMilliseconds; + if (waitTime > 0) { + Logger.Log($"Delaying next page by {waitTime}ms.", LogLevel.Debug); + await Task.Delay((int)Math.Ceiling(waitTime)); + } + + Logger.Log($"Sending GET request with uri: {request.RequestUri}", LogLevel.Debug); + HttpResponseMessage response = await http.SendAsync(request); + start = DateTime.Now; + + string data = null; + using (StreamReader reader = new StreamReader(await response.Content.ReadAsStreamAsync())) + { + string line = null; + while ((line = await reader.ReadLineAsync()) != null && data == null) + { + if (dataLineRegex.IsMatch(line)) { + data = line.Trim(); + Logger.Log($"Found line with listing data.", LogLevel.Debug); + } + } + } + if (data == null) { + Logger.Log($"Completed search prematurely with status {response.StatusCode} ({(int)response.StatusCode})."); + return null; + } + string itemsString = GetBracketSet(data, data.IndexOf(ITEM_LIST_SEQ) + ITEM_LIST_SEQ.Length, '[', ']'); + IEnumerable listingsStrs = GetItemsFromString(itemsString); + foreach (string listingStr in listingsStrs) + { + listings.Add(await GenerateListingFromString(listingStr, currency)); + } + return listings; + } + + private async Task GenerateListingFromString(string str, Currency currency) { + Regex itemRatingRegex = new Regex("\"starRating\":\"(\\d*.\\d*)\""); + Regex itemsSoldRegex = new Regex("\"tradeDesc\":\"(\\d+) sold\""); + Regex shippingPriceRegex = new Regex("Shipping: \\w+ ?\\$ ?(\\d*.\\d*)"); + Regex itemPriceRegex = new Regex("\"price\":\"\\w+ ?\\$ ?(\\d*.\\d*)( - (\\d+.\\d+))?\","); + + const string FREE_SHIPPING_STR = "\"logisticsDesc\":\"Free Shipping\""; + const string TITLE_SEQ = "\"title\":"; + const string IMAGE_URL_SEQ = "\"imageUrl\":"; + const string PRODUCT_URL_SEQ = "\"productDetailUrl\":"; + + ProductListing listing = new ProductListing(); + + string name = GetQuoteSet(str, str.IndexOf(TITLE_SEQ) + TITLE_SEQ.Length); + if (name != null) { + Logger.Log($"Found name: {name}", LogLevel.Debug); + listing.Name = name; + } else { + Logger.Log($"Unable to get listing name from: \n {str}", LogLevel.Warning); + } + Match ratingMatch = itemRatingRegex.Match(str); + if (ratingMatch.Success) { + Logger.Log($"Found rating: {ratingMatch.Groups[1].Value}", LogLevel.Debug); + listing.Rating = float.Parse(ratingMatch.Groups[1].Value) / 5f; + } + Match numberSoldMatch = itemsSoldRegex.Match(str); + if (numberSoldMatch.Success) { + Logger.Log($"Found quantity sold: {numberSoldMatch.Groups[1].Value}", LogLevel.Debug); + listing.PurchaseCount = int.Parse(numberSoldMatch.Groups[1].Value); + } + + Match priceMatch = itemPriceRegex.Match(str); + if (priceMatch.Success) { + listing.LowerPrice = (float)Math.Round(float.Parse(priceMatch.Groups[1].Value) * await conversionCache.UseAsync(("USD", currency), () => FetchConversion("USD", currency)), 2); + Logger.Log($"Found price: {listing.LowerPrice}", LogLevel.Debug); + if (priceMatch.Groups[3].Success) { + listing.UpperPrice = (float)Math.Round(float.Parse(priceMatch.Groups[3].Value) * await conversionCache.UseAsync(("USD", currency), () => FetchConversion("USD", currency)), 2); + Logger.Log($"Found a price range with upper bound: {listing.UpperPrice}", LogLevel.Debug); + } else { + listing.UpperPrice = (float)Math.Round(listing.LowerPrice * await conversionCache.UseAsync(("USD", currency), () => FetchConversion("USD", currency)), 2); + } + } else { + Logger.Log($"Unable to get listing price from: \n {str}", LogLevel.Warning); + } + + string prodUrl = GetQuoteSet(str, str.IndexOf(PRODUCT_URL_SEQ) + PRODUCT_URL_SEQ.Length).Substring(2); + if (prodUrl != null) { + Logger.Log($"Found URL: {prodUrl}", LogLevel.Debug); + listing.URL = "https://" + prodUrl; + } else { + Logger.Log($"Unable to get item URL from: \n {str}", LogLevel.Warning); + } + string imageUrl = GetQuoteSet(str, str.IndexOf(IMAGE_URL_SEQ) + IMAGE_URL_SEQ.Length).Substring(2); + if (imageUrl != null) { + Logger.Log($"Found image URL: {imageUrl}", LogLevel.Debug); + listing.ImageURL = "https://" + imageUrl; + } + Match shippingMatch = shippingPriceRegex.Match(str); + if (shippingMatch.Success) { + listing.Shipping = (float)Math.Round(float.Parse(shippingMatch.Groups[1].Value) * await conversionCache.UseAsync(("USD", currency), () => FetchConversion("USD", currency)), 2); + Logger.Log($"Found shipping price: {listing.Shipping}", LogLevel.Debug); + } else if (str.Contains(FREE_SHIPPING_STR)) { + listing.Shipping = 0; + } else { + listing.Shipping = null; + } + listing.ConvertedPrices = true; + return listing; + } + + private string GetQuoteSet(string str, int start) { + char[] cs = str.ToCharArray(); + int quoteCount = 0; + int a = -1; + if (start < 0) return null; + for (int b = start; b < cs.Length; b++) + { + if (cs[b] == '"' && !(b >= 1 && cs[b - 1] == '\\')) { + if (a == -1) { + a = b + 1; + } + quoteCount += 1; + + if (quoteCount >= 2) { + return str.Substring(a, b - a); + } + } + } + return null; + } + + private string GetBracketSet(string str, int start, char open = '{', char close = '}') { + if (start < 0) return null; + + char[] cs = str.ToCharArray(); + int bracketDepth = 0; + int a = -1; + for (int i = start; i < cs.Length; i++) + { + char c = cs[i]; + if (c == open) { + if (a < 0) { + a = i; + } + bracketDepth += 1; + } else if (c == close) { + bracketDepth -= 1; + if (bracketDepth == 0) { + if (i + 1 >= cs.Length) { + return str.Substring(a); + } + return str.Substring(a, i - a + 1); + } else if (bracketDepth < 0) { + return null; + } + } + } + return null; + } + + private IEnumerable GetItemsFromString(string str) { + int startPos = 0; + string itemString = null; + while ((itemString = GetBracketSet(str, startPos)) != null) + { + startPos += itemString.Length + 1; + yield return itemString; + } + } + + private async Task FetchConversion(string from, Currency to) { + if (from.Equals(to.ToString())) return 1; + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, string.Format("https://api.exchangerate.host/convert?from={0}&to={1}", from, to)); + HttpResponseMessage response = await http.SendAsync(request); + string results = null; + using (StreamReader reader = new StreamReader(await response.Content.ReadAsStreamAsync())) + { + results = await reader.ReadToEndAsync(); + } + Match match = Regex.Match(results, "\"result\":(\\d*.\\d*)"); + return float.Parse(match.Groups[1].Value); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + } + disposedValue = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + System.GC.SuppressFinalize(this); + } + + public async ValueTask MoveNextAsync() + { + if (pageListings == null || !pageListings.MoveNext()) { + pageListings?.Dispose(); + currentPage += 1; + IEnumerable currentListings = await ScrapePage(currentPage); + if (currentListings == null) { + return false; + } + pageListings = currentListings.GetEnumerator(); + pageListings.MoveNext(); + } + Current = pageListings.Current; + return true; + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/MultiShop.ShopFramework/IShop.cs b/src/MultiShop.ShopFramework/IShop.cs index 1ace891..3e289a4 100644 --- a/src/MultiShop.ShopFramework/IShop.cs +++ b/src/MultiShop.ShopFramework/IShop.cs @@ -1,16 +1,19 @@ using System; using System.Collections.Generic; +using System.Net.Http; +using System.Threading; using System.Threading.Tasks; namespace MultiShop.ShopFramework { - public interface IShop : IDisposable + public interface IShop : IAsyncEnumerable, IDisposable { string ShopName { get; } string ShopDescription { get; } string ShopModuleAuthor { get; } - - void Initiate(Currency currency); - Task> Search(string query, int maxPage = -1); + + public void SetupSession(string query, Currency currency); + + void Initialize(); } } \ No newline at end of file diff --git a/src/MultiShop.ShopFramework/ProductListing.cs b/src/MultiShop.ShopFramework/ProductListing.cs index a7e38c4..3e8e9d7 100644 --- a/src/MultiShop.ShopFramework/ProductListing.cs +++ b/src/MultiShop.ShopFramework/ProductListing.cs @@ -2,7 +2,7 @@ namespace MultiShop.ShopFramework { public struct ProductListing { - public float? LowerPrice { get; set; } + public float LowerPrice { get; set; } public float UpperPrice { get; set; } public float? Shipping { get; set; } public string Name { get; set; } @@ -11,5 +11,21 @@ namespace MultiShop.ShopFramework public float? Rating { get; set; } public int? PurchaseCount { get; set; } public int? ReviewCount { get; set; } + public bool ConvertedPrices { get; set; } + + public override bool Equals(object obj) + { + if (obj == null || GetType() != obj.GetType()) + { + return false; + } + ProductListing b = (ProductListing)obj; + return this.URL == b.URL; + } + + public override int GetHashCode() + { + return URL.GetHashCode(); + } } } \ No newline at end of file diff --git a/src/MultiShop/Load/LoaderContext.cs b/src/MultiShop/Load/LoaderContext.cs deleted file mode 100644 index f904763..0000000 --- a/src/MultiShop/Load/LoaderContext.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.Reflection; -using System.Runtime.Loader; - -// from https://docs.microsoft.com/en-us/dotnet/core/tutorials/creating-app-with-plugin-support#load-plugins -namespace MultiShop.Load -{ - public class LoaderContext : AssemblyLoadContext - { - private AssemblyDependencyResolver resolver; - public LoaderContext(string path) - { - resolver = new AssemblyDependencyResolver(path); - } - - protected override Assembly Load(AssemblyName assemblyName) - { - string path = resolver.ResolveAssemblyToPath(assemblyName); - return path == null ? LoadFromAssemblyPath(path) : null; - } - - protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) - { - string path = resolver.ResolveUnmanagedDllToPath(unmanagedDllName); - return path == null ? LoadUnmanagedDllFromPath(path) : IntPtr.Zero; - } - } -} \ No newline at end of file diff --git a/src/MultiShop/Load/ShopLoader.cs b/src/MultiShop/Load/ShopLoader.cs deleted file mode 100644 index 269ef0f..0000000 --- a/src/MultiShop/Load/ShopLoader.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Reflection; -using MultiShop.ShopFramework; - -namespace MultiShop.Load -{ - public class ShopLoader - { - public IEnumerable LoadShops(string shop) { - return InstantiateShops(LoadAssembly(shop)); - } - - public IReadOnlyDictionary> LoadAllShops(IEnumerable directories) { - Dictionary> res = new Dictionary>(); - foreach (string dir in directories) - { - res.Add(dir, LoadShops(dir)); - } - return res; - } - - private IEnumerable InstantiateShops(Assembly assembly) { - foreach (Type type in assembly.GetTypes()) - { - if (typeof(IShop).IsAssignableFrom(type)) { - IShop shop = Activator.CreateInstance(type) as IShop; - if (shop != null) { - yield return shop; - } - } - } - } - - private Assembly LoadAssembly(string path) { - LoaderContext context = new LoaderContext(path); - return context.LoadFromAssemblyName(new AssemblyName(Path.GetFileNameWithoutExtension(path))); - } - } -} \ No newline at end of file diff --git a/src/MultiShop/Pages/Counter.razor b/src/MultiShop/Pages/Counter.razor deleted file mode 100644 index bd823e5..0000000 --- a/src/MultiShop/Pages/Counter.razor +++ /dev/null @@ -1,16 +0,0 @@ -@page "/counter" - -

Counter

- -

Current count: @currentCount

- - - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} diff --git a/src/MultiShop/Pages/FetchData.razor b/src/MultiShop/Pages/FetchData.razor deleted file mode 100644 index 4432ee5..0000000 --- a/src/MultiShop/Pages/FetchData.razor +++ /dev/null @@ -1,55 +0,0 @@ -@page "/fetchdata" -@inject HttpClient Http - -

Weather forecast

- -

This component demonstrates fetching data from the server.

- -@if (forecasts == null) -{ -

Loading...

-} -else -{ - - - - - - - - - - - @foreach (var forecast in forecasts) - { - - - - - - - } - -
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
-} - -@code { - private WeatherForecast[] forecasts; - - protected override async Task OnInitializedAsync() - { - forecasts = await Http.GetFromJsonAsync("sample-data/weather.json"); - } - - public class WeatherForecast - { - public DateTime Date { get; set; } - - public int TemperatureC { get; set; } - - public string Summary { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - } -} diff --git a/src/MultiShop/Pages/Index.razor b/src/MultiShop/Pages/Index.razor index e54d914..e16ad3b 100644 --- a/src/MultiShop/Pages/Index.razor +++ b/src/MultiShop/Pages/Index.razor @@ -1,7 +1,3 @@ @page "/" -

Hello, world!

- -Welcome to your new app. - - +

Welcome to MultiShop!

diff --git a/src/MultiShop/Pages/Info.razor b/src/MultiShop/Pages/Info.razor new file mode 100644 index 0000000..dec2b7f --- /dev/null +++ b/src/MultiShop/Pages/Info.razor @@ -0,0 +1,11 @@ +@page "/info" +@using ShopFramework + +
+ +
+ +@code { + [CascadingParameter(Name = "Shops")] + public Dictionary Shops { get; set; } +} \ No newline at end of file diff --git a/src/MultiShop/Pages/Search.razor b/src/MultiShop/Pages/Search.razor new file mode 100644 index 0000000..e8ddd5d --- /dev/null +++ b/src/MultiShop/Pages/Search.razor @@ -0,0 +1,397 @@ +@page "/search/{Query?}" +@using Microsoft.Extensions.Configuration +@using ShopFramework +@using SimpleLogger +@using SearchStructures +@inject HttpClient Http +@inject IConfiguration Configuration +@inject IJSRuntime js + +@* TODO: Finish sorting, move things to individual components where possible, key search results. *@ + +
+
+ +
+ + +
+
+ @if (showSearchConfiguration) + { +
+

Configuration

+
+
+
+
Shop Quantity
+
How many results from each store?
+

This is the maximum number of results we gather for each store we have access to. The larger the result, the longer it takes to load search queries.

+
+ + +
+
+
+
+
+
Currency
+
What currency would you like results in?
+

The currency displayed may either be from the online store directly, or through currency conversion (we'll put a little tag beside the coonverted ones).

+
+
+ +
+ +
+
+
+
+
+
Minimum Rating
+
We'll crop out the lower rated stuff.
+

We'll only show products that have a rating greater than or equal to the set minimum rating. Optionally, we can also show those that don't have rating.

+
+ + +
+
+ + +
+
+
+
+
+
Price Range
+
Whats your budget?
+

Results will be pruned of budgets that fall outside of the designated range. The checkbox can enable or disable the upper bound. These bounds do include the shipping price if it's known.

+
+
+
+ +
+ Upper limit +
+ +
+ .00 +
+
+
+
+ Lower limit +
+ +
+ .00 +
+
+
+
+
+
+
Shops Searched
+
What's your preference?
+

We'll only look through shops that are enabled in this list. Of course, at least one shop has to be enabled.

+ @foreach (string shop in Shops.Keys) + { +
+ + +
+ } +
+
+
+
+
Minimum Purchases
+
If they're purchasing, I am too!
+

Only products that have enough purchases are shown. Optionally, we can also show results that don't have a purchase tally.

+
+
+ Minimum purchases +
+ +
+
+ + +
+
+
+
+
+
Minimum Reviews
+
Well if this many people say it's good...
+

Only products with enough reviews/ratings are shown. Optionally, we can also show the results that don't have this information.

+
+
+ Minimum reviews +
+ +
+
+ + +
+
+
+
+
+
Shipping
+
Free shipping?
+

Show results with shipping rates less than a certain value, and choose whether or not to display listings without shipping information.

+
+
+ + + + Max shipping +
+ +
+ .00 +
+
+
+ + +
+
+
+
+
+ } +
+ +
+
+
+ @if (searching) + { + @if (listings.Count != 0) + { +
+ Loading... +
+ Looked through @resultsChecked listings and found @listings.Count viable results. We're still looking! + } + else + { +
+ Loading... +
+ Hold tight! We're looking through the stores for viable results... + } + } + else if (listings.Count != 0) + { + @if (organizing) + { +
+ Loading... +
+ Organizing the data to your spec... + } + else + { + Looked through @resultsChecked listings and found @listings.Count viable results. + } + } + else if (searched) + { + We've found @resultsChecked listings and unfortunately none matched your search. + } + else + { + Search for something to see the results! + } +
+ + + + + + + + @(item.FriendlyName()) + + + + + +
+
+ +
+
+ + +@code { + [CascadingParameter(Name = "Shops")] + public Dictionary Shops { get; set; } + + [Parameter] + public string Query { get; set; } + + private SearchProfile activeProfile = new SearchProfile(); + private ResultsProfile activeResultsProfile = new ResultsProfile(); + + private bool showSearchConfiguration = false; + private string ToggleSearchConfigButtonCss + { + get => "btn btn-outline-secondary" + (showSearchConfiguration ? " active" : ""); + } + + private bool searched = false; + private bool searching = false; + private bool organizing = false; + + private int resultsChecked = 0; + private List listings = new List(); + + protected override void OnInitialized() + { + foreach (string shop in Shops.Keys) + { + activeProfile.shopStates[shop] = true; + } + base.OnInitialized(); + } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + } + + protected override async Task OnParametersSetAsync() + { + if (!string.IsNullOrEmpty(Query)) + { + await PerformSearch(Query); + } + await base.OnParametersSetAsync(); + } + + private async Task PerformSearch(string query) + { + if (string.IsNullOrWhiteSpace(query)) return; + if (searching) return; + searching = true; + Logger.Log($"Received search request for \"{query}\".", LogLevel.Debug); + resultsChecked = 0; + listings.Clear(); + Dictionary> greatest = new Dictionary>(); + foreach (string shopName in Shops.Keys) + { + if (activeProfile.shopStates[shopName]) + { + Logger.Log($"Querying \"{shopName}\" for products."); + Shops[shopName].SetupSession(query, activeProfile.currency); + int shopViableResults = 0; + await foreach (ProductListing listing in Shops[shopName]) + { + resultsChecked += 1; + if (resultsChecked % 50 == 0) { + StateHasChanged(); + await Task.Yield(); + } + + + if (listing.Shipping == null && !activeProfile.keepUnknownShipping || (activeProfile.enableMaxShippingFee && listing.Shipping > activeProfile.MaxShippingFee)) continue; + float shippingDifference = listing.Shipping != null ? listing.Shipping.Value : 0; + if (!(listing.LowerPrice + shippingDifference >= activeProfile.lowerPrice && (!activeProfile.enableUpperPrice || listing.UpperPrice + shippingDifference <= activeProfile.UpperPrice))) continue; + if ((listing.Rating == null && !activeProfile.keepUnrated) || activeProfile.minRating > (listing.Rating == null ? 0 : listing.Rating)) continue; + if ((listing.PurchaseCount == null && !activeProfile.keepUnknownPurchaseCount) || activeProfile.minPurchases > (listing.PurchaseCount == null ? 0 : listing.PurchaseCount)) continue; + if ((listing.ReviewCount == null && !activeProfile.keepUnknownRatingCount) || activeProfile.minReviews > (listing.ReviewCount == null ? 0 : listing.ReviewCount)) continue; + + ProductListingInfo info = new ProductListingInfo(listing, shopName); + listings.Add(info); + await Task.Yield(); + foreach (ResultsProfile.Category c in Enum.GetValues()) + { + if (!greatest.ContainsKey(c)) greatest[c] = new List(); + if (greatest[c].Count > 0) + { + int? compResult = c.CompareListings(info, greatest[c][0]); + if (compResult.HasValue) + { + if (compResult > 0) greatest[c].Clear(); + if (compResult >= 0) greatest[c].Add(info); + } + } + else + { + if (c.CompareListings(info, info).HasValue) + { + greatest[c].Add(info); + } + } + } + + shopViableResults += 1; + if (shopViableResults >= activeProfile.maxResults) break; + } + Logger.Log($"\"{shopName}\" has completed. There are {listings.Count} results in total.", LogLevel.Debug); + } + else + { + Logger.Log($"Skipping {shopName} since it's disabled."); + } + } + searching = false; + searched = true; + + foreach (ResultsProfile.Category c in greatest.Keys) + { + foreach (ProductListingInfo info in greatest[c]) + { + info.Tops.Add(c); + } + } + + await Organize(activeResultsProfile.Order); + } + + private async Task Organize(List order) + { + if (searching) return; + organizing = true; + StateHasChanged(); + + List sortedResults = await Task.Run>(() => { + List sorted = new List(listings); + sorted.Sort((a, b) => + { + foreach (ResultsProfile.Category category in activeResultsProfile.Order) + { + int? compareResult = category.CompareListings(a, b); + if (compareResult.HasValue && compareResult.Value != 0) + { + return -compareResult.Value; + } + } + return 0; + }); + return sorted; + }); + listings.Clear(); + listings.AddRange(sortedResults); + organizing = false; + StateHasChanged(); + } +} diff --git a/src/MultiShop/Program.cs b/src/MultiShop/Program.cs index 8a1ee24..24caf48 100644 --- a/src/MultiShop/Program.cs +++ b/src/MultiShop/Program.cs @@ -1,12 +1,8 @@ using System; using System.Net.Http; -using System.Collections.Generic; using System.Threading.Tasks; -using System.Text; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using SimpleLogger; namespace MultiShop @@ -15,12 +11,11 @@ namespace MultiShop { public static async Task Main(string[] args) { - Logger.AddLogListener(new ConsoleLogReceiver()); + + Logger.AddLogListener(new ConsoleLogReceiver() {Level = LogLevel.Debug}); var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.RootComponents.Add("#app"); - builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); - await builder.Build().RunAsync(); } } diff --git a/src/MultiShop/SearchStructures/ProductListingInfo.cs b/src/MultiShop/SearchStructures/ProductListingInfo.cs new file mode 100644 index 0000000..3fdb0dc --- /dev/null +++ b/src/MultiShop/SearchStructures/ProductListingInfo.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using MultiShop.ShopFramework; + +namespace MultiShop.SearchStructures +{ + public class ProductListingInfo + { + public ProductListing Listing { get; private set; } + public string ShopName { get; private set; } + public float RatingToPriceRatio { + get { + int reviewFactor = Listing.ReviewCount.HasValue ? Listing.ReviewCount.Value : 1; + int purchaseFactor = Listing.PurchaseCount.HasValue ? Listing.PurchaseCount.Value : 1; + float ratingFactor = 1 + (Listing.Rating.HasValue ? Listing.Rating.Value : 0); + return (ratingFactor * (reviewFactor > purchaseFactor ? reviewFactor : purchaseFactor))/(Listing.LowerPrice * Listing.UpperPrice); + } + } + public ISet Tops { get; private set; } = new HashSet(); + + public ProductListingInfo(ProductListing listing, string shopName) + { + this.Listing = listing; + this.ShopName = shopName; + } + } +} \ No newline at end of file diff --git a/src/MultiShop/SearchStructures/ResultCategoryExtensions.cs b/src/MultiShop/SearchStructures/ResultCategoryExtensions.cs new file mode 100644 index 0000000..315f845 --- /dev/null +++ b/src/MultiShop/SearchStructures/ResultCategoryExtensions.cs @@ -0,0 +1,46 @@ +using System; +using System.ComponentModel; +using MultiShop.ShopFramework; + +namespace MultiShop.SearchStructures +{ + public static class ResultCategoryExtensions + { + public static int? CompareListings(this ResultsProfile.Category category, ProductListingInfo a, ProductListingInfo b) + { + switch (category) + { + case ResultsProfile.Category.RatingPriceRatio: + float dealDiff = a.RatingToPriceRatio - b.RatingToPriceRatio; + int dealCeil = (int)Math.Ceiling(Math.Abs(dealDiff)); + return dealDiff < 0 ? -dealCeil : dealCeil; + case ResultsProfile.Category.Price: + float priceDiff = b.Listing.UpperPrice - a.Listing.UpperPrice; + int priceCeil = (int)Math.Ceiling(Math.Abs(priceDiff)); + return priceDiff < 0 ? -priceCeil : priceCeil; + case ResultsProfile.Category.Purchases: + return a.Listing.PurchaseCount - b.Listing.PurchaseCount; + case ResultsProfile.Category.Reviews: + return a.Listing.ReviewCount - b.Listing.ReviewCount; + } + + throw new ArgumentException($"{category} does not have a defined comparison."); + } + + public static string FriendlyName(this ResultsProfile.Category category) + { + switch (category) + { + case ResultsProfile.Category.RatingPriceRatio: + return "Best rating to price ratio first"; + case ResultsProfile.Category.Price: + return "Lowest price first"; + case ResultsProfile.Category.Purchases: + return "Most purchases first"; + case ResultsProfile.Category.Reviews: + return "Most reviews first"; + } + throw new ArgumentException($"{category} does not have a friendly name defined."); + } + } +} \ No newline at end of file diff --git a/src/MultiShop/SearchStructures/ResultsProfile.cs b/src/MultiShop/SearchStructures/ResultsProfile.cs new file mode 100644 index 0000000..ac428d4 --- /dev/null +++ b/src/MultiShop/SearchStructures/ResultsProfile.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MultiShop.SearchStructures +{ + public class ResultsProfile + { + public List Order { get; private set; } = new List(Enum.GetValues().Length); + + public ResultsProfile() + { + foreach (Category category in Enum.GetValues()) + { + Order.Add(category); + } + } + + public Category GetCategory(int position) + { + return Order[position]; + } + + public enum Category + { + RatingPriceRatio, + Price, + Purchases, + Reviews, + } + } +} \ No newline at end of file diff --git a/src/MultiShop/SearchStructures/SearchProfile.cs b/src/MultiShop/SearchStructures/SearchProfile.cs new file mode 100644 index 0000000..e306e9b --- /dev/null +++ b/src/MultiShop/SearchStructures/SearchProfile.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using MultiShop.ShopFramework; + +namespace MultiShop.SearchStructures +{ + public class SearchProfile + { + public Currency currency; + public int maxResults; + public float minRating; + public bool keepUnrated; + public bool enableUpperPrice; + private int upperPrice; + public int UpperPrice + { + get + { + return upperPrice; + } + set + { + if (enableUpperPrice) upperPrice = value; + } + } + public int lowerPrice; + public int minPurchases; + public bool keepUnknownPurchaseCount; + public int minReviews; + public bool keepUnknownRatingCount; + public bool enableMaxShippingFee; + private int maxShippingFee; + public int MaxShippingFee { + get { + return maxShippingFee; + } + set { + if (enableMaxShippingFee) maxShippingFee = value; + } + } + public bool keepUnknownShipping; + public ShopStateTracker shopStates = new ShopStateTracker(); + + public SearchProfile() + { + currency = Currency.CAD; + maxResults = 100; + minRating = 0.8f; + keepUnrated = true; + enableUpperPrice = false; + upperPrice = 0; + lowerPrice = 0; + minPurchases = 0; + keepUnknownPurchaseCount = true; + minReviews = 0; + keepUnknownRatingCount = true; + enableMaxShippingFee = false; + maxShippingFee = 0; + keepUnknownShipping = true; + } + + public class ShopStateTracker + { + private HashSet shopsEnabled = new HashSet(); + public bool this[string name] + { + get + { + return shopsEnabled.Contains(name); + } + + set + { + if (value == false && !CanDisableShop()) return; + if (value) + { + shopsEnabled.Add(name); + } + else + { + shopsEnabled.Remove(name); + } + } + } + public bool CanDisableShop() { + return shopsEnabled.Count > 1; + } + } + } +} \ No newline at end of file diff --git a/src/MultiShop/Shared/CustomDropdown.razor b/src/MultiShop/Shared/CustomDropdown.razor new file mode 100644 index 0000000..60160e8 --- /dev/null +++ b/src/MultiShop/Shared/CustomDropdown.razor @@ -0,0 +1,45 @@ +@inject IJSRuntime JS + +
+ + +
+ +@code { + [Parameter] + public RenderFragment ButtonContent { get; set; } + + [Parameter] + public RenderFragment DropdownContent { get; set; } + + [Parameter] + public string AdditionalButtonClasses { get; set; } + + [Parameter] + public string Justify { get; set; } + + private ElementReference dropdown; + private string ButtonCss + { + get => "btn " + AdditionalButtonClasses; + } + + protected override async Task OnParametersSetAsync() + { + AdditionalButtonClasses = AdditionalButtonClasses ?? ""; + Justify = Justify ?? "center"; + await base.OnParametersSetAsync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("customDropdown", dropdown, Justify); + } + await base.OnAfterRenderAsync(firstRender); + } + +} diff --git a/src/MultiShop/Shared/DragAndDropList.razor b/src/MultiShop/Shared/DragAndDropList.razor new file mode 100644 index 0000000..74f6bd9 --- /dev/null +++ b/src/MultiShop/Shared/DragAndDropList.razor @@ -0,0 +1,65 @@ +@using SimpleLogger +@typeparam TItem +@inject IJSRuntime JS + +
    + @foreach (TItem item in Items) + { +
  • + + @DraggableItem(item) +
  • + } +
+ + +@code { + [Parameter] + public List Items { get; set; } + + [Parameter] + public string AdditionalListClasses { get; set; } + + [Parameter] + public EventCallback OnOrderChange { get; set; } + + [Parameter] + public RenderFragment DraggableItem { get; set; } + + private ElementReference dragAndDrop; + + private int itemDraggedIndex = -1; + + + private string ListGroupCss + { + get => "list-group " + AdditionalListClasses; + } + + protected override async Task OnParametersSetAsync() + { + AdditionalListClasses = AdditionalListClasses ?? ""; + await base.OnParametersSetAsync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("dragAndDropList", dragAndDrop); + } + await base.OnAfterRenderAsync(firstRender); + } + + + private async Task OnDrop(TItem dropped) + { + TItem item = Items[itemDraggedIndex]; + if (item.Equals(dropped)) return; + int indexOfDrop = Items.IndexOf(dropped); + Items.RemoveAt(itemDraggedIndex); + Items.Insert(indexOfDrop, item); + itemDraggedIndex = -1; + await OnOrderChange.InvokeAsync(); + } +} diff --git a/src/MultiShop/Shared/ListingTableView.razor b/src/MultiShop/Shared/ListingTableView.razor new file mode 100644 index 0000000..60e6462 --- /dev/null +++ b/src/MultiShop/Shared/ListingTableView.razor @@ -0,0 +1,99 @@ +@using ShopFramework +@using SearchStructures + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
NamePriceShippingPurchasesRatingReviews
+
@product.Listing.Name
+ From @product.ShopName + @if (product.Listing.ConvertedPrices) + { + Converted price + } + @foreach (ResultsProfile.Category c in product.Tops) + { + @CategoryTags(c) + } +
+ @if (product.Listing.UpperPrice != product.Listing.LowerPrice) + { +
+ @product.Listing.LowerPrice to @product.Listing.UpperPrice +
+ } + else + { +
+ @GetOrNA(product.Listing.LowerPrice) +
+ } +
+
+ @GetOrNA(product.Listing.Shipping) +
+
+
+ @GetOrNA(product.Listing.PurchaseCount) +
+
+
+ @(product.Listing.Rating != null ? string.Format("{0:P2}", product.Listing.Rating) : "N/A") +
+
@GetOrNA(product.Listing.ReviewCount) + View +
+
+ +@code { + + [Parameter] + public List Products { get; set; } + + private string PriceCellHeight { get => "height: " + "4rem"; } + + private string GetOrNA(object data, string prepend = null, string append = null) + { + return data != null ? (prepend + data.ToString() + append) : "N/A"; + } + + private string CategoryTags(ResultsProfile.Category c) + { + switch (c) + { + case ResultsProfile.Category.RatingPriceRatio: + return "Best rating to price ratio"; + case ResultsProfile.Category.Price: + return "Lowest price"; + case ResultsProfile.Category.Purchases: + return "Most purchases"; + case ResultsProfile.Category.Reviews: + return "Most reviews"; + } + throw new ArgumentException($"{c} does not have an associated string."); + } + +} \ No newline at end of file diff --git a/src/MultiShop/Shared/ListingTableView.razor.css b/src/MultiShop/Shared/ListingTableView.razor.css new file mode 100644 index 0000000..d5fc6cc --- /dev/null +++ b/src/MultiShop/Shared/ListingTableView.razor.css @@ -0,0 +1,3 @@ +tbody > tr > th > div { + width: 45em; +} \ No newline at end of file diff --git a/src/MultiShop/Shared/MainLayout.razor b/src/MultiShop/Shared/MainLayout.razor index a76e097..d91bb71 100644 --- a/src/MultiShop/Shared/MainLayout.razor +++ b/src/MultiShop/Shared/MainLayout.razor @@ -1,17 +1,82 @@ +@using ShopFramework +@using SimpleLogger +@using System.Reflection +@using Microsoft.Extensions.Configuration +@inject HttpClient Http +@inject IConfiguration Configuration @inherits LayoutComponentBase +@implements IDisposable
- - +
-
- About -
-
- @Body + @if (modulesLoaded) + { + + @Body + + }
+ +@code { + private bool modulesLoaded = false; + + private Dictionary shops = new Dictionary(); + + protected override async Task OnInitializedAsync() + { + await DownloadShopModules(); + await base.OnInitializedAsync(); + } + private async Task DownloadShopModules() { + Logger.Log($"Fetching shop modules.", LogLevel.Debug); + string[] shopNames = await Http.GetFromJsonAsync(Configuration["ModulesList"]); + Task[] assemblyDownloadTasks = new Task[shopNames.Length]; + + for (int i = 0; i < shopNames.Length; i++) + { + string shopPath = Configuration["ModulesDir"] + shopNames[i] + ".dll"; + assemblyDownloadTasks[i] = Http.GetByteArrayAsync(shopPath); + Logger.Log($"Downloading \"{shopPath}\".", LogLevel.Debug); + } + + AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyDependencyRequest; + + foreach (Task task in assemblyDownloadTasks) + { + Assembly assembly = AppDomain.CurrentDomain.Load(await task); + + foreach (Type type in assembly.GetTypes()) + { + if (typeof(IShop).IsAssignableFrom(type)) { + IShop shop = Activator.CreateInstance(type) as IShop; + if (shop != null) { + shop.Initialize(); + shops.Add(shop.ShopName, shop); + Logger.Log($"Registered and started lifetime of module for \"{shop.ShopName}\".", LogLevel.Debug); + } + } + } + } + + modulesLoaded = true; + } + + + private Assembly OnAssemblyDependencyRequest(object sender, ResolveEventArgs args) { + Logger.Log($"Assembly {args.RequestingAssembly} is requesting dependency assembly {args.Name}. Attempting to retrieve...", LogLevel.Debug); + return AppDomain.CurrentDomain.Load(Http.GetByteArrayAsync(Configuration["ModulesDir"] + args.Name + ".dll").Result); + } + + + public void Dispose() { + foreach (string name in shops.Keys) + { + shops[name].Dispose(); + Logger.Log($"Ending lifetime of shop module for \"{name}\"."); + } + } +} \ No newline at end of file diff --git a/src/MultiShop/Shared/MainLayout.razor.css b/src/MultiShop/Shared/MainLayout.razor.css index 43c355a..b2332aa 100644 --- a/src/MultiShop/Shared/MainLayout.razor.css +++ b/src/MultiShop/Shared/MainLayout.razor.css @@ -8,28 +8,6 @@ flex: 1; } -.sidebar { - background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); -} - -.top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; - justify-content: flex-end; - height: 3.5rem; - display: flex; - align-items: center; -} - - .top-row ::deep a, .top-row .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - } - - .top-row a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } @media (max-width: 640.98px) { .top-row:not(.auth) { @@ -39,32 +17,4 @@ .top-row.auth { justify-content: space-between; } - - .top-row a, .top-row .btn-link { - margin-left: 0; - } -} - -@media (min-width: 641px) { - .page { - flex-direction: row; - } - - .sidebar { - width: 250px; - height: 100vh; - position: sticky; - top: 0; - } - - .top-row { - position: sticky; - top: 0; - z-index: 1; - } - - .main > div { - padding-left: 2rem !important; - padding-right: 1.5rem !important; - } -} +} \ No newline at end of file diff --git a/src/MultiShop/Shared/NavMenu.razor b/src/MultiShop/Shared/NavMenu.razor index 667fc4c..791fde0 100644 --- a/src/MultiShop/Shared/NavMenu.razor +++ b/src/MultiShop/Shared/NavMenu.razor @@ -1,34 +1,32 @@ - -
- -
+
+ +
+ @code { private bool collapseNavMenu = true; - private string NavMenuCssClass => collapseNavMenu ? "collapse" : null; + private string NavMenuCssClass => (collapseNavMenu ? "collapse " : " ") + "navbar-collapse"; private void ToggleNavMenu() { diff --git a/src/MultiShop/Shared/NavMenu.razor.css b/src/MultiShop/Shared/NavMenu.razor.css deleted file mode 100644 index acc5f9f..0000000 --- a/src/MultiShop/Shared/NavMenu.razor.css +++ /dev/null @@ -1,62 +0,0 @@ -.navbar-toggler { - background-color: rgba(255, 255, 255, 0.1); -} - -.top-row { - height: 3.5rem; - background-color: rgba(0,0,0,0.4); -} - -.navbar-brand { - font-size: 1.1rem; -} - -.oi { - width: 2rem; - font-size: 1.1rem; - vertical-align: text-top; - top: -2px; -} - -.nav-item { - font-size: 0.9rem; - padding-bottom: 0.5rem; -} - - .nav-item:first-of-type { - padding-top: 1rem; - } - - .nav-item:last-of-type { - padding-bottom: 1rem; - } - - .nav-item ::deep a { - color: #d7d7d7; - border-radius: 4px; - height: 3rem; - display: flex; - align-items: center; - line-height: 3rem; - } - -.nav-item ::deep a.active { - background-color: rgba(255,255,255,0.25); - color: white; -} - -.nav-item ::deep a:hover { - background-color: rgba(255,255,255,0.1); - color: white; -} - -@media (min-width: 641px) { - .navbar-toggler { - display: none; - } - - .collapse { - /* Never collapse the sidebar for wide screens */ - display: block; - } -} diff --git a/src/MultiShop/Shared/SurveyPrompt.razor b/src/MultiShop/Shared/SurveyPrompt.razor deleted file mode 100644 index 1ec40da..0000000 --- a/src/MultiShop/Shared/SurveyPrompt.razor +++ /dev/null @@ -1,16 +0,0 @@ - - -@code { - // Demonstrates how a parent component can supply parameters - [Parameter] - public string Title { get; set; } -} diff --git a/src/MultiShop/wwwroot/100.png b/src/MultiShop/wwwroot/100.png new file mode 100644 index 0000000..8a1daa0 Binary files /dev/null and b/src/MultiShop/wwwroot/100.png differ diff --git a/src/MultiShop/wwwroot/appsettings.json b/src/MultiShop/wwwroot/appsettings.json index 1010a01..3d11293 100644 --- a/src/MultiShop/wwwroot/appsettings.json +++ b/src/MultiShop/wwwroot/appsettings.json @@ -1,4 +1,5 @@ { - "LogLevel" : "DEBUG", - "ModulesList" : "modules/modules_content" + "LogLevel" : "Debug", + "ModulesDir" : "modules/", + "ModulesList" : "modules/modules_content.json" } \ No newline at end of file diff --git a/src/MultiShop/wwwroot/css/app.css b/src/MultiShop/wwwroot/css/app.css index caebf2a..d9b36b5 100644 --- a/src/MultiShop/wwwroot/css/app.css +++ b/src/MultiShop/wwwroot/css/app.css @@ -4,30 +4,35 @@ html, body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; } -a, .btn-link { - color: #0366d6; -} - -.btn-primary { - color: #fff; - background-color: #1b6ec2; - border-color: #1861ac; -} - .content { - padding-top: 1.1rem; + padding-top: 1.5rem; } -.valid.modified:not([type=checkbox]) { - outline: 1px solid #26b050; +.table.table-top-borderless thead th { + border-top-style: none; } -.invalid { - outline: 1px solid red; +.btn.btn-tab { + border-bottom-style: none; + border-bottom-left-radius: 0em; + border-bottom-right-radius: 0em; } -.validation-message { - color: red; +.list-group-top-square-left .list-group-item:first-child { + border-top-left-radius: 0em; +} + +.list-group-top-square-right .list-group-item:first-child { + border-top-right-radius: 0em; +} + +li.list-group-item.list-group-item-hover:hover { + background-color: #F5F5F5; +} + +li.list-group-nospacing { + padding: 0px; + margin: 0px; } #blazor-error-ui { diff --git a/src/MultiShop/wwwroot/index.html b/src/MultiShop/wwwroot/index.html index 671dc76..a4d232e 100644 --- a/src/MultiShop/wwwroot/index.html +++ b/src/MultiShop/wwwroot/index.html @@ -19,7 +19,8 @@ Reload 🗙 + - + \ No newline at end of file diff --git a/src/MultiShop/wwwroot/js/ComponentsSupport.js b/src/MultiShop/wwwroot/js/ComponentsSupport.js new file mode 100644 index 0000000..c4e2472 --- /dev/null +++ b/src/MultiShop/wwwroot/js/ComponentsSupport.js @@ -0,0 +1,69 @@ +function customDropdown(elem, justify) { + let btn = elem.querySelector("button"); + let dropdown = elem.querySelector("div"); + if (justify.toLowerCase() == "left") { + dropdown.style.left = "0px"; + } else if (justify.toLowerCase() == "center") { + dropdown.style.left = "50%"; + } else if (justify.toLowerCase() == "right") { + dropdown.style.right = "0px"; + } + + let openFunc = () => { + btn.classList.add("active"); + dropdown.classList.remove("invisible"); + dropdown.focus(); + } + + let closeFunc = () => { + btn.classList.remove("active"); + dropdown.classList.add("invisible"); + } + + btn.addEventListener("click", () => { + if (!btn.classList.contains("active")) { + openFunc(); + } else { + closeFunc(); + } + }); + dropdown.addEventListener("focusout", (e) => { + if (e.relatedTarget != btn) { + closeFunc(); + } + }); + dropdown.addEventListener("keyup", (e) => { + if (e.code == "Escape") { + dropdown.blur(); + } + }); +} + +function dragAndDropList(elem) { + elem.addEventListener("dragover", (e) => { + e.preventDefault(); + }); + let itemDragged; + for (let i = 0; i < elem.childElementCount; i++) { + let e = elem.children[i]; + e.addEventListener("dragstart", () => { + itemDragged = e; + e.classList.add("list-group-item-secondary"); + e.classList.remove("list-group-item-hover"); + }); + e.addEventListener("dragenter", () => { + e.classList.add("list-group-item-primary"); + e.classList.remove("list-group-item-hover"); + }); + e.addEventListener("dragleave", () => { + e.classList.remove("list-group-item-primary"); + e.classList.add("list-group-item-hover"); + }); + e.addEventListener("drop", () => { + e.classList.add("list-group-item-hover"); + e.classList.remove("list-group-item-primary"); + itemDragged.classList.remove("list-group-item-secondary"); + itemDragged.classList.add("list-group-item-hover"); + }); + } +} \ No newline at end of file diff --git a/src/MultiShop/wwwroot/modules/AliExpressShop.dll b/src/MultiShop/wwwroot/modules/AliExpressShop.dll new file mode 100644 index 0000000..9b583d3 Binary files /dev/null and b/src/MultiShop/wwwroot/modules/AliExpressShop.dll differ diff --git a/src/MultiShop/wwwroot/modules/SimpleLogger.dll b/src/MultiShop/wwwroot/modules/SimpleLogger.dll new file mode 100644 index 0000000..a0eb6c6 Binary files /dev/null and b/src/MultiShop/wwwroot/modules/SimpleLogger.dll differ diff --git a/src/MultiShop/wwwroot/modules/gen_metadata.py b/src/MultiShop/wwwroot/modules/gen_metadata.py index 8d84f14..a7d3edd 100644 --- a/src/MultiShop/wwwroot/modules/gen_metadata.py +++ b/src/MultiShop/wwwroot/modules/gen_metadata.py @@ -4,9 +4,10 @@ import json modulepaths = [] for content in os.listdir(os.getcwd()): - if (os.path.isfile(content) and os.path.splitext(content)[1] == ".dll"): - print("Adding \"{0}\" to list of modules.".format(content)) - modulepaths.append(content) + components = os.path.splitext(content) + if (os.path.isfile(content) and components[1] == ".dll"): + print("Adding \"{0}\" to list of modules.".format(components[0])) + modulepaths.append(components[0]) file = open("modules_content.json", "w") json.dump(modulepaths, file, sort_keys=True, indent=4) diff --git a/src/MultiShop/wwwroot/modules/modules_content.json b/src/MultiShop/wwwroot/modules/modules_content.json new file mode 100644 index 0000000..8c4a535 --- /dev/null +++ b/src/MultiShop/wwwroot/modules/modules_content.json @@ -0,0 +1,3 @@ +[ + "AliExpressShop" +] \ No newline at end of file diff --git a/src/MultiShop/wwwroot/sample-data/weather.json b/src/MultiShop/wwwroot/sample-data/weather.json deleted file mode 100644 index 23687ae..0000000 --- a/src/MultiShop/wwwroot/sample-data/weather.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "date": "2018-05-06", - "temperatureC": 1, - "summary": "Freezing" - }, - { - "date": "2018-05-07", - "temperatureC": 14, - "summary": "Bracing" - }, - { - "date": "2018-05-08", - "temperatureC": -13, - "summary": "Freezing" - }, - { - "date": "2018-05-09", - "temperatureC": -16, - "summary": "Balmy" - }, - { - "date": "2018-05-10", - "temperatureC": -2, - "summary": "Chilly" - } -] diff --git a/test/AliExpressShop.Tests/ShopTest.cs b/test/AliExpressShop.Tests/ShopTest.cs index 5fdc959..f09998f 100644 --- a/test/AliExpressShop.Tests/ShopTest.cs +++ b/test/AliExpressShop.Tests/ShopTest.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using GameServiceWarden.Core.Tests; using MultiShop.ShopFramework; @@ -16,57 +17,45 @@ namespace AliExpressShop.Tests } [Fact] - public void Search_SearchForItem_ImportantInfoFound() + public async void Search_SearchForItem_MultiplePages() { //Given + const int MAX_RESULTS = 120; Shop shop = new Shop(); - shop.Initiate(Currency.CAD); + shop.UseProxy = false; + shop.Initialize(); //When - Task> listingsTask = shop.Search("mpu6050", 1); - listingsTask.Wait(); - IEnumerable listings = listingsTask.Result; - Assert.NotEmpty(listings); - foreach (ProductListing listing in listings) + shop.SetupSession("mpu6050", Currency.CAD); + //Then + int count = 0; + await foreach (ProductListing listing in shop) { Assert.False(string.IsNullOrWhiteSpace(listing.Name)); - Assert.True(listing.LowerPrice != 0); + count += 1; + if (count > MAX_RESULTS) return; } } [Fact] - public void Search_SearchForItem_MultiplePages() - { - //Given - Shop shop = new Shop(); - shop.Initiate(Currency.CAD); - //When - Task> listingsTask = shop.Search("mpu6050", 2); - listingsTask.Wait(); - IEnumerable listings = listingsTask.Result; - //Then - Assert.NotEmpty(listings); - foreach (ProductListing listing in listings) - { - Assert.False(string.IsNullOrWhiteSpace(listing.Name)); - } - } - [Fact] - public void Search_USD_ResultsFound() + public async void Search_USD_ResultsFound() { //Given + const int MAX_RESULTS = 120; Shop shop = new Shop(); - shop.Initiate(Currency.USD); + shop.UseProxy = false; + shop.Initialize(); //When - Task> listingsTask = shop.Search("mpu6050", 1); - listingsTask.Wait(); - IEnumerable listings = listingsTask.Result; + shop.SetupSession("mpu6050", Currency.USD); //Then - Assert.NotEmpty(listings); - foreach (ProductListing listing in listings) + + int count = 0; + await foreach (ProductListing listing in shop) { Assert.False(string.IsNullOrWhiteSpace(listing.Name)); Assert.True(listing.LowerPrice != 0); + count += 1; + if (count > MAX_RESULTS) return; } } } diff --git a/test/AliExpressShop.Tests/XUnitLogger.cs b/test/AliExpressShop.Tests/XUnitLogger.cs index abaa83d..520149c 100644 --- a/test/AliExpressShop.Tests/XUnitLogger.cs +++ b/test/AliExpressShop.Tests/XUnitLogger.cs @@ -6,7 +6,7 @@ namespace GameServiceWarden.Core.Tests { public class XUnitLogger : ILogReceiver { - public LogLevel Level => LogLevel.DEBUG; + public LogLevel Level => LogLevel.Debug; public string Identifier => GetType().Name;