Contents
Summary
The purpose of this project was to provide an easy way to determine if a book (or other media) is sellable on Amazon. It uses the MWS (Marketplace Web Services) API provided by Amazon that allows merchants to programmatically send and receive information.
A major consideration for this project was to not “waste” API calls. Amazon, like most APIs, limits the number of requests that can be submitted in a given amount of time (Throttling).
When searching for a product on Amazon, they often return multiple items for a given query. Most of them are not a match, and need to be eliminated from consideration. Once the user has determined which products are a match, they click the Get button. An Ajax call is made to our server, which makes the API call to Amazon. This is getting a bit wordy, but the screens below should help make sense of it.
Description
I named this project the Triage project since it reminds me of the television series M*A*S*H where the doctors look over the new arrivals and decide which patients need immediate attention. Some are prepped for surgery while others are moved directly to post-op. That is similar to how we need to handle the batches of books. For ones that appear battered or not in very good shape, they will go in one pile. Ones that are missing a barcode will go in a different pile, and on and on.
When we have a good pile of products to investigate, we need so scan each one and determine if it is worth sending to Amazon. That is the purpose of this program.
Design
I broke this project into the following layers.
Data Layer
There are two primary sources of data for this application.
MySql / SQLite
The project has a pretty simple database for the information about each book. Some basic information like the source of the book, weight, condition, price and a few other things are stored in the database. This information is used when the listing is submitted to Amazon. I am using NHibernate as the ORM for the data layer. This makes it very easy to work with SQLite for development, and then change to MySql for production with a simple web.config change.
I also used Fluent NHibernate to make configuration simple. I especially like the Schema generation feature where I can just add a property to an object during development, and have my database generated from the objects. This along with the SQLite makes the development side much more efficient.
Amazon Marketplace Web Services
The Amazon MWS API consists of multiple sections. Most of what is required for this app is in the Products section of the API.
Methods called include:
-
ListMatchingProducts
-
GetCompetitivePricingForASIN
-
GetLowestOfferListingsForASIN
Business Layer (with Entities)
The project uses a manager class that calls the MWS API and converts the objects into our entity objects.
For a bigger project, I would have added a Service Layer that would have controlled access to the Business layer and provided a REST interface to the client applications. However, for this project, the MVC Controllers call the Business layer directly.
Presentation Layer (Asp.net MVC)
We are not shooting for any awards here. This project has one of the most plain looks of anything I have done recently. My goal was to keep it as uncluttered and easy to use as possible.
Screens
In the Stephen Covey fashion of “Begin with the End in Mind”, here are some screenshots of what I was shooting for.
Input Form
This is a pretty simple screen. It allows the user to scan a barcode or enter text for a lookup. When scanning a barcode, the scanner adds an <ENTER> after the barcode. The following code was added so the lookup button will be clicked “automatically” after a scan.
$("#txtLookup").keyup(function (event) { // console.log("lookup keypress: %O", event); var KEYCODE_ENTER = 13; if (event.keyCode == KEYCODE_ENTER) { $("#btnLookup").click(); } });
List View
I make one API call to get the results for the input. Often, items will be returned which do not match the product we are looking for. This screen allows us to eliminate the ones we don’t care about. The user will click the Hide button, and the item will be removed.
Results View
Finally, we get the actual results we are looking for.
Considerations
Quick and easy (for the user)
I wanted it to be very easy for a new user to start being productive with this screen. All they need is a browser and a pile of books and they can start scanning.
Go/NoGo
The original goal was to have a simple Go/NoGo (Red/Green) indicator on the screen to let the user know if we wanted to send the book to Amazon. That will involve a good deal more analysis of the many variables that play into that decision. For a first pass, I may do a Red/Yellow/Green indicator. I could inform the books we DO want to send (Green) and the ones we do NOT want to send (Red), and make everything else Yellow so they can determine if we should send it.
Throttling
Throttling must be considered when developing APIs. (That is a big part of the reason this project was an Asp.Net MVC application using Ajax). When we lookup a product on Amazon, we often get more than one result. Most of the results do not match the product we are searching, so we don’t want to “waste” our API calls on products we don’t care about.
Code Samples/Examples
Javascript ReplaceAll method
The Javascript Replace method only replaces the first occurrence of a string. This function will replaces all occurrences of a string. It is just a one liner and I didn’t write it, but I wanted to include it for future reference.
function ReplaceAll(string, search, replacement) { | |
// http://stackoverflow.com/questions/1144783/replacing-all-occurrences-of-a-string-in-javascript | |
return string.split(search).join(replacement); | |
} |
UrlEncodeObj
Return object as URL encoded string. I didn’t write this function (I hate one character variable names). I think it was provided by a fellow we call “Little Ben Hooper”.
// return object as URL encoded string | |
// I did not write this code, it was pasted from somewhere. | |
UrlEncodeObj = function (o) { | |
var sdata = ''; | |
for (var k in o) { | |
if (sdata) { | |
sdata += '&'; | |
} | |
sdata += k + '=' + escape(o[k]); | |
} | |
return sdata; | |
}; |
ViewModel
I like to use the view model for the page. This makes it much easier to pass all required objects to a view. It also makes it much easier to add future properties and objects.
public class ProductViewModel { public string Filter { get; set; } public string Condition { get; set; } private List<SelectListItem> _conditionList; public ProductViewModel() { _conditionList = new List<SelectListItem>(); _conditionList.Add(new SelectListItem { Text = "New", Value = "New" }); _conditionList.Add(new SelectListItem { Text = "Used, Like New", Value = "UsedLikeNew" }); _conditionList.Add(new SelectListItem { Text = "Used, Very Good", Value = "UsedVeryGood" }); _conditionList.Add(new SelectListItem { Text = "Used, Good", Value = "UsedGood" }); _conditionList.Add(new SelectListItem { Text = "Used, Acceptable", Value = "UsedAcceptable" }); } public IEnumerable<SelectListItem> ConditionList { get { return _conditionList.AsEnumerable(); } } }
Page Markup
This makes the actual page markup pretty simple.
@model Bfl.TriageWeb.ProductViewModel @{ ViewBag.Title = "Index"; } <h3>MatchingProducts</h3> <button id="btnClear" title="Clear Input field">X</button> <input type="text" id="txtLookup" value="" autofocus /> <button id="btnLookup">Lookup</button> <br /> Last Input: <b><span id="lastInput"></span></b> <div> Filter: @Html.RadioButtonFor(x => x.Filter, "", new { @class = "radioButton" })None @Html.RadioButtonFor(x => x.Filter, "VHS", new { @class = "radioButton" })VHS @Html.RadioButtonFor(x => x.Filter, "DVD", new { @class = "radioButton" })DVD @Html.RadioButtonFor(x => x.Filter, "Books", new { @class = "radioButton" })Books </div> <table id="tblProduct"> <tbody valign="top"> </tbody> </table> <hr />
Ajax Request
When the user clicks the Get button, the following method is called. It uses the index of the Get button clicked to change the caption and disable the button.
function ButtonGet(index, key) { // console.log("ButtonGet index: %O, key: %O", index, key); var data = {}; data.index = index; data.asin = key; // Disable the button and change the caption. $('#btn' + index).html("Retrieving Item..."); $('#btn' + index).attr("disabled", "disabled"); $.ajax("/Product/GetLowestOffer/", { type: "POST", dataType: 'json', data: UrlEncodeObj(data), success: ShowOneItem }); }
The controller returns a ViewModel for this specific request.
public class OfferInfoViewModel { public string Index { get; set; } public string Asin { get; set; } public List<LowestOfferDto> AmazonOffers { get; set; } public List<LowestOfferDto> MerchantOffers { get; set; } public CompetitivePricingDto CompetitivePricing { get; set; } public OfferInfoViewModel() { AmazonOffers = new List<LowestOfferDto>(); MerchantOffers = new List<LowestOfferDto>(); } }
And here is the controller code for the Ajax call.
public string GetLowestOffer() { string logMsg = LOG_PREFIX + "GetLowestOffer"; OfferInfoViewModel retVal = new OfferInfoViewModel(); try { if (Request.Form.Count > 0) { string asin = Request.Form["asin"]; string index = Request.Form["index"]; AmazonMwsProductManager mgr = new AmazonMwsProductManager(); LowestOfferListingsListResult lowestList = mgr.GetLowestOfferListingsForAsin(asin); retVal.Asin = asin; retVal.Index = index; Logging.LogTrace("Lowest Offer Result for Asin: " + asin); StringBuilder sb = new StringBuilder(); foreach (LowestOfferListingsResult lowestOffer in lowestList) { LowestOfferDto oneItem = new LowestOfferDto(lowestOffer); if (lowestOffer.FulfillmentChannel.ToLower().Contains("amazon")) { retVal.AmazonOffers.Add(oneItem); } else { retVal.MerchantOffers.Add(oneItem); } } CompetitivePricingResult competitivePricingResult = mgr.GetCompetitivePricingForAsin(asin); retVal.CompetitivePricing = new CompetitivePricingDto(competitivePricingResult); ProductCategoryListResult categoryList = mgr.GetProductCategoriesForAsin(asin); retVal.CompetitivePricing.AddCategoryNameForSalesRank(categoryList); } } catch (Exception ex) { Logging.LogError(logMsg + ", EXCEPTION: " + ex.Message, ex); } return JsonConvert.SerializeObject(retVal); }
Ajax Response
This code needs work.
- The inline styles need to be removed and placed into the site.css.
- It needs to be converted to use something like JsRender
- Portions need to be extracted into separate functions
Even with those disclaimers, I was still hesitant to post this code. In my experience, writing code is a very iterative process. We don’t always get what we want the first time. Often, we “try” something (like an inline style) to see if we get what we are looking for. Good code is a process of continuous refactoring.
In some ways, this snippet is “mid-refactor”. I should clean it up before posting it, but I plan to make the JsView refactor a separate post later.
function ShowOneItem(data) { console.log("ShowOneItem, data: %O", data); $('#btn' + data.Index).html("Get"); $('#btn' + data.Index).removeAttr("disabled"); var rowFba = ""; $.each(data.AmazonOffers, function (index, item) { rowFba += "" + "<tr width='400px' >" + "<td nowrap>" + item.LandedPrice + "<span style='font-size: small;'> (" + item.ListedPrice + " + " + item.Shipping + ") </span></td>" + "<td nowrap>" + item.Condition + "</td>" + "<td align='right'>" + item.NumberOfOfferListingsConsidered + "</td>" + "<td align='right' nowrap>" + item.SellerPositiveFeedbackRating + "</td>" + "<td align='right'>" + item.SellerFeedbackCount + "</td>" + "</tr>"; }); var rowMerchant = ""; $.each(data.MerchantOffers, function (index, item) { rowMerchant += "" + "<tr>" + "<td nowrap>" + item.LandedPrice + "<span style='font-size: small;'> (" + item.ListedPrice + " + " + item.Shipping + ") </span></td>" + "<td nowrap>" + item.Condition + "</td>" + "<td align='right'>" + item.NumberOfOfferListingsConsidered + "</td>" + "<td align='right' nowrap>" + item.SellerPositiveFeedbackRating + "</td>" + "<td align='right'>" + item.SellerFeedbackCount + "</td>" + "</tr>"; }); var rowSalesRank = ""; $.each(data.CompetitivePricing.SalesRankings, function (index, item) { rowSalesRank += "" + "<tr style='background-color: #A2D39C;'>" + "<td align='right'>" + item.Rank + "</td>" + "<td>" + item.CategoryName + " (" + item.ProductCategoryId + ")</td>" + "</tr>"; }); // Build the Html for the item. var html = "" + "<table class='itemTable' >" + "<tr>" + "<th> </th>" + "<th> </th>" + "<th> </th>" + "<th colspan='2' nowrap >----Feedback----</th>" + "</tr>" + "<tr style='border-bottom: solid medium blue;'>" + "<th nowrap>Price <span style='font-size: 10px;' >(Listed + Shipping)</span></th>" + "<th>Condition</th>" + "<th align='right'>Offers</th>" + "<th align='right'>Rating</th>" + "<th align='right'>Count</th>" + "</tr>" + "<tbody>" + "<tr><td colspan='9'><h3>Lowest FBA Offers</h3></td></tr>" + rowFba + "<tr><td colspan='9'><h3>Lowest Merchant Offers</h3></td></tr>" + rowMerchant; html = html + "<tr><td colspan='9'><h3>Buy Box Price</h3></td></tr>"; if (data.CompetitivePricing.NewPrice.Condition != null) { html = html + "" + "<tr style='background-color: #FDC68A;'>" + "<td nowrap>" + data.CompetitivePricing.NewPrice.LandedPrice + "<span style='font-size: small;'> (" + data.CompetitivePricing.NewPrice.ListedPrice + " + " + data.CompetitivePricing.NewPrice.Shipping + ") </span></td>" + "<td>" + data.CompetitivePricing.NewPrice.DisplayCondition + "</td>" + "<td> </td>" + "<td> </td>" + "</tr>"; } if (data.CompetitivePricing.UsedPrice.Condition != null) { html = html + "" + "<tr>" + "<td nowrap>" + data.CompetitivePricing.UsedPrice.LandedPrice + "<span style='font-size: small;'> (" + data.CompetitivePricing.UsedPrice.ListedPrice + " + " + data.CompetitivePricing.UsedPrice.Shipping + ") </span></td>" + "<td>" + data.CompetitivePricing.UsedPrice.DisplayCondition + "</td>" + "<td> </td>" + "<td> </td>" + "</tr>"; } html = html + "</tbody>" + "</table>"; // Add the Sales Ranking html = html + "<table class='itemTable'>" + "<tr style='border-bottom: solid medium blue;'>" + "<th align='right'>Rank</th>" + "<th align='right'>Category</th>" + "</tr>" + "<tbody>" + rowSalesRank + "</tbody>" + "</table>"; html = html + "<br /><div style='font-size: Large;'>" + "<b>Listings Available: </b>"; if (data.CompetitivePricing.NumberOfListingsNew != "0") { html = html + "<a href='http://www.amazon.com/gp/offer-listing/" + data.Asin + "/ref=dp_olp_new?ie=UTF8&condition=new' " + " target='_blank' >" + data.CompetitivePricing.NumberOfListingsNew + " new" + "</a> "; } if (data.CompetitivePricing.NumberOfListingsUsed != "0") { html = html + "<a href='http://www.amazon.com/gp/offer-listing/" + data.Asin + "/ref=dp_olp_used?ie=UTF8&condition=used' " + " target='_blank' >" + data.CompetitivePricing.NumberOfListingsUsed + " used" + "</a> "; } if (data.CompetitivePricing.NumberOfListingsCollectible != "0") { html = html + "<a href='http://www.amazon.com/gp/offer-listing/" + data.Asin + "/ref=dp_olp_collectible?ie=UTF8&condition=collectible' " + " target='_blank' >" + data.CompetitivePricing.NumberOfListingsCollectible + " collectible" + "</a> "; } html = html + "</div>"; $("#divLow" + data.Index).html(html); }
Technology
- Asp.Net MVC 4.5
- C#
- Amazon Marketplace Web Services
- jQuery
- Ajax