Added identifier and fetch time product listings; Added persistence to AdafruitShop.
Implemented in AdafruitShop. AdafruitShop Product listing data now persisted. AdafruitShop configuration now persisted.
This commit is contained in:
parent
38ffb3c7e1
commit
0b507b90a1
@ -1,15 +1,19 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Props.Shop.Adafruit.Api;
|
using Props.Shop.Adafruit.Api;
|
||||||
|
using Props.Shop.Adafruit.Persistence;
|
||||||
using Props.Shop.Framework;
|
using Props.Shop.Framework;
|
||||||
|
|
||||||
namespace Props.Shop.Adafruit
|
namespace Props.Shop.Adafruit
|
||||||
{
|
{
|
||||||
public class AdafruitShop : IShop
|
public class AdafruitShop : IShop
|
||||||
{
|
{
|
||||||
|
private string workspaceDir;
|
||||||
private ILoggerFactory loggerFactory;
|
private ILoggerFactory loggerFactory;
|
||||||
private ILogger<AdafruitShop> logger;
|
private ILogger<AdafruitShop> logger;
|
||||||
private SearchManager searchManager;
|
private SearchManager searchManager;
|
||||||
@ -32,22 +36,80 @@ namespace Props.Shop.Adafruit
|
|||||||
);
|
);
|
||||||
public void Initialize(string workspaceDir, ILoggerFactory loggerFactory)
|
public void Initialize(string workspaceDir, ILoggerFactory loggerFactory)
|
||||||
{
|
{
|
||||||
|
this.workspaceDir = workspaceDir;
|
||||||
this.loggerFactory = loggerFactory;
|
this.loggerFactory = loggerFactory;
|
||||||
|
logger = loggerFactory.CreateLogger<AdafruitShop>();
|
||||||
http = new HttpClient();
|
http = new HttpClient();
|
||||||
http.BaseAddress = new Uri("http://www.adafruit.com/api/");
|
http.BaseAddress = new Uri("http://www.adafruit.com/api/");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
configuration = JsonSerializer.Deserialize<Configuration>(File.ReadAllText(Path.Combine(workspaceDir, Configuration.FILE_NAME)));
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Could not read JSON file.");
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
logger.LogWarning("No working directory path provided.");
|
||||||
|
}
|
||||||
|
catch (DirectoryNotFoundException)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Directory could not be found.");
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException)
|
||||||
|
{
|
||||||
|
logger.LogWarning("File could not be found.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (configuration == null)
|
||||||
|
{
|
||||||
configuration = new Configuration();
|
configuration = new Configuration();
|
||||||
// TODO: Implement config persistence.
|
}
|
||||||
// TODO: Implement product listing persisted cache.
|
|
||||||
LiveProductListingManager productListingManager = new LiveProductListingManager(http, loggerFactory.CreateLogger<LiveProductListingManager>(), configuration.MinDownloadInterval);
|
|
||||||
this.searchManager = new SearchManager(productListingManager, configuration.Similarity);
|
|
||||||
productListingManager.StartUpdateTimer(delay: 0);
|
|
||||||
|
|
||||||
logger = loggerFactory.CreateLogger<AdafruitShop>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<ProductListing> GetProductListingFromUrl(string url)
|
ProductListingCacheData listingData = null;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
return searchManager.ProductListingManager.GetProductListingFromUrl(url);
|
listingData = JsonSerializer.Deserialize<ProductListingCacheData>(File.ReadAllText(Path.Combine(workspaceDir, ProductListingCacheData.FILE_NAME)));
|
||||||
|
if (listingData.LastUpdatedUtc - DateTime.UtcNow > TimeSpan.FromMilliseconds(configuration.CacheLifespan))
|
||||||
|
{
|
||||||
|
listingData = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Could not read JSON file.");
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
logger.LogWarning("No working directory path provided.");
|
||||||
|
}
|
||||||
|
catch (DirectoryNotFoundException)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Directory could not be found.");
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException)
|
||||||
|
{
|
||||||
|
logger.LogWarning("File could not be found.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (configuration == null)
|
||||||
|
{
|
||||||
|
configuration = new Configuration();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LiveProductListingManager productListingManager = new LiveProductListingManager(http, loggerFactory.CreateLogger<LiveProductListingManager>(), listingData?.ProductListings, configuration.MinDownloadInterval);
|
||||||
|
this.searchManager = new SearchManager(productListingManager, configuration.Similarity);
|
||||||
|
productListingManager.StartUpdateTimer(delay: 0, configuration.CacheLifespan);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProductListing> GetProductFromIdentifier(string identifier)
|
||||||
|
{
|
||||||
|
return await searchManager.ProductListingManager.GetProductListingFromIdentifier(identifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<ProductListing> Search(string query, Filters filters)
|
public IAsyncEnumerable<ProductListing> Search(string query, Filters filters)
|
||||||
@ -73,5 +135,17 @@ namespace Props.Shop.Adafruit
|
|||||||
Dispose(disposing: true);
|
Dispose(disposing: true);
|
||||||
GC.SuppressFinalize(this);
|
GC.SuppressFinalize(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async ValueTask SaveData()
|
||||||
|
{
|
||||||
|
if (workspaceDir != null)
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(workspaceDir, Configuration.FILE_NAME), JsonSerializer.Serialize(configuration));
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
Path.Combine(workspaceDir, configuration.ProductListingCacheFileName),
|
||||||
|
JsonSerializer.Serialize(new ProductListingCacheData(await searchManager.ProductListingManager.ProductListings))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,11 +7,11 @@ namespace Props.Shop.Adafruit.Api
|
|||||||
{
|
{
|
||||||
public interface IProductListingManager : IDisposable
|
public interface IProductListingManager : IDisposable
|
||||||
{
|
{
|
||||||
public Task<IDictionary<string, IList<ProductListing>>> ProductListings { get; }
|
public Task<IReadOnlyDictionary<string, IList<ProductListing>>> ProductListings { get; }
|
||||||
public void RefreshProductListings();
|
public void RefreshProductListings();
|
||||||
public void StartUpdateTimer(int delay = 1000 * 60 * 5, int period = 1000 * 60 * 5);
|
public void StartUpdateTimer(int delay = 1000 * 60 * 5, int period = 1000 * 60 * 5);
|
||||||
public void StopUpdateTimer();
|
public void StopUpdateTimer();
|
||||||
|
|
||||||
public Task<ProductListing> GetProductListingFromUrl(string url);
|
public Task<ProductListing> GetProductListingFromIdentifier(string url);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -16,6 +16,7 @@ namespace Props.Shop.Adafruit.Api
|
|||||||
{
|
{
|
||||||
using (StreamReader streamReader = new StreamReader(stream))
|
using (StreamReader streamReader = new StreamReader(stream))
|
||||||
{
|
{
|
||||||
|
DateTime startTime = DateTime.UtcNow;
|
||||||
dynamic data = JArray.Load(new JsonTextReader(streamReader));
|
dynamic data = JArray.Load(new JsonTextReader(streamReader));
|
||||||
List<ProductListing> parsed = new List<ProductListing>();
|
List<ProductListing> parsed = new List<ProductListing>();
|
||||||
foreach (dynamic item in data)
|
foreach (dynamic item in data)
|
||||||
@ -23,6 +24,7 @@ namespace Props.Shop.Adafruit.Api
|
|||||||
if (item.products_discontinued == 0)
|
if (item.products_discontinued == 0)
|
||||||
{
|
{
|
||||||
ProductListing res = new ProductListing();
|
ProductListing res = new ProductListing();
|
||||||
|
res.TimeFetchedUtc = startTime;
|
||||||
res.Name = item.product_name;
|
res.Name = item.product_name;
|
||||||
res.LowerPrice = item.product_price;
|
res.LowerPrice = item.product_price;
|
||||||
res.UpperPrice = res.LowerPrice;
|
res.UpperPrice = res.LowerPrice;
|
||||||
@ -40,6 +42,7 @@ namespace Props.Shop.Adafruit.Api
|
|||||||
res.URL = item.product_url;
|
res.URL = item.product_url;
|
||||||
res.InStock = item.product_stock > 0;
|
res.InStock = item.product_stock > 0;
|
||||||
parsed.Add(res);
|
parsed.Add(res);
|
||||||
|
res.Identifier = res.URL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProductListings = parsed;
|
ProductListings = parsed;
|
||||||
|
@ -16,20 +16,21 @@ namespace Props.Shop.Adafruit.Api
|
|||||||
private int minDownloadInterval;
|
private int minDownloadInterval;
|
||||||
private DateTime? lastDownload;
|
private DateTime? lastDownload;
|
||||||
private object refreshLock = new object();
|
private object refreshLock = new object();
|
||||||
private volatile Task<IDictionary<string, IList<ProductListing>>> productListingsTask;
|
private volatile Task<IReadOnlyDictionary<string, IList<ProductListing>>> productListingsTask;
|
||||||
|
|
||||||
public Task<IDictionary<string, IList<ProductListing>>> ProductListings => productListingsTask;
|
public Task<IReadOnlyDictionary<string, IList<ProductListing>>> ProductListings => productListingsTask;
|
||||||
private readonly ConcurrentDictionary<string, ProductListing> activeProductListingUrls = new ConcurrentDictionary<string, ProductListing>();
|
private readonly ConcurrentDictionary<string, ProductListing> identifierMap = new ConcurrentDictionary<string, ProductListing>();
|
||||||
|
|
||||||
private ProductListingsParser parser = new ProductListingsParser();
|
private ProductListingsParser parser = new ProductListingsParser();
|
||||||
private HttpClient httpClient;
|
private HttpClient httpClient;
|
||||||
private Timer updateTimer;
|
private Timer updateTimer;
|
||||||
|
|
||||||
public LiveProductListingManager(HttpClient httpClient, ILogger<LiveProductListingManager> logger, int minDownloadInterval = 5 * 60 * 1000)
|
public LiveProductListingManager(HttpClient httpClient, ILogger<LiveProductListingManager> logger, IReadOnlyDictionary<string, IList<ProductListing>> productListings = null, int minDownloadInterval = 5 * 60 * 1000)
|
||||||
{
|
{
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.minDownloadInterval = minDownloadInterval;
|
this.minDownloadInterval = minDownloadInterval;
|
||||||
this.httpClient = httpClient;
|
this.httpClient = httpClient;
|
||||||
|
productListingsTask = Task.FromResult(productListings);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RefreshProductListings()
|
public void RefreshProductListings()
|
||||||
@ -44,14 +45,14 @@ namespace Props.Shop.Adafruit.Api
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ProductListing> GetProductListingFromUrl(string url)
|
public async Task<ProductListing> GetProductListingFromIdentifier(string identifier)
|
||||||
{
|
{
|
||||||
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
|
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
|
||||||
await productListingsTask;
|
await productListingsTask;
|
||||||
return activeProductListingUrls[url];
|
return identifierMap[identifier];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IDictionary<string, IList<ProductListing>>> DownloadListings()
|
private async Task<IReadOnlyDictionary<string, IList<ProductListing>>> DownloadListings()
|
||||||
{
|
{
|
||||||
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
|
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
|
||||||
logger.LogDebug("Beginning listing database download.");
|
logger.LogDebug("Beginning listing database download.");
|
||||||
@ -59,10 +60,10 @@ namespace Props.Shop.Adafruit.Api
|
|||||||
parser.BuildProductListings(responseMessage.Content.ReadAsStream());
|
parser.BuildProductListings(responseMessage.Content.ReadAsStream());
|
||||||
logger.LogDebug("Listing database parsed.");
|
logger.LogDebug("Listing database parsed.");
|
||||||
Dictionary<string, IList<ProductListing>> listingNames = new Dictionary<string, IList<ProductListing>>();
|
Dictionary<string, IList<ProductListing>> listingNames = new Dictionary<string, IList<ProductListing>>();
|
||||||
activeProductListingUrls.Clear();
|
identifierMap.Clear();
|
||||||
foreach (ProductListing product in parser.ProductListings)
|
foreach (ProductListing product in parser.ProductListings)
|
||||||
{
|
{
|
||||||
activeProductListingUrls.TryAdd(product.URL, product);
|
identifierMap.TryAdd(product.Identifier, product);
|
||||||
IList<ProductListing> sameProducts = listingNames.GetValueOrDefault(product.Name);
|
IList<ProductListing> sameProducts = listingNames.GetValueOrDefault(product.Name);
|
||||||
if (sameProducts == null)
|
if (sameProducts == null)
|
||||||
{
|
{
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using FuzzySharp;
|
using FuzzySharp;
|
||||||
@ -26,7 +27,8 @@ namespace Props.Shop.Adafruit.Api
|
|||||||
if (ProductListingManager.ProductListings == null) {
|
if (ProductListingManager.ProductListings == null) {
|
||||||
ProductListingManager.RefreshProductListings();
|
ProductListingManager.RefreshProductListings();
|
||||||
}
|
}
|
||||||
IDictionary<string, IList<ProductListing>> productListings = await ProductListingManager.ProductListings;
|
IReadOnlyDictionary<string, IList<ProductListing>> productListings = await ProductListingManager.ProductListings;
|
||||||
|
if (productListings == null) throw new InvalidAsynchronousStateException("productListings can't be null");
|
||||||
foreach (ExtractedResult<string> listingNames in Process.ExtractAll(query, productListings.Keys, cutoff: (int)(Similarity * 100)))
|
foreach (ExtractedResult<string> listingNames in Process.ExtractAll(query, productListings.Keys, cutoff: (int)(Similarity * 100)))
|
||||||
{
|
{
|
||||||
foreach (ProductListing same in productListings[listingNames.Value])
|
foreach (ProductListing same in productListings[listingNames.Value])
|
||||||
|
@ -1,15 +0,0 @@
|
|||||||
namespace Props.Shop.Adafruit
|
|
||||||
{
|
|
||||||
public class Configuration
|
|
||||||
{
|
|
||||||
public int MinDownloadInterval { get; set; }
|
|
||||||
|
|
||||||
public float Similarity { get; set; }
|
|
||||||
|
|
||||||
public Configuration()
|
|
||||||
{
|
|
||||||
MinDownloadInterval = 5 * 60 * 1000;
|
|
||||||
Similarity = 0.8f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,19 @@
|
|||||||
|
namespace Props.Shop.Adafruit.Persistence
|
||||||
|
{
|
||||||
|
public class Configuration
|
||||||
|
{
|
||||||
|
public const string FILE_NAME = "config.json";
|
||||||
|
public int MinDownloadInterval { get; set; }
|
||||||
|
public int CacheLifespan { get; set; }
|
||||||
|
public float Similarity { get; set; }
|
||||||
|
public string ProductListingCacheFileName { get; set; }
|
||||||
|
|
||||||
|
public Configuration()
|
||||||
|
{
|
||||||
|
MinDownloadInterval = 5 * 60 * 1000;
|
||||||
|
Similarity = 0.8f;
|
||||||
|
CacheLifespan = 5 * 60 * 1000;
|
||||||
|
ProductListingCacheFileName = "ProductListings.json";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Props.Shop.Framework;
|
||||||
|
|
||||||
|
namespace Props.Shop.Adafruit.Persistence
|
||||||
|
{
|
||||||
|
public class ProductListingCacheData
|
||||||
|
{
|
||||||
|
public const string FILE_NAME = "Product-listing-cache.json";
|
||||||
|
public DateTime LastUpdatedUtc { get; private set; }
|
||||||
|
public IReadOnlyDictionary<string, IList<ProductListing>> ProductListings { get; set; }
|
||||||
|
|
||||||
|
public ProductListingCacheData(IReadOnlyDictionary<string, IList<ProductListing>> productListings)
|
||||||
|
{
|
||||||
|
this.ProductListings = productListings;
|
||||||
|
LastUpdatedUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProductListingCacheData()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,51 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Props.Shop.Framework;
|
|
||||||
|
|
||||||
namespace Props.Shop.Ebay.Actions
|
|
||||||
{
|
|
||||||
public class SearchRequest : IAsyncEnumerable<ProductListing>
|
|
||||||
{
|
|
||||||
private HttpClient http;
|
|
||||||
private string[] query;
|
|
||||||
public SearchRequest(HttpClient http, string[] query)
|
|
||||||
{
|
|
||||||
this.http = http ?? throw new ArgumentNullException("http");
|
|
||||||
this.query = query ?? throw new ArgumentNullException("query");
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAsyncEnumerator<ProductListing> GetAsyncEnumerator(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
throw new System.NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Enumerator : IAsyncEnumerator<ProductListing>
|
|
||||||
{
|
|
||||||
private HttpClient http;
|
|
||||||
private string[] query;
|
|
||||||
|
|
||||||
public Enumerator(HttpClient http, string[] query)
|
|
||||||
{
|
|
||||||
this.http = http;
|
|
||||||
this.query = query;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ProductListing Current { get; private set; }
|
|
||||||
|
|
||||||
public ValueTask<bool> MoveNextAsync()
|
|
||||||
{
|
|
||||||
// TODO: Implement this.
|
|
||||||
throw new System.NotImplementedException();
|
|
||||||
}
|
|
||||||
public ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
// TODO: Implement this.
|
|
||||||
throw new System.NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,29 +0,0 @@
|
|||||||
using System.Collections;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using Props.Shop.Framework;
|
|
||||||
|
|
||||||
namespace Props.Shop.Ebay.Api.ItemSummary
|
|
||||||
{
|
|
||||||
public class SearchResultParser
|
|
||||||
{
|
|
||||||
private dynamic data;
|
|
||||||
public int BeginIndex => data.offset;
|
|
||||||
public int EndIndex => BeginIndex + data.limit;
|
|
||||||
public IEnumerable<ProductListing> ProductListings { get; private set; }
|
|
||||||
|
|
||||||
public SearchResultParser(string result)
|
|
||||||
{
|
|
||||||
data = JObject.Parse(result);
|
|
||||||
|
|
||||||
List<ProductListing> parsed = new List<ProductListing>();
|
|
||||||
foreach (dynamic itemSummary in data.itemSummaries)
|
|
||||||
{
|
|
||||||
ProductListing listing = new ProductListing();
|
|
||||||
// TODO: Finish parsing the data.
|
|
||||||
parsed.Add(listing);
|
|
||||||
}
|
|
||||||
ProductListings = parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,57 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Props.Shop.Ebay.Api.ItemSummary
|
|
||||||
{
|
|
||||||
public class SearchUriBuilder
|
|
||||||
{
|
|
||||||
UriBuilder uriBuilder = new UriBuilder("/search");
|
|
||||||
private HashSet<string> queries = new HashSet<string>();
|
|
||||||
private bool autoCorrect = false;
|
|
||||||
private int? maxResults = 100;
|
|
||||||
private int? offset = 0;
|
|
||||||
public bool AutoCorrect
|
|
||||||
{
|
|
||||||
set
|
|
||||||
{
|
|
||||||
autoCorrect = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public int? MaxResults
|
|
||||||
{
|
|
||||||
set
|
|
||||||
{
|
|
||||||
maxResults = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public int? Offset
|
|
||||||
{
|
|
||||||
set
|
|
||||||
{
|
|
||||||
offset = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AddSearchQuery(string query)
|
|
||||||
{
|
|
||||||
queries.Add(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Uri Build()
|
|
||||||
{
|
|
||||||
StringBuilder queryBuilder = new StringBuilder("q=");
|
|
||||||
queryBuilder.Append('(');
|
|
||||||
queryBuilder.AppendJoin(", ", queries);
|
|
||||||
queryBuilder.Append(')');
|
|
||||||
uriBuilder.Query += queryBuilder.ToString();
|
|
||||||
|
|
||||||
if (autoCorrect) uriBuilder.Query += "&auto_correct=KEYWORD";
|
|
||||||
if (maxResults.HasValue) uriBuilder.Query += "&limit=" + maxResults.Value;
|
|
||||||
if (offset.HasValue) uriBuilder.Query += "&offset=" + offset.Value;
|
|
||||||
return uriBuilder.Uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,7 +0,0 @@
|
|||||||
namespace Props.Shop.Ebay
|
|
||||||
{
|
|
||||||
public class Configuration
|
|
||||||
{
|
|
||||||
public bool Sandbox { get; set; } = true;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,74 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Props.Shop.Framework;
|
|
||||||
|
|
||||||
namespace Props.Shop.Ebay
|
|
||||||
{
|
|
||||||
public class EbayShop : IShop
|
|
||||||
{
|
|
||||||
private bool disposedValue;
|
|
||||||
|
|
||||||
public string ShopName => "Ebay";
|
|
||||||
|
|
||||||
public string ShopDescription => "A multi-national online store host to consumer-to-consumer and business-to-consumer sales.";
|
|
||||||
|
|
||||||
public string ShopModuleAuthor => "Reslate";
|
|
||||||
|
|
||||||
public SupportedFeatures SupportedFeatures => new SupportedFeatures(
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
Configuration configuration;
|
|
||||||
|
|
||||||
private HttpClient httpClient;
|
|
||||||
|
|
||||||
public void Initialize(string workspaceDir)
|
|
||||||
{
|
|
||||||
httpClient = new HttpClient();
|
|
||||||
configuration = new Configuration(); // TODO: Implement config persistence.
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public Task InitializeAsync(string workspaceDir)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected virtual void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
if (!disposedValue)
|
|
||||||
{
|
|
||||||
if (disposing)
|
|
||||||
{
|
|
||||||
httpClient.Dispose();
|
|
||||||
}
|
|
||||||
disposedValue = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
Dispose(disposing: true);
|
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
public byte[] GetDataForPersistence()
|
|
||||||
{
|
|
||||||
return JsonSerializer.SerializeToUtf8Bytes(configuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IEnumerable<ProductListing> Search(string query, Filters filters)
|
|
||||||
{
|
|
||||||
// TODO: Implement the search system.
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Framework\Props.Shop.Framework.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
@ -15,9 +15,10 @@ namespace Props.Shop.Framework
|
|||||||
|
|
||||||
public IAsyncEnumerable<ProductListing> Search(string query, Filters filters);
|
public IAsyncEnumerable<ProductListing> Search(string query, Filters filters);
|
||||||
|
|
||||||
public Task<ProductListing> GetProductListingFromUrl(string url);
|
public Task<ProductListing> GetProductFromIdentifier(string identifier);
|
||||||
|
|
||||||
void Initialize(string workspaceDir, ILoggerFactory loggerFactory);
|
void Initialize(string workspaceDir, ILoggerFactory loggerFactory);
|
||||||
|
ValueTask SaveData();
|
||||||
public SupportedFeatures SupportedFeatures { get; }
|
public SupportedFeatures SupportedFeatures { get; }
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,3 +1,5 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
namespace Props.Shop.Framework
|
namespace Props.Shop.Framework
|
||||||
{
|
{
|
||||||
public class ProductListing
|
public class ProductListing
|
||||||
@ -13,5 +15,41 @@ namespace Props.Shop.Framework
|
|||||||
public int? ReviewCount { get; set; }
|
public int? ReviewCount { get; set; }
|
||||||
public bool ConvertedPrices { get; set; }
|
public bool ConvertedPrices { get; set; }
|
||||||
public bool? InStock { get; set; }
|
public bool? InStock { get; set; }
|
||||||
|
public string Identifier { get; set; }
|
||||||
|
public DateTime TimeFetchedUtc { get; set; }
|
||||||
|
|
||||||
|
public override bool Equals(object obj)
|
||||||
|
{
|
||||||
|
if (obj == null || GetType() != obj.GetType())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProductListing other = obj as ProductListing;
|
||||||
|
return
|
||||||
|
this.LowerPrice == other.LowerPrice &&
|
||||||
|
this.UpperPrice == other.UpperPrice &&
|
||||||
|
this.Shipping == other.Shipping &&
|
||||||
|
this.Name == other.Name &&
|
||||||
|
this.URL == other.URL &&
|
||||||
|
this.ImageURL == other.ImageURL &&
|
||||||
|
this.Rating == other.Rating &&
|
||||||
|
this.PurchaseCount == other.PurchaseCount &&
|
||||||
|
this.ReviewCount == other.ReviewCount &&
|
||||||
|
this.ConvertedPrices == other.ConvertedPrices &&
|
||||||
|
this.InStock == other.InStock &&
|
||||||
|
this.Identifier == other.Identifier &&
|
||||||
|
this.TimeFetchedUtc == other.TimeFetchedUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
return (Name, URL, UpperPrice, LowerPrice, ImageURL).GetHashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProductListing Copy()
|
||||||
|
{
|
||||||
|
return MemberwiseClone() as ProductListing;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,6 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Props.Shop.Framework;
|
using Props.Shop.Framework;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
@ -11,31 +11,29 @@ namespace Props.Shop.Adafruit.Tests.Api
|
|||||||
{
|
{
|
||||||
public class FakeProductListingManager : IProductListingManager
|
public class FakeProductListingManager : IProductListingManager
|
||||||
{
|
{
|
||||||
private Timer refreshTimer;
|
|
||||||
private bool disposedValue;
|
private bool disposedValue;
|
||||||
private volatile Task<IDictionary<string, IList<ProductListing>>> activeListings;
|
|
||||||
private DateTime? lastDownload;
|
private DateTime? lastDownload;
|
||||||
private ProductListingsParser parser = new ProductListingsParser();
|
private ProductListingsParser parser = new ProductListingsParser();
|
||||||
private readonly ConcurrentDictionary<string, ProductListing> activeProductListingUrls = new ConcurrentDictionary<string, ProductListing>();
|
private readonly ConcurrentDictionary<string, ProductListing> activeProductListingUrls = new ConcurrentDictionary<string, ProductListing>();
|
||||||
|
|
||||||
|
|
||||||
public Task<IDictionary<string, IList<ProductListing>>> ProductListings => activeListings;
|
public Task<IReadOnlyDictionary<string, IList<ProductListing>>> ProductListings { get; private set; }
|
||||||
|
|
||||||
public async Task<ProductListing> GetProductListingFromUrl(string url)
|
public async Task<ProductListing> GetProductListingFromIdentifier(string url)
|
||||||
{
|
{
|
||||||
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
|
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
|
||||||
await activeListings;
|
await ProductListings;
|
||||||
return activeProductListingUrls[url];
|
return activeProductListingUrls[url];
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RefreshProductListings()
|
public void RefreshProductListings()
|
||||||
{
|
{
|
||||||
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
|
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
|
||||||
if ((lastDownload != null && DateTime.UtcNow - lastDownload <= TimeSpan.FromMilliseconds(5 * 60 * 1000)) || (activeListings != null && !activeListings.IsCompleted)) return;
|
if ((lastDownload != null && DateTime.UtcNow - lastDownload <= TimeSpan.FromMilliseconds(5 * 60 * 1000)) || (ProductListings != null && !ProductListings.IsCompleted)) return;
|
||||||
activeListings = DownloadListings();
|
ProductListings = DownloadListings();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<IDictionary<string, IList<ProductListing>>> DownloadListings() {
|
private Task<IReadOnlyDictionary<string, IList<ProductListing>>> DownloadListings() {
|
||||||
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
|
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
|
||||||
lastDownload = DateTime.UtcNow;
|
lastDownload = DateTime.UtcNow;
|
||||||
parser.BuildProductListings(File.OpenRead("./Assets/products.json"));
|
parser.BuildProductListings(File.OpenRead("./Assets/products.json"));
|
||||||
@ -52,24 +50,16 @@ namespace Props.Shop.Adafruit.Tests.Api
|
|||||||
|
|
||||||
sameProducts.Add(product);
|
sameProducts.Add(product);
|
||||||
}
|
}
|
||||||
return Task.FromResult<IDictionary<string, IList<ProductListing>>>(listingNames);
|
return Task.FromResult<IReadOnlyDictionary<string, IList<ProductListing>>>(listingNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void StartUpdateTimer(int delay = 300000, int period = 300000)
|
public void StartUpdateTimer(int delay = 300000, int period = 300000)
|
||||||
{
|
{
|
||||||
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
|
|
||||||
if (refreshTimer != null) throw new InvalidOperationException("Refresh timer already running.");
|
|
||||||
refreshTimer = new Timer((state) => {
|
|
||||||
RefreshProductListings();
|
RefreshProductListings();
|
||||||
}, null, delay, period);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void StopUpdateTimer()
|
public void StopUpdateTimer()
|
||||||
{
|
{
|
||||||
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
|
|
||||||
if (refreshTimer == null) throw new InvalidOperationException("Refresh timer not running.");
|
|
||||||
refreshTimer.Dispose();
|
|
||||||
refreshTimer = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected virtual void Dispose(bool disposing)
|
protected virtual void Dispose(bool disposing)
|
||||||
@ -78,8 +68,6 @@ namespace Props.Shop.Adafruit.Tests.Api
|
|||||||
{
|
{
|
||||||
if (disposing)
|
if (disposing)
|
||||||
{
|
{
|
||||||
refreshTimer?.Dispose();
|
|
||||||
refreshTimer = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disposedValue = true;
|
disposedValue = true;
|
||||||
|
@ -65,6 +65,18 @@ namespace Props.Data
|
|||||||
c => c.Copy()
|
c => c.Copy()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<ProductListingInfo>()
|
||||||
|
.Property(e => e.ProductListing)
|
||||||
|
.HasConversion(
|
||||||
|
v => JsonSerializer.Serialize(v, null),
|
||||||
|
v => JsonSerializer.Deserialize<ProductListing>(v, null),
|
||||||
|
new ValueComparer<ProductListing>(
|
||||||
|
(a, b) => a.Equals(b),
|
||||||
|
c => c.GetHashCode(),
|
||||||
|
c => c.Copy()
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ using Props.Data;
|
|||||||
namespace Props.Data.Migrations
|
namespace Props.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(ApplicationDbContext))]
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
[Migration("20210805055109_InitialCreate")]
|
[Migration("20210809194646_InitialCreate")]
|
||||||
partial class InitialCreate
|
partial class InitialCreate
|
||||||
{
|
{
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@ -185,13 +185,10 @@ namespace Props.Data.Migrations
|
|||||||
b.Property<uint>("Hits")
|
b.Property<uint>("Hits")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<DateTime>("LastUpdated")
|
b.Property<string>("ProductListing")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("ProductName")
|
b.Property<string>("ProductListingIdentifier")
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("ProductUrl")
|
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("ShopName")
|
b.Property<string>("ShopName")
|
@ -68,9 +68,8 @@ namespace Props.Data.Migrations
|
|||||||
.Annotation("Sqlite:Autoincrement", true),
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
ShopName = table.Column<string>(type: "TEXT", nullable: true),
|
ShopName = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
Hits = table.Column<uint>(type: "INTEGER", nullable: false),
|
Hits = table.Column<uint>(type: "INTEGER", nullable: false),
|
||||||
LastUpdated = table.Column<DateTime>(type: "TEXT", nullable: false),
|
ProductListing = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
ProductUrl = table.Column<string>(type: "TEXT", nullable: true),
|
ProductListingIdentifier = table.Column<string>(type: "TEXT", nullable: true)
|
||||||
ProductName = table.Column<string>(type: "TEXT", nullable: true)
|
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
@ -183,13 +183,10 @@ namespace Props.Data.Migrations
|
|||||||
b.Property<uint>("Hits")
|
b.Property<uint>("Hits")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<DateTime>("LastUpdated")
|
b.Property<string>("ProductListing")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("ProductName")
|
b.Property<string>("ProductListingIdentifier")
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("ProductUrl")
|
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("ShopName")
|
b.Property<string>("ShopName")
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Props.Shop.Framework;
|
using Props.Shop.Framework;
|
||||||
|
|
||||||
namespace Props.Extensions
|
namespace Props.Extensions
|
||||||
@ -10,5 +11,7 @@ namespace Props.Extensions
|
|||||||
int purchaseFactor = productListing.PurchaseCount.HasValue ? productListing.PurchaseCount.Value : 1;
|
int purchaseFactor = productListing.PurchaseCount.HasValue ? productListing.PurchaseCount.Value : 1;
|
||||||
return (productListing.Rating * (reviewFactor > purchaseFactor ? reviewFactor : purchaseFactor)) / (productListing.LowerPrice * productListing.UpperPrice);
|
return (productListing.Rating * (reviewFactor > purchaseFactor ? reviewFactor : purchaseFactor)) / (productListing.LowerPrice * productListing.UpperPrice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -11,10 +11,8 @@ namespace Props.Models.Search
|
|||||||
|
|
||||||
public uint Hits { get; set; }
|
public uint Hits { get; set; }
|
||||||
|
|
||||||
public DateTime LastUpdated { get; set; }
|
public ProductListing ProductListing { get; set; }
|
||||||
|
|
||||||
public string ProductUrl { get; set; }
|
public string ProductListingIdentifier { get; set; }
|
||||||
|
|
||||||
public string ProductName { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -68,16 +68,15 @@ namespace Props.Services.Modules
|
|||||||
{
|
{
|
||||||
ProductListingInfo productListingInfo =
|
ProductListingInfo productListingInfo =
|
||||||
(from info in dbContext.ProductListingInfos
|
(from info in dbContext.ProductListingInfos
|
||||||
where info.ProductUrl.Equals(productListing.URL)
|
where info.ProductListingIdentifier.Equals(productListing.Identifier)
|
||||||
select info).SingleOrDefault() ?? new ProductListingInfo();
|
select info).SingleOrDefault() ?? new ProductListingInfo();
|
||||||
if (productListingInfo.Hits == 0)
|
if (productListingInfo.Hits == 0)
|
||||||
{
|
{
|
||||||
dbContext.Add(productListingInfo);
|
dbContext.Add(productListingInfo);
|
||||||
}
|
}
|
||||||
productListingInfo.ShopName = shopName;
|
productListingInfo.ShopName = shopName;
|
||||||
productListingInfo.ProductName = productListing.Name;
|
productListingInfo.ProductListing = productListing;
|
||||||
productListingInfo.ProductUrl = productListing.URL;
|
productListingInfo.ProductListingIdentifier = productListing.Identifier;
|
||||||
productListingInfo.LastUpdated = DateTime.UtcNow;
|
|
||||||
productListingInfo.Hits += 1;
|
productListingInfo.Hits += 1;
|
||||||
dbContext.SaveChanges();
|
dbContext.SaveChanges();
|
||||||
}
|
}
|
||||||
|
@ -55,7 +55,6 @@ namespace Props.Services.Modules
|
|||||||
|
|
||||||
public void LoadShops()
|
public void LoadShops()
|
||||||
{
|
{
|
||||||
// TODO: Figure out how to best call this.
|
|
||||||
string shopsDir = options.ModulesDir;
|
string shopsDir = options.ModulesDir;
|
||||||
string shopRegex = options.ShopRegex;
|
string shopRegex = options.ShopRegex;
|
||||||
bool recursiveLoad = options.RecursiveLoad;
|
bool recursiveLoad = options.RecursiveLoad;
|
||||||
@ -87,7 +86,8 @@ namespace Props.Services.Modules
|
|||||||
IShop shop = Activator.CreateInstance(type) as IShop;
|
IShop shop = Activator.CreateInstance(type) as IShop;
|
||||||
if (shop != null)
|
if (shop != null)
|
||||||
{
|
{
|
||||||
DirectoryInfo dataDir = Directory.CreateDirectory(Path.Combine(options.ModuleDataDir, file));
|
DirectoryInfo dataDir = Directory.CreateDirectory(Path.Combine(options.ModuleDataDir, file.Substring(file.IndexOf(Path.DirectorySeparatorChar) + 1)));
|
||||||
|
logger.LogDebug("Checking data directory for \"{0}\" at \"{1}\"", Path.GetFileName(file), dataDir.FullName);
|
||||||
shop.Initialize(dataDir.FullName, loggerFactory);
|
shop.Initialize(dataDir.FullName, loggerFactory);
|
||||||
success += 1;
|
success += 1;
|
||||||
if (!shops.TryAdd(shop.ShopName, shop))
|
if (!shops.TryAdd(shop.ShopName, shop))
|
||||||
@ -105,8 +105,6 @@ namespace Props.Services.Modules
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.LogDebug("Waiting for all shops to finish asynchronous initialization.");
|
|
||||||
logger.LogDebug("All shops finished asynchronous initialization.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected virtual void Dispose(bool disposing)
|
protected virtual void Dispose(bool disposing)
|
||||||
@ -117,7 +115,7 @@ namespace Props.Services.Modules
|
|||||||
{
|
{
|
||||||
foreach (string shopName in shops.Keys)
|
foreach (string shopName in shops.Keys)
|
||||||
{
|
{
|
||||||
// TODO: Get shop data to persist.
|
shops[shopName].SaveData().AsTask().Wait();
|
||||||
shops[shopName].Dispose();
|
shops[shopName].Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user