itunes-connect 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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