├── .gitattributes ├── .gitignore ├── AmazonScrape.csproj ├── AmazonScrape.sln ├── App.config ├── App.xaml ├── App.xaml.cs ├── Constants.cs ├── Model ├── Logic │ ├── ItemValidator.cs │ ├── PageManager.cs │ ├── Parser.cs │ ├── Scraper.cs │ └── SearchManager.cs └── Structure │ ├── AmazonItem.cs │ ├── IValidatable.cs │ ├── Result.cs │ ├── ScoreDistribution.cs │ └── SearchCriteria.cs ├── Properties ├── AssemblyInfo.cs ├── Resources.Designer.cs ├── Resources.resx ├── Settings.Designer.cs └── Settings.settings ├── README.md ├── UI ├── Controls │ ├── DataGridPlus.cs │ ├── RangeBoxes.xaml │ ├── RangeBoxes.xaml.cs │ ├── RangeSlider.xaml │ ├── RangeSlider.xaml.cs │ ├── ScoreDistributionControl.cs │ ├── ScoreDistributionControl.xaml │ ├── ScoreDistributionControl.xaml.cs │ ├── TextBoxPlus.xaml │ ├── TextBoxPlus.xaml.cs │ └── ValueRangeControl.cs ├── MainWindow.xaml ├── MainWindow.xaml.cs └── Resources │ ├── ControlStyles.xaml │ ├── Images │ ├── application_icon.ico │ ├── icon.ico │ └── prime_logo.png │ └── ResourceLoader.cs ├── UtilityClasses ├── DoubleRange.cs ├── DoubleRangeConverter.cs ├── IntRange.cs └── NumericRange.cs └── packages.config /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.sln.docstates 8 | 9 | # Build results 10 | 11 | [Dd]ebug/ 12 | [Rr]elease/ 13 | x64/ 14 | build/ 15 | [Bb]in/ 16 | [Oo]bj/ 17 | 18 | # Enable "build/" folder in the NuGet Packages folder since NuGet packages use it for MSBuild targets 19 | !packages/*/build/ 20 | 21 | # MSTest test Results 22 | [Tt]est[Rr]esult*/ 23 | [Bb]uild[Ll]og.* 24 | 25 | *_i.c 26 | *_p.c 27 | *.ilk 28 | *.meta 29 | *.obj 30 | *.pch 31 | *.pdb 32 | *.pgc 33 | *.pgd 34 | *.rsp 35 | *.sbr 36 | *.tlb 37 | *.tli 38 | *.tlh 39 | *.tmp 40 | *.tmp_proj 41 | *.log 42 | *.vspscc 43 | *.vssscc 44 | .builds 45 | *.pidb 46 | *.log 47 | *.scc 48 | 49 | # Visual C++ cache files 50 | ipch/ 51 | *.aps 52 | *.ncb 53 | *.opensdf 54 | *.sdf 55 | *.cachefile 56 | 57 | # Visual Studio profiler 58 | *.psess 59 | *.vsp 60 | *.vspx 61 | 62 | # Guidance Automation Toolkit 63 | *.gpState 64 | 65 | # ReSharper is a .NET coding add-in 66 | _ReSharper*/ 67 | *.[Rr]e[Ss]harper 68 | 69 | # TeamCity is a build add-in 70 | _TeamCity* 71 | 72 | # DotCover is a Code Coverage Tool 73 | *.dotCover 74 | 75 | # NCrunch 76 | *.ncrunch* 77 | .*crunch*.local.xml 78 | 79 | # Installshield output folder 80 | [Ee]xpress/ 81 | 82 | # DocProject is a documentation generator add-in 83 | DocProject/buildhelp/ 84 | DocProject/Help/*.HxT 85 | DocProject/Help/*.HxC 86 | DocProject/Help/*.hhc 87 | DocProject/Help/*.hhk 88 | DocProject/Help/*.hhp 89 | DocProject/Help/Html2 90 | DocProject/Help/html 91 | 92 | # Click-Once directory 93 | publish/ 94 | 95 | # Publish Web Output 96 | *.Publish.xml 97 | 98 | # NuGet Packages Directory 99 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 100 | #packages/ 101 | 102 | # Windows Azure Build Output 103 | csx 104 | *.build.csdef 105 | 106 | # Windows Store app package directory 107 | AppPackages/ 108 | 109 | # Others 110 | sql/ 111 | *.Cache 112 | ClientBin/ 113 | [Ss]tyle[Cc]op.* 114 | ~$* 115 | *~ 116 | *.dbmdl 117 | *.[Pp]ublish.xml 118 | *.pfx 119 | *.publishsettings 120 | 121 | # RIA/Silverlight projects 122 | Generated_Code/ 123 | 124 | # Backup & report files from converting an old project file to a newer 125 | # Visual Studio version. Backup files are not needed, because we have git ;-) 126 | _UpgradeReport_Files/ 127 | Backup*/ 128 | UpgradeLog*.XML 129 | UpgradeLog*.htm 130 | 131 | # SQL Server files 132 | App_Data/*.mdf 133 | App_Data/*.ldf 134 | 135 | 136 | #LightSwitch generated files 137 | GeneratedArtifacts/ 138 | _Pvt_Extensions/ 139 | ModelManifest.xml 140 | 141 | # ========================= 142 | # Windows detritus 143 | # ========================= 144 | 145 | # Windows image file caches 146 | Thumbs.db 147 | ehthumbs.db 148 | 149 | # Folder config file 150 | Desktop.ini 151 | 152 | # Recycle Bin used on file shares 153 | $RECYCLE.BIN/ 154 | 155 | # Mac desktop service store files 156 | .DS_Store 157 | -------------------------------------------------------------------------------- /AmazonScrape.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | {B855C03D-E2F8-4C93-ADB4-690956D1D567} 8 | WinExe 9 | Properties 10 | AmazonScrape 11 | AmazonScrape 12 | v4.5 13 | 512 14 | {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} 15 | 4 16 | false 17 | publish\ 18 | true 19 | Disk 20 | false 21 | Foreground 22 | 7 23 | Days 24 | false 25 | false 26 | true 27 | 0 28 | 1.0.0.%2a 29 | false 30 | true 31 | 32 | 33 | AnyCPU 34 | true 35 | full 36 | false 37 | bin\Debug\ 38 | DEBUG;TRACE 39 | prompt 40 | 4 41 | false 42 | 43 | 44 | AnyCPU 45 | pdbonly 46 | true 47 | bin\Release\ 48 | TRACE 49 | prompt 50 | 4 51 | 52 | 53 | UI\Resources\Images\application_icon.ico 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 4.0 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | MSBuild:Compile 79 | Designer 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | RangeBoxes.xaml 88 | 89 | 90 | RangeSlider.xaml 91 | 92 | 93 | ScoreDistributionControl.xaml 94 | 95 | 96 | TextBoxPlus.xaml 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | Component 107 | 108 | 109 | Component 110 | 111 | 112 | 113 | 114 | 115 | Designer 116 | MSBuild:Compile 117 | 118 | 119 | MSBuild:Compile 120 | Designer 121 | 122 | 123 | App.xaml 124 | Code 125 | 126 | 127 | MainWindow.xaml 128 | Code 129 | 130 | 131 | Designer 132 | MSBuild:Compile 133 | 134 | 135 | Designer 136 | MSBuild:Compile 137 | 138 | 139 | Designer 140 | MSBuild:Compile 141 | 142 | 143 | Designer 144 | MSBuild:Compile 145 | 146 | 147 | 148 | 149 | Code 150 | 151 | 152 | True 153 | True 154 | Resources.resx 155 | 156 | 157 | True 158 | Settings.settings 159 | True 160 | 161 | 162 | ResXFileCodeGenerator 163 | Resources.Designer.cs 164 | 165 | 166 | 167 | SettingsSingleFileGenerator 168 | Settings.Designer.cs 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | False 181 | Microsoft .NET Framework 4.5 %28x86 and x64%29 182 | true 183 | 184 | 185 | False 186 | .NET Framework 3.5 SP1 Client Profile 187 | false 188 | 189 | 190 | False 191 | .NET Framework 3.5 SP1 192 | false 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 210 | -------------------------------------------------------------------------------- /AmazonScrape.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 2012 4 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AmazonScrape", "AmazonScrape.csproj", "{B855C03D-E2F8-4C93-ADB4-690956D1D567}" 5 | EndProject 6 | Global 7 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 8 | Debug|Any CPU = Debug|Any CPU 9 | Release|Any CPU = Release|Any CPU 10 | EndGlobalSection 11 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 12 | {B855C03D-E2F8-4C93-ADB4-690956D1D567}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 13 | {B855C03D-E2F8-4C93-ADB4-690956D1D567}.Debug|Any CPU.Build.0 = Debug|Any CPU 14 | {B855C03D-E2F8-4C93-ADB4-690956D1D567}.Release|Any CPU.ActiveCfg = Release|Any CPU 15 | {B855C03D-E2F8-4C93-ADB4-690956D1D567}.Release|Any CPU.Build.0 = Release|Any CPU 16 | {011577DC-F356-4004-B97D-3B0117D48498}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {011577DC-F356-4004-B97D-3B0117D48498}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {011577DC-F356-4004-B97D-3B0117D48498}.Release|Any CPU.ActiveCfg = Release|Any CPU 19 | {011577DC-F356-4004-B97D-3B0117D48498}.Release|Any CPU.Build.0 = Release|Any CPU 20 | EndGlobalSection 21 | GlobalSection(SolutionProperties) = preSolution 22 | HideSolutionNode = FALSE 23 | EndGlobalSection 24 | EndGlobal 25 | -------------------------------------------------------------------------------- /App.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /App.xaml: -------------------------------------------------------------------------------- 1 |  5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /App.xaml.cs: -------------------------------------------------------------------------------- 1 | using System.Windows; 2 | using System.Windows.Controls; 3 | using System.Windows.Media; 4 | 5 | namespace AmazonScrape 6 | { 7 | /// 8 | /// Interaction logic for App.xaml 9 | /// 10 | public partial class App : Application 11 | { 12 | static App() 13 | { 14 | // Ensure that displayed text uses ClearType globally 15 | TextOptions.TextFormattingModeProperty.OverrideMetadata(typeof(Window), 16 | new FrameworkPropertyMetadata(TextFormattingMode.Display, 17 | FrameworkPropertyMetadataOptions.AffectsMeasure | 18 | FrameworkPropertyMetadataOptions.AffectsRender | 19 | FrameworkPropertyMetadataOptions.Inherits)); 20 | 21 | } 22 | 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Constants.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace AmazonScrape 3 | { 4 | public static class Constants 5 | { 6 | // ------------------------------------------------------------------- 7 | // Default search parameters 8 | // ------------------------------------------------------------------- 9 | public const string DEFAULT_SEARCH_TEXT = @""; 10 | public const string DEFAULT_RESULT_COUNT = @"20"; 11 | public const string DEFAULT_MIN_REVIEWS = @"0"; 12 | public const bool DEFAULT_MATCH_ALL_TERMS = false; 13 | public const string DEFAULT_LOW_PRICERANGE = @""; 14 | public const string DEFAULT_HIGH_PRICERANGE = @""; 15 | // todo: add review distribution defaults 16 | 17 | 18 | 19 | // ------------------------------------------------------------------- 20 | // Major formatting settings 21 | // ( other settings are found in .\UI\Resources\ControlStyles.xaml ) 22 | // ------------------------------------------------------------------- 23 | public const double DEFAULT_LARGE_TEXT_SIZE = 16.0; 24 | public const double DEFAULT_MEDIUM_TEXT_SIZE = 12.0; 25 | public const double DEFAULT_SMALL_TEXT_SIZE = 10.0; 26 | public const double DEFAULT_BUTTON_FONT_SIZE = 12.0; 27 | 28 | 29 | 30 | // ------------------------------------------------------------------- 31 | // URLS and parameters (used by Scraper/Parser) 32 | // ------------------------------------------------------------------- 33 | public const string BASE_URL = @"www.amazon.com"; 34 | public const string SEARCH_URL = 35 | @"http://www.amazon.com/s/field-keywords="; 36 | public const string SEARCH_URL_PAGE_PARAM = @"&page="; 37 | public const string REVIEW_HISTOGRAM_URL = 38 | @"http://www.amazon.com/gp/customer-reviews/common/du/displayHistoPopAjax.html?&ASIN="; 39 | 40 | 41 | 42 | // ------------------------------------------------------------------- 43 | // Program operation settings 44 | // ------------------------------------------------------------------- 45 | 46 | // The number of async pageload threads to run (set to 2 or more) 47 | public const int MAX_THREADS = 3; 48 | 49 | // Ensures prime eligibility accuracy by loading an additional 50 | // page for each result (slows application execution significantly) 51 | // Under normal circumstances, this should remain "false". 52 | public const bool USE_STRICT_PRIME_ELIGIBILITY = false; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Model/Logic/ItemValidator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace AmazonScrape 8 | { 9 | /// 10 | /// Static methods determine whether the inputs meet the user's 11 | /// search criteria 12 | /// 13 | public static class ItemValidator 14 | { 15 | public static bool ValidateItemName(SearchCriteria criteria, string itemName) 16 | { 17 | if (criteria.MatchAllSearchTerms) 18 | { 19 | if (itemName == null) { return false; } 20 | 21 | // Make sure every search term is present in the item name 22 | string[] terms = criteria.SearchText.ToLower().Split(' '); 23 | for (int i = 0; i < terms.Count(); i++) 24 | { 25 | if (!itemName.ToLower().Contains(terms[i])) 26 | { 27 | return false; 28 | } 29 | } 30 | } 31 | 32 | return true; 33 | } 34 | 35 | public static bool ValidateReviewCount(SearchCriteria criteria, int count) 36 | { 37 | if (count < criteria.MinNumberReviews) 38 | { 39 | return false; 40 | } 41 | 42 | return true; 43 | } 44 | 45 | public static bool ValidatePriceRange(SearchCriteria criteria, DoubleRange priceRange) 46 | { 47 | // If the user specified a price range, but the item has no price information, fail 48 | if (criteria.PriceRange.HasRangeSpecified && !priceRange.HasRangeSpecified) { return false; } 49 | 50 | if (criteria.PriceRange != null && criteria.PriceRange.HasRangeSpecified) 51 | { 52 | if (priceRange == null) return false; 53 | if (!criteria.PriceRange.Overlaps(priceRange)) return false; 54 | } 55 | 56 | return true; 57 | } 58 | 59 | public static bool ValidateReviewDistribution(SearchCriteria criteria, ScoreDistribution scoreDistribution) 60 | { 61 | 62 | // Test each of the review percentage criteria 63 | if (scoreDistribution != null) 64 | { 65 | if (!criteria.ScoreDistribution.OneStar.Contains(scoreDistribution.OneStar)) 66 | { return false; } 67 | 68 | if (!criteria.ScoreDistribution.TwoStar.Contains(scoreDistribution.TwoStar)) 69 | { return false; } 70 | 71 | if (!criteria.ScoreDistribution.ThreeStar.Contains(scoreDistribution.ThreeStar)) 72 | { return false; } 73 | 74 | if (!criteria.ScoreDistribution.FourStar.Contains(scoreDistribution.FourStar)) 75 | { return false; } 76 | 77 | if (!criteria.ScoreDistribution.FiveStar.Contains(scoreDistribution.FiveStar)) 78 | { return false; } 79 | } 80 | 81 | return true; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Model/Logic/PageManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Diagnostics; 5 | using System.Threading; 6 | using System.Windows.Media.Imaging; 7 | 8 | namespace AmazonScrape 9 | { 10 | /// 11 | /// Manages the workload of one page of search results. 12 | /// Calls appropriate methods to load, parse, 13 | /// validate and return results to the SearchManager. 14 | /// 15 | /// Directly calls ItemValidator, Parser, Scraper 16 | public class PageManager : BackgroundWorker 17 | { 18 | 19 | public int PageNumber { get { return _pageNumber; } } 20 | public int ResultsOnPage { get { return _pageResultCount; } } 21 | public bool IsFirstPage { get { return _pageNumber == 1; } } 22 | 23 | // NoResults means that there were zero parsable results 24 | // Finished means that the results that were found are all returned 25 | // Error indicates a problem loading the page 26 | public enum Status { Working, NoResults, Finished, Error }; 27 | 28 | public Status WorkStatus { get { return _status; } } 29 | private Status _status; 30 | 31 | private readonly int _pageNumber; // Which search page index we're on 32 | private int _pageResultCount; // The number of results on this page 33 | 34 | // Holds the html for each individual product returned 35 | private List _productHtmlSegments = new List(); 36 | 37 | // Pass in the method you'd like to use to get the html for this page 38 | // (allows easier test injection) 39 | // int: Search page number to load 40 | // string: Search terms to use 41 | // 42 | // Returns: string html of the page load result. 43 | private readonly Func _pageLoadMethod; 44 | private readonly SearchCriteria _searchCriteria; 45 | 46 | // The final list of results to be passed back 47 | private List> _results = 48 | new List>(); 49 | 50 | /// 51 | /// Creates and dispatches a PageManager to load a search page, 52 | /// parse, extract, validate and return results using the 53 | /// parameter callback EventHandlers 54 | /// 55 | /// Which search page number to load 56 | /// The user's search criteria 57 | /// Supply the method to retrieve html 58 | /// Callback for progress updates 59 | /// Callback when work complete 60 | public PageManager(int pageNumber, 61 | SearchCriteria criteria, 62 | Func pageLoadMethod, // see explanation above 63 | ProgressChangedEventHandler updateProgressMethod, 64 | RunWorkerCompletedEventHandler workerCompleteMethod) 65 | { 66 | 67 | if (pageNumber < 1) 68 | { 69 | string msg = "Supplied page number ({0}) was < 0!"; 70 | msg = string.Format(msg,pageNumber); 71 | throw new ArgumentOutOfRangeException(msg); 72 | } 73 | 74 | if (pageLoadMethod == null) 75 | { 76 | string msg = "Provided a null method to obtain page HTML!"; 77 | throw new InvalidOperationException(msg); 78 | } 79 | 80 | ProgressChanged += updateProgressMethod; // Callback 81 | RunWorkerCompleted += workerCompleteMethod; // Callback 82 | DoWork += Work; 83 | 84 | _pageLoadMethod = pageLoadMethod; 85 | _searchCriteria = criteria; 86 | _pageNumber = pageNumber; 87 | 88 | WorkerReportsProgress = true; 89 | WorkerSupportsCancellation = true; 90 | 91 | } 92 | 93 | /// 94 | /// Loads, chops up, parses and validates one page worth of results. 95 | /// 96 | /// 97 | /// 98 | public void Work(object sender, DoWorkEventArgs e) 99 | { 100 | _status = Status.Working; 101 | if (Thread.CurrentThread.Name == null) 102 | Thread.CurrentThread.Name = "Page " + _pageNumber.ToString() + " worker"; 103 | 104 | // Set the RunWorkEventArgs so we can check its status on completion 105 | e.Result = this; 106 | 107 | // Will hold the page's html broken up by each individual product 108 | _productHtmlSegments = new List(); 109 | 110 | // Gets the entire page's html 111 | string pageHtml = _pageLoadMethod(_pageNumber, 112 | _searchCriteria.SearchText); 113 | 114 | // Get the number of results on this page 115 | _pageResultCount = Parser.GetPageResultCount(pageHtml); 116 | 117 | // If there are no results, set the status accordingly and exit 118 | if (_pageResultCount == 0) 119 | { 120 | _status = Status.NoResults; 121 | return; 122 | } 123 | else // There are results 124 | { 125 | // Break apart the page html by product 126 | // so they can be parsed individually 127 | _productHtmlSegments = Parser.GetPageResultItemHtml(pageHtml, 128 | _pageResultCount); 129 | } 130 | 131 | List> results = new List>(); 132 | 133 | // Parse and validate each result, adding to the result list 134 | foreach (string productHtml in _productHtmlSegments) 135 | { 136 | Result result = 137 | ParseAndValidateProductHtml(productHtml); 138 | 139 | // Don't worry about reporting the progress percentage here. 140 | // The SearchManager will look at the total results returned 141 | // and compare with the results requested and report that 142 | // percentage to the UI (passing in a dummy zero here) 143 | ReportProgress(0, result); 144 | } 145 | 146 | // The RunWorkerComplete method fires when method completes 147 | // This is used as a signal to the SearchManager that we 148 | // are clear to spawn another thread if necessary. 149 | _status = Status.Finished; 150 | } 151 | 152 | /// 153 | /// Parses and validates a single product's html, returning a 154 | /// Result containing error messages or the valid AmazonItem 155 | /// 156 | /// Product html to parse 157 | /// List of AmazonItem Results 158 | private Result ParseAndValidateProductHtml(string html) 159 | { 160 | Result result = new Result(); 161 | 162 | // Parse each item's html and exit early if validation fails on any item. 163 | string name = Parser.GetProductName(html); 164 | if (name == null || name.Length == 0) 165 | { 166 | // Do not report a "missing product name" status message here. 167 | // Sometimes Amazon injects blurbs or information 168 | // sections in lieu of results (book results, for example). 169 | // This should not trigger an error. 170 | return result; 171 | } 172 | 173 | if (!ItemValidator.ValidateItemName(_searchCriteria, name)) 174 | { 175 | result.StatusMessage = name + " doesn't contain all search criteria."; 176 | return result; 177 | } 178 | 179 | // Scrape the review histogram to obtain the review distribution 180 | // and the review count (originally review count was being 181 | // obtained on the main page, but Amazon removes review 182 | // information from search results if they smell a bot). 183 | string reviewHistogramHtml = Parser.GetReviewHistogramHtml(html); 184 | if (reviewHistogramHtml == null || reviewHistogramHtml.Length == 0) 185 | { 186 | string msg = "Couldn't obtain review histogram data"; 187 | result.ErrorMessage = msg; 188 | } 189 | 190 | ScoreDistribution scoreDistribution = 191 | Parser.GetScoreDistribution(reviewHistogramHtml); 192 | if (!ItemValidator.ValidateReviewDistribution(_searchCriteria, scoreDistribution)) 193 | { 194 | result.StatusMessage = name + " doesn't fall within your review distribution."; 195 | return result; 196 | } 197 | 198 | int reviewCount = Parser.GetReviewCount(reviewHistogramHtml); 199 | if (!ItemValidator.ValidateReviewCount(_searchCriteria, reviewCount)) 200 | { 201 | string message = name + " "; 202 | 203 | if (reviewCount == 0) { message += "doesn't have any reviews."; } 204 | else 205 | { 206 | message += "only has " + reviewCount.ToString() + " reviews."; 207 | } 208 | result.StatusMessage = message; 209 | return result; 210 | } 211 | 212 | DoubleRange priceRange = Parser.GetPriceRange(html); 213 | if (!ItemValidator.ValidatePriceRange(_searchCriteria, priceRange)) 214 | { 215 | result.StatusMessage = name + " doesn't fit in your price range."; 216 | return result; 217 | } 218 | 219 | // Grab the item's URL so the user can go directly to the product page 220 | Uri url = Parser.GetURL(html); 221 | 222 | // Note: Right now there's no UI capability of validating average rating 223 | double rating = Parser.GetRating(reviewHistogramHtml); 224 | 225 | // TODO: implement a "prime-only" checkbox in the UI 226 | bool primeEligibility; 227 | if (_searchCriteria.StrictPrimeEligibility) 228 | { 229 | primeEligibility = Parser.GetStrictPrimeEligibility(url); 230 | } 231 | else 232 | { 233 | primeEligibility = Parser.GetFuzzyPrimeEligibility(html); 234 | } 235 | 236 | // Leave the image load for last since it takes longer and if the 237 | // item doesn't pass validation we don't waste time downloading 238 | BitmapImage image = Parser.GetImageThumbnail(html); 239 | 240 | // We have everything we need, build the AmazonItem to be returned 241 | result.Value = new AmazonItem(name, 242 | reviewCount, 243 | priceRange, 244 | scoreDistribution, 245 | url, 246 | rating, 247 | primeEligibility, 248 | image); 249 | 250 | return result; 251 | } 252 | 253 | /// 254 | /// Outputs useful information about the PageManager 255 | /// 256 | /// string description 257 | public override string ToString() 258 | { 259 | string msg = base.ToString() + Environment.NewLine; 260 | msg += "----------------------------------" + Environment.NewLine; 261 | msg += "Search Page Number: {0}" + Environment.NewLine; 262 | msg += "Worker Status: {1}" + Environment.NewLine; 263 | msg += "Results on Page: {2}" + Environment.NewLine; 264 | 265 | return string.Format(msg, 266 | _pageNumber, 267 | WorkStatus, 268 | _pageResultCount); 269 | 270 | } 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /Model/Logic/Parser.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.Diagnostics; 5 | using System.Text.RegularExpressions; 6 | using System.Windows.Media.Imaging; 7 | 8 | namespace AmazonScrape 9 | { 10 | /// 11 | /// Contains static methods to process Amazon html and return product information 12 | /// 13 | public static class Parser 14 | { 15 | /// 16 | /// Finds and returns a list of signed/unsigned integers/doubles 17 | /// parsed from the supplied string. Comma-formatted numbers are 18 | /// recognized. 19 | /// 20 | /// Only recognizes "correctly formatted" comma pattern: 21 | /// e.g. 1,234.123 or 12,345,678.123 but not 1,23,4.123 22 | /// Optional parameter parseCount allows the user to limit the number 23 | /// of numbers returned. 24 | /// Note: limiting the amount of results does NOT improve performance; 25 | /// it simply returns the firs N results found. 26 | /// The string to parse 27 | /// The number of double values 28 | /// it will attempt to parse 29 | /// List of Double values 30 | public static List ParseDoubleValues(string text, 31 | int parseCount = -1) 32 | { 33 | // Full pattern: 34 | // (((-?)(\d{1,3}(,\d{3})+)|(-?)(\d)+)(\.(\d)*)?)|((-)?\.(\d)+) 35 | 36 | List results = new List(); 37 | if (text == null) { return results; } 38 | 39 | // Optional negative sign and one or more digits 40 | // Valid: "1234", "-1234", "0", "-0" 41 | string signedIntegerNoCommas = @"(-?)(\d)+"; 42 | 43 | // Optional negative sign and digits grouped by commas 44 | // Valid: "1,234", "-1,234", "1,234,567" 45 | // INVALID: "12,34" <-- does not fit "normal" comma pattern 46 | string signedIntegerCommas = @"(-?)(\d{1,3}(,\d{3})+)"; 47 | 48 | string or = @"|"; 49 | 50 | // Optional decimal point and digits 51 | // Valid: ".123", ".0", "", ".12345", "." 52 | string optionalUnsignedDecimalAndTrailingNumbers = @"(\.(\d)*)?"; 53 | 54 | // Optional negative sign, decimal point and at least one digit 55 | // Valid: "-.12", ".123" 56 | // INVALID: "", ".", "-." 57 | string requiredSignedDecimalAndTrailingNumbers = @"((-)?\.(\d)+)"; 58 | 59 | string pattern = @""; 60 | 61 | // Allow a signed integer with or without commas 62 | // and an optional decimal portion 63 | pattern += @"(" + signedIntegerCommas + or + signedIntegerNoCommas 64 | + @")" + optionalUnsignedDecimalAndTrailingNumbers; 65 | 66 | // OR allow just a decimal portion (with or without sign) 67 | pattern = @"(" + pattern + @")" + or 68 | + requiredSignedDecimalAndTrailingNumbers; 69 | 70 | List matches = GetMultipleRegExMatches(text, pattern); 71 | 72 | int matchIndex = 0; 73 | foreach (string match in matches) 74 | { 75 | // If the user supplied a max number of 76 | // doubles to parse, check to make sure we don't exceed it 77 | if (parseCount > 0) 78 | { 79 | if (matchIndex + 1 > parseCount) break; 80 | } 81 | 82 | try 83 | { 84 | // Get rid of any commas before converting 85 | results.Add(Convert.ToDouble(match.Replace(",", ""))); 86 | } 87 | catch 88 | { 89 | string msg = "Unable to convert {0} to a double"; 90 | Debug.WriteLine(string.Format(msg, match)); 91 | } 92 | matchIndex += 1; 93 | } 94 | 95 | return results; 96 | } 97 | 98 | /// 99 | /// Returns the number of reviews for the product, given the 100 | /// review histogram html (not the product html) 101 | /// 102 | /// html for the review histogram 103 | /// integer number of product reviews 104 | public static int GetReviewCount(string reviewHistogramHtml) 105 | { 106 | string reviewCountPattern = @"(?<=See all ).*?(?= reviews)"; 107 | 108 | string reviewCountMatch = GetSingleRegExMatch(reviewHistogramHtml, 109 | reviewCountPattern); 110 | 111 | if (reviewCountPattern.Length == 0) return 0; 112 | reviewCountMatch = reviewCountMatch.Replace(",", ""); 113 | int reviewCount = 0; 114 | 115 | try 116 | { 117 | reviewCount = Convert.ToInt32(reviewCountMatch); 118 | } 119 | catch 120 | { 121 | string msg = "Unable to cast review count to an integer "; 122 | msg += "(probably because there were no reviews)"; 123 | Debug.WriteLine(msg); 124 | } 125 | 126 | return reviewCount; 127 | } 128 | 129 | /// 130 | /// Given the html of an Amazon search page result, returns 131 | /// the number of product results. 132 | /// 133 | /// html of entire search page 134 | public static int GetPageResultCount(string pageHtml) 135 | { 136 | 137 | // Three possible formats for figuring out the 138 | // number of results on the page: 139 | // ------------------------------------------------- 140 | // Case 1: "Showing X Results" (one page) 141 | // Case 2: "Showing X - Y of Z Results" ( >1 page) 142 | // Case 3: "Your search "" did 143 | // not match any products." 144 | 145 | // Grab the section after the resultCount id attribute 146 | // until the next id attribute 147 | string resultCountPattern = @"(?<=id=""resultCount"").*?(?= id=)"; 148 | string match = GetSingleRegExMatch(pageHtml, resultCountPattern); 149 | 150 | int resultTotal = 0; 151 | 152 | if (match.Length == 0) return resultTotal; 153 | 154 | // Parse out the numeric values, 155 | // limiting to two maximum (as in Case 2 above) 156 | List resultRange = ParseDoubleValues(match, 2); 157 | 158 | switch (resultRange.Count) 159 | { 160 | case 1: 161 | try 162 | { resultTotal = Convert.ToInt32(resultRange[0]); } 163 | catch { } 164 | break; 165 | case 2: 166 | try 167 | { 168 | // ParseDoubleValues thinks the hyphen in the results 169 | // denotes a negative number. 170 | // e.g. "17-32 of 65,130" will return 17, -32 171 | // Get the absolute values before subtracting. 172 | resultTotal = Convert.ToInt32( 173 | Math.Abs(resultRange[1]) - 174 | (Math.Abs(resultRange[0]) - 1)); 175 | } 176 | catch { } 177 | break; 178 | } 179 | 180 | // (Case 3 doesn't need to be handled, since resultTotal 181 | // will fall through and correctly remain 0) 182 | 183 | return resultTotal; 184 | } 185 | 186 | /// 187 | /// Returns a list of individual html product results from an html page 188 | /// 189 | /// The string containing a single page of Amazon search results 190 | /// Specity the number of results the method will attempt to parse 191 | /// A list of strings representing individual html item results 192 | public static List GetPageResultItemHtml(string pageHtml, int resultCount) 193 | { 194 | // NOTE: 195 | // Amazon injects additional search results (commented out in 196 | // javascript) at the bottom of each search page to cache results. 197 | // The parameter _resultCount is obtained from the top of the page 198 | // so that only the results that are visible to the user are 199 | // returned. This was mainly done to fix a bug where duplicate 200 | // records were being returned, but it's probably good practice 201 | // to only consider "visible" results in case Amazon changes its 202 | // caching strategy. 203 | 204 | List results = new List(); 205 | TimeSpan timeOut = new TimeSpan(0, 0, 10); 206 | 207 | // Grab the text between each of the results 208 | string resultPattern = @"(?<=result_[0-9]?[0-9]).*?(?=result_[0-9]?[0-9])"; 209 | List matches = GetMultipleRegExMatches(pageHtml, resultPattern); 210 | 211 | if (matches.Count < resultCount) { return results; } 212 | 213 | for (int i = 0; i < resultCount ; i++) 214 | { 215 | results.Add(matches[i]); 216 | } 217 | 218 | return results; 219 | } 220 | 221 | /// 222 | /// Extracts the product's name from a single product's html 223 | /// 224 | /// Single product result html 225 | /// Name of product 226 | public static string GetProductName(string itemHtml) 227 | { 228 | string productNamePattern = @"(?<= title="").*?(?="" )"; 229 | string match = GetSingleRegExMatch(itemHtml, productNamePattern); 230 | 231 | if (match.Length == 0) 232 | { return null; } 233 | 234 | string productName = Scraper.DecodeHTML(match); 235 | 236 | return productName; 237 | } 238 | 239 | /// 240 | /// Parses a DoubleRange object representing the "high" and "low" 241 | /// prices from the item's html. 242 | /// 243 | /// If there is only one price supplied, set the range to that single value. 244 | /// Single product result html 245 | /// DoubleRange representing the product's pricerange 246 | public static DoubleRange GetPriceRange(string itemHtml) 247 | { 248 | // Dollarsign and Digits grouped by commas plus decimal 249 | // and change (change is required) 250 | string dollarCurrencyFormat = @"\$(\d{1,3}(,\d{3})*).(\d{2})"; 251 | 252 | // Optional spaces and hyphen 253 | string spacesAndHyphen = @"\s+-\s+"; 254 | 255 | // Grab the end of the preceeding tag, the dollar amount, and 256 | // optionally a hyphen and a high range amount before the 257 | // beginning bracket of the next tag 258 | string pricePattern = ">" + dollarCurrencyFormat + "(" + spacesAndHyphen + dollarCurrencyFormat + ")?" + "<"; 259 | 260 | string match = GetSingleRegExMatch(itemHtml, pricePattern); 261 | 262 | // Need to remove the tag beginning and end: 263 | match = match.Trim(new char[] { '<', '>' }); 264 | 265 | if (match.Length == 0) 266 | { return new DoubleRange(); } 267 | 268 | List prices = ParseDoubleValues(match, 2); 269 | DoubleRange priceRange = new DoubleRange(); 270 | if (prices.Count > 0) 271 | { 272 | priceRange.Low = prices[0]; 273 | } 274 | 275 | if (prices.Count > 1) 276 | { 277 | priceRange.High = prices[1]; 278 | } 279 | 280 | if (!priceRange.HasHigh) 281 | { 282 | priceRange.High = priceRange.Low; 283 | } 284 | 285 | return priceRange; 286 | } 287 | 288 | /// 289 | /// Given a specific product result html, provides the review histogram 290 | /// html. Used for obtaining review count and review distribution. 291 | /// 292 | /// 293 | /// string html of review histogram 294 | public static string GetReviewHistogramHtml(string itemHtml) 295 | { 296 | // To get the review information without loading an entire new page, 297 | // we will call the review histogram popup URL instead of the main URL 298 | // We need the ASIN of the product to make the call, which is in the same 299 | // DIV tag as the product result #: 300 | string productASINPattern = @"(?<=name="").*?(?="">)"; 301 | 302 | string match = GetSingleRegExMatch(itemHtml, productASINPattern); 303 | 304 | // Occassionally Amazon adds attributes to the end of the tag, so 305 | // find the end of attribute containing the ASIN (should be the first 306 | // double quote we encounter). 307 | int endAttributeIndex = match.IndexOf('"'); 308 | if (endAttributeIndex > 0) 309 | { 310 | // Truncate anything after 311 | match = match.Substring(0, endAttributeIndex); 312 | } 313 | 314 | if (match.Length == 0) 315 | { return null; } 316 | 317 | // With the product ASIN, make the httprequest to get the review popup data 318 | return Scraper.LoadReviewHistogram(match); 319 | } 320 | 321 | /// 322 | /// Returns a product's review distribution (percentage of reviews in each category) 323 | /// 324 | /// Avoids a full page load by extrating the data from an Ajax popup. 325 | /// Review histogram html 326 | /// ScoreDistribution of reviews 327 | public static ScoreDistribution 328 | GetScoreDistribution(string reviewHistogramHtml) 329 | { 330 | // Find each instance of review percentage. This regex includes more than we need, but we 331 | // wind up only grabbing the first five results, which are the ones we care about. 332 | string reviewDistributionPatterh = @"(?<=title="").*?(?=%)"; 333 | 334 | List matches = GetMultipleRegExMatches(reviewHistogramHtml, 335 | reviewDistributionPatterh); 336 | 337 | //MatchCollection reviewScoreMatches = Regex.Matches(reviewHistogramHtml, 338 | // reviewDistributionPatterh, RegexOptions.Singleline); 339 | 340 | // If we can't find any more results, exit 341 | if (matches.Count == 0) 342 | { return null; } 343 | 344 | double[] reviews = new double[5]; 345 | 346 | // Feed them into the array backwards so that 347 | // one star reviews are in the zero index 348 | for (int i = 0; i <5; i++) 349 | { 350 | // Reverse the order of the reviews so that index 0 is 1-star, 351 | // index 1 is 2-star, etc. 352 | try 353 | { 354 | // The percentage is at the very end of each string 355 | // Work backwards to build the value 356 | var stack = new Stack(); 357 | 358 | for (var strIndex = matches[i].Length - 1; strIndex >= 0; strIndex--) 359 | { 360 | if (!char.IsNumber(matches[i][strIndex])) 361 | { 362 | break; 363 | } 364 | stack.Push(matches[i][strIndex]); 365 | } 366 | 367 | matches[i] = new string(stack.ToArray()); 368 | 369 | reviews[4 - i] = Convert.ToDouble(matches[i]); 370 | } 371 | catch (InvalidCastException) 372 | { 373 | string msg = "Unable to cast review score match {0}"; 374 | Debug.WriteLine(string.Format(msg,i)); 375 | reviews[i] = -1; 376 | } 377 | } 378 | 379 | return new ScoreDistribution(reviews); 380 | } 381 | 382 | /// 383 | /// Attempts to match the supplied pattern to the input 384 | /// string. Obtains multiple matches and returns a 385 | /// list of string matches if successful and an empty 386 | /// list of strings if no matches found. 387 | /// 388 | /// String to search 389 | /// RegEx pattern to search for 390 | /// List of matches or empty list if no matches 391 | private static List GetMultipleRegExMatches( 392 | string inputString, 393 | string regExPattern) 394 | { 395 | string msg; 396 | List results = new List(); 397 | try 398 | { 399 | MatchCollection matches = Regex.Matches(inputString, 400 | regExPattern, 401 | RegexOptions.Singleline); 402 | if (matches.Count == 0) return results; 403 | 404 | IEnumerator e = matches.GetEnumerator(); 405 | while (e.MoveNext()) 406 | { 407 | results.Add(((Match)e.Current).Value); 408 | } 409 | } 410 | catch (ArgumentException ex) 411 | { 412 | msg = regExPattern; 413 | Debug.WriteLine(ex.InnerException + 414 | " argument exception for pattern " + msg); 415 | } 416 | catch (RegexMatchTimeoutException ex) 417 | { 418 | msg = regExPattern; 419 | Debug.WriteLine(ex.InnerException + 420 | " timeout exception for pattern " + msg); 421 | } 422 | return results; 423 | 424 | } 425 | 426 | /// 427 | /// Attempts to match the supplied pattern to the input 428 | /// string. Only obtains a single match and returns the 429 | /// matching string if successful and an empty string if not. 430 | /// 431 | /// String to be searched 432 | /// Pattern to be matched 433 | /// String match or empty string if match not found 434 | private static string GetSingleRegExMatch(string inputString, 435 | string regExPattern) 436 | { 437 | string msg; 438 | string result = ""; 439 | try 440 | { 441 | Match match = Regex.Match(inputString, 442 | regExPattern, 443 | RegexOptions.Singleline); 444 | if (match.Success) 445 | { 446 | result = match.Value; 447 | } 448 | } 449 | catch (ArgumentException ex) 450 | { 451 | msg = regExPattern; 452 | Debug.WriteLine(ex.InnerException + 453 | " argument exception for pattern " + msg); 454 | } 455 | catch (RegexMatchTimeoutException ex) 456 | { 457 | msg = regExPattern; 458 | Debug.WriteLine(ex.InnerException + 459 | " timeout exception for pattern " + msg); 460 | } 461 | return result; 462 | } 463 | 464 | /// 465 | /// Returns a product's average review rating (double) 466 | /// 467 | /// html of the review histogram 468 | /// -1 if no rating, otherwise double rating value 469 | public static double GetRating(string reviewHistogramHtml) 470 | { 471 | string ratingPattern = @"[0-5].?[0-9]? out of 5 stars"; 472 | 473 | string rating = GetSingleRegExMatch(reviewHistogramHtml, 474 | ratingPattern); 475 | 476 | double result = -1; 477 | 478 | if (rating.Length == 0) return result; 479 | 480 | try 481 | { 482 | // Two possible formats: 483 | // 1) Decimal value included, e.g. 4.5 484 | if (rating.Contains(".")) 485 | { 486 | 487 | result = Convert.ToDouble(rating.Substring(0, 3)); 488 | } 489 | else // 2) No decimal, e.g. 4 490 | { 491 | result = Convert.ToDouble(rating.Substring(0, 1)); 492 | } 493 | 494 | } 495 | catch (InvalidCastException) 496 | { 497 | Debug.WriteLine("Unable to cast product rating to a double."); 498 | } 499 | 500 | return result; 501 | } 502 | 503 | /// 504 | /// Using an item's html, determines Prime eligibility with passable accuracy. 505 | /// 506 | /// Bases Prime eligibility on "Super Saver Shipping", which is accurate most of the time. 507 | /// Done this way to avoid an extra page load. Notable speed increase. 508 | /// 509 | /// 510 | public static bool GetFuzzyPrimeEligibility(string itemHtml) 511 | { 512 | string fuzzyPrimeEligibilityPattern = @".*?FREE.*?Shipping"; 513 | 514 | string match = GetSingleRegExMatch(itemHtml, fuzzyPrimeEligibilityPattern); 515 | 516 | return (match.Length > 0); 517 | } 518 | 519 | /// 520 | /// Uses an additional page load to determine Prime eligibility 521 | /// with accuracy 522 | /// 523 | /// 524 | /// 525 | public static bool GetStrictPrimeEligibility(Uri productURL) 526 | { 527 | string html = Scraper.CreateHttpRequest(productURL); 528 | 529 | // Non-prime eligible results call this function with a "0" first 530 | // parameter; here we look specifically for "1", which 531 | // denotes prime eligibility 532 | string primeEligiblePattern = @"bbopJS.initialize\(1,"; 533 | 534 | string match = GetSingleRegExMatch(html, primeEligiblePattern); 535 | 536 | return (match.Length > 0); 537 | } 538 | 539 | /// 540 | /// Returns product ASIN - unique product identifier, used in URLs 541 | /// 542 | /// 543 | /// String ASIN value 544 | public static string GetProductAsin(string itemHtml) 545 | { 546 | string productAsinPattern = @"(?<=data-asin="").*?(?="" )"; 547 | return GetSingleRegExMatch(itemHtml, productAsinPattern); 548 | } 549 | 550 | /// 551 | /// Extracts a product's Amazon URL. 552 | /// 553 | /// Used when user clicks to access the product's Amazon listing. 554 | /// 555 | /// 556 | public static Uri GetURL(string itemHtml) 557 | { 558 | string asin = GetProductAsin(itemHtml); 559 | string url = "https://" + Constants.BASE_URL + "/dp/" + asin; 560 | 561 | if (Uri.IsWellFormedUriString(url, UriKind.Absolute)) 562 | { 563 | return new Uri(url); 564 | } 565 | else 566 | { 567 | return null; 568 | } 569 | } 570 | 571 | /// 572 | /// Parses out the URL to the product's image thumbnail (if one exists) 573 | // and then calls DownloadWebImage to return a BitmapImage 574 | /// 575 | /// 576 | /// 577 | public static BitmapImage GetImageThumbnail(string itemHtml) 578 | { 579 | // TODO: does Amazon use a standardized image format? 580 | // For now, allowing for multiple possible formats. 581 | string imageURLPattern = @"(http(s?):/)(/[^/]+)+\.(?:jpg|gif|png)"; 582 | 583 | string match = GetSingleRegExMatch(itemHtml, imageURLPattern); 584 | 585 | if (match.Length == 0) 586 | { return null; } 587 | 588 | if (Uri.IsWellFormedUriString(match, UriKind.Absolute)) 589 | { 590 | Uri imageURL = new Uri(match); 591 | return Scraper.DownloadWebImage(imageURL); 592 | } 593 | else 594 | { 595 | return null; 596 | } 597 | } 598 | } 599 | } 600 | -------------------------------------------------------------------------------- /Model/Logic/Scraper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using System.Net; 7 | using System.Text.RegularExpressions; 8 | using System.IO; 9 | using System.Windows; 10 | using System.Xml.Linq; 11 | using System.Windows.Media.Imaging; 12 | using System.Windows.Threading; 13 | using System.Threading; 14 | using System.Windows.Controls; 15 | using System.Web; 16 | 17 | namespace AmazonScrape 18 | { 19 | /// 20 | /// Performs page and image loads and provides methods to 21 | /// encode/decode strings. 22 | /// 23 | public static class Scraper 24 | { 25 | /// 26 | /// Encodes the supplied string for use as a URL 27 | /// 28 | /// string to encode 29 | /// URL-encoded string 30 | public static string EncodeURL(string URL) 31 | { 32 | try 33 | { 34 | return HttpUtility.UrlEncode(URL).Replace("+","%20"); 35 | } 36 | catch 37 | { 38 | string msg = "Unable to encode URL: " + URL; 39 | throw new ArgumentException(msg); 40 | } 41 | 42 | } 43 | 44 | /// 45 | /// Decodes the HTML-encoded sections of the supplied string 46 | /// 47 | /// HTML-encoded string 48 | /// decoded string 49 | public static string DecodeHTML(string html) 50 | { 51 | string result = ""; 52 | try 53 | { 54 | result = HttpUtility.HtmlDecode(html); 55 | } 56 | catch 57 | { 58 | string msg = "Unable to decode HTML: " + html; 59 | throw new ArgumentException(msg); 60 | } 61 | 62 | return result; 63 | } 64 | 65 | /// 66 | /// Given a product's unique Amazon ID, loads the review distribution histogram. 67 | /// Much faster than an entire pageload for detailed review info. 68 | /// 69 | /// 70 | /// 71 | public static string LoadReviewHistogram(string asin) 72 | { 73 | Uri reviewHistogramPopupURL = new Uri(Constants.REVIEW_HISTOGRAM_URL + asin); 74 | 75 | return Scraper.CreateHttpRequest(reviewHistogramPopupURL); 76 | } 77 | 78 | /// 79 | /// Loads the result page based on the criteria and the supplied page index, determines the number of valid 80 | /// results on the page, and returns a list of strings representing the markup for each result on the page. 81 | /// Those results can be used by the other Scraper methods to obtain specific pieces of data (e.g. product name). 82 | /// 83 | /// 84 | /// 85 | /// 86 | public static string LoadSearchPage(int pageIndex, string searchTerms) 87 | { 88 | if (searchTerms == null) return ""; 89 | 90 | // Encode characters that are not URL-friendly 91 | // example: "C#" should become "C%23" 92 | searchTerms = EncodeURL(searchTerms); 93 | 94 | string URL = Constants.SEARCH_URL + searchTerms + Constants.SEARCH_URL_PAGE_PARAM + pageIndex.ToString(); 95 | 96 | return CreateHttpRequest(new Uri(URL)); 97 | } 98 | 99 | public static BitmapImage DownloadWebImage(Uri url) 100 | { 101 | // TODO: this method needs to gracefully handle unresolvable URLs and connection time-outs 102 | BitmapImage bitmap = new BitmapImage(url, 103 | new System.Net.Cache.RequestCachePolicy(System.Net.Cache.RequestCacheLevel.CacheIfAvailable)); 104 | 105 | // To make the BitmapImages thread-safe for the BackgroundWorker, they need to 106 | // be frozen (bitmap.Freeze()), but they shouldn't be frozen until they're done downloading. 107 | // We have to force the UI thread to wait until the image is downloaded so we can freeze it. 108 | // Otherwise, we get images in the grid that are partially-downloaded. 109 | 110 | // TODO: this is poor design, but after much searching, there may not be a better solution. 111 | // according to MSDN, DispatcherFrames can be implemented for 112 | // "Short running, very specific frames that exit when an important criteria is met." 113 | while (bitmap.IsDownloading) { DoEvents(); }; 114 | bitmap.Freeze(); // Bitmap is now thread-safe and can be operated on by the backgroundworker 115 | return bitmap; 116 | } 117 | 118 | /// 119 | /// Used to force the background worker to wait for a condition before proceeding. 120 | /// 121 | public static void DoEvents() 122 | { 123 | try 124 | { 125 | DispatcherFrame frame = new DispatcherFrame(); 126 | Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Background, 127 | new DispatcherOperationCallback((f) => 128 | { 129 | ((DispatcherFrame)f).Continue = false; 130 | return null; 131 | }), frame); 132 | Dispatcher.PushFrame(frame); 133 | } 134 | catch (OutOfMemoryException) 135 | { 136 | // TODO: If this Exception is ever hit, we want to handle it by 137 | // cancelling the search. See if there is a clean way of doing that. 138 | MessageBox.Show("Out of memory"); 139 | } 140 | } 141 | 142 | /// 143 | /// Given a URL, loads and returns a string representing a web page's markup 144 | /// 145 | /// 146 | /// 147 | public static String CreateHttpRequest(Uri URL) 148 | { 149 | WebRequest request = HttpWebRequest.Create(URL); 150 | request.Method = "GET"; 151 | 152 | String html = ""; 153 | try 154 | { 155 | using (StreamReader reader = new StreamReader(request.GetResponse().GetResponseStream())) 156 | { 157 | html = reader.ReadToEnd(); 158 | } 159 | } 160 | catch (Exception) 161 | { 162 | MessageBox.Show("Unable to load page " + URL.ToString() + ". Check your internet connection."); 163 | } 164 | return html; 165 | } 166 | 167 | } // Class 168 | } // Namespace -------------------------------------------------------------------------------- /Model/Logic/SearchManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Threading; 6 | 7 | namespace AmazonScrape 8 | { 9 | /// 10 | /// Manages the logical workflow of the application. 11 | /// Creates and deploys PageManagers. 12 | /// Returns WorkProgress object to report status and successful results. 13 | /// 14 | public sealed class SearchManager: BackgroundWorker 15 | { 16 | // Total number of results considered in the search (used for testing) 17 | private int _resultCount = 0; 18 | 19 | // The number of results matching the user's criteria. 20 | // Used when calculating percent complete. 21 | private int _validResultCount = 0; 22 | 23 | private SearchCriteria _searchCriteria; // User-specified search criteria 24 | private bool _working; // Is the main search thread still active? 25 | private readonly int _threadCount; // Number of PageManagers to spawn 26 | 27 | // Loads/parses/validates individual pages asyncrhonously 28 | // The "worker threads" of the application 29 | private PageManager[] _pageManagers; 30 | 31 | // Maintain the highest search page currently being scraped 32 | private int _pageNumber = 1; 33 | 34 | /// 35 | /// Create and start work on a new search. Spawns PageManagers. 36 | /// 37 | /// User-supplied search criteria 38 | /// Number of pages to search asynchronously 39 | public SearchManager(SearchCriteria searchCriteria, int threadCount) 40 | { 41 | if (threadCount < 2) 42 | { 43 | string msg = "Application misconfigured. "; 44 | msg += "Set the MAX_THREADS constant to a value > 1"; 45 | throw new ArgumentException(msg); 46 | } 47 | 48 | _searchCriteria = searchCriteria; 49 | _threadCount = threadCount; 50 | DoWork += Work; 51 | 52 | WorkerReportsProgress = true; 53 | WorkerSupportsCancellation = true; 54 | } 55 | 56 | /// 57 | /// Sets up PageManager threads and begin search 58 | /// 59 | /// 60 | /// 61 | private void Work(object sender, DoWorkEventArgs e) 62 | { 63 | _working = true; 64 | 65 | if (Thread.CurrentThread.Name == null) 66 | Thread.CurrentThread.Name = "Search Manager: " + 67 | _searchCriteria.SearchText; 68 | 69 | System.Diagnostics.Stopwatch stopwatch = new System.Diagnostics.Stopwatch(); 70 | stopwatch.Start(); 71 | 72 | // Set up the page managers, each running on an async thread 73 | _pageManagers = new PageManager[_threadCount]; 74 | 75 | for (int i = 0; i < _pageManagers.Count(); i++) 76 | { 77 | // Keep track of the highest page we've 78 | // attempted to scrape (page numbers are not zero-based) 79 | _pageNumber = i+1; 80 | 81 | // PageManagers internally fire off an async worker which each 82 | // call the ResultReturned / WorkerFinished event handlers. 83 | _pageManagers[i] = new PageManager( 84 | _pageNumber, 85 | _searchCriteria, 86 | Scraper.LoadSearchPage, // inject method for testing here 87 | ResultReturned, 88 | WorkerFinished); 89 | _pageManagers[i].RunWorkerAsync(); 90 | } 91 | 92 | while (_working) 93 | { 94 | // User can cancel a search through the UI. 95 | if (CancellationPending) 96 | { 97 | HaltAllOngoingWork(); 98 | } 99 | } 100 | 101 | stopwatch.Stop(); 102 | string msg = "Search time : {0} ms" + Environment.NewLine; 103 | msg = string.Format(msg, stopwatch.ElapsedMilliseconds); 104 | Debug.WriteLine(msg); 105 | } 106 | 107 | /// 108 | /// True if the search was cancelled, we've reached 100% of the 109 | /// desired number of results, or all of the threads are finished 110 | /// 111 | /// 112 | private bool IsWorkFinished() 113 | { 114 | // If the main search has been cancelled by the user 115 | if (_working == false) 116 | { 117 | string msg = "SearchManager no longer set to 'Working'"; 118 | Debug.WriteLine(msg); 119 | return true; 120 | } 121 | 122 | // If all specified results have been returned 123 | if (GetPercentComplete() >= 100) 124 | { 125 | Debug.WriteLine("=== Percent Complete at or above 100 ==="); 126 | return true; 127 | } 128 | 129 | // If all worker threads are no longer working 130 | // (finished / no more results to search) 131 | int workingCount = _pageManagers.Where(i => i.WorkStatus == 132 | PageManager.Status.Working).Count(); 133 | if (workingCount == 0) 134 | { 135 | Debug.WriteLine("No working PageManagers (none with Working status)"); 136 | OutputPageManagerStatus(); 137 | return true; 138 | } 139 | 140 | return false; 141 | } 142 | 143 | /// 144 | /// Called by the PageManagers whenever they have a result. Returns 145 | /// the result back to the UI. Also checks and stops work if finished. 146 | /// 147 | /// sender object 148 | /// Result 149 | public void ResultReturned(object obj, ProgressChangedEventArgs args) 150 | { 151 | 152 | string msg = "Number of results considered:{0}"; 153 | msg = string.Format(msg, _resultCount); 154 | _resultCount += 1; 155 | Debug.WriteLine(msg); 156 | 157 | Result result; 158 | 159 | // Grab the result 160 | if (args.UserState.GetType() == typeof(Result)) 161 | { 162 | result = (Result)args.UserState; 163 | 164 | // If we're already done, stop all threads 165 | // still active and exit 166 | if (IsWorkFinished()) 167 | { 168 | HaltAllOngoingWork(); 169 | return; 170 | } 171 | 172 | ReportProgress(GetPercentComplete(), result); 173 | 174 | // If it was a result that fit our search critera, update 175 | // our counter (used by the GetPercentComplete method) 176 | if (result.HasReturnValue) _validResultCount += 1; 177 | 178 | } 179 | } 180 | 181 | /// 182 | /// Stops and disposes all PageManager threads and marks 183 | /// the main search as finished working 184 | /// 185 | private void HaltAllOngoingWork() 186 | { 187 | // End each worker thread 188 | foreach (PageManager page in _pageManagers) 189 | { 190 | page.CancelAsync(); 191 | page.Dispose(); 192 | } 193 | 194 | _working = false; 195 | 196 | } 197 | 198 | /// 199 | /// Called by PageManagers when their task is complete. 200 | /// If there is still work to be done, replaces the finished 201 | /// pageManager with a new one (creates a new thread) 202 | /// Exits if all work is complete. 203 | /// 204 | /// sender object 205 | /// Result 206 | public void WorkerFinished(object obj, RunWorkerCompletedEventArgs args) 207 | { 208 | // See if any of the workers are reporting that they're out 209 | // of results. 210 | bool outOfResults = _pageManagers.Any(i => i.WorkStatus == 211 | PageManager.Status.NoResults); 212 | 213 | // If so, don't deploy another thread. 214 | if (outOfResults) 215 | { 216 | string msg = "PageManager reporting no more results;"; 217 | msg += " not deploying another thread."; 218 | Debug.WriteLine(msg); 219 | return; 220 | } 221 | 222 | // Or if there are no threads that are marked "Working", 223 | // we are done 224 | if (IsWorkFinished()) 225 | { 226 | HaltAllOngoingWork(); 227 | return; 228 | } 229 | 230 | if (args == null) return; 231 | if (args.Error != null) 232 | { 233 | Debug.WriteLine(args.Error.Message); 234 | return; 235 | } 236 | 237 | if (args.Result == null || 238 | args.Result.GetType() != typeof(PageManager)) return; 239 | 240 | // If this PageManager is done but we haven't hit our 241 | // target number of results, we should spawn a new thread 242 | PageManager finished = (PageManager)args.Result; 243 | 244 | // Get the index of the PageManager whose search page number 245 | // matches the one that just finished (we're going to replace 246 | // it with a new PageManager) 247 | int index =_pageManagers.ToList().FindIndex(i => 248 | i.PageNumber == finished.PageNumber); 249 | 250 | // Increment the variable that tracks the 251 | // highest page number we've searched so far 252 | // TODO: since page number is shared state, there is a 253 | // slight chance that two PageManagers might hit this 254 | // code 255 | _pageNumber += 1; 256 | 257 | // Start searching a new page 258 | PageManager newPageManager = new PageManager( 259 | _pageNumber, 260 | _searchCriteria, 261 | Scraper.LoadSearchPage, // inject method for testing here 262 | ResultReturned, 263 | WorkerFinished); 264 | 265 | 266 | _pageManagers[index].Dispose(); // get rid of old one 267 | _pageManagers[index] = newPageManager; 268 | _pageManagers[index].RunWorkerAsync(); 269 | 270 | } 271 | 272 | /// 273 | /// Returns integer percentage of overall progress, based on the number 274 | /// of retrieved and validated results. 275 | /// 276 | /// 277 | public int GetPercentComplete() 278 | { 279 | // NumberOfResults is validated in the UI to be > 0 280 | // Catch DBZ error anyway (would result in end of search if error) 281 | int percent = 100; 282 | try 283 | { 284 | decimal resultsCount = (decimal)_validResultCount; 285 | decimal totalResults = (decimal)_searchCriteria.NumberOfResults; 286 | percent = (int)((resultsCount / totalResults)*100); 287 | } catch 288 | { } 289 | 290 | return percent; 291 | } 292 | 293 | /// 294 | /// Write PageManager WorkStatus to console for debugging 295 | /// 296 | private void OutputPageManagerStatus() 297 | { 298 | string msg = ""; 299 | for (int i = 0; i < _pageManagers.Count(); i++) 300 | { 301 | msg = "Thread index : {0}" + Environment.NewLine; 302 | msg += "{1}" + Environment.NewLine + Environment.NewLine; 303 | msg = string.Format(msg, i, _pageManagers[i]); 304 | Debug.WriteLine(msg); 305 | } 306 | } 307 | 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /Model/Structure/AmazonItem.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using System.Windows.Media; 7 | using System.Windows.Media.Imaging; 8 | 9 | namespace AmazonScrape 10 | { 11 | /// 12 | /// Represents an Amazon product 13 | /// Used to populate the data grid. 14 | /// 15 | public class AmazonItem 16 | { 17 | // Data grid doesn't display null values, so using 18 | // all nullable public fields 19 | public String Name { get { return _name; } } 20 | public ImageSource ProductImage { get { return _image; } set { ProductImage = value; } } 21 | public Uri URL { get { return _url; } } 22 | 23 | public Double? LowPrice 24 | { 25 | get 26 | { 27 | if (_priceRange.Low == double.MinValue) { return null; } 28 | else { return _priceRange.Low; } 29 | } 30 | } 31 | 32 | public Double? HighPrice 33 | { 34 | get 35 | { 36 | if (_priceRange.High == double.MaxValue || _priceRange.High == _priceRange.Low ) { return null; } 37 | else { return _priceRange.High; } 38 | } 39 | } 40 | 41 | public Double? Rating 42 | { 43 | get 44 | { 45 | if (_rating == -1) return null; 46 | else return _rating; 47 | } 48 | } 49 | public Int32? ReviewCount { get { return _reviewCount; } } 50 | public ImageSource PrimeLogoImage 51 | { 52 | get 53 | { 54 | if (_primeEligible) return ResourceLoader.GetPrimeLogoBitmap(); 55 | else return null; 56 | } 57 | } 58 | public bool IsPrimeEligible { get { return (PrimeLogoImage != null); } } 59 | public String ReviewDistribution 60 | { 61 | get 62 | { 63 | if (_reviewDistribution != null) 64 | { 65 | return _reviewDistribution.ToString(); 66 | } 67 | else 68 | { 69 | return null; 70 | } 71 | } 72 | } 73 | 74 | String _name; 75 | int _reviewCount; 76 | DoubleRange _priceRange; 77 | ScoreDistribution _reviewDistribution; 78 | Uri _url; 79 | double _rating; 80 | bool _primeEligible; 81 | BitmapImage _image; 82 | 83 | public AmazonItem(string name, 84 | int reviewCount, 85 | DoubleRange priceRange, 86 | ScoreDistribution reviewDistribution, 87 | Uri url, 88 | double rating, 89 | bool primeEligible, 90 | BitmapImage image) 91 | { 92 | _name = name; 93 | _reviewCount = reviewCount; 94 | _priceRange = priceRange; 95 | _reviewDistribution = reviewDistribution; 96 | _url = url; 97 | _rating = rating; 98 | _primeEligible = primeEligible; 99 | _image = image; 100 | 101 | } 102 | 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Model/Structure/IValidatable.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace AmazonScrape 8 | { 9 | /// 10 | /// Defines controls that can be validated. 11 | /// Result contains status/error information and an optional generic return value type. 12 | /// 13 | interface IValidatable 14 | { 15 | Result Validate(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Model/Structure/Result.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace AmazonScrape 8 | { 9 | /// 10 | /// Container class for a method result, including any status/error messages and 11 | /// a generic return type. 12 | /// Used in validation and to return results to the main Window. 13 | /// 14 | /// 15 | public class Result 16 | { 17 | public T Value { get { return _returnValue; } set { _returnValue = value; } } 18 | public string StatusMessage { get { return _statusMessage; } set { _statusMessage = value; } } 19 | public string ErrorMessage { get { return _errorMessage; } set { _errorMessage = value; } } 20 | public bool HasReturnValue { get { return ( ! Equals(_returnValue, default(T))); } } 21 | public bool HasError { get { return (_errorMessage.Length > 0); } } 22 | 23 | private T _returnValue; 24 | private string _statusMessage; 25 | private string _errorMessage; 26 | 27 | public Result() 28 | { 29 | _returnValue = default(T); 30 | ErrorMessage = ""; 31 | StatusMessage = ""; 32 | } 33 | 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Model/Structure/ScoreDistribution.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace AmazonScrape 8 | { 9 | /// 10 | /// Represents the percentage range of values of each Amazon review star category (convenience class) 11 | /// 12 | public class ScoreDistribution 13 | { 14 | public DoubleRange OneStar { get { return _oneStar; } } 15 | public DoubleRange TwoStar { get { return _twoStar; } } 16 | public DoubleRange ThreeStar { get { return _threeStar; } } 17 | public DoubleRange FourStar { get { return _fourStar; } } 18 | public DoubleRange FiveStar { get { return _fiveStar; } } 19 | 20 | private DoubleRange _oneStar; 21 | private DoubleRange _twoStar; 22 | private DoubleRange _threeStar; 23 | private DoubleRange _fourStar; 24 | private DoubleRange _fiveStar; 25 | 26 | public ScoreDistribution(DoubleRange oneStar, 27 | DoubleRange twoStar, 28 | DoubleRange threeStar, 29 | DoubleRange fourStar, 30 | DoubleRange fiveStar) 31 | { 32 | _oneStar = oneStar; 33 | _twoStar = twoStar; 34 | _threeStar = threeStar; 35 | _fourStar = fourStar; 36 | _fiveStar = fiveStar; 37 | } 38 | 39 | public ScoreDistribution(double[] scores) 40 | { 41 | // Ensure that there are five categories being supplied. 42 | if (scores.Length != 5) 43 | { 44 | throw new ArgumentException("Score distribution must have five scores."); 45 | } 46 | 47 | _oneStar = new DoubleRange(scores[0],scores[0]); 48 | _twoStar = new DoubleRange(scores[1], scores[1]); 49 | _threeStar = new DoubleRange(scores[2], scores[2]); 50 | _fourStar = new DoubleRange(scores[3], scores[3]); 51 | _fiveStar = new DoubleRange(scores[4], scores[4]); 52 | 53 | } 54 | 55 | public override string ToString() 56 | { 57 | string results = string.Format("Five star: {0} %", _fiveStar.Low) + Environment.NewLine + 58 | string.Format("Four star: {0} %", _fourStar.Low) + Environment.NewLine + 59 | string.Format("Three star: {0} %", _threeStar.Low) + Environment.NewLine + 60 | string.Format("Two star: {0} %", _twoStar.Low) + Environment.NewLine + 61 | string.Format("One star: {0} %", _oneStar.Low) + Environment.NewLine; 62 | 63 | return results; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Model/Structure/SearchCriteria.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace AmazonScrape 8 | { 9 | /// 10 | /// Used to pass around the user's search criteria (convenience class) 11 | /// 12 | public class SearchCriteria 13 | { 14 | public string SearchText { get { return _searchText; } } 15 | public double NumberOfResults { get { return _numberOfResults; } } 16 | public double MinNumberReviews { get { return _minNumberReviews; } } 17 | public bool StrictPrimeEligibility { get { return _strictPrimeEligibility; } } 18 | public bool MatchAllSearchTerms { get { return _matchAllSearchTerms; } } 19 | public DoubleRange PriceRange { get { return _priceRange; } } 20 | public ScoreDistribution ScoreDistribution { get { return _distribution; } } 21 | 22 | private string _searchText; 23 | private double _numberOfResults; 24 | private double _minNumberReviews; 25 | private bool _matchAllSearchTerms; 26 | private ScoreDistribution _distribution; 27 | DoubleRange _priceRange; 28 | 29 | // Amazon items that "qualify for super-saver 2 day shipping" are 30 | // almost always Prime Eligible as well. If strictPrimeEligibility is set to false, 31 | // we use this criteria for determining prime eligibility. 32 | // Otherwise, the code goes to each product page and checks for Prime Eligibility. 33 | // While the second method is guaranteed to work, it results in an entire pageload 34 | // for each record, which doubles load times. 35 | private bool _strictPrimeEligibility; 36 | 37 | public SearchCriteria(string searchText, 38 | double numberOfResults, 39 | DoubleRange priceRange, 40 | ScoreDistribution distribution, 41 | double minNumberReviews = 0, 42 | bool matchAllSearchTerms = false, 43 | bool strictPrimeEligibility = false 44 | ) 45 | { 46 | _searchText = searchText; 47 | _numberOfResults = numberOfResults; 48 | _matchAllSearchTerms = matchAllSearchTerms; 49 | _minNumberReviews = minNumberReviews; 50 | _strictPrimeEligibility = strictPrimeEligibility; 51 | _distribution = distribution; 52 | _priceRange = priceRange; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Resources; 3 | using System.Runtime.CompilerServices; 4 | using System.Runtime.InteropServices; 5 | using System.Windows; 6 | 7 | // General Information about an assembly is controlled through the following 8 | // set of attributes. Change these attribute values to modify the information 9 | // associated with an assembly. 10 | [assembly: AssemblyTitle("AmazonScrape")] 11 | [assembly: AssemblyDescription("")] 12 | [assembly: AssemblyConfiguration("")] 13 | [assembly: AssemblyCompany("")] 14 | [assembly: AssemblyProduct("AmazonScrape")] 15 | [assembly: AssemblyCopyright("Copyright © 2012")] 16 | [assembly: AssemblyTrademark("")] 17 | [assembly: AssemblyCulture("")] 18 | 19 | // Setting ComVisible to false makes the types in this assembly not visible 20 | // to COM components. If you need to access a type in this assembly from 21 | // COM, set the ComVisible attribute to true on that type. 22 | [assembly: ComVisible(false)] 23 | 24 | //In order to begin building localizable applications, set 25 | //CultureYouAreCodingWith in your .csproj file 26 | //inside a . For example, if you are using US english 27 | //in your source files, set the to en-US. Then uncomment 28 | //the NeutralResourceLanguage attribute below. Update the "en-US" in 29 | //the line below to match the UICulture setting in the project file. 30 | 31 | //[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)] 32 | 33 | 34 | [assembly: ThemeInfo( 35 | ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located 36 | //(used if a resource is not found in the page, 37 | // or application resource dictionaries) 38 | ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located 39 | //(used if a resource is not found in the page, 40 | // app, or any theme specific resource dictionaries) 41 | )] 42 | 43 | 44 | // Version information for an assembly consists of the following four values: 45 | // 46 | // Major Version 47 | // Minor Version 48 | // Build Number 49 | // Revision 50 | // 51 | // You can specify all the values or you can default the Build and Revision Numbers 52 | // by using the '*' as shown below: 53 | // [assembly: AssemblyVersion("1.0.*")] 54 | [assembly: AssemblyVersion("1.1.0.0")] 55 | [assembly: AssemblyFileVersion("1.0.0.0")] 56 | -------------------------------------------------------------------------------- /Properties/Resources.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.17929 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace AmazonScrape.Properties 12 | { 13 | 14 | 15 | /// 16 | /// A strongly-typed resource class, for looking up localized strings, etc. 17 | /// 18 | // This class was auto-generated by the StronglyTypedResourceBuilder 19 | // class via a tool like ResGen or Visual Studio. 20 | // To add or remove a member, edit your .ResX file then rerun ResGen 21 | // with the /str option, or rebuild your VS project. 22 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] 23 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 24 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 25 | internal class Resources 26 | { 27 | 28 | private static global::System.Resources.ResourceManager resourceMan; 29 | 30 | private static global::System.Globalization.CultureInfo resourceCulture; 31 | 32 | [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] 33 | internal Resources() 34 | { 35 | } 36 | 37 | /// 38 | /// Returns the cached ResourceManager instance used by this class. 39 | /// 40 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 41 | internal static global::System.Resources.ResourceManager ResourceManager 42 | { 43 | get 44 | { 45 | if ((resourceMan == null)) 46 | { 47 | global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("AmazonScrape.Properties.Resources", typeof(Resources).Assembly); 48 | resourceMan = temp; 49 | } 50 | return resourceMan; 51 | } 52 | } 53 | 54 | /// 55 | /// Overrides the current thread's CurrentUICulture property for all 56 | /// resource lookups using this strongly typed resource class. 57 | /// 58 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 59 | internal static global::System.Globalization.CultureInfo Culture 60 | { 61 | get 62 | { 63 | return resourceCulture; 64 | } 65 | set 66 | { 67 | resourceCulture = value; 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Properties/Resources.resx: -------------------------------------------------------------------------------- 1 |  2 | 3 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | text/microsoft-resx 107 | 108 | 109 | 2.0 110 | 111 | 112 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 113 | 114 | 115 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 116 | 117 | -------------------------------------------------------------------------------- /Properties/Settings.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.17929 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace AmazonScrape.Properties 12 | { 13 | 14 | 15 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 16 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")] 17 | internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase 18 | { 19 | 20 | private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); 21 | 22 | public static Settings Default 23 | { 24 | get 25 | { 26 | return defaultInstance; 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Properties/Settings.settings: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | AmazonScrape 2 | ============ 3 | 4 | Amazon Product scraper. Supplies search tools and sortable results (WPF/C#) 5 | -------------------------------------------------------------------------------- /UI/Controls/DataGridPlus.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.ComponentModel; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using System.Windows; 9 | using System.Windows.Controls; 10 | using System.Windows.Data; 11 | using System.Windows.Controls.Primitives; 12 | using System.Windows.Media; 13 | 14 | namespace AmazonScrape 15 | { 16 | /// 17 | /// Extends DataGrid to allow columns to be easily added through code-behind 18 | /// Avoids overly-cluttered XAML 19 | /// 20 | class DataGridPlus : DataGrid 21 | { 22 | // Create dependency properties so we can change the formatting through xaml ( header font size vs. result font size) 23 | public DataGridPlus() 24 | { 25 | RowHeight = Double.NaN; 26 | IsReadOnly = true; 27 | CanUserAddRows = false; 28 | AutoGenerateColumns = true; 29 | CanUserResizeColumns = false; 30 | GridLinesVisibility = DataGridGridLinesVisibility.Horizontal; 31 | CanUserResizeColumns = true; 32 | CanUserReorderColumns = false; 33 | CanUserResizeRows = true; 34 | } 35 | 36 | public void AddImageColumn(string bindingName, 37 | string headerText, 38 | int widthPercent, 39 | BindingMode bindingMode, 40 | string sortOn="",Style style=null) 41 | { 42 | FrameworkElementFactory ef = new FrameworkElementFactory(typeof(Image)); 43 | Binding binding = new Binding(bindingName); 44 | binding.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged; 45 | binding.Mode = bindingMode; 46 | ef.SetValue(Image.SourceProperty, binding); 47 | AddColumn(ef, widthPercent,headerText,sortOn,style); 48 | } 49 | 50 | public void AddButtonColumn(string buttonText, int widthPercent, RoutedEventHandler clickHandler,Style style = null) 51 | { 52 | FrameworkElementFactory ef = new FrameworkElementFactory(typeof(Button)); 53 | ef.SetValue(Button.StyleProperty, ResourceLoader.GetControlStyle("ButtonStyle")); 54 | ef.SetValue(Button.ContentProperty, buttonText); 55 | ef.AddHandler(Button.ClickEvent, clickHandler, true); 56 | AddColumn(ef, widthPercent, "","",style); 57 | } 58 | 59 | public void AddTextColumn(string bindingName,string headerText,int widthPercent,Style style = null) 60 | { 61 | FrameworkElementFactory ef = new FrameworkElementFactory(typeof(TextBlock)); 62 | ef.SetValue(TextBlock.TextWrappingProperty, TextWrapping.Wrap); 63 | ef.SetValue(TextBlock.TextAlignmentProperty, TextAlignment.Center); 64 | ef.SetValue(TextBlock.VerticalAlignmentProperty, VerticalAlignment.Center); 65 | ef.SetValue(TextBlock.TextProperty, new Binding(bindingName)); 66 | AddColumn(ef, widthPercent, headerText, bindingName, style); 67 | } 68 | 69 | private void AddColumn(FrameworkElementFactory ef,int widthPercent,string headerText,string sortOn = "",Style style=null) 70 | { 71 | // If overriding the default style 72 | // TODO: test this 73 | if (!(style == null)) ef.SetValue(StyleProperty, style); 74 | 75 | DataGridTemplateColumn newCol = new DataGridTemplateColumn(); 76 | 77 | if (sortOn.Length > 0) 78 | { 79 | newCol.CanUserSort = true; 80 | newCol.SortMemberPath = sortOn; 81 | } 82 | 83 | newCol.Header = headerText; 84 | newCol.Width = new DataGridLength(widthPercent, DataGridLengthUnitType.Star); 85 | 86 | DataTemplate template = new DataTemplate(); 87 | template.VisualTree = ef; 88 | newCol.CellTemplate = template; 89 | this.Columns.Add(newCol); 90 | } 91 | 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /UI/Controls/RangeBoxes.xaml: -------------------------------------------------------------------------------- 1 |  9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 24 | Price from 25 | 26 | 27 | 32 | 33 | 36 | to 37 | 38 | 39 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /UI/Controls/RangeBoxes.xaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Windows.Controls; 3 | namespace AmazonScrape 4 | { 5 | /// 6 | /// Interaction logic for RangeBoxes 7 | /// 8 | [global::System.ComponentModel.TypeConverter(typeof(DoubleRangeConverter))] 9 | public partial class RangeBoxes : UserControl, IValidatable 10 | { 11 | protected string _name; 12 | protected DoubleRange _range = new DoubleRange(); // The allowable range for this control 13 | 14 | public string LowText 15 | { 16 | get 17 | { 18 | return TextLow.Text; 19 | } 20 | set 21 | { 22 | TextLow.Text = value; 23 | } 24 | } 25 | 26 | public string HighText 27 | { 28 | get 29 | { 30 | return TextHigh.Text; 31 | } 32 | set 33 | { 34 | TextHigh.Text = value; 35 | } 36 | } 37 | 38 | public RangeBoxes() 39 | { 40 | InitializeComponent(); 41 | } 42 | 43 | public DoubleRange GetValues() 44 | { 45 | DoubleRange values = new DoubleRange(); 46 | 47 | if (TextLow.ContainsDoubleValue) 48 | { values.Low = Convert.ToDouble(TextLow.Text); } 49 | 50 | if (TextHigh.ContainsDoubleValue) 51 | { values.High = Convert.ToDouble(TextHigh.Text); } 52 | 53 | return values; 54 | } 55 | 56 | public Result Validate() 57 | { 58 | double low = _range.Low; 59 | double high = _range.High; 60 | 61 | Result result = new Result(); 62 | 63 | // Try to cast the contents of the low value to double 64 | if (TextLow.Text.Length > 0) 65 | { 66 | try 67 | { 68 | low = Convert.ToDouble(TextLow.Text); 69 | } 70 | catch (Exception) 71 | { 72 | string message = _name + " low value must be numeric."; 73 | result.ErrorMessage = message; 74 | return result; 75 | } 76 | } 77 | 78 | // Try to cast the contents of the high value to double 79 | if (TextHigh.Text.Length > 0) 80 | { 81 | try 82 | { high = Convert.ToDouble(TextHigh.Text); } 83 | catch (Exception) 84 | { 85 | string message = _name + " high value must be numeric."; 86 | result.ErrorMessage = message; 87 | return result; 88 | } 89 | } 90 | 91 | if (!_range.Contains(low) || !_range.Contains(high)) 92 | { 93 | string message = "Specified " + _name + " values are out of allowable range."; 94 | result.ErrorMessage = message; 95 | return result; 96 | } 97 | 98 | if (low > high) 99 | { 100 | string message = _name + " low value is greater than high value."; 101 | result.ErrorMessage = message; 102 | return result; 103 | } 104 | 105 | return result; 106 | } 107 | 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /UI/Controls/RangeSlider.xaml: -------------------------------------------------------------------------------- 1 |  10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | to 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /UI/Controls/RangeSlider.xaml.cs: -------------------------------------------------------------------------------- 1 | using System.Windows; 2 | using System.Windows.Controls; 3 | using System.Windows.Data; 4 | namespace AmazonScrape 5 | { 6 | /// 7 | /// Interaction logic for RangeSlider.xaml 8 | /// 9 | public partial class RangeSlider : UserControl 10 | { 11 | public static readonly DependencyProperty HeaderTextProperty = DependencyProperty.Register 12 | ( 13 | "HeaderText", 14 | typeof(string), 15 | typeof(RangeSlider), 16 | new PropertyMetadata(string.Empty) 17 | ); 18 | 19 | public string HeaderText 20 | { 21 | get { return (string)GetValue(HeaderTextProperty); } 22 | set { SetValue(HeaderTextProperty, value); } 23 | } 24 | 25 | public RangeSlider() 26 | { 27 | InitializeComponent(); 28 | 29 | LowText.textBox.Text = "0"; 30 | HighText.textBox.Text = "100"; 31 | 32 | // Set bindings for the sliders to change the values of the 33 | // text boxes 34 | Binding b = new Binding(); 35 | b.Source = LowSlider; 36 | b.Path = new PropertyPath("Value", LowSlider.Value); 37 | b.Mode = BindingMode.TwoWay; 38 | b.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged; 39 | LowText.textBox.SetBinding(TextBox.TextProperty, b); 40 | 41 | b = new Binding(); 42 | b.Source = HighSlider; 43 | b.Path = new PropertyPath("Value", HighSlider.Value); 44 | b.Mode = BindingMode.TwoWay; 45 | b.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged; 46 | HighText.textBox.SetBinding(TextBox.TextProperty, b); 47 | 48 | // Check to ensure that the low slider doesn't exceed the high slider 49 | LowSlider.ValueChanged += LowSlider_ValueChanged; 50 | HighSlider.ValueChanged += HighSlider_ValueChanged; 51 | } 52 | 53 | public double Low { get { return LowSlider.Value; } set { LowSlider.Value = value; } } 54 | public double High { get { return HighSlider.Value; } set { HighSlider.Value = value; } } 55 | 56 | // The range of possible values for the sliders 57 | protected DoubleRange range; 58 | 59 | public delegate void LowValueChangedEventHandler(RangeSlider slider, double oldValue, double newValue); 60 | public event LowValueChangedEventHandler LowValueChanged; 61 | 62 | void HighSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) 63 | { 64 | if (e.NewValue < LowSlider.Value) 65 | { 66 | LowSlider.Value = e.NewValue; 67 | } 68 | } 69 | 70 | void LowSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) 71 | { 72 | if (e.NewValue > HighSlider.Value) 73 | { 74 | HighSlider.Value = e.NewValue; 75 | } 76 | 77 | if (this.LowValueChanged != null) 78 | { 79 | LowValueChanged(this, e.OldValue, e.NewValue); 80 | } 81 | } 82 | 83 | public DoubleRange GetRange() 84 | { 85 | return new DoubleRange(LowSlider.Value, HighSlider.Value); 86 | } 87 | 88 | } // class 89 | } // namespace 90 | -------------------------------------------------------------------------------- /UI/Controls/ScoreDistributionControl.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using System.Windows; 7 | using System.Windows.Controls; 8 | using System.Windows.Data; 9 | using System.Windows.Media; 10 | 11 | namespace AmazonScrape 12 | { 13 | class ScoreDistributionControl : GridPlus, IValidatable 14 | { 15 | /// 16 | /// Provides "low range" and "high range" sliders for each Amazon review 17 | /// category. Allows the user to supply the desired minimum and maximum 18 | /// percentage values for the product results. 19 | /// 20 | /// Examples: 21 | /// 22 | /// If the user specifies a 5% high range value for one-star reviews, 23 | /// no more than 5% of all reviews will be one-star reviews. 24 | /// 25 | /// If the user specifies a 50% low range value for five-star reviews, 26 | /// no fewer than 50% of all reviews will be five-star reviews. 27 | /// 28 | public DoubleRange OneStarRange { get { return _sliders[0].GetRange(); } } 29 | public DoubleRange TwoStarRange { get { return _sliders[1].GetRange(); } } 30 | public DoubleRange ThreeStarRange { get { return _sliders[2].GetRange(); } } 31 | public DoubleRange FourStarRange { get { return _sliders[3].GetRange(); } } 32 | public DoubleRange FiveStarRange { get { return _sliders[4].GetRange(); } } 33 | public ScoreDistribution Distribution 34 | { 35 | get 36 | { 37 | return new ScoreDistribution(OneStarRange, 38 | TwoStarRange, 39 | ThreeStarRange, 40 | FourStarRange, 41 | FiveStarRange); 42 | } 43 | } 44 | 45 | // One RangeSlider for each star-rating category 46 | private RangeSliderX[] _sliders = new RangeSliderX[5]; 47 | 48 | // The control prevents the user from setting the "low" ranges in such 49 | // a way that the total exceeds 100%. When a user tries to raise a value 50 | // that woudld cause the total to exceed 100%, it reduces the other control 51 | // values accordingly. This boolean prevents an event cascade when the other 52 | // control values are being set. 53 | bool resolvingPercentageError = false; 54 | 55 | public ScoreDistributionControl() 56 | { 57 | // Five columns, one for each star category RangeSlider 58 | //ColumnDefinition col; 59 | for (int i = 0; i < 5; i++) 60 | { 61 | AddColumn(20, GridUnitType.Star); 62 | } 63 | 64 | // Two rows, one for the header and one for the controls 65 | AddRow(10, GridUnitType.Star); 66 | AddRow(90, GridUnitType.Star); 67 | 68 | // Control header 69 | TextBlock text = new TextBlock(); 70 | text.Background = new SolidColorBrush(Colors.LightGray); 71 | ToolTip tip = new ToolTip(); 72 | tip.Content = "Specify how the results should be distributed." + Environment.NewLine; 73 | tip.Content += "For instance, if you set the one-star 'high' slider to 5%, it means that" + Environment.NewLine; 74 | tip.Content += "the returned items will have no more than 5% one-star reviews."; 75 | text.ToolTip = tip; 76 | text.Foreground = new SolidColorBrush(Colors.Blue); 77 | text.Text = "Result percentage ranges per star category"; 78 | text.TextAlignment = TextAlignment.Center; 79 | 80 | AddContent(text, 0, 0, 5); 81 | 82 | // Five range sliders; one for each star category 83 | for (int i = 0; i < 5; i++) 84 | { 85 | RangeSliderX slider = new RangeSliderX(); 86 | slider.HeaderText = (i+1).ToString() + " - Star"; 87 | slider.High = 100; 88 | slider.Low = 0; 89 | slider.VerticalAlignment = VerticalAlignment.Stretch; 90 | slider.HorizontalAlignment = HorizontalAlignment.Stretch; 91 | _sliders[i] = slider; 92 | //_sliders[i] = new RangeSliderX((i+1).ToString() + " - Star", new DoubleRange(0, 100)) 93 | //{ 94 | // VerticalAlignment = System.Windows.VerticalAlignment.Stretch, 95 | // HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, 96 | //}; 97 | _sliders[i].LowValueChanged += slider_LowValueChanged; 98 | 99 | AddContent(_sliders[i], 1, i, 1); 100 | } 101 | } 102 | 103 | /// 104 | /// Returns the sum total of "low" range scores, given a RangeSlider 105 | /// that just had its value modified. 106 | /// 107 | /// 108 | /// 109 | /// Sum of current "low" values 110 | double GetNewSumOfLowRange(RangeSliderX sender,double newValue) 111 | { 112 | // Start with the supplied new value 113 | double total = newValue; 114 | for (int i = 0; i < 5; i++) 115 | { 116 | if (_sliders[i] != sender) 117 | { 118 | total += _sliders[i].Low; 119 | } 120 | } 121 | return total; 122 | } 123 | 124 | 125 | /// 126 | /// Given a RangeSlider, provides a list of the other RangeSliders that have 127 | /// a positive "low" range value. Used when adjusting the total of the "low" 128 | /// range sliders to not exceed 100%. 129 | /// 130 | /// 131 | /// 132 | List GetOtherSlidersWithPositiveLowScore(RangeSliderX sender) 133 | { 134 | List results = new List(); 135 | 136 | // Loop through and get the sliders (besides the sender) 137 | // that have a positive "low" range value. 138 | for (int i = 0; i < 5; i++) 139 | { 140 | if (_sliders[i] != sender && _sliders[i].Low > 0) 141 | { results.Add(_sliders[i]); } 142 | } 143 | return results; 144 | } 145 | 146 | /// 147 | /// Prevents user from specifying a total minimum number of results that exceeds 100% 148 | /// by dynamically reducing the values of the other sliders. 149 | /// 150 | /// e.g. the user specifies 40% for the "low" range value of each of the five 151 | /// star categories. The sum would be 200% and wouldn't make sense. 152 | /// 153 | /// 154 | /// 155 | void slider_LowValueChanged(RangeSliderX sender, double oldValue, double newValue) 156 | { 157 | // If we're already dealing with with this problem, allow it to resolve. 158 | // This prevents a cascade of events as every change to a low score causes 159 | // this method to be called 160 | if (resolvingPercentageError) { return; } 161 | 162 | // Figure out whether the change puts the "low" sum over 100% 163 | double sum = GetNewSumOfLowRange(sender, newValue); 164 | 165 | if (sum <= 100) { return; } 166 | 167 | resolvingPercentageError = true; 168 | 169 | // Loop through the sliders, reducing each until 170 | // the total amount is less than or equal to 100 171 | while (sum > 100) 172 | { 173 | // We can't do anything with sliders with a zero "low" value, so get 174 | // a list of other sliders that have a non-zero "low" value 175 | List hasLowRangeValue = GetOtherSlidersWithPositiveLowScore(sender); 176 | 177 | foreach (RangeSliderX rangeSlider in hasLowRangeValue) 178 | { 179 | // how much more we have to distribute 180 | double amountAboveOneHundred = sum - 100; 181 | 182 | // The amount over 100 divided by the number of sliders that have 183 | // a "low" value. 184 | double shareOfAmountOver = Math.Ceiling(amountAboveOneHundred / hasLowRangeValue.Count); 185 | 186 | // Try to subtract this amount from the current slider 187 | double remainder = rangeSlider.Low - Math.Ceiling(amountAboveOneHundred / hasLowRangeValue.Count); 188 | 189 | // Two possibilities: 190 | // 1) The slider had enough "low" value to subtract its share of the overage 191 | if (remainder > 0) 192 | { 193 | sum -= shareOfAmountOver; 194 | rangeSlider.Low = remainder; 195 | } 196 | else // 2) We could only subtract a part of this slider's share before it became zero 197 | { 198 | // We've reduced this slider's "low" value to zero 199 | rangeSlider.Low = 0; 200 | 201 | // The negative remainder represents the amount that we couldn't subtract 202 | // from this slider. Adjust sum to reflect the portion that we subtracted 203 | sum -= (shareOfAmountOver - Math.Abs(remainder)); 204 | 205 | } 206 | 207 | } // for each rangeslider with a low value 208 | 209 | } // sum > 100 210 | 211 | // The problem has been successfully resolved. 212 | resolvingPercentageError = false; 213 | } 214 | 215 | /// 216 | /// Returns whether the control is validated 217 | /// Always returns true, as the control ensures valid values. 218 | /// 219 | /// 220 | public ValidationResult Validate() 221 | { 222 | return new ValidationResult(ValidationResult.Status.Valid); 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /UI/Controls/ScoreDistributionControl.xaml: -------------------------------------------------------------------------------- 1 |  9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 25 | Result Percentage per star category 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 44 | 45 | 52 | 53 | 60 | 61 | 68 | 69 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /UI/Controls/ScoreDistributionControl.xaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Windows.Controls; 4 | using System.Windows.Controls.Primitives; 5 | using System.Windows.Media; 6 | 7 | namespace AmazonScrape 8 | { 9 | /// 10 | /// Interaction logic for ScoreDistribution.xaml 11 | /// 12 | public partial class ScoreDistributionControl : UserControl 13 | { 14 | // The control prevents the user from setting the "low" ranges in such 15 | // a way that the total exceeds 100%. When a user tries to raise a value 16 | // that woudld cause the total to exceed 100%, it reduces the other control 17 | // values accordingly. This boolean prevents an event cascade when the other 18 | // control values are being set. 19 | bool resolvingPercentageError = false; 20 | private RangeSlider[] _sliders; 21 | private Popup _description; 22 | 23 | public ScoreDistribution Distribution 24 | { 25 | get 26 | { 27 | return new ScoreDistribution( 28 | OneStar.GetRange(), 29 | TwoStar.GetRange(), 30 | ThreeStar.GetRange(), 31 | FourStar.GetRange(), 32 | FiveStar.GetRange()); 33 | } 34 | } 35 | 36 | public ScoreDistributionControl() 37 | { 38 | InitializeComponent(); 39 | 40 | // Makes validating the values of the sliders easier 41 | _sliders = new RangeSlider[5]; 42 | _sliders[0] = OneStar; 43 | _sliders[1] = TwoStar; 44 | _sliders[2] = ThreeStar; 45 | _sliders[3] = FourStar; 46 | _sliders[4] = FiveStar; 47 | 48 | string description = "Usage examples: " + Environment.NewLine + Environment.NewLine + 49 | " - If you wanted products that had no more than 10% one-star " + Environment.NewLine + 50 | " reviews, you would set the one-star review control range to " + Environment.NewLine + 51 | " 0 to 10." + 52 | Environment.NewLine + Environment.NewLine + 53 | " - If you wanted products that had no fewer than 40% five-star" + Environment.NewLine + 54 | " reviews, you would set the five-star review control range to " + Environment.NewLine + 55 | " 40 to 100."; 56 | 57 | 58 | _description = new Popup(); 59 | _description.PlacementTarget = ControlTitle; 60 | _description.Placement = PlacementMode.Left; 61 | var textBlock = new TextBlock(); 62 | textBlock.Text = description; 63 | textBlock.Foreground = new SolidColorBrush(Colors.Black); 64 | textBlock.Background = new SolidColorBrush(Colors.White); 65 | _description.Child = textBlock; 66 | 67 | ControlTitle.MouseEnter += ControlTitle_MouseEnter; 68 | ControlTitle.MouseLeave += ControlTitle_MouseLeave; 69 | 70 | } 71 | 72 | void ControlTitle_MouseEnter(object sender, System.Windows.Input.MouseEventArgs e) 73 | { 74 | _description.IsOpen = true; 75 | } 76 | 77 | void ControlTitle_MouseLeave(object sender, System.Windows.Input.MouseEventArgs e) 78 | { 79 | _description.IsOpen = false; 80 | } 81 | 82 | 83 | 84 | 85 | /// 86 | /// Returns the sum total of "low" range scores, given a RangeSlider 87 | /// that just had its value modified. 88 | /// 89 | /// 90 | /// 91 | /// Sum of current "low" values 92 | double GetNewSumOfLowRange(RangeSlider sender, double newValue) 93 | { 94 | // Start with the supplied new value 95 | double total = newValue; 96 | 97 | for (int i = 0; i < 5; i++) 98 | { 99 | if (_sliders[i] != sender) 100 | { 101 | total += _sliders[i].Low; 102 | } 103 | } 104 | return total; 105 | } 106 | 107 | /// 108 | /// Given a RangeSlider, provides a list of the other RangeSliders that have 109 | /// a positive "low" range value. Used when adjusting the total of the "low" 110 | /// range sliders to not exceed 100%. 111 | /// 112 | /// 113 | /// 114 | List GetOtherSlidersWithPositiveLowScore(RangeSlider sender) 115 | { 116 | List results = new List(); 117 | 118 | // Loop through and get the sliders (besides the sender) 119 | // that have a positive "low" range value. 120 | for (int i = 0; i < 5; i++) 121 | { 122 | if (_sliders[i] != sender && _sliders[i].Low > 0) 123 | { results.Add(_sliders[i]); } 124 | } 125 | return results; 126 | } 127 | 128 | /// 129 | /// Prevents user from specifying a total minimum number of results that exceeds 100% 130 | /// by dynamically reducing the values of the other sliders. 131 | /// 132 | /// e.g. the user specifies 40% for the "low" range value of each of the five 133 | /// star categories. The sum would be 200% and wouldn't make sense. 134 | /// 135 | /// 136 | /// 137 | void slider_LowValueChanged(RangeSlider sender, double oldValue, double newValue) 138 | { 139 | // If we're already dealing with with this problem, allow it to resolve. 140 | // This prevents a cascade of events as every change to a low score causes 141 | // this method to be called 142 | if (resolvingPercentageError) { return; } 143 | 144 | // Figure out whether the change puts the "low" sum over 100% 145 | double sum = GetNewSumOfLowRange(sender, newValue); 146 | 147 | if (sum <= 100) { return; } 148 | 149 | resolvingPercentageError = true; 150 | 151 | // Loop through the sliders, reducing each until 152 | // the total amount is less than or equal to 100 153 | while (sum > 100) 154 | { 155 | // We can't do anything with sliders with a zero "low" value, so get 156 | // a list of other sliders that have a non-zero "low" value 157 | List hasLowRangeValue = GetOtherSlidersWithPositiveLowScore(sender); 158 | 159 | foreach (RangeSlider rangeSlider in hasLowRangeValue) 160 | { 161 | // how much more we have to distribute 162 | double amountAboveOneHundred = sum - 100; 163 | 164 | // The amount over 100 divided by the number of sliders that have 165 | // a "low" value. 166 | double shareOfAmountOver = Math.Ceiling(amountAboveOneHundred / hasLowRangeValue.Count); 167 | 168 | // Try to subtract this amount from the current slider 169 | double remainder = rangeSlider.Low - Math.Ceiling(amountAboveOneHundred / hasLowRangeValue.Count); 170 | 171 | // Two possibilities: 172 | // 1) The slider had enough "low" value to subtract its share of the overage 173 | if (remainder > 0) 174 | { 175 | sum -= shareOfAmountOver; 176 | rangeSlider.Low = remainder; 177 | } 178 | else // 2) We could only subtract a part of this slider's share before it became zero 179 | { 180 | // We've reduced this slider's "low" value to zero 181 | rangeSlider.Low = 0; 182 | 183 | // The negative remainder represents the amount that we couldn't subtract 184 | // from this slider. Adjust sum to reflect the portion that we subtracted 185 | sum -= (shareOfAmountOver - Math.Abs(remainder)); 186 | 187 | } 188 | 189 | } // for each rangeslider with a low value 190 | 191 | } // sum > 100 192 | 193 | // The problem has been successfully resolved. 194 | resolvingPercentageError = false; 195 | } 196 | 197 | /// 198 | /// Returns whether the control is validated 199 | /// Always returns true, as the control ensures valid values. 200 | /// 201 | /// 202 | public Result Validate() 203 | { 204 | return new Result(); 205 | } 206 | 207 | 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /UI/Controls/TextBoxPlus.xaml: -------------------------------------------------------------------------------- 1 |  10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /UI/Controls/TextBoxPlus.xaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Windows; 3 | using System.Windows.Controls; 4 | using System.Windows.Data; 5 | using System.Windows.Input; 6 | using System.Windows.Media; 7 | 8 | namespace AmazonScrape 9 | { 10 | /// 11 | /// Interaction logic for TextBoxPlus.xaml 12 | /// 13 | [global::System.ComponentModel.TypeConverter(typeof(DoubleRangeConverter))] 14 | public partial class TextBoxPlus : UserControl, IValidatable 15 | { 16 | public TextBoxPlus() 17 | { 18 | Focusable = true; 19 | InitializeComponent(); 20 | 21 | LostFocus += TextBoxPlus_LostFocus; 22 | PreviewGotKeyboardFocus += TextBoxPlus_PreviewGotKeyboardFocus; 23 | GotFocus += TextBoxPlus_GotFocus; 24 | } 25 | 26 | private void TextBoxPlus_GotFocus(object sender, RoutedEventArgs e) 27 | { 28 | textBox.SelectAll(); 29 | } 30 | 31 | void TextBoxPlus_PreviewGotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e) 32 | { 33 | if (e.OriginalSource == sender) 34 | { 35 | textBox.Focus(); 36 | e.Handled = true; 37 | } 38 | } 39 | 40 | public string Title { get { return _title; } set { _title = value; } } 41 | public bool ContainsDoubleValue { get { return HasDoubleValue(); } } 42 | private string _title = ""; // What to call the control if validation fails. 43 | 44 | public static readonly DependencyProperty TextProperty = DependencyProperty.Register 45 | ( 46 | "Text", 47 | typeof(string), 48 | typeof(TextBoxPlus), 49 | new PropertyMetadata(string.Empty) 50 | ); 51 | 52 | public string Text 53 | { 54 | get 55 | { 56 | // Force the textbox to grab the current value 57 | // This prevents stale data from being returned 58 | BindingExpression be = textBox.GetBindingExpression(TextBox.TextProperty); 59 | be.UpdateSource(); 60 | return (string)GetValue(TextProperty); 61 | } 62 | set { SetValue(TextProperty, value); } 63 | } 64 | 65 | public static readonly DependencyProperty RequiresValueProperty = DependencyProperty.Register 66 | ( 67 | "RequiresValue", 68 | typeof(bool), 69 | typeof(TextBoxPlus), 70 | new PropertyMetadata(false) 71 | ); 72 | 73 | public bool RequiresValue 74 | { 75 | get { return (bool)GetValue(RequiresValueProperty); } 76 | set { SetValue(RequiresValueProperty, value); } 77 | } 78 | 79 | public static readonly DependencyProperty IsNumericOnlyProperty = DependencyProperty.Register 80 | ( 81 | "NumericOnly", 82 | typeof(bool), 83 | typeof(TextBoxPlus), 84 | new PropertyMetadata(false) 85 | ); 86 | 87 | public bool IsNumericOnly 88 | { 89 | get { return (bool)GetValue(IsNumericOnlyProperty); } 90 | set { SetValue(IsNumericOnlyProperty, value); } 91 | } 92 | 93 | public static readonly DependencyProperty NumericRangeProperty = DependencyProperty.Register 94 | ( 95 | "NumericRange", 96 | typeof(DoubleRange), 97 | typeof(TextBoxPlus), 98 | new PropertyMetadata(null) 99 | ); 100 | 101 | public DoubleRange NumericRange 102 | { 103 | get { return (DoubleRange)GetValue(NumericRangeProperty); } 104 | set { IsNumericOnly = true; SetValue(NumericRangeProperty, value); } 105 | } 106 | 107 | // Specify a numeric value range that the text must fall within 108 | // Otherwise, cause a validation error in Validate() 109 | //private DoubleRange _numericRange; 110 | //private string _name; 111 | protected bool HasDoubleValue() 112 | { 113 | double result; 114 | if (Double.TryParse(textBox.Text, out result)) 115 | { 116 | return true; 117 | } 118 | return false; 119 | } 120 | 121 | 122 | /// 123 | /// Type converter for range values. 124 | /// Range should be in the form of: "X,Y" where X is the numeric low value and Y is the numeric high value 125 | /// If one of the range values is blank, it means that there is no boundary on that end of the range. 126 | /// For example, "X," would mean X is the low value, and the top end of the range is the max value for a double. 127 | /// Note: A comma is required even if no values are being supplied. 128 | /// 129 | /// 130 | /// 131 | public static DoubleRange Parse(string data) 132 | { 133 | return DoubleRange.Parse(data); 134 | } 135 | 136 | void TextBoxPlus_OnGotFocus(object sender, EventArgs e) 137 | { 138 | textBox.SelectAll(); 139 | } 140 | 141 | void TextBoxPlus_LostFocus(object sender, System.Windows.RoutedEventArgs e) 142 | { this.Background = new SolidColorBrush(Color.FromRgb(255, 255, 255)); } 143 | 144 | public Result Validate() 145 | { 146 | Result result = new Result(); 147 | 148 | // Error if blank and a value is required 149 | if (RequiresValue && textBox.Text.Length == 0) 150 | { 151 | result.ErrorMessage = _title + " cannot be blank."; 152 | return result; 153 | } 154 | 155 | if (IsNumericOnly) 156 | { 157 | double value; 158 | try 159 | { 160 | value = Convert.ToDouble(textBox.Text); 161 | } 162 | catch 163 | { 164 | result.ErrorMessage = _title + " is not numeric or exceeds the allowable numeric range."; 165 | return result; 166 | } 167 | 168 | DoubleRange range = NumericRange; 169 | if (range != null) 170 | { 171 | if (!range.Contains(value)) 172 | { 173 | result.ErrorMessage = _title + " must be " + range.ToString(); 174 | } 175 | } 176 | } 177 | return result; 178 | } // validate 179 | 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /UI/Controls/ValueRangeControl.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using System.Windows; 7 | using System.Windows.Controls; 8 | 9 | namespace AmazonScrape 10 | 11 | { 12 | class ValueRangeControl : Grid 13 | { 14 | DoubleRange _range; 15 | 16 | public ValueRangeControl(string name, DoubleRange range) 17 | { 18 | this._range = range; 19 | 20 | // Set up grid with four coulmns, one row 21 | ColumnDefinition col = new ColumnDefinition(); 22 | col.Width = new GridLength(20, GridUnitType.Star); 23 | this.ColumnDefinitions.Add(col); 24 | 25 | col = new ColumnDefinition(); 26 | col.Width = new GridLength(20, GridUnitType.Star); 27 | this.ColumnDefinitions.Add(col); 28 | 29 | col = new ColumnDefinition(); 30 | col.Width = new GridLength(20, GridUnitType.Star); 31 | this.ColumnDefinitions.Add(col); 32 | 33 | col = new ColumnDefinition(); 34 | col.Width = new GridLength(20, GridUnitType.Star); 35 | this.ColumnDefinitions.Add(col); 36 | 37 | RowDefinition row = new RowDefinition(); 38 | row.Height = new GridLength(100, GridUnitType.Star); 39 | 40 | // TODO: remove the set row if it's not necessary 41 | TextBlock from = new TextBlock(); 42 | from.Text = name + " from :"; 43 | this.Children.Add(from); 44 | Grid.SetColumn(from,0); 45 | Grid.SetRow(from,0); 46 | 47 | TextBoxPlus low = new TextBoxPlus(); 48 | low.Text = range.Low.ToString(); 49 | this.Children.Add(low); 50 | Grid.SetColumn(low, 1); 51 | Grid.SetRow(low,0); 52 | 53 | TextBlock to = new TextBlock(); 54 | to.Text = " to : "; 55 | this.Children.Add(to); 56 | Grid.SetColumn(to, 2); 57 | Grid.SetRow(to, 0); 58 | 59 | TextBoxPlus high = new TextBoxPlus(); 60 | if (range.High < double.MaxValue) 61 | { high.Text = range.High.ToString(); } 62 | Grid.SetColumn(high, 3); 63 | Grid.SetRow(high, 0); 64 | 65 | 66 | } 67 | 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /UI/MainWindow.xaml: -------------------------------------------------------------------------------- 1 |  2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 49 | 50 | 51 | 58 | 59 | 60 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 96 | 97 | 98 | 102 | Search 103 | 104 | 105 | 106 | 117 | 118 | 119 | 120 | 130 | 131 | Match all terms 136 | 137 | 138 | 145 | 146 | 150 | # of results 151 | 152 | 153 | 166 | 167 | 171 | Min # of reviews 172 | 173 | 174 | 187 | 188 | 196 | 197 | 206 | 207 | 212 | 213 | 214 | 217 | 220 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 255 | 256 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | -------------------------------------------------------------------------------- /UI/MainWindow.xaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Diagnostics; 5 | using System.Reflection; 6 | using System.Threading; 7 | using System.Windows; 8 | using System.Windows.Controls; 9 | using System.Windows.Data; 10 | using System.Windows.Input; 11 | using System.Windows.Media; 12 | using System.Windows.Media.Animation; 13 | using System.Windows.Threading; 14 | 15 | namespace AmazonScrape 16 | { 17 | /// 18 | /// Interaction logic for MainWindow.xaml 19 | /// 20 | public partial class MainWindow : Window 21 | { 22 | private SearchManager _searchManager; // Oversees product searches 23 | private List requireValidation; // list of controls that require validation 24 | 25 | public MainWindow() 26 | { 27 | // Catch any vague XAML exceptions 28 | try { InitializeComponent(); } 29 | catch 30 | { 31 | MessageBox.Show("XAML initialization error."); 32 | } 33 | 34 | Icon = ResourceLoader.GetProgramIconBitmap(); 35 | Title = "AmazonScrape"; 36 | 37 | Version version = Assembly.GetExecutingAssembly().GetName().Version; 38 | Title += " " + version; 39 | WindowState = System.Windows.WindowState.Maximized; 40 | 41 | // Specify the controls requiring validation 42 | // (validation properties are set in XAML) 43 | requireValidation = new List(); 44 | requireValidation.Add(txtSearch); 45 | requireValidation.Add(txtNumberOfResults); 46 | requireValidation.Add(txtMinNumberOfReviews); 47 | requireValidation.Add(PriceRange); 48 | 49 | Style smallTextStyle = ResourceLoader.GetControlStyle("DataGridSmallTextStyle"); 50 | Style mediumTextStyle = ResourceLoader.GetControlStyle("DataGridMediumTextStyle"); 51 | Style largeTextStyle = ResourceLoader.GetControlStyle("DataGridLargeTextStyle"); 52 | 53 | // Specify the result grid format 54 | ResultGrid.ColumnHeaderHeight = 40; 55 | ResultGrid.MouseDoubleClick += dataGrid_MouseDoubleClick; 56 | ResultGrid.PreviewMouseLeftButtonDown += dataGrid_PreviewMouseLeftButtonDown; 57 | ResultGrid.AddImageColumn("ProductImage", "Product", 5, BindingMode.TwoWay); 58 | ResultGrid.AddImageColumn("PrimeLogoImage", "Prime", 4, BindingMode.OneWay,"IsPrimeEligible"); 59 | ResultGrid.AddTextColumn("Name", "Product Name", 13, mediumTextStyle); 60 | ResultGrid.AddTextColumn("LowPrice", "Low Price", 5, largeTextStyle); 61 | ResultGrid.AddTextColumn("HighPrice", "High Price", 5, largeTextStyle); 62 | ResultGrid.AddTextColumn("Rating", "Rating", 3, largeTextStyle); 63 | ResultGrid.AddTextColumn("ReviewCount", "Reviews", 5, largeTextStyle); 64 | ResultGrid.AddTextColumn("ReviewDistribution", "Distribution", 5,smallTextStyle); 65 | ResultGrid.AddButtonColumn("Open", 3, new RoutedEventHandler(OpenInBrowser_Click)); 66 | 67 | // Set focus to the search control once the window is loaded 68 | this.Loaded += MainWindow_Loaded; 69 | } 70 | 71 | void SearchControl_KeyDown(object sender, KeyEventArgs e) 72 | { 73 | if (e.Key == Key.Enter) 74 | { 75 | Search(); 76 | } 77 | } 78 | 79 | void MainWindow_Loaded(object sender, RoutedEventArgs e) 80 | { 81 | txtSearch.Focus(); 82 | } 83 | 84 | /// 85 | /// If the user clicks in a data grid area that is not a result, remove the 86 | /// grid selection. 87 | /// 88 | /// This is to prevent the user from being able to double click 89 | /// an empty part of the data grid to open a selected item in a browser tab. 90 | /// The intended behavior is to only open an item in a browser if the user 91 | /// double-clicks directly on a data grid result. 92 | /// 93 | /// 94 | void dataGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) 95 | { 96 | IInputElement element = e.MouseDevice.DirectlyOver; 97 | if (element != null && element is FrameworkElement) 98 | { 99 | // If the element selected is of type scroll viewer, it means that the 100 | // user is not clicking on a data grid result. In that case, remove 101 | // the current selection 102 | if (element.GetType() == typeof(System.Windows.Controls.ScrollViewer)) 103 | { ResultGrid.SelectedIndex = -1; } 104 | } 105 | } 106 | 107 | /// 108 | /// Double clicking a result item will attempt to open the item's URL 109 | /// in whatever program they have associated with URLs. 110 | /// 111 | /// 112 | /// 113 | void dataGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e) 114 | { 115 | // If the user is clicking the scrollbar (the arrow 116 | // or the "thumb"), don't attempt to open an item in the browser 117 | IInputElement element = e.MouseDevice.DirectlyOver; 118 | if (element != null && element is FrameworkElement) 119 | { 120 | var elementType = element.GetType(); 121 | 122 | if (elementType == typeof(System.Windows.Controls.Primitives.RepeatButton) || 123 | elementType == typeof(System.Windows.Controls.Primitives.Thumb)) 124 | { return; } 125 | } 126 | 127 | if (ResultGrid.SelectedItem == null) return; 128 | AmazonItem item = ResultGrid.SelectedItem as AmazonItem; 129 | if (item.URL == null) 130 | { 131 | MessageBox.Show("The item's URL cannot be parsed."); 132 | return; 133 | } 134 | OpenWebpage(item.URL.ToString()); 135 | } 136 | 137 | /// 138 | /// Attempts to open the user's default web browser and direct 139 | /// it to the supplied url. 140 | /// 141 | /// 142 | private void OpenWebpage(string url) 143 | { 144 | // TODO: reported bug that this doesn't work on a machine that 145 | // does not have framework 4.5 installed. Get more details. 146 | try 147 | { 148 | System.Diagnostics.Process.Start(url); 149 | } 150 | catch (Exception) 151 | { 152 | MessageBox.Show("Error while trying to open browser for the requested product."); 153 | } 154 | } 155 | 156 | /// 157 | /// Obtains the selected result item's URL and attempts to open 158 | /// the user's default web browser to that page. 159 | /// 160 | /// 161 | /// 162 | private void OpenInBrowser_Click(object sender, RoutedEventArgs e) 163 | { 164 | AmazonItem item = ((FrameworkElement)sender).DataContext as AmazonItem; 165 | 166 | if (item.URL == null) 167 | { 168 | MessageBox.Show("The item's URL cannot be parsed."); 169 | return; 170 | } 171 | OpenWebpage(item.URL.ToString()); 172 | } 173 | 174 | /// 175 | /// Cancels an ongoing search. 176 | /// 177 | /// Because the search is being processed by a BackgroundWorker thread, 178 | /// the cancellation will not be instantaneous. 179 | /// 180 | /// 181 | void btnCancel_Click(object sender, RoutedEventArgs e) 182 | { 183 | StatusTextBox.Text += Environment.NewLine + Environment.NewLine; 184 | StatusTextBox.Text += "Canceling search! Please be patient."; 185 | CancelSearch(); 186 | } 187 | 188 | void textBox_KeyDown(object sender, KeyEventArgs e) 189 | { 190 | // Search if the user presses the enter key on the search box 191 | if (e.Key == Key.Enter) Search(); 192 | } 193 | 194 | /// 195 | /// Loop through each control marked as validatable and check its 196 | /// validation status. If a single control is not valid (Status.Error), 197 | /// validation stops and the ValidationResult is immediately returned. 198 | /// 199 | /// ValidationResult 200 | private Result ValidateControls() 201 | { 202 | Result result = new Result(); 203 | 204 | foreach (IValidatable validatable in requireValidation) 205 | { 206 | result = validatable.Validate(); 207 | if (result.ErrorMessage.Length > 0) return result; 208 | } 209 | return result; 210 | } 211 | 212 | /// 213 | /// Obtains the current control values and returns a SearchCriteria object. 214 | /// Note: does not validate the controls; perform validation before calling. 215 | /// 216 | /// 217 | private SearchCriteria GetSearchCriteria() 218 | { 219 | DoubleRange priceRange = PriceRange.GetValues(); 220 | 221 | double minReviewCount = Convert.ToDouble(txtMinNumberOfReviews.Text); 222 | 223 | return new SearchCriteria(txtSearch.Text, 224 | Convert.ToDouble(txtNumberOfResults.Text), 225 | priceRange, 226 | ScoreDistribution.Distribution, 227 | minReviewCount, 228 | (bool)chkMatchAll.IsChecked, 229 | Constants.USE_STRICT_PRIME_ELIGIBILITY); 230 | } 231 | 232 | /// 233 | /// Validates controls, obtains the control values, and begins the 234 | /// asynchronous scraping / parsing / result handling process. 235 | /// 236 | private void Search() 237 | { 238 | // Validate the search controls. 239 | Result result = ValidateControls(); 240 | 241 | if (result.ErrorMessage.Length > 0) 242 | { 243 | MessageBox.Show(result.ErrorMessage); 244 | return; 245 | } 246 | 247 | SearchCriteria searchCriteria = GetSearchCriteria(); 248 | 249 | // Replace the search controls with the progress control(s) 250 | ShowSearchProgressGrid(); 251 | 252 | StatusTextBox.Text = "Loading results. Please be patient."; 253 | Progress.Value = 0; 254 | ResultGrid.Items.Clear(); 255 | 256 | // Stop any previous search if it's still working 257 | // (in practice this should not be necessary) 258 | if (_searchManager != null && _searchManager.IsBusy) 259 | { 260 | string msg = "Please wait one moment before searching. "; 261 | MessageBox.Show(msg); 262 | _searchManager.CancelAsync(); 263 | } 264 | 265 | // Coordinates the actual scraping/parsing/validation/results: 266 | _searchManager = new SearchManager(searchCriteria, 267 | Constants.MAX_THREADS); 268 | _searchManager.ProgressChanged += ScraperProgressChanged; 269 | _searchManager.RunWorkerCompleted += ScrapeComplete; 270 | _searchManager.RunWorkerAsync(); 271 | } 272 | 273 | /// 274 | /// Replaces the search controls with "search progress" controls when a user searches 275 | /// A cancel button and a text area that displays which items were excluded from the results. 276 | /// 277 | private void ShowSearchProgressGrid() 278 | { 279 | SearchLayoutGrid.Visibility = System.Windows.Visibility.Hidden; 280 | SearchProgressGrid.Visibility = System.Windows.Visibility.Visible; 281 | 282 | DataLayoutGrid.ColumnDefinitions[0].Width = new GridLength(84, GridUnitType.Star); 283 | DataLayoutGrid.ColumnDefinitions[1].Width = new GridLength(1, GridUnitType.Star); 284 | DataLayoutGrid.ColumnDefinitions[2].Width = new GridLength(15, GridUnitType.Star); 285 | 286 | // The initial page load can take a few moments, so make the 287 | // progress bar animate to show that work is being done. 288 | SolidColorBrush bg = new SolidColorBrush(Colors.Black); 289 | Progress.Background = bg; 290 | 291 | ColorAnimation animation = new ColorAnimation() 292 | { 293 | From = Colors.Black, 294 | To = Colors.Gray, 295 | Duration = TimeSpan.FromMilliseconds(750), 296 | RepeatBehavior = RepeatBehavior.Forever, 297 | AutoReverse = true, 298 | }; 299 | 300 | bg.BeginAnimation(SolidColorBrush.ColorProperty, animation); 301 | 302 | CancelButton.Focus(); 303 | } 304 | 305 | /// 306 | /// Makes the search criteria controls visible again after a search is completed (or canceled). 307 | /// 308 | private void ShowSearchCriteriaGrid() 309 | { 310 | DataLayoutGrid.ColumnDefinitions[0].Width = new GridLength(70, GridUnitType.Star); 311 | DataLayoutGrid.ColumnDefinitions[1].Width = new GridLength(1, GridUnitType.Star); 312 | DataLayoutGrid.ColumnDefinitions[2].Width = new GridLength(30, GridUnitType.Star); 313 | 314 | SearchProgressGrid.Visibility = System.Windows.Visibility.Hidden; 315 | SearchLayoutGrid.Visibility = System.Windows.Visibility.Visible; 316 | 317 | // Return focus to the search textbox 318 | txtSearch.Focus(); 319 | } 320 | 321 | private void btnSearch_Click(object sender, RoutedEventArgs e) 322 | { Search(); } 323 | 324 | /// 325 | /// Called when the user cancels a search. May take a few 326 | /// seconds while it waits for the async thread to be ready. 327 | /// 328 | private void CancelSearch() 329 | { 330 | if (_searchManager != null) 331 | { 332 | _searchManager.CancelAsync(); 333 | } 334 | 335 | // Reset progress bar 336 | Progress.Value = 0; 337 | } 338 | 339 | /// 340 | /// Displays a work progress message in the status textbox. 341 | /// Right now this only displays items that have failed validation. Mostly 342 | /// this is there to show that the application is working and isn't stalled out. 343 | /// 344 | /// 345 | void AppendStatusMessage(string message) 346 | { 347 | if (message == null || message == "") return; 348 | 349 | // Add results to the data grid as soon as they are available 350 | StatusTextBox.Text += Environment.NewLine + Environment.NewLine; 351 | StatusTextBox.Text += message; 352 | StatusTextBox.Focus(); 353 | StatusTextBox.CaretIndex = StatusTextBox.Text.Length; 354 | StatusTextBox.ScrollToEnd(); 355 | 356 | } 357 | 358 | /// 359 | /// Takes a validated result and adds it to the datagrid 360 | /// 361 | /// 362 | void AddResultToGrid(AmazonItem result) 363 | { 364 | if (result == null) return; 365 | // Add results to the data grid as soon as they are available 366 | try 367 | { ResultGrid.Items.Add(result); } 368 | catch 369 | { 370 | string msg = "Error adding item to the result grid: " + 371 | result.ToString(); 372 | Debug.WriteLine(msg); 373 | } 374 | } 375 | 376 | /// 377 | /// Returns the state of the application to "search mode" after a search is complete. 378 | /// 379 | /// 380 | /// 381 | void ScrapeComplete(object sender, RunWorkerCompletedEventArgs e) 382 | { 383 | _searchManager.CancelAsync(); 384 | _searchManager.Dispose(); 385 | 386 | TaskbarItemInfo.ProgressValue = 1.0; 387 | Progress.Value = 0; 388 | 389 | SolidColorBrush bg = new SolidColorBrush(Colors.Black); 390 | Progress.Background = bg; 391 | 392 | // Show the search controls again 393 | ShowSearchCriteriaGrid(); 394 | 395 | } 396 | 397 | /// 398 | /// Called whenever a result comes back. Updates the progress bars. 399 | /// 400 | /// 401 | /// 402 | private void ScraperProgressChanged(object sender, ProgressChangedEventArgs args) 403 | { 404 | 405 | if (args == null || args.UserState == null || 406 | args.UserState.GetType() != typeof(Result)) return; 407 | 408 | Result result = (Result)args.UserState; 409 | 410 | // Update the status textbox with the result message 411 | AppendStatusMessage(result.StatusMessage); 412 | 413 | // If a new result is found (it passed validation), add it to the grid 414 | if (result.HasReturnValue) AddResultToGrid(result.Value); 415 | 416 | int intPercent = args.ProgressPercentage; 417 | double doublePercent = intPercent / 100.0; 418 | 419 | // Update progress controls 420 | if (intPercent > 0) 421 | { 422 | // Progress bars 423 | TaskbarItemInfo.ProgressValue = doublePercent; 424 | Progress.Value = intPercent; 425 | } 426 | } 427 | 428 | 429 | } 430 | } -------------------------------------------------------------------------------- /UI/Resources/ControlStyles.xaml: -------------------------------------------------------------------------------- 1 |  4 | 5 | 6 | 14 | 15 | 16 | 22 | 23 | 24 | 30 | 31 | 33 | 43 | 44 | 45 | 74 | 75 | 76 | 79 | 80 | 81 | 85 | 86 | 87 | 91 | 92 | 93 | 97 | 98 | 99 | 100 | 114 | 115 | 118 | 119 | 120 | 130 | 131 | 134 | 135 | 138 | 139 | 142 | 143 | 172 | 173 | 174 | 187 | 188 | -------------------------------------------------------------------------------- /UI/Resources/Images/application_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRush/AmazonScrape/0d4702607527968d388cd889e3110179a67f742f/UI/Resources/Images/application_icon.ico -------------------------------------------------------------------------------- /UI/Resources/Images/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRush/AmazonScrape/0d4702607527968d388cd889e3110179a67f742f/UI/Resources/Images/icon.ico -------------------------------------------------------------------------------- /UI/Resources/Images/prime_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRush/AmazonScrape/0d4702607527968d388cd889e3110179a67f742f/UI/Resources/Images/prime_logo.png -------------------------------------------------------------------------------- /UI/Resources/ResourceLoader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Windows; 4 | using System.Windows.Media.Imaging; 5 | 6 | namespace AmazonScrape 7 | { 8 | /// 9 | /// Loads image or control style resources 10 | /// 11 | public static class ResourceLoader 12 | { 13 | 14 | /// 15 | /// Load the Amazon Prime logo 16 | /// 17 | /// 18 | public static BitmapImage GetPrimeLogoBitmap() 19 | { 20 | BitmapImage primeLogoBitmap = new BitmapImage(); 21 | try 22 | { 23 | primeLogoBitmap = new BitmapImage(new Uri("pack://application:,,,/UI/Resources/Images/prime_logo.png", UriKind.RelativeOrAbsolute)); 24 | } 25 | catch (Exception) 26 | { Debug.WriteLine("Can't load prime logo resource."); } 27 | 28 | return primeLogoBitmap; 29 | } 30 | 31 | /// 32 | /// Load the program icon 33 | /// 34 | /// 35 | public static BitmapImage GetProgramIconBitmap() 36 | { 37 | BitmapImage programIconBitmap = new BitmapImage(); 38 | try 39 | { 40 | programIconBitmap = new BitmapImage(new Uri("pack://application:,,,/UI/Resources/Images/icon.ico", UriKind.RelativeOrAbsolute)); 41 | } 42 | catch (Exception) 43 | { Debug.WriteLine("Couldn't load program icon."); } 44 | 45 | return programIconBitmap; 46 | } 47 | 48 | /// 49 | /// Dynamically load a control style. 50 | /// 51 | /// 52 | /// 53 | public static Style GetControlStyle(string styleName) 54 | { 55 | if (!UriParser.IsKnownScheme("pack")) 56 | UriParser.Register(new GenericUriParser(GenericUriParserOptions.GenericAuthority), "pack", -1); 57 | 58 | ResourceDictionary dict = new ResourceDictionary(); 59 | Uri uri = new Uri("/UI/Resources/ControlStyles.xaml", UriKind.Relative); 60 | dict.Source = uri; 61 | Application.Current.Resources.MergedDictionaries.Add(dict); 62 | 63 | Style style; 64 | try 65 | { 66 | style = (Style)Application.Current.Resources[styleName]; 67 | } 68 | catch 69 | { 70 | throw new ResourceReferenceKeyNotFoundException("Can't find the Style " + styleName, styleName); 71 | } 72 | 73 | return style; 74 | } 75 | 76 | 77 | } // class 78 | } // namespace 79 | -------------------------------------------------------------------------------- /UtilityClasses/DoubleRange.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | 4 | namespace AmazonScrape 5 | { 6 | /// 7 | /// Represents a range of double values 8 | /// 9 | [DebuggerDisplay("Range = {Low},{High}")] 10 | [global::System.ComponentModel.TypeConverter(typeof(DoubleRangeConverter))] 11 | public class DoubleRange: NumericRange 12 | { 13 | public double Span { get { return High - Low; } } 14 | public bool HasLow { get { return _low > double.MinValue; } } 15 | public bool HasHigh { get { return _high < double.MaxValue; } } 16 | 17 | // Default constructor sets the range to the min/max 18 | // allowable for a double. 19 | public DoubleRange() 20 | { 21 | _low = double.MinValue; 22 | _high = double.MaxValue; 23 | } 24 | 25 | public DoubleRange(double low, double high) : base(low, high) { } 26 | 27 | // TODO: allow the user to specify one boundary or the other? 28 | 29 | /// 30 | /// Returns true if the supplied double is contained within the range (inclusive) 31 | /// 32 | /// 33 | /// 34 | public override bool Contains(double testDouble) 35 | { 36 | return (testDouble >= Low && testDouble <= High); 37 | } 38 | 39 | /// 40 | /// Returns true if the supplied NumericRange exists entirely within this range (inclusive) 41 | /// 42 | /// 43 | /// 44 | public override bool Contains(NumericRange range) 45 | { 46 | return (Low <= range.Low && High >= range.High); 47 | } 48 | 49 | /// 50 | /// Returns true if there is any overlap between the supplied NumericRange (inclusive) 51 | /// 52 | /// 53 | /// 54 | public override bool Overlaps(NumericRange range) 55 | { 56 | if (Contains(range)) return true; 57 | 58 | if ((range.Low <= Low && range.High >= Low) || 59 | (range.High >= High && range.Low <= High)) return true; 60 | 61 | return false; 62 | } 63 | 64 | /// 65 | /// Type converter for range values. 66 | /// Range should be in the form of: "X,Y" where X is the numeric low value and Y is the numeric high value 67 | /// If one of the range values is blank, it means that there is no boundary on that end of the range. 68 | /// For example, "X," would mean X is the low value, and the top end of the range is the max value for a double. 69 | /// Note: A comma is required even if no values are being supplied. 70 | /// 71 | /// 72 | /// 73 | public static DoubleRange Parse(string data) 74 | { 75 | 76 | DoubleRange result = new DoubleRange(); 77 | if (string.IsNullOrEmpty(data)) return result; 78 | 79 | string[] boundaries = data.Split(','); 80 | 81 | if (boundaries.Length != 2) 82 | { 83 | string msg = "Double Range requires values separated by a comma. Note: you can supply one boundary and leave the other blank, e.g. '0,' means a value zero or greater"; 84 | throw new FormatException(msg); 85 | } 86 | 87 | double low = double.MinValue; 88 | double high = double.MaxValue; 89 | 90 | if (boundaries[0].Length > 0) 91 | { 92 | try 93 | { 94 | low = Convert.ToDouble(boundaries[0]); 95 | result.Low = low; 96 | } 97 | catch { throw new FormatException("Can't convert the low boundary in the TextBox control range property. ('" + boundaries[0] + "' supplied)"); } 98 | } 99 | 100 | if (boundaries[1].Length > 0) 101 | { 102 | try 103 | { 104 | high = Convert.ToDouble(boundaries[1]); 105 | result.High = high; 106 | } 107 | catch { throw new FormatException("Can't convert the high boundary in the TextBox control range property. ('" + boundaries[1] + "' supplied)"); } 108 | } 109 | 110 | return result; 111 | } 112 | 113 | public override string ToString() 114 | { 115 | string result = ""; 116 | 117 | // Changed format here to support type converter 118 | 119 | if (HasLow) 120 | { 121 | result += Low.ToString(); 122 | } 123 | 124 | result += ","; 125 | 126 | if (HasHigh) 127 | { 128 | result += High.ToString(); 129 | } 130 | 131 | return result; 132 | } 133 | 134 | } // class 135 | 136 | } // namespace 137 | -------------------------------------------------------------------------------- /UtilityClasses/DoubleRangeConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | using System.Globalization; 4 | 5 | namespace AmazonScrape 6 | { 7 | class DoubleRangeConverter : global::System.ComponentModel.TypeConverter 8 | { 9 | // True if source is string 10 | public override bool CanConvertFrom( 11 | System.ComponentModel.ITypeDescriptorContext context, Type sourceType) 12 | { 13 | return sourceType == typeof(string); 14 | } 15 | 16 | // True if DestinationType is DoubleRange 17 | public override bool CanConvertTo( 18 | System.ComponentModel.ITypeDescriptorContext context, Type destinationType) 19 | { 20 | return destinationType == typeof(DoubleRange); 21 | 22 | } 23 | 24 | // Convert string to DoubleRange 25 | public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) 26 | { 27 | if (value is string) 28 | { 29 | try 30 | { 31 | return DoubleRange.Parse(value as string); 32 | } 33 | catch (Exception ex) 34 | { 35 | throw new Exception(string.Format( 36 | "Cannot convert '{0}' ({1}) because {2}", value, value.GetType(), ex.Message), ex); 37 | } 38 | } 39 | 40 | return base.ConvertFrom(context, culture, value); 41 | } 42 | 43 | 44 | public override object ConvertTo( 45 | System.ComponentModel.ITypeDescriptorContext context, 46 | System.Globalization.CultureInfo culture, object value, Type destinationType) 47 | { 48 | if (destinationType == null) 49 | throw new ArgumentNullException("destinationType"); 50 | 51 | DoubleRange range = value as DoubleRange; 52 | 53 | if (range != null) 54 | if (this.CanConvertTo(context, destinationType)) 55 | return range.ToString(); 56 | 57 | return base.ConvertTo(context, culture, value, destinationType); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /UtilityClasses/IntRange.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace AmazonScrape 8 | { 9 | class IntRange : NumericRange 10 | { 11 | public IntRange(int low, int high) : base(low, high) {} 12 | 13 | // The difference between the high and low values 14 | public int Span { get { return _high - _low; } } 15 | 16 | // Default constructor sets the range to the min/max 17 | // allowable for an integer. 18 | public IntRange() 19 | { 20 | this.Low = int.MinValue; 21 | this.High = int.MaxValue; 22 | } 23 | 24 | /// 25 | /// Returns true if the supplied integer falls within this range (inclusive). 26 | /// 27 | /// 28 | /// 29 | public override bool Contains(int testInt) 30 | { 31 | return (testInt >= Low && testInt <= High); 32 | } 33 | 34 | public override bool Contains(NumericRange range) 35 | { 36 | return (Low <= range.Low && High >= range.High); 37 | } 38 | 39 | /// 40 | /// Returns true if this range value overlaps at any point with the supplied range. 41 | /// 42 | /// 43 | /// 44 | public override bool Overlaps(NumericRange range) 45 | { 46 | return (Low <= range.High || High >= range.Low); 47 | } 48 | 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /UtilityClasses/NumericRange.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | //using System.Threading.Tasks; 6 | 7 | namespace AmazonScrape 8 | { 9 | // Represents a numeric range of values. 10 | // Require that the extending classes implement IComparable (numeric types, String, Char, Datetime) 11 | // This gets us some compile-time type-checking (although DateTime, String & Char still cause runtime exceptions) 12 | public abstract class NumericRange where T : IComparable 13 | { 14 | // Allow user to specify range after construction, but ensure that the 15 | // "low" value is less than the "high" value 16 | // (otherwise throws ArgumentOutOfRangeException) 17 | public T Low 18 | { 19 | get 20 | { return _low;} 21 | set 22 | { CheckRange(value, _high); _low = value; HasRangeSpecified = true; } 23 | } 24 | 25 | public T High 26 | { 27 | get 28 | { return _high; } 29 | set 30 | { CheckRange(_low, value); _high = value; HasRangeSpecified = true; } 31 | } 32 | 33 | protected T _low; 34 | protected T _high; 35 | 36 | /// 37 | /// True if the user specifies a range of values; otherwise the range is set to the 38 | /// minimum/maximum of the inheriting numeric data type 39 | /// 40 | public bool HasRangeSpecified { get; protected set; } 41 | 42 | 43 | /// 44 | /// Default constructor 45 | /// 46 | public NumericRange() 47 | { 48 | CheckType(); 49 | HasRangeSpecified = false; 50 | } 51 | 52 | /// 53 | /// Constructor allows user to specify the low and high bounds 54 | /// for the numeric range. 55 | /// 56 | /// 57 | /// 58 | public NumericRange(T low, T high) 59 | { 60 | CheckType(); 61 | 62 | CheckRange(low, high); 63 | _low = low; 64 | _high = high; 65 | HasRangeSpecified = true; 66 | } 67 | 68 | /// 69 | /// Ensure that the 'low' value does not exceed the 'high' value 70 | /// 71 | /// Note: it is allowable that they are the same value. 72 | /// 73 | /// 74 | private void CheckRange(T low, T high) 75 | { 76 | // Low value can't be greater than high value. 77 | if (low.CompareTo(high) > 0) 78 | { 79 | string msg = "The 'low' value for the range cannot be greater than the 'high' value."; 80 | throw new ArgumentOutOfRangeException(msg); 81 | } 82 | } 83 | 84 | /// 85 | /// Ensure that the implementing class uses a numeric type 86 | /// ( implementing IComparable addresses other types at compile-time ) 87 | /// 88 | private void CheckType() 89 | { 90 | Type t = typeof(T); 91 | 92 | // Restrict to numeric data only 93 | // According to MSDN, 94 | // "All numeric types (such as Int32 and Double) implement IComparable, 95 | /// as do String, Char, and DateTime." 96 | // Explicitly check for String, Char, and DateTime : 97 | if (t == typeof(string) || 98 | t == typeof(DateTime) || 99 | t == typeof(char)) 100 | { throw new NotSupportedException("Only numeric values are supported."); } 101 | } 102 | 103 | public abstract bool Contains(T value); // Generic parameter is contained within the range (inclusive). 104 | public abstract bool Contains(NumericRange testRange); // Range parameter is contained (inclusive) 105 | public abstract bool Overlaps(NumericRange testRange); // Range parameter partially overlaps this range (inclusive) 106 | 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /packages.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | --------------------------------------------------------------------------------