UNPKG

ebay-api

Version:
604 lines (480 loc) 19.5 kB
// eBay API client for Node.js var restler = require('restler'), _ = require('underscore'), util = require('util'), async = require('async'); // [internal] convert params hash to url string. // some items may be arrays, use key(0)..(n) // param usage: // - use null values for plain params // - use arrays for repeating keys var buildUrlParams = function(params) { var urlFilters = []; // string parts to be joined // (force each to be string w/ ''+var) _(params).each(function(value, key) { if (value === null) urlFilters.push('' + key); else if (_.isArray(value)) { _(value).each(function(subValue, subInd) { urlFilters.push('' + key + '(' + subInd + ')' + "=" + subValue); }); } else urlFilters.push( '' + key + "=" + value ); }); return urlFilters.join('&'); }; // [helper] constructor for an 'itemFilter' filter (used by the Finding Service) module.exports.ItemFilter = function(name, value, paramName, paramValue) { // required this.name = name; this.value = value; // optional this.paramName = _.isUndefined(paramName) ? '' : paramName; this.paramValue = _.isUndefined(paramValue) ? '' : paramValue; }; // [internal] convert a filters array to a url string // adapted from client-side JS example in ebay docs var buildFilters = function(filterType, filters) { var urlFilter = ''; _(filters).each(function eachItemFilter(filter, filterInd) { // each parameter in each item filter _(filter).each(function eachItemParam(paramVal, paramKey) { // Check to see if the paramter has a value (some don't) if (paramVal !== "") { // multi-value param if (_.isArray(paramVal)) { _(paramVal).each(function eachSubFilter(paramSubVal, paramSubIndex) { urlFilter += '&' + filterType + '(' + filterInd + ').' + paramKey + '(' + paramSubIndex + ')=' + paramSubVal; }); } // single-value param else { urlFilter += '&' + filterType + '(' + filterInd + ').' + paramKey + '=' + paramVal; } } }); }); return urlFilter; }; // build URL to API endpoints // set sandbox=true for sandbox, otherwise production // - params is a 1D obj // - filters is an obj of { filterType:[filters] } (where filters is an array of ItemFilter) // params,filters only apply to GET requests; for POST pass in empty {} or null var buildRequestUrl = function(serviceName, params, filters, sandbox) { var url; params = params || {}; filters = filters || {}; sandbox = (typeof sandbox === 'boolean') ? sandbox : false; switch (serviceName) { case 'FindingService': if (sandbox) { // url = // @todo throw new Error("Sandbox endpoing for FindingService not yet implemented. Please add."); } else url = "https://svcs.ebay.com/services/search/" + serviceName + "/v1?"; break; case 'Shopping': if (sandbox) { // url = // @todo throw new Error("Sandbox endpoing for Shopping service not yet implemented. Please add."); } else url = "http://open.api.ebay.com/shopping?"; break; case 'Trading': // ...and the other XML APIs if (sandbox) url = 'https://api.sandbox.ebay.com/ws/api.dll'; else url = 'https://api.ebay.com/ws/api.dll'; // params and filters don't apply to URLs w/ these return url; // break; default: if (sandbox) { // url = // @todo throw new Error("Sandbox endpoing for " + serviceName + " service not yet implemented. Please add."); } else url = "https://svcs.ebay.com/" + serviceName + '?'; } url += buildUrlParams(params); // no trailing & _(filters).each(function(typeFilters, type) { url += buildFilters(type, typeFilters); // each has leading & }); return url; }; module.exports.buildRequestUrl = buildRequestUrl; // build XML input for XML-POST requests // params should include: authToken, ... var buildXmlInput = function(opType, params) { var xmlBuilder = require('xml'); var data = {}, top; switch(opType) { // @todo others might have different top levels... case 'GetOrders': default: data[opType + 'Request'] = []; // e.g. <GetOrdersRequest> top = data[opType + 'Request']; top.push({ '_attr' : { 'xmlns' : "urn:ebay:apis:eBLBaseComponents" } }); } if (typeof params.authToken !== 'undefined') { top.push({ 'RequesterCredentials' : [ { 'eBayAuthToken' : params.authToken } ] }); delete params.authToken; } // handle top level // (e.g. OrderStatus, etc) // @todo better way to handle deeper inputs? _(params).each(function(value, key) { var el = {}; el[key] = value; top.push(el); }); // console.log(util.inspect(data,true,10)); data = [ data ]; return '<?xml version="1.0" encoding="UTF-8"?>' + "\n" + xmlBuilder(data, true); }; // default params per service type. // for GET requests these go into URL. for POST requests these go into headers. // options differ by service, see below. var defaultParams = function(options) { var params = {}, defaultGetParams = { 'OPERATION-NAME': options.opType, 'GLOBAL-ID': 'EBAY-US', 'RESPONSE-DATA-FORMAT': 'JSON', 'REST-PAYLOAD': null // (not sure what this does) }; options = options || {}; switch (options.serviceName) { // [GET params > case 'FindingService': params = _.extend({}, defaultGetParams, { 'SECURITY-APPNAME': options.appId ? options.appId : null, 'SERVICE-VERSION': '1.11.0' }); break; case 'MerchandisingService': params = _.extend({}, defaultGetParams, { 'SERVICE-NAME': options.serviceName, 'CONSUMER-ID': options.appId ? options.appId : null, 'SERVICE-VERSION': '1.5.0' // based on response data }); break; case 'Shopping': params = _.extend({}, defaultGetParams, { 'appid': options.appId ? options.appId : null, 'version': '771', 'siteid': '0', 'responseencoding': 'JSON', 'callname': options.opType }); break; // [POST params > case 'Trading': params = { 'X-EBAY-API-CALL-NAME' : options.opType, 'X-EBAY-API-COMPATIBILITY-LEVEL' : '773', 'X-EBAY-API-SITEID' : '0', // US 'X-EBAY-API-DEV-NAME': options.devName, 'X-EBAY-API-CERT-NAME': options.cert, 'X-EBAY-API-APP-NAME': options.appName }; break; } return params; }; // make a single GET request to a JSON service var ebayApiGetRequest = function(options, callback) { if (! options.serviceName) return callback(new Error("Missing serviceName")); if (! options.opType) return callback(new Error("Missing opType")); if (! options.appId) return callback(new Error("Missing appId")); options.params = options.params || {}; options.filters = options.filters || {}; options.reqOptions = options.reqOptions || {}; options.parser = options.parser || parseItemsFromResponse; options.sandbox = options.sandbox || false; if (options.serviceName === 'MerchandisingService') { options.reqOptions.decoding = 'buffer'; // otherwise fails to decode json. doesn't seem to be necessary w/ FindingService. } // fill in default params. explicit options above will override defaults. _.defaults(options.params, defaultParams(options)); var url = buildRequestUrl(options.serviceName, options.params, options.filters, options.sandbox); // console.log('url for', options.opType, 'request:\n', url.replace(/\&/g, '\n&')); var request = restler.get(url, options.reqOptions); var data; // emitted when the request has finished whether it was successful or not request.on('complete', function(result, response) { // [restler docs] 'If some error has occurred, result is always instance of Error' if (result instanceof Error) { var error = result; error.message = "Completed with error: " + error.message; return callback(error); } else if (response.statusCode !== 200) { return callback(new Error(util.format("Bad response status code", response.statusCode, result.toString()))); } try { data = JSON.parse(result); // drill down to item(s). each service has its own structure. if (options.serviceName !== 'Shopping') { var responseKey = options.opType + 'Response'; if (_.isUndefined(data[responseKey])) { return callback(new Error("Response missing " + responseKey + " element")); } data = data[responseKey]; } if (_.isArray(data)) { data = _(data).first(); } // 'ack' and 'errMsg' indicate errors. // - in FindingService it's nested, in Merchandising it's flat - flatten to normalize if (!_.isUndefined(data.ack)) data.ack = flatten(data.ack); else if (!_.isUndefined(data.Ack)) { // uppercase, standardize. data.ack = flatten(data.Ack); delete data.Ack; } if (_.isUndefined(data.ack) || data.ack !== 'Success') { var errMsg = _.isUndefined(data.errorMessage) ? null : flatten(data.errorMessage); return callback(new Error(util.format("Bad 'ack' code", data.ack, 'errorMessage?', util.inspect(errMsg, true, 3)))); } } catch(error) { return callback(error); } // console.log('completed successfully:\n', util.inspect(data, true, 10, true)); // parse the response options.parser(data, function(error, items) { callback(error, items); }); }); // emitted when some errors have occurred // either this OR 'completed' should fire request.on('error', function(error, response) { error.message = "Request error: " + error.message; callback(error); }); // emitted when the request was successful // -- overlaps w/ 'completed', don't use // request.on('success', function(data, response) { // }); // emitted when the request was successful, but 4xx status code returned // -- overlaps w/ 'completed', don't use // request.on('fail', function(data, response) { // }); }; module.exports.ebayApiGetRequest = ebayApiGetRequest; // make a single POST request to an XML service var ebayApiPostXmlRequest = function(options, callback) { if (! options.serviceName) return callback(new Error("Missing serviceName")); if (! options.opType) return callback(new Error("Missing opType")); options.params = options.params || {}; options.reqOptions = options.reqOptions || {}; options.sandbox = options.sandbox || false; // options.parser = options.parser || ...; // @todo // app/auth params go into headers (see defaultParams()) options.reqOptions.headers = options.reqOptions.headers || {}; _.defaults(options.reqOptions.headers, defaultParams(options)); // console.dir(options); var url = buildRequestUrl(options.serviceName, {}, {}, options.sandbox); // console.log('URL:', url); options.reqOptions.data = buildXmlInput(options.opType, options.params); // console.dir(options.reqOptions); var request = restler.post(url, options.reqOptions); request.on('complete', function(result, response) { if (result instanceof Error) { var error = result; error.message = "Completed with error: " + error.message; return callback(error); } else if (response.statusCode !== 200) { return callback(new Error(util.format("Bad response status code", response.statusCode, result.toString()))); } async.waterfall([ // convert xml to json function toJson(next) { var xml2js = require('xml2js'), parser = new xml2js.Parser(); parser.parseString(result, function parseXmlCallback(error, data) { if (error) { error.message = "Error parsing XML: " + error.message; return next(error); } next(data); }); }, function parseData(data, next) { //// @todo parse the response // options.parser(data, next); next(data); } ], function(error, data){ if (error) return callback(error); callback(null, data); }); }); // emitted when some errors have occurred // either this OR 'completed' should fire request.on('error', function(error, response) { error.message = "Request error: " + error.message; callback(error); }); }; module.exports.ebayApiPostXmlRequest = ebayApiPostXmlRequest; // PAGINATE multiple GET/JSON requests in parallel (max 100 per page, 100 pages = 10k items) var paginateGetRequest = function(options, callback) { if (! options.serviceName) return callback(new Error("Missing serviceName")); if (! options.opType) return callback(new Error("Missing opType")); if (! options.appId) return callback(new Error("Missing appId")); options.params = options.params || {}; options.filters = options.filters || {}; options.reqOptions = options.reqOptions || {}; options.pages = options.pages || 2; options.perPage = options.perPage || 10; options.parser = options.parser || parseItemsFromResponse; console.log('Paginated request to', options.serviceName, 'for', options.pages, 'pages of', options.perPage, 'items each'); var mergedItems = [], // to be merged pageParams = [], p; if (!(_.isNumber(options.pages) && options.pages > 0)) return callback(new Error("Invalid number of pages requested", options.pages)); // index is pageInd-1. can't start array from 1, it just fills in 0 with undefined. for(p = 0; p < options.pages; p++) { pageParams[p] = { 'paginationInput.entriesPerPage': options.perPage, 'paginationInput.pageNumber': p+1 }; } // console.log(pageParams.length, 'pages:', pageParams); // run pagination requests in parallel async.forEach(pageParams, function eachPage(thisPageParams, nextPage) { // merge the pagination params. new var to avoid confusing scope. var thisPageOptions = _.extend({}, options); thisPageOptions.params = _.extend({}, thisPageOptions.params, thisPageParams); console.log("Requesting page", thisPageOptions.params['paginationInput.pageNumber'], 'with', thisPageOptions.params['paginationInput.entriesPerPage'], 'items...'); ebayApiGetRequest(thisPageOptions, function(error, items) { // console.log("Got response from page", thisPageOptions.params['paginationInput.pageNumber']); if (error) { error.message = "Error on page " + thisPageOptions.params['paginationInput.pageNumber'] + ": " + error.message; return nextPage(error); } if (!_.isArray(items)) { return nextPage(new Error("Parser did not return an array, returned a " + typeof items)); } console.log('Got', items.length, 'items from page', thisPageOptions.params['paginationInput.pageNumber']); // console.log('have', mergedItems.length, 'previous items, adding', items.length, 'new items...'); mergedItems = mergedItems.concat(items); // console.log('now have', mergedItems.length, 'merged items'); nextPage(null); }); }, function pagesDone(error) { // console.log('pages are done'); if (error) callback(error); else callback(null, mergedItems); } ); //forEach }; module.exports.paginateGetRequest = paginateGetRequest; // helper: RECURSIVELY turn 1-element arrays/objects into flat vars // (different from _.flatten() which returns an array) var flatten = function(el, iter) { // sanity check if (_.isUndefined(iter)) var iter = 1; if (iter > 100) { console.error("recursion error, stop at", iter); return; } // flatten 1-item arrays if (_.isArray(el) && el.length === 1) { el = _.first(el); } // special value-pair structure in the ebay API: turn { @key:KEY, __value__:VALUE } into { KEY: VALUE } if (isValuePair(el)) { var values = _.values(el); // console.log('found special:', el); el = {}; el[ values[0] ] = values[1]; // console.log('handled special:', el); } // previous fix just creates an array of these. we want a clean key:val obj. // so, is this an array of special value-pairs? if (isArrayOfValuePairs(el)) { var fixEl = {}; _(el).each(function(pair) { _.extend(fixEl, flatten(pair)); // fix each, combine }); el = fixEl; } // flatten sub-elements if (_.isArray(el) || _.isObject(el)) { _.each(el, function(subEl, subInd) { el[subInd] = flatten(el[subInd], iter++); }); } return el; }; module.exports.flatten = flatten; // helper: identify a structure returned from the API: // { @key:KEY, __value__:VALUE } => want to turn into { KEY: VALUE } // (and array of these into single obj) var isValuePair = function(el) { if (_.isObject(el) && _.size(el) === 2) { var keys = _.keys(el); if (new RegExp(/^@/).test(keys[0]) && keys[1] === '__value__') { return true; } } return false; }; // helper: find an array containing only special key-value pairs // e.g. 'galleryURL' (makes it easier to handle in MongoDB) var isArrayOfValuePairs = function(el) { if (_.isArray(el)) { if (_.all(el, isValuePair)) return true; } return false; }; // extract an array of items from responses. differs by query type. // @todo build this out as more queries are added... var parseItemsFromResponse = function(data, callback) { // console.log('parse data', data); var items = []; try { if (typeof data.Item !== 'undefined') { // e.g. for Shopping::GetSingleItem items = [ data.Item ]; // preserve array for standardization (?) } else if (typeof data.searchResult !== 'undefined') { // e.g. for FindingService // reduce in steps so successful-but-empty responses don't throw error if (!_.isEmpty(data.searchResult)) { data = _(data.searchResult).first(); if (typeof data !== 'undefined') { if (typeof data.item !== 'undefined') { items = data.item; } } } } else if (typeof data.itemRecommendations !== 'undefined') { if (typeof data.itemRecommendations !== 'undefined') { if (typeof data.itemRecommendations.item !== 'undefined') { items = _.isArray(data.itemRecommendations.item) ? data.itemRecommendations.item : []; } } } // recursively flatten 1-level arrays and "@key:__VALUE__" pairs items = _(items).map(function(item) { return flatten(item); }); } catch(error) { callback(error); } callback(null, items); }; module.exports.parseItemsFromResponse = parseItemsFromResponse; // check if an item URL is an affiliate URL // non-affil URLs look like 'http://www.ebay.com...', affil URLs look like 'http://rover.ebay.com/rover...' // and have param &campid=TRACKINGID (campid=1234567890) module.exports.checkAffiliateUrl = function(url) { var regexAffil = /http\:\/\/rover\.ebay\.com\/rover/, regexNonAffil = /http\:\/\/www\.ebay\.com/, regexCampaign = /campid=[0-9]{5}/; return (regexAffil.test(url) && !regexNonAffil.test(url) && regexCampaign.test(url)); };