//! itc-report.js
//! version : 0.0.1
//! authors : Marek Serafin
//! license : MIT
//! github.com/stoprocent/itc-report
/*
* Module dependencies.
*/
var async = require('async');
request = require('request'),
cheerio = require('cheerio'),
moment = require('moment'),
fs = require('fs'),
path = require('path'),
_ = require('underscore');
/**
* @module itunesconnect
*/
/*
* Expose `Connect`
*/
exports.Connect = Connect;
/*
* Expose `Report`.
*/
exports.Report = Report;
/*
* Expose `Type`.
*/
exports.type = {
inapp : "IA0, IA1, IA4, IA9, IAA, IAC, IAW, IAY, IA3, IA6, IAB, IAD, IAX, IAZ",
app : "1E, 1EP, 1EU,1F,1T, 4E, 4EP, 4EU, 4F, 4T, 7, 8, 7F, 7T, 8F, 8T, 2, 3, 5, 6, 2F, 2T, 3F, 3T, 5F, 5T, 6F, 6T, 1, 4"
}
/*
* Expose `Transaction`.
*/
exports.transaction = {
free : "1, 2, 3, 4",
paid : "5, 6, 7, 8, 9, 54",
redownload : "1001, 1005, 1006",
update : "1002",
refund : "1003"
}
/*
* Expose `Platform`.
*/
exports.platform = {
desktop : "Windows, Macintosh, UNKNOWN",
iphone : "iPhone",
ipad : "iPad",
ipod : "iPod",
}
/*
* Expose `Measure`.
*/
exports.measure = {
proceeds : "Royalty",
units : "units"
}
/**
* Initialize a new `Connect` with the given `username`, `password` and `options`.
*
* Examples:
*
* // Import itc-report
* var itc = require("itunesconnect"),
* Report = itc.Report;
*
* // Init new iTunes Connect
* var itunes = new itc.Connect('apple@id.com', 'password');
*
* // Init new iTunes Connect
* var itunes = new itc.Connect('apple@id.com', 'password', {
* errorCallback: function(error) {
* console.log(error);
* },
* concurrentRequests: 1
* });
*
* @class Connect
* @constructor
* @param {String} username Apple ID login
* @param {String} password Apple ID password
* @param {Object} [options]
* @param {String} [options.baseURL] iTunes Connect Login URL
* @param {String} [options.apiURL] iTunes Connect API URL
* @param {Number} [options.concurrentRequests] Number of concurrent requests
* @param {Array} [options.cookies] Cookies array. If you provide cookies array it will not login and use this instead.
* @param {Function} [options.errorCallback] Error callback function called when requests are failing
* @param {Function} [options.errorCallback.error] Login error
* @param {Function} [options.loginCallback] Login callback function called when login to iTunes Connect was a success.
* @param {Function} [options.loginCallback.cookies] cookies are passed as a first argument. You can get it and cache it for later.
*/
function Connect(username, password, options) {
// Default Options
this.options = {
baseURL : "https://itunesconnect.apple.com",
apiURL : "https://reportingitc2.apple.com/api/",
concurrentRequests : 2,
errorCallback : function(e) {},
loginCallback : function(c) {}
};
// Extend options
_.extend(this.options, options);
// Set cookies
this._cookies = [];
// Task Executor
this._queue = async.queue(
this.executeRequest.bind(this),
this.options.concurrentRequests
);
// Pasue queue and wait for login to complete
this._queue.pause();
// Login to iTunes Connect
if(typeof this.options["cookies"] !== 'undefined') {
this._cookies = this.options.cookies;
this._queue.resume();
}
else {
this.login(username, password);
}
}
/**
* Request iTunes Connect report with the given `query` and `completed` callback.
*
* Examples:
*
* // Import itc-report
* var itc = require("itunesconnect"),
* Report = itc.Report;
*
* // Init new iTunes Connect
* var itunes = new itc.Connect('apple@id.com', 'password');
*
* // Request timed report from yesterday to today
* itunes.request(Report.timed().time(1, 'day'), function(error, result) {
* console.log(result);
* })
*
* @method request
* @for Connect
* @param {Query} query
* @param {Function} completed
* @param {Error} completed.error Just an error if occure
* @param {Object} completed.result Report result
* @param {Object} [completed.query] Query that was sent
* @chainable
*/
Connect.prototype.request = function(query, completed) {
// Push request to queue
this._queue.push({
query: query,
completed: completed
});
return this;
}
/**
* Fetch iTunes Connect Reporting metadata with given `completed` callback.
*
* Examples:
*
* // Import itc-report
* var itc = require("itunesconnect"),
* Report = itc.Report;
*
* // Init new iTunes Connect
* var itunes = new itc.Connect('apple@id.com', 'password');
*
* // Fetch API Metadata
* itunes.metadata(function(error, result) {
* console.log(result);
* })
*
* @method metadata
* @for Connect
* @for Connect
* @param {Function} completed
* @param {Error} completed.error Just an error if occure
* @param {Object} completed.result Metadata result
* @param {Object} [completed.query] Query that was sent
*/
Connect.prototype.metadata = function(completed) {
var query = {
body: function(){},
endpoint: "all_metadata"
}
this._queue.push({query: query, completed: completed});
}
/**
* Execute iTunes Connect report request with given `task` and `callback`.
*
* @private
* @method executeRequest
* @for Connect
* @param {Object} task
* @param {Function} callback
*/
Connect.prototype.executeRequest = function(task, callback) {
var query = task.query;
var completed = task.completed;
// Keep request body for callback
var requestBody = query.body();
// Run request
request.post({
url : this.options.apiURL + query.endpoint,
body : requestBody,
headers : {
'Content-Type': 'application/json',
'Cookie': this._cookies
}
}, function(error, response, body) {
if(response.statusCode == 401) {
error = new Error('This request requires authentication. Please check your username and password.');
body = null;
}
else {
try {
body = JSON.parse(body);
} catch (e) {
error = new Error('There was an error while parsing JSON.');
body = null;
}
}
// Call completed callback
completed(error, body, requestBody);
// Call callback to mark queue task as done
callback();
})
}
/**
* Login to iTunes Connect with given `username` and `password`.
*
* @private
* @method login
* @for Connect
* @param {String} username Apple ID login
* @param {String} password Apple ID password
*/
Connect.prototype.login = function(username, password) {
var self = this;
// Request ITC to get fresh post action
request.get(this.options.baseURL, function(error, response, body) {
// Handle Errors
// Search for action attribute
var html = cheerio.load(body);
var action = html('form').attr('action');
// Login to ITC
request.post({
url : self.options.baseURL + action,
form: {
'theAccountName' : username,
'theAccountPW' : password,
'theAuxValue' : ""
}
}, function(error, response, body) {
var cookies = response.headers['set-cookie'];
// Handle Errors
if(error || !cookies.length) {
error = error || new Error('There was a problem with recieving cookies. Please check your username and password.');
this.options.errorCallback( error );
}
else {
// Set _cookies and run callback
self._cookies = cookies;
self.options.loginCallback(cookies);
// Start requests queue
self._queue.resume();
}
});
});
}
/**
* Initialize a new `Query` with the given `type` and `config`.
*
* Examples:
*
* // Import itc-report
* var itc = require("itunesconnect"),
* Report = itc.Report;
*
* // Init new iTunes Connect
* var itunes = new itc.Connect('apple@id.com', 'password');
*
* // Timed type query
* var query = Report('timed');
*
* // Ranked type query with config object
* var query = Report('ranked', { limit: 100 });
*
* // Advanced Example
* var advancedQuery = Report('timed', {
* start : '2014-04-08',
* end : '2014-04-25',
* limit : 100,
* filters : {
* content: [{AppID}, {AppID}, {AppID}],
* location: [{LocationID}, {LocationID}],
* transaction: itc.transaction.free,
* type: [
* itc.type.inapp,
* itc.type.app
* ],
* category: {CategoryID}
* },
* group: 'content'
* });
*
* @class Report
* @constructor
* @param {String} <type>
* @param {Object} [config]
* @param {String|Date} [config.start] Date or if String must be in format YYYY-MM-DD
* @param {Object} [config.end] Date or if String must be in format YYYY-MM-DD
* @param {String} [config.interval] One of the following:
* @param {String} config.interval.day
* @param {String} config.interval.week
* @param {String} config.interval.month
* @param {String} config.interval.quarter
* @param {String} config.interval.year
* @param {Object} [config.filters] Possible keys:
* @param {Number|Array} [config.filters.content]
* @param {String|Array} [config.filters.type]
* @param {String|Array} [config.filters.transaction]
* @param {Number|Array} [config.filters.category]
* @param {String|Array} [config.filters.platform]
* @param {Number|Array} [config.filters.location]
* @param {String} [config.group] One of following:
* @param {String} config.group.content
* @param {String} config.group.type
* @param {String} config.group.transaction
* @param {String} config.group.category
* @param {String} config.group.platform
* @param {String} config.group.location
* @param {Object} [config.measures]
* @param {Number} [config.limit]
* @return {Query}
*/
function Report(type, config) {
var fn = Query.prototype[type];
if(typeof fn !== 'function') {
throw new Error('Unknown Report type: ' + type);
}
return new Query(config)[type]();
}
/**
* Initialize a new `Query` with the ranked type and given `config`.
*
* Examples:
*
* // Import itc-report
* var itc = require("itunesconnect"),
* Report = itc.Report;
*
* // Init new iTunes Connect
* var itunes = new itc.Connect('apple@id.com', 'password');
*
* // Ranked type query
* var query = Report.ranked();
*
* // Another query
* var otherQuery = Report.ranked({
* limit: 10
* });
*
* @method ranked
* @for Report
* @param {Object} [config]
* @chainable
* @return {Query}
*/
Report.ranked = function(config) {
return new Query(config).ranked();
}
/**
* Initialize a new `Query` with the timed type and given `config`.
*
* Examples:
*
* // Import itc-report
* var itc = require("itunesconnect"),
* Report = itc.Report;
*
* // Init new iTunes Connect
* var itunes = new itc.Connect('apple@id.com', 'password');
*
* // Timed type query
* var query = Report.timed();
*
* // Another query
* var otherQuery = Report.timed({
* limit: 10
* });
*
* @method timed
* @for Report
* @param {Object} [config]
* @chainable
* @return {Query}
*/
Report.timed = function(config) {
return new Query(config).timed();
}
/**
* Initialize a new `Query` with the given `query`.
*
* Constants to use with Query
*
* // Import itc-report
* var itc = require("itunesconnect"),
*
* // Types
* itc.type.inapp
* itc.type.app
*
* // Transactions
* itc.transaction.free
* itc.transaction.paid
* itc.transaction.redownload
* itc.transaction.update
* itc.transaction.refund
*
* // Platforms
* itc.platform.desktop
* itc.platform.iphone
* itc.platform.ipad
* itc.platform.ipod
*
* // Measures
* itc.measure.proceeds
* itc.measure.units
*
* @class Query
* @constructor
* @private
* @param {Object} config
* @chainable
* @return {Query}
*/
function Query(config) {
this.type = null;
this.endpoint = null;
this.config = {
start : moment(),
end : moment(),
filters : {},
measures : ['units'],
limit : 100
};
// Extend options with user stuff
_.extend(this.config, config);
// Private Options
this._time = null;
this._body = {};
}
/**
* Builds and returns body for Connect request as JSON string
*
* @method body
* @for Query
* @private
* @return {String} JSON String
*/
Query.prototype.body = function() {
// Ensure date is moment object
this.config.start = TransformValue.toMomentObject(this.config.start);
this.config.end = TransformValue.toMomentObject(this.config.end);
// If start and end date are same and time() was used in query calculate new start date
if (this.config.end.diff(this.config.start, 'days') === 0 && _.isArray(this._time)) {
this.config.start = this.config.start.subtract(this._time[0], this._time[1]);
}
else if (this.config.end.diff(this.config.start, 'days') < 0) {
this.config.start = this.config.end;
}
// Building body
this._body = {
"start_date" : this.config.start.format("YYYY-MM-DD[T00:00:00.000Z]"),
"end_date" : this.config.end.format("YYYY-MM-DD[T00:00:00.000Z]"),
"interval" : this.config.interval,
"filters" : TransformValue.toBodyFilters(this.config.filters),
"group" : TransformValue.toAppleKey(this.config.group),
"measures" : this.config.measures,
"limit" : this.config.limit
};
return JSON.stringify(this._body);
}
/**
* Initialize a new `Query` with the timed type.
*
* @private
* @method timed
* @for Query
* @chainable
*/
Query.prototype.timed = function() {
this.type = 'timed';
this.endpoint = 'data/timeseries';
// Defaults for ranked type
this.config.group = this.config.group || null;
this.config.interval = this.config.interval || 'day';
return this;
}
/**
* Initialize a new `Query` with the ranked type.
*
* @private
* @method ranked
* @for Query
* @chainable
*/
Query.prototype.ranked = function() {
this.type = 'ranked';
this.endpoint = 'data/ranked';
// Defaults for ranked type
this.config.group = this.config.group || 'content';
this.config.interval = this.config.interval || 'day';
return this;
}
/**
* Sets interval property for `Query`
*
* @method interval
* @for Query
* @param {String} value One of the following:
* @param {String} value.day
* @param {String} value.week
* @param {String} value.month
* @param {String} value.quarter
* @param {String} value.year
* @chainable
*/
Query.prototype.interval = function(value) {
this.config.interval = value;
return this;
}
/**
* Sets start and end property for `Query`
*
* Examples:
*
* // Start and end date set manualy
* query.date('2014-04-08', new Date());
*
* // Start and end date will be todays date
* query.date(new Date());
*
* // Start and end date will be set as 8th of April 2014
* query.date('2014-04-08');
*
* @method date
* @for Query
* @param {String|Date} <start> If end is undefined it will be set same as start. If String, must be in format YYYY-MM-DD
* @param {String|Date} [end] If String, must be in format YYYY-MM-DD
* @chainable
*/
Query.prototype.date = function(start, end) {
this.config.start = TransformValue.toMomentObject( start );
this.config.end = TransformValue.toMomentObject(
((typeof end == 'undefined') ? start : end)
);
return this;
}
/**
* Sets start property for `Query` in more easy/generic way
*
* Examples:
*
* query.time(1, 'week');
* query.time(20, 'days');
*
* @method time
* @for Query
* @param {Number} <value>
* @param {String} <unit> day, week, month, etc...
* @chainable
*/
Query.prototype.time = function(value, unit) {
this._time = [value, unit];
return this;
}
/**
* Sets `group by` property for `Query`
*
* @method group
* @for Query
* @param {String} value One of following:
* @param {String} value.content
* @param {String} value.type
* @param {String} value.transaction
* @param {String} value.category
* @param {String} value.platform
* @param {String} value.location
* @chainable
*/
Query.prototype.group = function(value) {
this.config.group = value;
return this;
}
/**
* Sets measures property for `Query`
*
* @method measures
* @for Query
* @param {String|Array} <value>
* @chainable
*/
Query.prototype.measures = function(value) {
this.config.measures = value;
return this;
}
/**
* Sets limit property for `Query`
*
* @method limit
* @for Query
* @param {Number} <value> Not sure if Apple is using limit in ranked type query
* @chainable
*/
Query.prototype.limit = function(value) {
this.config.limit = value;
return this;
}
/**
* Sets content filter for `Query`
*
* @method content
* @for Query
* @param {Number|Array} <value> AppStore ID
* @chainable
*/
Query.prototype.content = function(value) {
if(typeof this.config.filters["content"] === "undefined")
this.config.filters.content = [];
if(!_.isArray(this.config.filters.content))
this.config.filters.content = [this.config.filters.content];
if(_.isArray(value))
this.config.filters.content = this.config.filters.content.concat(value);
else
this.config.filters.content.push(value);
return this;
}
/**
* Sets category filter for `Query`
*
* Examples:
*
* // Import itc-report
* var itc = require("itunesconnect"),
* Report = itc.Report;
*
* // Query
* var query = Report.timed({
* limit: 10
* }).category(6001);
*
* // Another Query
* var otherQuery = Report.timed({
* limit: 10
* });
*
* //
* otherQuery.category([6001, 6002, 6003]);
* otherQuery.category([6004, 6005, 6006]).category(6007);
*
* @method category
* @for Query
* @param {Number|Array} <value> Visit https://github.com/stoprocent/itc-report/wiki/Cheet-Sheet#categories for available options
* @chainable
*/
Query.prototype.category = function(value) {
if(typeof this.config.filters["category"] === "undefined")
this.config.filters.category = [];
if(!_.isArray(this.config.filters.category))
this.config.filters.category = [this.config.filters.category];
if(_.isArray(value))
this.config.filters.category = this.config.filters.category.concat(value);
else
this.config.filters.category.push(value);
return this;
}
/**
* Sets location filter for `Query`
*
* @method location
* @for Query
* @param {Number|Array} <value> Visit https://github.com/stoprocent/itc-report/wiki/Cheet-Sheet#countries for available options
* @chainable
*/
Query.prototype.location = function(value) {
if(typeof this.config.filters["location"] === "undefined")
this.config.filters.location = [];
if(!_.isArray(this.config.filters.location))
this.config.filters.location = [this.config.filters.location];
if(_.isArray(value))
this.config.filters.location = this.config.filters.location.concat(value);
else
this.config.filters.location.push(value);
return this;
}
/**
* Sets platform filter for `Query`
*
* @method platform
* @for Query
* @param {String|Array} <value> (Look in Constants under Platforms)
* @chainable
*/
Query.prototype.platform = function(value) {
if(typeof this.config.filters["platform"] === "undefined")
this.config.filters.platform = [];
if(!_.isArray(this.config.filters.platform))
this.config.filters.platform = [this.config.filters.platform];
if(_.isArray(value))
this.config.filters.platform = this.config.filters.platform.concat(value);
else
this.config.filters.platform.push(value);
return this;
}
/**
* Sets type filter for `Query`
*
* @method type
* @for Query
* @param {String|Array} <value> (Look in Constants under Types)
* @chainable
*/
Query.prototype.type = function(value) {
if(typeof this.config.filters["type"] === "undefined")
this.config.filters.type = [];
if(!_.isArray(this.config.filters.type))
this.config.filters.type = [this.config.filters.type];
if(_.isArray(value))
this.config.filters.type = this.config.filters.type.concat(value);
else
this.config.filters.type.push(value);
return this;
}
/**
* Sets transaction filter for `Query`
*
* @method transaction
* @for Query
* @param {String|Array} <value> (Look in Constants under Transactions)
* @chainable
*/
Query.prototype.transaction = function(value) {
if(typeof this.config.filters["transaction"] === "undefined")
this.config.filters.transaction = [];
if(!_.isArray(this.config.filters.transaction))
this.config.filters.transaction = [this.config.filters.transaction];
if(_.isArray(value))
this.config.filters.transaction = this.config.filters.transaction.concat(value);
else
this.config.filters.transaction.push(value);
return this;
}
/*
* Transform Value Object
*
* @private
*/
var TransformValue = {};
/*
* Translates simple filters object to apple api format
*
* @private
* @function toBodyFilters
* @for TransformValue
* @param {Object} filters
* @return {Object}
*/
TransformValue.toBodyFilters = function(filters) {
var result = [];
_.each(filters, function(value, dimension) {
if(!_.isArray(value))
value = [value];
result.push({
dimension_key : TransformValue.toAppleKey(dimension),
option_keys : value
});
});
return result;
}
/*
* Translates key to apple key
*
* @private
* @function toAppleKey
* @for TransformValue
* @param {String} key
* @return {String}
*/
TransformValue.toAppleKey = function(key) {
if(key === null)
return null;
var keys = {
content : "content",
type : "content_type",
transaction : "transaction_type",
category : "Category",
platform : "platform",
location : "piano_location"
};
if(typeof keys[key] === 'undefined')
throw new Error('Unknown Apple Key for key: ' + key);
return keys[key];
}
/*
* Translates given date to moment object
*
* @private
* @function toMomentObject
* @for TransformValue
* @param {Mixed} date
* @return {Moment}
*/
TransformValue.toMomentObject = function(date) {
// Quick check if moment
if(moment.isMoment(date)) {
return date;
}
else if(date instanceof Date) {
return moment(date);
}
else if(_.isString(date) && !!(date.match(new RegExp(/([0-9]{4})-([0-9]{2})-([0-9]{2})/)))) {
return moment(date, "YYYY-MM-DD");
}
else {
throw new Error('Unknown date format. Please use Date() object or String() with format YYYY-MM-DD.');
}
}