Basic search and sorting complete.
This commit is contained in:
parent
5d3a74a89e
commit
9dc8917aa5
@ -1 +1 @@
|
||||
Subproject commit c1c14d96ea5ab91a45acced9bb342ed228347eab
|
||||
Subproject commit f275ff330db936d7eabc6dc435952ec0752edbc9
|
108
src/AliExpressShop/LRUCache.cs
Normal file
108
src/AliExpressShop/LRUCache.cs
Normal file
@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GameServiceWarden.Core.Collection
|
||||
{
|
||||
public class LRUCache<K, V> : IEnumerable<V>
|
||||
{
|
||||
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<K, Node> valueDictionary;
|
||||
private Action<V> cleanupAction;
|
||||
|
||||
public LRUCache(int size = 100, Action<V> cleanup = null)
|
||||
{
|
||||
this.size = size;
|
||||
valueDictionary = new Dictionary<K, Node>(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<V> 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<V> UseAsync(K key, Func<Task<V>> 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<V> GetEnumerator()
|
||||
{
|
||||
foreach (Node node in valueDictionary.Values)
|
||||
{
|
||||
yield return node.value;
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
@ -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<IEnumerable<ProductListing>> Search(string query, int maxPage = -1)
|
||||
{
|
||||
if (disposedValue) throw new ObjectDisposedException("Shop");
|
||||
if (client == null) throw new InvalidOperationException("HTTP client is not initiated.");
|
||||
List<ProductListing> listings = new List<ProductListing>();
|
||||
|
||||
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<string> 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<string> 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<ProductListing> GetAsyncEnumerator(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ShopEnumerator(query, currency, http, UseProxy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
281
src/AliExpressShop/ShopEnumerator.cs
Normal file
281
src/AliExpressShop/ShopEnumerator.cs
Normal file
@ -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<ProductListing>
|
||||
{
|
||||
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<ProductListing> 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<IEnumerable<ProductListing>> 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<ProductListing> listings = new List<ProductListing>();
|
||||
|
||||
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<string> listingsStrs = GetItemsFromString(itemsString);
|
||||
foreach (string listingStr in listingsStrs)
|
||||
{
|
||||
listings.Add(await GenerateListingFromString(listingStr, currency));
|
||||
}
|
||||
return listings;
|
||||
}
|
||||
|
||||
private async Task<ProductListing> 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<string> 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<float> 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<bool> MoveNextAsync()
|
||||
{
|
||||
if (pageListings == null || !pageListings.MoveNext()) {
|
||||
pageListings?.Dispose();
|
||||
currentPage += 1;
|
||||
IEnumerable<ProductListing> 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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<ProductListing>, IDisposable
|
||||
{
|
||||
string ShopName { get; }
|
||||
string ShopDescription { get; }
|
||||
string ShopModuleAuthor { get; }
|
||||
|
||||
void Initiate(Currency currency);
|
||||
Task<IEnumerable<ProductListing>> Search(string query, int maxPage = -1);
|
||||
|
||||
public void SetupSession(string query, Currency currency);
|
||||
|
||||
void Initialize();
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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<IShop> LoadShops(string shop) {
|
||||
return InstantiateShops(LoadAssembly(shop));
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, IEnumerable<IShop>> LoadAllShops(IEnumerable<string> directories) {
|
||||
Dictionary<string, IEnumerable<IShop>> res = new Dictionary<string, IEnumerable<IShop>>();
|
||||
foreach (string dir in directories)
|
||||
{
|
||||
res.Add(dir, LoadShops(dir));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
private IEnumerable<IShop> 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)));
|
||||
}
|
||||
}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
@page "/counter"
|
||||
|
||||
<h1>Counter</h1>
|
||||
|
||||
<p>Current count: @currentCount</p>
|
||||
|
||||
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
|
||||
|
||||
@code {
|
||||
private int currentCount = 0;
|
||||
|
||||
private void IncrementCount()
|
||||
{
|
||||
currentCount++;
|
||||
}
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
@page "/fetchdata"
|
||||
@inject HttpClient Http
|
||||
|
||||
<h1>Weather forecast</h1>
|
||||
|
||||
<p>This component demonstrates fetching data from the server.</p>
|
||||
|
||||
@if (forecasts == null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Temp. (C)</th>
|
||||
<th>Temp. (F)</th>
|
||||
<th>Summary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var forecast in forecasts)
|
||||
{
|
||||
<tr>
|
||||
<td>@forecast.Date.ToShortDateString()</td>
|
||||
<td>@forecast.TemperatureC</td>
|
||||
<td>@forecast.TemperatureF</td>
|
||||
<td>@forecast.Summary</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private WeatherForecast[] forecasts;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("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);
|
||||
}
|
||||
}
|
@ -1,7 +1,3 @@
|
||||
@page "/"
|
||||
|
||||
<h1>Hello, world!</h1>
|
||||
|
||||
Welcome to your new app.
|
||||
|
||||
<SurveyPrompt Title="How is Blazor working for you?" />
|
||||
<h1>Welcome to MultiShop!</h1>
|
||||
|
11
src/MultiShop/Pages/Info.razor
Normal file
11
src/MultiShop/Pages/Info.razor
Normal file
@ -0,0 +1,11 @@
|
||||
@page "/info"
|
||||
@using ShopFramework
|
||||
|
||||
<div>
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[CascadingParameter(Name = "Shops")]
|
||||
public Dictionary<string, IShop> Shops { get; set; }
|
||||
}
|
397
src/MultiShop/Pages/Search.razor
Normal file
397
src/MultiShop/Pages/Search.razor
Normal file
@ -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. *@
|
||||
|
||||
<div class="my-2">
|
||||
<div class="input-group my-2">
|
||||
<input type="text" class="form-control" placeholder="What are you looking for?" aria-label="What are you looking for?" id="search-input" @bind="Query" @onkeyup="@(async (a) => {if (a.Code == "Enter" || a.Code == "NumpadEnter") await PerformSearch(Query);})" disabled="@searching">
|
||||
<div class="input-group-append">
|
||||
<button class=@ToggleSearchConfigButtonCss type="button" @onclick="@(() => showSearchConfiguration = !showSearchConfiguration)" title="Configure"><span class="oi oi-cog align-text-top"></span></button>
|
||||
<button class="btn btn-outline-primary" type="button" @onclick="@(async () => await PerformSearch(Query))" disabled="@searching">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (showSearchConfiguration)
|
||||
{
|
||||
<div class="mb-2 mt-4 py-2">
|
||||
<h4>Configuration</h4>
|
||||
<div class="d-flex flex-wrap justify-content-start">
|
||||
<div class="card m-2" style="width: 24em;">
|
||||
<div class="card-body">
|
||||
<h5>Shop Quantity</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">How many results from each store?</h6>
|
||||
<p class="card-text">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.</p>
|
||||
<div class="form-group">
|
||||
<label for="quantitySlider">Quantity: @activeProfile.maxResults</label>
|
||||
<input class="form-control-range" type="range" id="quantitySlider" min="1" max="200" step="1" @bind="activeProfile.maxResults" @bind:event="oninput">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card m-2" style="width: 18em;">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Currency</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">What currency would you like results in?</h6>
|
||||
<p class="card-text">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).</p>
|
||||
<div class="input-group my-3">
|
||||
<div class="input-group-prepend">
|
||||
<label class="input-group-text" for="currency-select">Currency</label>
|
||||
</div>
|
||||
<select class="form-control custom-select" id="currency-select" @bind="activeProfile.currency">
|
||||
@foreach (Currency currency in Enum.GetValues<Currency>())
|
||||
{
|
||||
@if (currency == activeProfile.currency)
|
||||
{
|
||||
<option selected>@currency</option>
|
||||
}
|
||||
else
|
||||
{
|
||||
<option value="@currency">@currency</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card m-2" style="width: 23em;">
|
||||
<div class="card-body">
|
||||
<h5>Minimum Rating</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">We'll crop out the lower rated stuff.</h6>
|
||||
<p class="card-text">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.</p>
|
||||
<div class="form-group">
|
||||
<label for="ratingSlider">Minimum rating: @(string.Format("{0:P0}", activeProfile.minRating))</label>
|
||||
<input class="form-control-range" type="range" id="ratingSlider" min="0" max="1" step="0.01" @bind="activeProfile.minRating" @bind:event="oninput">
|
||||
</div>
|
||||
<div class="form-group form-check">
|
||||
<input class="form-check-input" type="checkbox" id="keepUnratedCheckbox" @bind="activeProfile.keepUnrated">
|
||||
<label class="form-check-label" for="keepUnratedCheckbox">Keep unrated results</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card m-2" style="width: 25em;">
|
||||
<div class="card-body">
|
||||
<h5>Price Range</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">Whats your budget?</h6>
|
||||
<p class="card-text">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.</p>
|
||||
<div class="input-group my-2">
|
||||
<div class="input-group-prepend">
|
||||
<div class="input-group-text">
|
||||
<input type="checkbox" @bind="activeProfile.enableUpperPrice">
|
||||
</div>
|
||||
<span class="input-group-text">Upper limit</span>
|
||||
</div>
|
||||
<input type="number" class="form-control" @bind="activeProfile.UpperPrice" disabled="@(!activeProfile.enableUpperPrice)">
|
||||
<div class="input-group-append">
|
||||
<span class="input-group-text">.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group my-2">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">Lower limit</span>
|
||||
</div>
|
||||
<input type="number" class="form-control" @bind="activeProfile.lowerPrice">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card m-2" style="width: 22em;">
|
||||
<div class="card-body">
|
||||
<h5>Shops Searched</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">What's your preference?</h6>
|
||||
<p class="card-text">We'll only look through shops that are enabled in this list. Of course, at least one shop has to be enabled.</p>
|
||||
@foreach (string shop in Shops.Keys)
|
||||
{
|
||||
<div class="form-group form-check my-2">
|
||||
<input class="form-check-input" type="checkbox" id=@(shop + "Checkbox") @bind="activeProfile.shopStates[shop]" disabled="@(!activeProfile.shopStates.CanDisableShop())">
|
||||
<label class="form-check-label" for=@(shop + "Checkbox")>@shop enabled</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card m-2" style="width: 20em;">
|
||||
<div class="card-body">
|
||||
<h5>Minimum Purchases</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">If they're purchasing, I am too!</h6>
|
||||
<p class="card-text">Only products that have enough purchases are shown. Optionally, we can also show results that don't have a purchase tally.</p>
|
||||
<div class="input-group my-2">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">Minimum purchases</span>
|
||||
</div>
|
||||
<input type="number" class="form-control" min="0" step="1" @bind="activeProfile.minPurchases">
|
||||
</div>
|
||||
<div class="form-group form-check my-2">
|
||||
<input class="form-check-input" type="checkbox" id="keepNullPurchasesCheckbox" @bind="activeProfile.keepUnknownPurchaseCount">
|
||||
<label class="form-check-label" for="keepNullPurchasesCheckbox">Keep unknown listings</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card m-2" style="width: 20em;">
|
||||
<div class="card-body">
|
||||
<h5>Minimum Reviews</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">Well if this many people say it's good...</h6>
|
||||
<p class="card-text">Only products with enough reviews/ratings are shown. Optionally, we can also show the results that don't have this information.</p>
|
||||
<div class="input-group my-2">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">Minimum reviews</span>
|
||||
</div>
|
||||
<input type="number" class="form-control" min="0" step="1" @bind="activeProfile.minReviews">
|
||||
</div>
|
||||
<div class="form-group form-check my-2">
|
||||
<input class="form-check-input" type="checkbox" id="keepNullRatingsCheckbox" @bind="activeProfile.keepUnknownRatingCount">
|
||||
<label class="form-check-label" for="keepNullRatingsCheckbox">Keep unknown listings</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card m-2" style="width: 22rem;">
|
||||
<div class="card-body">
|
||||
<h5>Shipping</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">Free shipping?</h6>
|
||||
<p class="card-text">Show results with shipping rates less than a certain value, and choose whether or not to display listings without shipping information.</p>
|
||||
<div class="input-group my-2">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<input type="checkbox" @bind="activeProfile.enableMaxShippingFee">
|
||||
</span>
|
||||
<span class="input-group-text">Max shipping</span>
|
||||
</div>
|
||||
<input type="number" class="form-control" min="0" step="1" @bind="activeProfile.MaxShippingFee" disabled="@(!activeProfile.enableMaxShippingFee)">
|
||||
<div class="input-group-append">
|
||||
<span class="input-group-text">.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group form-check my-2">
|
||||
<input class="form-check-input" type="checkbox" id="keepNullShipping" @bind="activeProfile.keepUnknownShipping">
|
||||
<label class="form-check-label" for="keepNullShipping">Keep unknown listings</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="my-3 py-2">
|
||||
<div class="d-flex flex-wrap justify-content-between" style="width: 100%; border-bottom-width: 1px; border-bottom-style: solid; border-bottom-color: lightgray;">
|
||||
<div class="align-self-end">
|
||||
@if (searching)
|
||||
{
|
||||
@if (listings.Count != 0)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-secondary my-auto mr-1" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
<span class="text-muted mx-1">Looked through @resultsChecked listings and found @listings.Count viable results. We're still looking!</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-primary my-auto mr-1" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
<span class="text-muted">Hold tight! We're looking through the stores for viable results...</span>
|
||||
}
|
||||
}
|
||||
else if (listings.Count != 0)
|
||||
{
|
||||
@if (organizing)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-success my-auto mr-1" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
<span class="text-muted">Organizing the data to your spec...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Looked through @resultsChecked listings and found @listings.Count viable results.</span>
|
||||
}
|
||||
}
|
||||
else if (searched)
|
||||
{
|
||||
<span class="text-muted">We've found @resultsChecked listings and unfortunately none matched your search.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Search for something to see the results!</span>
|
||||
}
|
||||
</div>
|
||||
<CustomDropdown AdditionalButtonClasses="btn-outline-secondary btn-tab" Justify="right">
|
||||
<ButtonContent>
|
||||
<span class="oi oi-sort-descending"></span>
|
||||
</ButtonContent>
|
||||
<DropdownContent>
|
||||
<DragAndDropList Items="@(activeResultsProfile.Order)" Context="item" AdditionalListClasses="list-group-empty-top-left" OnOrderChange="@(async () => await Organize(activeResultsProfile.Order))">
|
||||
<DraggableItem>
|
||||
@(item.FriendlyName())
|
||||
</DraggableItem>
|
||||
</DragAndDropList>
|
||||
</DropdownContent>
|
||||
</CustomDropdown>
|
||||
|
||||
</div>
|
||||
<div>
|
||||
<ListingTableView Products="@listings" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@code {
|
||||
[CascadingParameter(Name = "Shops")]
|
||||
public Dictionary<string, IShop> 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<ProductListingInfo> listings = new List<ProductListingInfo>();
|
||||
|
||||
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<ResultsProfile.Category, List<ProductListingInfo>> greatest = new Dictionary<ResultsProfile.Category,
|
||||
List<ProductListingInfo>>();
|
||||
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<ResultsProfile.Category>())
|
||||
{
|
||||
if (!greatest.ContainsKey(c)) greatest[c] = new List<ProductListingInfo>();
|
||||
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<ResultsProfile.Category> order)
|
||||
{
|
||||
if (searching) return;
|
||||
organizing = true;
|
||||
StateHasChanged();
|
||||
|
||||
List<ProductListingInfo> sortedResults = await Task.Run<List<ProductListingInfo>>(() => {
|
||||
List<ProductListingInfo> sorted = new List<ProductListingInfo>(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();
|
||||
}
|
||||
}
|
@ -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>("#app");
|
||||
|
||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
}
|
||||
}
|
||||
|
26
src/MultiShop/SearchStructures/ProductListingInfo.cs
Normal file
26
src/MultiShop/SearchStructures/ProductListingInfo.cs
Normal file
@ -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<ResultsProfile.Category> Tops { get; private set; } = new HashSet<ResultsProfile.Category>();
|
||||
|
||||
public ProductListingInfo(ProductListing listing, string shopName)
|
||||
{
|
||||
this.Listing = listing;
|
||||
this.ShopName = shopName;
|
||||
}
|
||||
}
|
||||
}
|
46
src/MultiShop/SearchStructures/ResultCategoryExtensions.cs
Normal file
46
src/MultiShop/SearchStructures/ResultCategoryExtensions.cs
Normal file
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
32
src/MultiShop/SearchStructures/ResultsProfile.cs
Normal file
32
src/MultiShop/SearchStructures/ResultsProfile.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace MultiShop.SearchStructures
|
||||
{
|
||||
public class ResultsProfile
|
||||
{
|
||||
public List<Category> Order { get; private set; } = new List<Category>(Enum.GetValues<Category>().Length);
|
||||
|
||||
public ResultsProfile()
|
||||
{
|
||||
foreach (Category category in Enum.GetValues<Category>())
|
||||
{
|
||||
Order.Add(category);
|
||||
}
|
||||
}
|
||||
|
||||
public Category GetCategory(int position)
|
||||
{
|
||||
return Order[position];
|
||||
}
|
||||
|
||||
public enum Category
|
||||
{
|
||||
RatingPriceRatio,
|
||||
Price,
|
||||
Purchases,
|
||||
Reviews,
|
||||
}
|
||||
}
|
||||
}
|
89
src/MultiShop/SearchStructures/SearchProfile.cs
Normal file
89
src/MultiShop/SearchStructures/SearchProfile.cs
Normal file
@ -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<string> shopsEnabled = new HashSet<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
45
src/MultiShop/Shared/CustomDropdown.razor
Normal file
45
src/MultiShop/Shared/CustomDropdown.razor
Normal file
@ -0,0 +1,45 @@
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div style="position: relative;" @ref="dropdown">
|
||||
<button type="button" class=@ButtonCss>@ButtonContent</button>
|
||||
<div class="invisible" style="position: absolute;" tabindex="0">
|
||||
@DropdownContent
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
}
|
65
src/MultiShop/Shared/DragAndDropList.razor
Normal file
65
src/MultiShop/Shared/DragAndDropList.razor
Normal file
@ -0,0 +1,65 @@
|
||||
@using SimpleLogger
|
||||
@typeparam TItem
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<ul class=@ListGroupCss style="width: max-content;" @ref="dragAndDrop">
|
||||
@foreach (TItem item in Items)
|
||||
{
|
||||
<li class="list-group-item list-group-item-hover" draggable="true" @ondragstart="@(() => itemDraggedIndex = Items.IndexOf(item))" @ondrop="@(async () => await OnDrop(item))">
|
||||
<span class="mx-1 oi oi-elevator"></span>
|
||||
@DraggableItem(item)
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<TItem> Items { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string AdditionalListClasses { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnOrderChange { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment<TItem> 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();
|
||||
}
|
||||
}
|
99
src/MultiShop/Shared/ListingTableView.razor
Normal file
99
src/MultiShop/Shared/ListingTableView.razor
Normal file
@ -0,0 +1,99 @@
|
||||
@using ShopFramework
|
||||
@using SearchStructures
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-top-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Price</th>
|
||||
<th scope="col">Shipping</th>
|
||||
<th scope="col">Purchases</th>
|
||||
<th scope="col">Rating</th>
|
||||
<th scope="col">Reviews</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Virtualize Items="@Products" Context="product">
|
||||
<tr>
|
||||
<th scope="row" @key="product.Listing">
|
||||
<div class="text-truncate">@product.Listing.Name</div>
|
||||
<small>From @product.ShopName</small>
|
||||
@if (product.Listing.ConvertedPrices)
|
||||
{
|
||||
<span class="ml-3 mr-1 badge badge-warning">Converted price</span>
|
||||
}
|
||||
@foreach (ResultsProfile.Category c in product.Tops)
|
||||
{
|
||||
<span class="mx-1 badge badge-primary">@CategoryTags(c)</span>
|
||||
}
|
||||
</th>
|
||||
<td>
|
||||
@if (product.Listing.UpperPrice != product.Listing.LowerPrice)
|
||||
{
|
||||
<div class="text-truncate">
|
||||
@product.Listing.LowerPrice to @product.Listing.UpperPrice
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-truncate">
|
||||
@GetOrNA(product.Listing.LowerPrice)
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-truncate">
|
||||
@GetOrNA(product.Listing.Shipping)
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-truncate">
|
||||
@GetOrNA(product.Listing.PurchaseCount)
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-truncate">
|
||||
@(product.Listing.Rating != null ? string.Format("{0:P2}", product.Listing.Rating) : "N/A")
|
||||
</div>
|
||||
</td>
|
||||
<td>@GetOrNA(product.Listing.ReviewCount)</td>
|
||||
<td>
|
||||
<a href="@product.Listing.URL" class="btn btn-primary" target="_blank">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
</Virtualize>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public List<ProductListingInfo> 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.");
|
||||
}
|
||||
|
||||
}
|
3
src/MultiShop/Shared/ListingTableView.razor.css
Normal file
3
src/MultiShop/Shared/ListingTableView.razor.css
Normal file
@ -0,0 +1,3 @@
|
||||
tbody > tr > th > div {
|
||||
width: 45em;
|
||||
}
|
@ -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
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
|
||||
<NavMenu />
|
||||
<div class="main">
|
||||
<div class="top-row px-4">
|
||||
<a href="http://blazor.net" target="_blank" class="ml-md-auto">About</a>
|
||||
</div>
|
||||
|
||||
<div class="content px-4">
|
||||
@Body
|
||||
@if (modulesLoaded)
|
||||
{
|
||||
<CascadingValue Value="shops" Name="Shops">
|
||||
@Body
|
||||
</CascadingValue>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool modulesLoaded = false;
|
||||
|
||||
private Dictionary<string, IShop> shops = new Dictionary<string, IShop>();
|
||||
|
||||
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<string[]>(Configuration["ModulesList"]);
|
||||
Task<byte[]>[] assemblyDownloadTasks = new Task<byte[]>[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<byte[]> 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}\".");
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,34 +1,32 @@
|
||||
<div class="top-row pl-4 navbar navbar-dark">
|
||||
<a class="navbar-brand" href="">MultiShop</a>
|
||||
<button class="navbar-toggler" @onclick="ToggleNavMenu">
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<a class="navbar-brand" href="">
|
||||
<img src="100.png" width="30" height="30" class="d-inline-block align-top">
|
||||
MultiShop
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" @onclick="ToggleNavMenu">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="oi oi-home" aria-hidden="true"></span> Home
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="counter">
|
||||
<span class="oi oi-plus" aria-hidden="true"></span> Counter
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="fetchdata">
|
||||
<span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class=@NavMenuCssClass>
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="oi oi-home" aria-hidden="true"></span> Home
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="search" Match="NavLinkMatch.Prefix">
|
||||
<span class="oi oi-magnifying-glass"></span> Search
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
private bool collapseNavMenu = true;
|
||||
|
||||
private string NavMenuCssClass => collapseNavMenu ? "collapse" : null;
|
||||
private string NavMenuCssClass => (collapseNavMenu ? "collapse " : " ") + "navbar-collapse";
|
||||
|
||||
private void ToggleNavMenu()
|
||||
{
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
<div class="alert alert-secondary mt-4" role="alert">
|
||||
<span class="oi oi-pencil mr-2" aria-hidden="true"></span>
|
||||
<strong>@Title</strong>
|
||||
|
||||
<span class="text-nowrap">
|
||||
Please take our
|
||||
<a target="_blank" class="font-weight-bold" href="https://go.microsoft.com/fwlink/?linkid=2137916">brief survey</a>
|
||||
</span>
|
||||
and tell us what you think.
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// Demonstrates how a parent component can supply parameters
|
||||
[Parameter]
|
||||
public string Title { get; set; }
|
||||
}
|
BIN
src/MultiShop/wwwroot/100.png
Normal file
BIN
src/MultiShop/wwwroot/100.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 255 B |
@ -1,4 +1,5 @@
|
||||
{
|
||||
"LogLevel" : "DEBUG",
|
||||
"ModulesList" : "modules/modules_content"
|
||||
"LogLevel" : "Debug",
|
||||
"ModulesDir" : "modules/",
|
||||
"ModulesList" : "modules/modules_content.json"
|
||||
}
|
@ -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 {
|
||||
|
@ -19,7 +19,8 @@
|
||||
<a href="" class="reload">Reload</a>
|
||||
<a class="dismiss">🗙</a>
|
||||
</div>
|
||||
<script src="js/ComponentsSupport.js"></script>
|
||||
<script src="_framework/blazor.webassembly.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
69
src/MultiShop/wwwroot/js/ComponentsSupport.js
Normal file
69
src/MultiShop/wwwroot/js/ComponentsSupport.js
Normal file
@ -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");
|
||||
});
|
||||
}
|
||||
}
|
BIN
src/MultiShop/wwwroot/modules/AliExpressShop.dll
Normal file
BIN
src/MultiShop/wwwroot/modules/AliExpressShop.dll
Normal file
Binary file not shown.
BIN
src/MultiShop/wwwroot/modules/SimpleLogger.dll
Normal file
BIN
src/MultiShop/wwwroot/modules/SimpleLogger.dll
Normal file
Binary file not shown.
@ -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)
|
||||
|
3
src/MultiShop/wwwroot/modules/modules_content.json
Normal file
3
src/MultiShop/wwwroot/modules/modules_content.json
Normal file
@ -0,0 +1,3 @@
|
||||
[
|
||||
"AliExpressShop"
|
||||
]
|
@ -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"
|
||||
}
|
||||
]
|
@ -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<IEnumerable<ProductListing>> listingsTask = shop.Search("mpu6050", 1);
|
||||
listingsTask.Wait();
|
||||
IEnumerable<ProductListing> 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<IEnumerable<ProductListing>> listingsTask = shop.Search("mpu6050", 2);
|
||||
listingsTask.Wait();
|
||||
IEnumerable<ProductListing> 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<IEnumerable<ProductListing>> listingsTask = shop.Search("mpu6050", 1);
|
||||
listingsTask.Wait();
|
||||
IEnumerable<ProductListing> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user