diff --git a/src/BanggoodShop/BanggoodShop.csproj b/src/BanggoodShop/BanggoodShop.csproj new file mode 100644 index 0000000..38c6a2a --- /dev/null +++ b/src/BanggoodShop/BanggoodShop.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + + + net5.0 + + + diff --git a/src/BanggoodShop/Shop.cs b/src/BanggoodShop/Shop.cs new file mode 100644 index 0000000..3f82e85 --- /dev/null +++ b/src/BanggoodShop/Shop.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading; +using MultiShop.ShopFramework; + +namespace BanggoodShop +{ + public class Shop : IShop + { + public bool UseProxy { get; set; } = true; + private bool disposedValue; + + public string ShopName => "Banggood"; + + public string ShopDescription => "A online retailer based in China."; + + public string ShopModuleAuthor => "Reslate"; + + private HttpClient http; + private string query; + private Currency currency; + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + return new ShopEnumerator(query, currency, http, UseProxy, cancellationToken); + } + + public void Initialize() + { + this.http = new HttpClient(); + } + + public void SetupSession(string query, Currency currency) + { + this.query = query; + this.currency = currency; + } + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + http.Dispose(); + } + disposedValue = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/BanggoodShop/ShopEnumerator.cs b/src/BanggoodShop/ShopEnumerator.cs new file mode 100644 index 0000000..3e53fc5 --- /dev/null +++ b/src/BanggoodShop/ShopEnumerator.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using HtmlAgilityPack; +using MultiShop.ShopFramework; +using SimpleLogger; + +namespace BanggoodShop +{ + class ShopEnumerator : IAsyncEnumerator + { + const string PROXY_FORMAT = "https://cors.bridged.cc/{0}"; + private const string QUERY_FORMAT = "https://www.banggood.com/search/{0}/0-0-0-1-1-60-0-price-0-0_p-{1}.html?DCC=CA¤cy={2}"; + HttpClient http; + private string query; + private Currency currency; + private bool useProxy; + private CancellationToken cancellationToken; + + private IEnumerator pageListings; + private int currentPage; + private DateTime lastScrape; + + public ProductListing Current { get; private set; } + + public ShopEnumerator(string query, Currency currency, HttpClient http, bool useProxy, CancellationToken cancellationToken) + { + query = query.Replace(' ', '-'); + this.query = query; + this.currency = currency; + this.http = http; + this.useProxy = useProxy; + this.cancellationToken = cancellationToken; + } + + private async Task> ScrapePage(int page) + { + string requestUrl = string.Format(QUERY_FORMAT, query, page, currency.ToString()); + if (useProxy) requestUrl = string.Format(PROXY_FORMAT, requestUrl); + TimeSpan difference = DateTime.Now - lastScrape; + if (difference.TotalMilliseconds < 200) { + await Task.Delay((int)Math.Ceiling(200 - difference.TotalMilliseconds)); + } + HttpResponseMessage response = await http.GetAsync(requestUrl); + lastScrape = DateTime.Now; + HtmlDocument html = new HtmlDocument(); + html.Load(await response.Content.ReadAsStreamAsync()); + HtmlNodeCollection collection = html.DocumentNode.SelectNodes(@"//div[@class='product-list']/ul[@class='goodlist cf']/li"); + if (collection == null) return null; + List results = new List(); + foreach (HtmlNode node in collection) + { + ProductListing listing = new ProductListing(); + HtmlNode productNode = node.SelectSingleNode(@"div/a[1]"); + listing.Name = productNode.InnerText; + Logger.Log($"Found name: {listing.Name}", LogLevel.Debug); + listing.URL = productNode.GetAttributeValue("href", null); + Logger.Log($"Found URL: {listing.URL}", LogLevel.Debug); + listing.ImageURL = node.SelectSingleNode(@"div/span[@class='img notranslate']/a/img").GetAttributeValue("data-src", null); + Logger.Log($"Found image URL: {listing.ImageURL}", LogLevel.Debug); + listing.LowerPrice = float.Parse(Regex.Match(node.SelectSingleNode(@"div/span[@class='price-box']/span").InnerText, @"(\d*\.\d*)").Groups[1].Value); + Logger.Log($"Found price: {listing.LowerPrice}", LogLevel.Debug); + listing.UpperPrice = listing.LowerPrice; + listing.ReviewCount = int.Parse(Regex.Match(node.SelectSingleNode(@"div/a[2]").InnerText, @"(\d+) reviews?").Groups[1].Value); + Logger.Log($"Found reviews: {listing.ReviewCount}", LogLevel.Debug); + results.Add(listing); + } + return results; + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + public async ValueTask MoveNextAsync() + { + if (pageListings == null || !pageListings.MoveNext()) + { + currentPage += 1; + pageListings?.Dispose(); + IEnumerable pageEnumerable = await ScrapePage(currentPage); + if (pageEnumerable == null) return false; + pageListings = pageEnumerable.GetEnumerator(); + pageListings.MoveNext(); + } + Current = pageListings.Current; + return true; + } + } +} \ No newline at end of file diff --git a/src/MultiShop/DataStructures/SearchProfile.cs b/src/MultiShop/DataStructures/SearchProfile.cs index c5130b6..e0d2ed4 100644 --- a/src/MultiShop/DataStructures/SearchProfile.cs +++ b/src/MultiShop/DataStructures/SearchProfile.cs @@ -70,7 +70,7 @@ namespace MultiShop.DataStructures set { - if (value == false && !CanDisableShop()) return; + if (value == false && !(shopsEnabled.Count > 1)) return; if (value) { shopsEnabled.Add(name); @@ -81,8 +81,8 @@ namespace MultiShop.DataStructures } } } - public bool CanDisableShop() { - return shopsEnabled.Count > 1; + public bool IsToggleable(string shop) { + return (shopsEnabled.Contains(shop) && shopsEnabled.Count > 1) || !shopsEnabled.Contains(shop); } } } diff --git a/src/MultiShop/Pages/Search.razor b/src/MultiShop/Pages/Search.razor index a504b34..9867f74 100644 --- a/src/MultiShop/Pages/Search.razor +++ b/src/MultiShop/Pages/Search.razor @@ -109,7 +109,7 @@ @foreach (string shop in Shops.Keys) {
- +
} @@ -330,7 +330,7 @@ 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.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; diff --git a/src/MultiShop/Shared/MainLayout.razor b/src/MultiShop/Shared/MainLayout.razor index d91bb71..c20b2fd 100644 --- a/src/MultiShop/Shared/MainLayout.razor +++ b/src/MultiShop/Shared/MainLayout.razor @@ -25,6 +25,7 @@ private bool modulesLoaded = false; private Dictionary shops = new Dictionary(); + private Dictionary unusedDependencies = new Dictionary(); protected override async Task OnInitializedAsync() { @@ -48,7 +49,7 @@ foreach (Task task in assemblyDownloadTasks) { Assembly assembly = AppDomain.CurrentDomain.Load(await task); - + bool assigned = false; foreach (Type type in assembly.GetTypes()) { if (typeof(IShop).IsAssignableFrom(type)) { @@ -58,17 +59,34 @@ shops.Add(shop.ShopName, shop); Logger.Log($"Registered and started lifetime of module for \"{shop.ShopName}\".", LogLevel.Debug); } + assigned = true; } } + if (!assigned) { + unusedDependencies.Add(assembly.FullName, assembly); + Logger.Log($"Assembly \"{assembly.FullName}\" did not contain a shop module. Storing it as potential extension.", LogLevel.Debug); + } } - + foreach (string assembly in unusedDependencies.Keys) + { + Logger.Log($"{assembly} was unused.", LogLevel.Warning); + } + unusedDependencies.Clear(); 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); + Logger.Log($"Assembly {args.RequestingAssembly} is requesting dependency assembly {args.Name}.", LogLevel.Debug); + if (unusedDependencies.ContainsKey(args.Name)) + { + Logger.Log("Dependency found.", LogLevel.Debug); + Assembly dependency = unusedDependencies[args.Name]; + unusedDependencies.Remove(args.Name); + return dependency; + } + Logger.Log($"No dependency under name {args.Name}", LogLevel.Debug); + return null; } diff --git a/src/MultiShop/wwwroot/modules/BanggoodShop.dll b/src/MultiShop/wwwroot/modules/BanggoodShop.dll new file mode 100644 index 0000000..e6051e5 Binary files /dev/null and b/src/MultiShop/wwwroot/modules/BanggoodShop.dll differ diff --git a/src/MultiShop/wwwroot/modules/HtmlAgilityPack.dll b/src/MultiShop/wwwroot/modules/HtmlAgilityPack.dll new file mode 100644 index 0000000..e18de30 Binary files /dev/null and b/src/MultiShop/wwwroot/modules/HtmlAgilityPack.dll differ diff --git a/src/MultiShop/wwwroot/modules/modules_content.json b/src/MultiShop/wwwroot/modules/modules_content.json index 8c4a535..6c67e7b 100644 --- a/src/MultiShop/wwwroot/modules/modules_content.json +++ b/src/MultiShop/wwwroot/modules/modules_content.json @@ -1,3 +1,5 @@ [ - "AliExpressShop" + "AliExpressShop", + "HtmlAgilityPack", + "BanggoodShop" ] \ No newline at end of file diff --git a/test/AliExpressShop.Tests/ShopTest.cs b/test/AliExpressShop.Tests/ShopTest.cs index f09998f..0214025 100644 --- a/test/AliExpressShop.Tests/ShopTest.cs +++ b/test/AliExpressShop.Tests/ShopTest.cs @@ -1,7 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using GameServiceWarden.Core.Tests; using MultiShop.ShopFramework; using SimpleLogger; using Xunit; @@ -34,10 +30,10 @@ namespace AliExpressShop.Tests count += 1; if (count > MAX_RESULTS) return; } + shop.Dispose(); } [Fact] - public async void Search_USD_ResultsFound() { //Given @@ -57,6 +53,7 @@ namespace AliExpressShop.Tests count += 1; if (count > MAX_RESULTS) return; } + shop.Dispose(); } } } diff --git a/test/AliExpressShop.Tests/XUnitLogger.cs b/test/AliExpressShop.Tests/XUnitLogger.cs index 520149c..7fe2715 100644 --- a/test/AliExpressShop.Tests/XUnitLogger.cs +++ b/test/AliExpressShop.Tests/XUnitLogger.cs @@ -2,7 +2,7 @@ using System; using SimpleLogger; using Xunit.Abstractions; -namespace GameServiceWarden.Core.Tests +namespace AliExpressShop { public class XUnitLogger : ILogReceiver { diff --git a/test/BanggoodShop.Tests/BanggoodShop.Tests.csproj b/test/BanggoodShop.Tests/BanggoodShop.Tests.csproj new file mode 100644 index 0000000..a421dbc --- /dev/null +++ b/test/BanggoodShop.Tests/BanggoodShop.Tests.csproj @@ -0,0 +1,27 @@ + + + + net5.0 + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/test/BanggoodShop.Tests/ShopTest.cs b/test/BanggoodShop.Tests/ShopTest.cs new file mode 100644 index 0000000..9fe8f81 --- /dev/null +++ b/test/BanggoodShop.Tests/ShopTest.cs @@ -0,0 +1,36 @@ +using MultiShop.ShopFramework; +using SimpleLogger; +using Xunit; +using Xunit.Abstractions; + +namespace BanggoodShop.Tests +{ + public class ShopTest + { + public ShopTest(ITestOutputHelper output) + { + Logger.AddLogListener(new XUnitLogger(output)); + } + + + [Fact] + public async void Search_CAD_ResultsFound() + { + //Given + const int MAX_RESULTS = 100; + Shop shop = new Shop(); + shop.UseProxy = false; + //When + shop.Initialize(); + shop.SetupSession("samsung galaxy 20 case", Currency.CAD); + //Then + int count = 0; + await foreach (ProductListing listing in shop) + { + count += 1; + Assert.False(string.IsNullOrWhiteSpace(listing.Name)); + if (count >= MAX_RESULTS) return; + } + } + } +} diff --git a/test/BanggoodShop.Tests/XUnitLogger.cs b/test/BanggoodShop.Tests/XUnitLogger.cs new file mode 100644 index 0000000..7e76ffd --- /dev/null +++ b/test/BanggoodShop.Tests/XUnitLogger.cs @@ -0,0 +1,33 @@ +using System; +using SimpleLogger; +using Xunit.Abstractions; + +namespace BanggoodShop +{ + public class XUnitLogger : ILogReceiver + { + public LogLevel Level => LogLevel.Debug; + + public string Identifier => GetType().Name; + + private ITestOutputHelper outputHelper; + + public XUnitLogger(ITestOutputHelper output) + { + this.outputHelper = output; + } + + public void Flush() + { + } + + public void LogMessage(string message, DateTime time, LogLevel level) + { + try + { + outputHelper.WriteLine($"[{time.ToShortTimeString()}][{level.ToString()}]: {message}"); + } + catch (InvalidOperationException) { }; + } + } +} \ No newline at end of file