itunes-connect 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Alex Vollmer
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,86 @@
1
+ = itunes_connect
2
+
3
+ This gem provides a very simple command-line utility and backing
4
+ "library" (if I can be so bold to use the term in this context) for
5
+ accessing sales reports from Apple's iTunes Connect website. If you
6
+ want to automate getting reports out of the App Store, this tool is
7
+ for you.
8
+
9
+ == Usage
10
+
11
+ === Command-Line Usage
12
+
13
+ This gem comes with the <tt>itunes_connect</tt> executable which you can use
14
+ to download reports, import into a sqlite database and report
15
+ from.
16
+
17
+ You can specify the default values for a handful of command-line
18
+ options by putting them in a file named <tt>.itunesrc</tt> in your
19
+ home directory. The file is in YAML format and should have the
20
+ following keys:
21
+
22
+ * username
23
+ * password
24
+ * database (path to sqlite3 file, optional)
25
+
26
+ ==== Downloading Reports
27
+ You can download reports from iTunes Connect using <tt>itunes_connect
28
+ download</tt>. You may specify your iTunes Connect credentials on
29
+ the command line _or_ you can put them in YAML format in
30
+ <tt>~/.itunesrc</tt> with the keys of <tt>:username</tt> and
31
+ <tt>:password</tt>.
32
+
33
+ You can also dump the report to a file (or standard out):
34
+
35
+ itunes_connect download -o /tmp/report.txt
36
+
37
+ Or you can dump it directly into a sqlite3 database:
38
+
39
+ itunes_connect download -b /tmp/report.db
40
+
41
+ By default the <tt>download</tt> command will retrieve the most recent
42
+ daily report. If you have a <tt>database</tt> key in your
43
+ <tt>~/.itunesrc</tt> file and you _don't_ specify an out file, the
44
+ report will be automatically imported into the database.
45
+
46
+ You can also ask for weekly or monthly reports by using the
47
+ <tt>-r</tt> command-line option. Note that you can _not_ import a
48
+ montly report directly into the database because the monthly reports
49
+ don't have any days associated with the entries.
50
+
51
+ Run <tt>itunes_connect help download</tt> for full usage details.
52
+
53
+ ==== Importing Reports
54
+ The <tt>import</tt> command allows you to dump an existing report file
55
+ into the database. This is useful if you've already downloaded a
56
+ number of reports from iTunes Connect and you just want to put them
57
+ into the database.
58
+
59
+ Run <tt>itunes_connect help import</tt> for full usage details.
60
+
61
+ ==== Reporting
62
+ The <tt>report</tt> command queries your database and can produce
63
+ either detailed, or grouped output. In both cases you can constrain
64
+ the query to any combination of country, start date and end date.
65
+
66
+ Run <tt>itunes_connect help report</tt> for full usage details.
67
+
68
+ === Programmatic Usage
69
+
70
+ See the documentation for the ItunesConnect::Connection,
71
+ ItunesConnect::Report and ItunesConnect::Store classes for details.
72
+
73
+ == Note on Patches/Pull Requests
74
+
75
+ * Fork the project.
76
+ * Make your feature addition or bug fix.
77
+ * Add tests for it. This is important so I don't break it in a
78
+ future version unintentionally.
79
+ * Commit, do not mess with rakefile, version, or history.
80
+ (if you want to have your own version, that is fine but
81
+ bump version in a commit by itself I can ignore when I pull)
82
+ * Send me a pull request. Bonus points for topic branches.
83
+
84
+ == Copyright
85
+
86
+ Copyright (c) 2009 Alex Vollmer. See LICENSE for details.
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "itunes_connect"
4
+ require "clip"
5
+
6
+ ItunesConnect::Commands.usage("No command given") if ARGV.empty?
7
+
8
+ case command_name = ARGV.shift
9
+ when '--help', '-h'
10
+ cli = Clip::Parser.new
11
+ ItunesConnect::Commands::Help.new(cli).execute!(cli)
12
+ else
13
+ cli = ItunesConnect::Commands.default_clip
14
+ command = ItunesConnect::Commands.for_name(command_name, cli)
15
+ ItunesConnect::Commands.usage("Unrecognized command '#{command_name}'") if command.nil?
16
+ begin
17
+ cli.parse(ARGV)
18
+ if cli.valid?
19
+ command.execute!(cli, cli.remainder)
20
+ else
21
+ $stderr.puts(cli)
22
+ end
23
+ rescue => e
24
+ $stderr.puts(e.message)
25
+ $stderr.puts e.backtrace.join("\n") if cli.verbose?
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ require "itunes_connect/connection"
2
+ require "itunes_connect/report"
3
+ require "itunes_connect/store"
4
+ require "itunes_connect/commands"
5
+
@@ -0,0 +1,41 @@
1
+ require "itunes_connect/commands/download"
2
+ require "itunes_connect/commands/import"
3
+ require "itunes_connect/commands/report"
4
+ require "itunes_connect/commands/help"
5
+ require "clip"
6
+
7
+ module ItunesConnect::Commands # :nodoc:
8
+ class << self
9
+ def for_name(name, clip)
10
+ self.const_get(name.capitalize.to_sym).new(clip)
11
+ rescue NameError => e
12
+ nil
13
+ end
14
+
15
+ def all
16
+ [Download, Import, Report, Help]
17
+ end
18
+
19
+ def usage(msg)
20
+ $stderr.puts msg if msg
21
+ $stderr.puts "USAGE: itunes_connect [command] [options]"
22
+ ItunesConnect::Commands.all.each do |cmd_cls|
23
+ cli = Clip do |c|
24
+ c.banner = "'#{cmd_cls.to_s.split('::').last.downcase}' command options:"
25
+
26
+ cmd_cls.new(c)
27
+ end
28
+ puts(cli.help)
29
+ puts
30
+ end
31
+ exit 1
32
+ end
33
+
34
+ def default_clip
35
+ cli = Clip::Parser.new
36
+ cli.flag('v', 'verbose', :desc => 'Make output more verbose')
37
+ cli.flag('g', 'debug', :desc => 'Enable debug output/features (dev only)')
38
+ cli
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,76 @@
1
+ require "itunes_connect/rc_file"
2
+ require "itunes_connect/report"
3
+
4
+ module ItunesConnect::Commands
5
+ class Download # :nodoc:
6
+ def initialize(c, rcfile=ItunesConnect::RcFile.default)
7
+ c.opt('u', 'username', :desc => 'iTunes Connect username')
8
+ c.opt('p', 'password', :desc => 'iTunes Connect password')
9
+ c.opt('d', 'date', :desc => 'Daily report date (MM/DD/YYYY format)',
10
+ :default => (Date.today - 1).strftime('%m/%d/%Y'))
11
+ c.opt('o', 'out', :desc => 'Dump report to file, - is stdout')
12
+ c.opt('b', 'db', :desc => 'Dump report to sqlite DB at the given path')
13
+ c.opt('r', 'report',
14
+ :desc => 'Report type. One of "Daily", "Weekly", "Monthly"',
15
+ :default => 'Daily') do |r|
16
+ r.capitalize
17
+ end
18
+ @rcfile = rcfile
19
+ end
20
+
21
+ def execute!(opts, args=[])
22
+ username, password = if opts.username and opts.password
23
+ [opts.username, opts.password]
24
+ else
25
+ [@rcfile.username, @rcfile.password]
26
+ end
27
+
28
+ raise ArgumentError.new("Please provide a username") unless username
29
+ raise ArgumentError.new("Please provide a password") unless password
30
+
31
+ if opts.db and opts.out
32
+ raise ArgumentError.new("You can only specify :out or :db, not both")
33
+ end
34
+
35
+ if opts.report =~ /^Monthly/ and opts.db
36
+ raise ArgumentError.new("You cannot persist monthly reports to a " +
37
+ "database because these reports have no dates " +
38
+ "associated with them")
39
+ end
40
+
41
+ connection = ItunesConnect::Connection.new(username,
42
+ password,
43
+ opts.verbose?,
44
+ opts.debug?)
45
+ db = opts.db || @rcfile.database
46
+ out = if opts.out.nil?
47
+ db ? StringIO.new : $stdout
48
+ else
49
+ opts.out == "-" ? $stdout : File.open(opts.out, "w")
50
+ end
51
+ connection.get_report(opts.date || Date.today - 1, out, opts.report)
52
+
53
+ if db and StringIO === out
54
+ $stdout.puts "Importing into database file: #{db}" if opts.verbose?
55
+ store = ItunesConnect::Store.new(db, opts.verbose?)
56
+ out.rewind
57
+ report = ItunesConnect::Report.new(out)
58
+ count = 0
59
+ report.each do |entry|
60
+ count += 1 if store.add(entry.date,
61
+ entry.country,
62
+ entry.install_count,
63
+ entry.upgrade_count)
64
+ end
65
+ $stdout.puts "Inserted #{count} rows into #{opts.db}" if opts.verbose?
66
+ end
67
+
68
+ out.flush
69
+ out.close unless out == $stdout
70
+ end
71
+
72
+ def description
73
+ "Retrieves reports from the iTunes Connect site"
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,31 @@
1
+ require "itunes_connect/commands"
2
+
3
+ module ItunesConnect::Commands
4
+ class Help # :nodoc:
5
+ def initialize(c)
6
+ # nothing to do here
7
+ end
8
+
9
+ def execute!(opts={ }, args=[], out=$stdout)
10
+ if args.empty?
11
+ out.puts "Available commands:"
12
+ out.puts
13
+ ItunesConnect::Commands.all.each do |cmd|
14
+ out.printf("%-9s %s\n",
15
+ cmd.to_s.split('::').last.downcase,
16
+ cmd.new(Clip::Parser.new).description)
17
+ end
18
+ else
19
+ cli = ItunesConnect::Commands.default_clip
20
+ cmd = ItunesConnect::Commands.for_name(args.first, cli)
21
+ cli.banner = "Command options for '#{cmd.class.to_s.split('::').last.downcase}':"
22
+ raise ArgumentError.new("Unrecognized command '#{args.first}'") if cmd.nil?
23
+ out.puts(cli.help)
24
+ end
25
+ end
26
+
27
+ def description
28
+ "Describe a particular command"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,35 @@
1
+ require "itunes_connect/rc_file"
2
+ require "itunes_connect/report"
3
+
4
+ module ItunesConnect::Commands
5
+ class Import # :nodoc:
6
+ def initialize(c, rcfile=ItunesConnect::RcFile.default)
7
+ c.opt('b', 'db', :desc => 'Dump report to sqlite DB at the given path')
8
+ c.req('f', 'file', :desc => 'The file to import, - means standard in')
9
+ @rcfile = rcfile
10
+ end
11
+
12
+ def execute!(opts, args=[])
13
+ db = opts.db || @rcfile.database || nil
14
+ raise ArgumentError.new("Missing :db option") unless db
15
+ raise ArgumentError.new("Missing :file option") if opts.file.nil?
16
+ store = ItunesConnect::Store.new(db, opts.verbose?)
17
+ input = opts.file == '-' ? $stdin : open(opts.file, 'r')
18
+ count = 0
19
+ ItunesConnect::Report.new(input).each do |entry|
20
+ count += 1 if store.add(entry.date,
21
+ entry.country,
22
+ entry.install_count,
23
+ entry.upgrade_count)
24
+ end
25
+
26
+ if opts.verbose?
27
+ $stdout.puts "Added #{count} rows to the database"
28
+ end
29
+ end
30
+
31
+ def description
32
+ "Imports report data into a database file"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,78 @@
1
+ require "itunes_connect/rc_file"
2
+ require "itunes_connect/store"
3
+
4
+ module ItunesConnect::Commands
5
+ class Report # :nodoc:
6
+ def initialize(c, rcfile=ItunesConnect::RcFile.default)
7
+ c.opt('b', 'db', :desc => 'Dump report to sqlite DB at the given path')
8
+ c.opt('c', 'country',
9
+ :desc => 'A two-letter country code to filter results with')
10
+ c.opt('f', 'from', :desc => 'The starting date, inclusive') do |f|
11
+ Date.parse(f)
12
+ end
13
+ c.opt('t', 'to', :desc => 'The ending date, inclusive') do |t|
14
+ Date.parse(t)
15
+ end
16
+ c.flag('s', 'summarize', :desc => 'Summarize results by country code')
17
+ c.flag('n', 'no-header', :desc => 'Suppress the column headers on output')
18
+ c.opt('d', 'delimiter',
19
+ :desc => 'The delimiter to use for output (normally TAB)',
20
+ :default => "\t")
21
+ c.flag('o', 'total', :desc => 'Add totals at the end of the report')
22
+ @rcfile = rcfile
23
+ end
24
+
25
+ def execute!(opts, args=[], out=$stdout)
26
+ db = opts.db || @rcfile.database || nil
27
+ raise ArgumentError.new("Missing :db option") if db.nil?
28
+ store = ItunesConnect::Store.new(db)
29
+ params = {
30
+ :to => opts.to,
31
+ :from => opts.from,
32
+ :country => opts.country
33
+ }
34
+
35
+ total_installs, total_upgrades = 0, 0
36
+
37
+ unless opts.no_header?
38
+ out.puts([opts.summarize? ? nil : "Date",
39
+ "Country",
40
+ "Installs",
41
+ "Upgrades"
42
+ ].compact.join(opts.delimiter))
43
+ end
44
+
45
+ if opts.summarize?
46
+ store.country_counts(params).each do |x|
47
+ out.puts [x.country,
48
+ x.install_count,
49
+ x.update_count].join(opts.delimiter)
50
+ total_installs += x.install_count
51
+ total_upgrades += x.update_count
52
+ end
53
+ else
54
+ store.counts(params).each do |x|
55
+ out.puts [x.report_date,
56
+ x.country,
57
+ x.install_count,
58
+ x.update_count].join(opts.delimiter)
59
+ total_installs += x.install_count
60
+ total_upgrades += x.update_count
61
+ end
62
+ end
63
+
64
+ if opts.total?
65
+ out.puts ["Total",
66
+ opts.summarize? ? nil : "-",
67
+ total_installs,
68
+ total_upgrades
69
+ ].compact.join(opts.delimiter)
70
+ end
71
+ out.flush
72
+ end
73
+
74
+ def description
75
+ "Generates reports from a local database"
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,162 @@
1
+ require "digest/md5"
2
+ require "tempfile"
3
+ require "yaml"
4
+ require "zlib"
5
+ require "rubygems"
6
+ require "httpclient"
7
+ require "nokogiri"
8
+
9
+ module ItunesConnect
10
+
11
+ # Abstracts the iTunes Connect website.
12
+ # Implementation inspired by
13
+ # http://code.google.com/p/itunes-connect-scraper/
14
+ class Connection
15
+
16
+ REPORT_PERIODS = ["Monthly Free", "Weekly", "Daily"]
17
+
18
+ BASE_URL = 'https://itts.apple.com' # :nodoc:
19
+ REFERER_URL = 'https://itts.apple.com/cgi-bin/WebObjects/Piano.woa' # :nodoc:
20
+
21
+ # Create a new instance with the username and password used to sign
22
+ # in to the iTunes Connect website
23
+ def initialize(username, password, verbose=false, debug=false)
24
+ @username, @password = username, password
25
+ @verbose = verbose
26
+ @debug = debug
27
+ end
28
+
29
+ def verbose? # :nodoc:
30
+ !!@verbose
31
+ end
32
+
33
+ def debug? # :nodoc:
34
+ !!@debug
35
+ end
36
+
37
+ # Retrieve a report from iTunes Connect. This method will return the
38
+ # raw report file as a String. If specified, the <tt>date</tt>
39
+ # parameter should be a <tt>Date</tt> instance, and the
40
+ # <tt>period</tt> parameter must be one of the values identified
41
+ # in the <tt>REPORT_PERIODS</tt> array, or this method will raise
42
+ # and <tt>ArgumentError</tt>.
43
+ #
44
+ # Any dates given that equal the current date or newer will cause
45
+ # this method to raise an <tt>ArgumentError</tt>.
46
+ #
47
+ def get_report(date, out, period='Daily')
48
+ date = Date.parse(date) if String === date
49
+ if date >= Date.today
50
+ raise ArgumentError, "You must specify a date before today"
51
+ end
52
+
53
+ period = 'Monthly Free' if period == 'Monthly'
54
+ unless REPORT_PERIODS.member?(period)
55
+ raise ArgumentError, "'period' must be one of #{REPORT_PERIODS.join(', ')}"
56
+ end
57
+
58
+ # grab the home page
59
+ doc = Nokogiri::HTML(get_content(REFERER_URL))
60
+ login_path = (doc/"form/@action").to_s
61
+
62
+ # login
63
+ doc = Nokogiri::HTML(get_content(login_path, {
64
+ 'theAccountName' => @username,
65
+ 'theAccountPW' => @password,
66
+ '1.Continue.x' => '36',
67
+ '1.Continue.y' => '17',
68
+ 'theAuxValue' => ''
69
+ }))
70
+
71
+ report_url = (doc / "//*[@name='frmVendorPage']/@action").to_s
72
+ report_type_name = (doc / "//*[@id='selReportType']/@name").to_s
73
+ date_type_name = (doc / "//*[@id='selDateType']/@name").to_s
74
+
75
+ # handle first report form
76
+ doc = Nokogiri::HTML(get_content(report_url, {
77
+ report_type_name => 'Summary',
78
+ date_type_name => period,
79
+ 'hiddenDayOrWeekSelection' => period,
80
+ 'hiddenSubmitTypeName' => 'ShowDropDown'
81
+ }))
82
+ report_url = (doc / "//*[@name='frmVendorPage']/@action").to_s
83
+ report_type_name = (doc / "//*[@id='selReportType']/@name").to_s
84
+ date_type_name = (doc / "//*[@id='selDateType']/@name").to_s
85
+ date_name = (doc / "//*[@id='dayorweekdropdown']/@name").to_s
86
+
87
+ # now get the report
88
+ date_str = case period
89
+ when 'Daily'
90
+ date.strftime("%m/%d/%Y")
91
+ when 'Weekly', 'Monthly Free'
92
+ date = (doc / "//*[@id='dayorweekdropdown']/option").find do |d|
93
+ d1, d2 = d.text.split(' To ').map { |x| Date.parse(x) }
94
+ date >= d1 and date <= d2
95
+ end[:value] rescue nil
96
+ end
97
+
98
+ raise ArgumentError, "No reports are available for that date" unless date_str
99
+
100
+ report = get_content(report_url, {
101
+ report_type_name => 'Summary',
102
+ date_type_name => period,
103
+ date_name => date_str,
104
+ 'download' => 'Download',
105
+ 'hiddenDayOrWeekSelection' => date_str,
106
+ 'hiddenSubmitTypeName' => 'Download'
107
+ })
108
+
109
+ begin
110
+ gunzip = Zlib::GzipReader.new(StringIO.new(report))
111
+ out << gunzip.read
112
+ rescue => e
113
+ doc = Nokogiri::HTML(report)
114
+ msg = (doc / "//font[@id='iddownloadmsg']").text.strip
115
+ $stderr.puts "Unable to download the report, reason:"
116
+ $stderr.puts msg.strip
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ def client
123
+ @client ||= client = HTTPClient.new
124
+ end
125
+
126
+ def get_content(uri, query=nil, headers={ })
127
+ $stdout.puts "Querying #{uri} with #{query.inspect}" if self.debug?
128
+ if @referer
129
+ headers = {
130
+ 'Referer' => @referer,
131
+ 'User-Agent' => 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_8; en-us) AppleWebKit/531.9 (KHTML, like Gecko) Version/4.0.3 Safari/531.9'
132
+ }.merge(headers)
133
+ end
134
+ url = case uri
135
+ when /^https?:\/\//
136
+ uri
137
+ else
138
+ BASE_URL + uri
139
+ end
140
+
141
+ response = client.get(url, query, headers)
142
+
143
+ if self.debug?
144
+ md5 = Digest::MD5.new; md5 << url; md5 << Time.now.to_s
145
+ path = File.join(Dir.tmpdir, md5.to_s + ".html")
146
+ out = open(path, "w") do |f|
147
+ f << "Status: #{response.status}\n"
148
+ f << response.header.all.map do |name, value|
149
+ "#{name}: #{value}"
150
+ end.join("\n")
151
+ f << "\n\n"
152
+ f << response.body.dump
153
+ end
154
+ puts "#{url} -> #{path}"
155
+ end
156
+
157
+ @referer = url
158
+ response.body.dump
159
+ end
160
+
161
+ end
162
+ end