itunes-connect 0.9.0 → 0.10.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.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- require "itunes_connect"
3
+ $:.unshift File.join(File.dirname(__FILE__), '..', 'lib')
4
+ require 'itunes_connect'
4
5
  require "clip"
5
6
 
6
7
  ItunesConnect::Commands.usage("No command given") if ARGV.empty?
@@ -3,20 +3,50 @@ require "tempfile"
3
3
  require "yaml"
4
4
  require "zlib"
5
5
  require "rubygems"
6
- require "httpclient"
7
- require "nokogiri"
6
+ gem 'mechanize'
7
+ require "mechanize"
8
+
9
+ # mechanize monkey patch
10
+ # handle Content-Encoding of 'agzip'
11
+ begin
12
+ require 'mechanize/chain/body_decoding_handler'
13
+
14
+ class Mechanize
15
+ class Chain
16
+ class BodyDecodingHandler
17
+ alias :orig_handle :handle
18
+
19
+ def handle(ctx, options = {})
20
+ response = options[:response]
21
+ encoding = response['Content-Encoding'] || ''
22
+ response['Content-Encoding'] = 'gzip' if encoding.downcase == 'agzip'
23
+ orig_handle(ctx, options)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
8
29
 
9
30
  module ItunesConnect
10
31
 
32
+ NETWORK_TIMEOUT = 60 # seconds
33
+
11
34
  # Abstracts the iTunes Connect website.
12
35
  # Implementation inspired by
13
36
  # http://code.google.com/p/itunes-connect-scraper/
14
37
  class Connection
15
38
 
16
- REPORT_PERIODS = ["Monthly Free", "Weekly", "Daily"]
39
+ REPORT_PERIODS = ["Weekly", "Daily"]
40
+
41
+ BASE_URL = 'https://itunesconnect.apple.com' # login base
42
+ REPORT_URL = 'https://reportingitc.apple.com/sales.faces'
43
+ LOGIN_URL = 'https://itunesconnect.apple.com/WebObjects/iTunesConnect.woa'
17
44
 
18
- BASE_URL = 'https://itts.apple.com' # :nodoc:
19
- REFERER_URL = 'https://itts.apple.com/cgi-bin/WebObjects/Piano.woa' # :nodoc:
45
+ # select ids:
46
+ # theForm:datePickerSourceSelectElementSales (daily)
47
+ # theForm:weekPickerSourceSelectElement (weekly)
48
+ ID_SELECT_DAILY = "theForm:datePickerSourceSelectElementSales"
49
+ ID_SELECT_WEEKLY = "theForm:weekPickerSourceSelectElement"
20
50
 
21
51
  # Create a new instance with the username and password used to sign
22
52
  # in to the iTunes Connect website
@@ -24,6 +54,7 @@ module ItunesConnect
24
54
  @username, @password = username, password
25
55
  @verbose = verbose
26
56
  @debug = debug
57
+ @current_period = "Daily" # default period in reportingitc interface
27
58
  end
28
59
 
29
60
  def verbose? # :nodoc:
@@ -34,6 +65,10 @@ module ItunesConnect
34
65
  !!@debug
35
66
  end
36
67
 
68
+ def logged_in? # :nodoc:
69
+ !!@logged_in
70
+ end
71
+
37
72
  # Retrieve a report from iTunes Connect. This method will return the
38
73
  # raw report file as a String. If specified, the <tt>date</tt>
39
74
  # parameter should be a <tt>Date</tt> instance, and the
@@ -44,118 +79,212 @@ module ItunesConnect
44
79
  # Any dates given that equal the current date or newer will cause
45
80
  # this method to raise an <tt>ArgumentError</tt>.
46
81
  #
47
- def get_report(date, out, period='Daily')
82
+ def get_report(date, out, period = 'Daily')
48
83
  date = Date.parse(date) if String === date
49
84
  if date >= Date.today
50
85
  raise ArgumentError, "You must specify a date before today"
51
86
  end
52
87
 
53
- period = 'Monthly Free' if period == 'Monthly'
54
88
  unless REPORT_PERIODS.member?(period)
55
89
  raise ArgumentError, "'period' must be one of #{REPORT_PERIODS.join(', ')}"
56
90
  end
57
91
 
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
92
+ login unless self.logged_in?
93
+
94
+ # fetch report
95
+ # (cache report page)
96
+ fetch_report_page unless @report_page
97
+
98
+ # requested download date
99
+ date_str = date.strftime("%m/%d/%Y")
100
+ debug_msg("download date: #{date_str}")
101
+
102
+ # determine available download options
103
+ @select_name = period == 'Daily' ? ID_SELECT_DAILY : ID_SELECT_WEEKLY
104
+ options = @report_page.search(".//select[@id='#{@select_name}']/option")
105
+ options = options.collect { |i| i ? i['value'] : nil } if options
106
+ raise "unable to determine daily report options" unless options
107
+
108
+ debug_msg("options: #{options.inspect}")
109
+
110
+ # constrain download to available reports
111
+ available = options.find { |i| i <= date_str } ? true : false
112
+
113
+ unless available
114
+ raise ArgumentError, "No #{period} reports are available for #{date_str}"
115
+ end
116
+
117
+ # get ajax parameter name for Daily/Weekly (<a> id)
118
+ report_period_link = @report_page.link_with(:text => /#{period}/)
119
+ @report_period_id = report_period_link.node['id']
120
+ raise "could not determine form period AJAX parameter" unless @report_period_id
121
+
122
+ # get ajax parameter name from <select> onchange attribute
123
+ # 'parameters':{'theForm:j_id_jsp_4933398_30':'theForm:j_id_jsp_4933398_30'}
124
+ report_date_select = @report_page.search(".//select[@id='#{@select_name}']")
125
+ @report_date_id = report_date_select[0]['onchange'].match(/parameters':\{'(.*?)'/)[1] rescue nil
126
+ raise "could not determine form date AJAX parameter" unless @report_date_id
127
+
128
+ # select report period to download (Weekly/Daily)
129
+ if @current_period != period
130
+ change_report(@report_page, date_str, @report_period_id => @report_period_id)
131
+ @current_period = period
117
132
  end
133
+
134
+ # select report date
135
+ page = change_report(@report_page, date_str, @report_date_id => @report_date_id)
136
+
137
+ # after selecting report type, recheck if selection is available.
138
+ # (selection options exist even when the report isn't available, so
139
+ # we need to do another check here)
140
+ dump(client, page)
141
+ available = !page.body.match(/There is no report available for this selection/)
142
+ unless available
143
+ raise ArgumentError, "No #{period} reports are available for #{date_str}"
144
+ end
145
+
146
+ # download the report
147
+ page = @report_page.form_with(:name => 'theForm') do |form|
148
+ form['theForm:xyz'] = 'notnormal'
149
+ form['theForm:downloadLabel2'] = 'theForm:downloadLabel2'
150
+ form[@select_name] = date_str
151
+
152
+ form.delete_field!('AJAXREQUEST')
153
+ form.delete_field!(@report_period_id)
154
+ form.delete_field!(@report_date_id)
155
+
156
+ debug_form(form)
157
+ end.submit
158
+
159
+ dump(client, page)
160
+ report = page.body.to_s
161
+ debug_msg("report is #{report.length} bytes")
162
+ out << report
163
+ report
118
164
  end
119
165
 
120
166
  private
121
167
 
122
- def client
123
- @client ||= client = HTTPClient.new
168
+ def debug_msg(message)
169
+ return unless self.debug?
170
+ puts message
124
171
  end
125
172
 
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
173
+ def change_report(report_page, date_str, params = {})
174
+ page = report_page.form_with(:name => 'theForm') do |form|
175
+ form.delete_field!(@report_period_id)
176
+ form.delete_field!(@report_date_id)
177
+ form.delete_field!('theForm:downloadLabel2')
178
+
179
+ params.each { |k,v| form[k] = v }
180
+
181
+ form['AJAXREQUEST'] = @ajax_id
182
+ form['theForm:xyz'] = 'notnormal'
183
+ form[@select_name] = date_str
184
+
185
+ debug_form(form)
186
+ end.submit
187
+ end
188
+
189
+ # fetch main report page (sales.faces)
190
+ def fetch_report_page
191
+ @report_page = client.get(REPORT_URL)
192
+ dump(client, @report_page)
193
+
194
+ # get ajax parameter name for AJAXREQUEST (<a> id)
195
+ # AJAX.Submit('theForm:j_id_jsp_4933398_2'
196
+ @ajax_id = @report_page.body.match(/AJAX\.Submit\('([^\']+)'/)[1] rescue nil
197
+ raise "could not determine form AJAX id" unless @ajax_id
198
+
199
+ @report_page
200
+ end
201
+
202
+ # log in and navigate to the reporting interface
203
+ def login
204
+ debug_msg("getting login page")
205
+ page = client.get(LOGIN_URL)
206
+
207
+ while true do
208
+ debug_msg("logging in")
209
+ page = page.form_with(:name => 'appleConnectForm') do |form|
210
+ raise "login form not found" unless form
211
+
212
+ form['theAccountName'] = @username
213
+ form['theAccountPW'] = @password
214
+ form['1.Continue.x'] = '35'
215
+ form['1.Continue.y'] = '16'
216
+ form['theAuxValue'] = ''
217
+ end.submit
218
+
219
+ dump(client, page)
220
+
221
+ # 'session expired' message sometimes appears after logging in. weird.
222
+ expired = page.body.match(/Your session has expired.*?href\="(.*?)"/)
223
+ if expired
224
+ debug_msg("expired session detected, retrying login")
225
+ page = client.get(expired[1])
226
+ next # retry login
153
227
  end
154
- puts "#{url} -> #{path}"
228
+
229
+ break # done logging in
230
+ end
231
+
232
+ # skip past new license available notifications
233
+ new_license = page.body.match(/License Agreement Update/)
234
+ if new_license
235
+ debug_msg("new license detected, skipping")
236
+ submit_parameter = page.body.match(/input.*?type\="image".*?name="(.*?)"/)[1] rescue nil
237
+ raise "could not determine how to skip new license agreement" unless submit_parameter
238
+ page = page.form_with(:name => 'mainForm') do |form|
239
+ form["#{submit_parameter}.x"] = 40
240
+ form["#{submit_parameter}.y"] = 18
241
+ end.submit
155
242
  end
156
243
 
157
- @referer = url
158
- response.body.dump
244
+ # Click the sales and trends link
245
+ sales_link = page.link_with(:text => /Sales and Trends/)
246
+ raise "Sales and Trends link not found" unless sales_link
247
+ page2 = client.click(sales_link)
248
+ dump(client, page2)
249
+
250
+ @report_page = nil # clear any cached report page
251
+ @logged_in = true
252
+ end
253
+
254
+ def client
255
+ return @client if @client
256
+ @client = Mechanize.new
257
+ @client.read_timeout = NETWORK_TIMEOUT
258
+ @client.open_timeout = NETWORK_TIMEOUT
259
+ @client.user_agent_alias = 'Mac FireFox'
260
+ @client
261
+ end
262
+
263
+ # dump state information to a file (debugging)
264
+ def dump(a, page)
265
+ return unless self.debug?
266
+
267
+ url = a.current_page.uri.request_uri
268
+ puts "current page: #{url}"
269
+
270
+ md5 = Digest::MD5.new; md5 << url; md5 << Time.now.to_s
271
+ path = File.join(Dir.tmpdir, md5.to_s + ".html")
272
+ out = open(path, "w") do |f|
273
+ f << "Current page: #{url}"
274
+ f << "Headers: #{page.header}"
275
+ f << page.body
276
+ end
277
+
278
+ puts "#{url} -> #{path}"
279
+ end
280
+
281
+ def debug_form(form)
282
+ return unless self.debug?
283
+
284
+ puts "\nsubmitting form:"
285
+ form.keys.each do |key|
286
+ puts "#{key}: #{form[key]}"
287
+ end
159
288
  end
160
289
 
161
290
  end
@@ -11,7 +11,8 @@ module ItunesConnect
11
11
  @db = SQLite3::Database.new(file)
12
12
  if @db.table_info("reports").empty?
13
13
  @db.execute("CREATE TABLE reports (id INTEGER PRIMARY KEY, " +
14
- "report_date DATE, country TEXT, install_count INTEGER, " +
14
+ "report_date DATE NOT NULL, country TEXT NOT NULL, " +
15
+ "install_count INTEGER, " +
15
16
  "update_count INTEGER)")
16
17
  @db.execute("CREATE UNIQUE INDEX u_reports_idx ON reports " +
17
18
  "(report_date, country)")
@@ -25,11 +26,11 @@ module ItunesConnect
25
26
 
26
27
  # Add a record to this instance
27
28
  def add(date, country, install_count, update_count)
28
- @db.execute("INSERT INTO reports (report_date, country, " +
29
- "install_count, update_count) VALUES (?, ?, ?, ?)",
30
- date, country, install_count, update_count)
29
+ ret = @db.execute("INSERT INTO reports (report_date, country, " +
30
+ "install_count, update_count) VALUES (?, ?, ?, ?)",
31
+ [format_date(date), country, install_count, update_count])
31
32
  true
32
- rescue SQLite3::SQLException => e
33
+ rescue SQLite3::ConstraintException => e
33
34
  if e.message =~ /columns .* are not unique/
34
35
  $stdout.puts "Skipping existing row for #{country} on #{date}" if verbose?
35
36
  false
@@ -56,12 +57,12 @@ module ItunesConnect
56
57
 
57
58
  if opts[:from]
58
59
  clauses << "report_date >= ?"
59
- params << opts[:from]
60
+ params << format_date(opts[:from])
60
61
  end
61
62
 
62
63
  if opts[:to]
63
64
  clauses << "report_date <= ?"
64
- params << opts[:to]
65
+ params << format_date(opts[:to])
65
66
  end
66
67
 
67
68
  if opts[:country]
@@ -73,9 +74,9 @@ module ItunesConnect
73
74
  sql << clauses.join(" AND ") unless params.empty?
74
75
  sql << " ORDER BY report_date DESC"
75
76
 
76
- @db.execute(sql, *params).map do |row|
77
+ @db.execute(sql, params).map do |row|
77
78
  OpenStruct.new({
78
- :report_date => Date.parse(row[1]),
79
+ :report_date => row[1] ? Date.parse(row[1]) : nil,
79
80
  :country => row[2],
80
81
  :install_count => row[3].to_i,
81
82
  :update_count => row[4].to_i
@@ -100,12 +101,12 @@ module ItunesConnect
100
101
 
101
102
  if opts[:from]
102
103
  clauses << "report_date >= ?"
103
- params << opts[:from]
104
+ params << format_date(opts[:from])
104
105
  end
105
106
 
106
107
  if opts[:to]
107
108
  clauses << "report_date <= ?"
108
- params << opts[:to]
109
+ params << format_date(opts[:to])
109
110
  end
110
111
 
111
112
  if opts[:country]
@@ -117,7 +118,7 @@ module ItunesConnect
117
118
  sql << clauses.join(" AND ") unless params.empty?
118
119
  sql << " GROUP BY country ORDER BY country"
119
120
 
120
- @db.execute(sql, *params).map do |row|
121
+ @db.execute(sql, params).map do |row|
121
122
  OpenStruct.new({
122
123
  :country => row[0],
123
124
  :install_count => row[1].to_i,
@@ -125,5 +126,11 @@ module ItunesConnect
125
126
  })
126
127
  end
127
128
  end
129
+
130
+ private
131
+
132
+ def format_date(date)
133
+ date.is_a?(Date) ? date.strftime("%Y-%m-%d") : date
134
+ end
128
135
  end
129
136
  end
metadata CHANGED
@@ -1,7 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: itunes-connect
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ hash: 55
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 10
9
+ - 0
10
+ version: 0.10.0
5
11
  platform: ruby
6
12
  authors:
7
13
  - Alex Vollmer
@@ -9,69 +15,84 @@ autorequire:
9
15
  bindir: bin
10
16
  cert_chain: []
11
17
 
12
- date: 2009-11-07 00:00:00 -08:00
18
+ date: 2010-09-22 00:00:00 +09:30
13
19
  default_executable: itunes_connect
14
20
  dependencies:
15
21
  - !ruby/object:Gem::Dependency
16
- name: httpclient
17
- type: :runtime
18
- version_requirement:
19
- version_requirements: !ruby/object:Gem::Requirement
22
+ name: mechanize
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
20
26
  requirements:
21
27
  - - ~>
22
28
  - !ruby/object:Gem::Version
23
- version: "2.1"
24
- version:
25
- - !ruby/object:Gem::Dependency
26
- name: nokogiri
29
+ hash: 23
30
+ segments:
31
+ - 1
32
+ - 0
33
+ - 0
34
+ version: 1.0.0
27
35
  type: :runtime
28
- version_requirement:
29
- version_requirements: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - ~>
32
- - !ruby/object:Gem::Version
33
- version: "1.3"
34
- version:
36
+ version_requirements: *id001
35
37
  - !ruby/object:Gem::Dependency
36
38
  name: clip
37
- type: :runtime
38
- version_requirement:
39
- version_requirements: !ruby/object:Gem::Requirement
39
+ prerelease: false
40
+ requirement: &id002 !ruby/object:Gem::Requirement
41
+ none: false
40
42
  requirements:
41
43
  - - ">="
42
44
  - !ruby/object:Gem::Version
45
+ hash: 21
46
+ segments:
47
+ - 1
48
+ - 0
49
+ - 1
43
50
  version: 1.0.1
44
- version:
51
+ type: :runtime
52
+ version_requirements: *id002
45
53
  - !ruby/object:Gem::Dependency
46
54
  name: sqlite3-ruby
47
- type: :runtime
48
- version_requirement:
49
- version_requirements: !ruby/object:Gem::Requirement
55
+ prerelease: false
56
+ requirement: &id003 !ruby/object:Gem::Requirement
57
+ none: false
50
58
  requirements:
51
59
  - - ~>
52
60
  - !ruby/object:Gem::Version
61
+ hash: 11
62
+ segments:
63
+ - 1
64
+ - 2
53
65
  version: "1.2"
54
- version:
66
+ type: :runtime
67
+ version_requirements: *id003
55
68
  - !ruby/object:Gem::Dependency
56
69
  name: rspec
57
- type: :development
58
- version_requirement:
59
- version_requirements: !ruby/object:Gem::Requirement
70
+ prerelease: false
71
+ requirement: &id004 !ruby/object:Gem::Requirement
72
+ none: false
60
73
  requirements:
61
74
  - - ">="
62
75
  - !ruby/object:Gem::Version
76
+ hash: 3
77
+ segments:
78
+ - 0
63
79
  version: "0"
64
- version:
80
+ type: :development
81
+ version_requirements: *id004
65
82
  - !ruby/object:Gem::Dependency
66
83
  name: fakeweb
67
- type: :development
68
- version_requirement:
69
- version_requirements: !ruby/object:Gem::Requirement
84
+ prerelease: false
85
+ requirement: &id005 !ruby/object:Gem::Requirement
86
+ none: false
70
87
  requirements:
71
88
  - - ">="
72
89
  - !ruby/object:Gem::Version
90
+ hash: 3
91
+ segments:
92
+ - 0
73
93
  version: "0"
74
- version:
94
+ type: :development
95
+ version_requirements: *id005
75
96
  description: Programmatic and command-line access to iTunes Connect Reports
76
97
  email: alex.vollmer@gmail.com
77
98
  executables:
@@ -116,21 +137,27 @@ rdoc_options:
116
137
  require_paths:
117
138
  - lib
118
139
  required_ruby_version: !ruby/object:Gem::Requirement
140
+ none: false
119
141
  requirements:
120
142
  - - ">="
121
143
  - !ruby/object:Gem::Version
144
+ hash: 3
145
+ segments:
146
+ - 0
122
147
  version: "0"
123
- version:
124
148
  required_rubygems_version: !ruby/object:Gem::Requirement
149
+ none: false
125
150
  requirements:
126
151
  - - ">="
127
152
  - !ruby/object:Gem::Version
153
+ hash: 3
154
+ segments:
155
+ - 0
128
156
  version: "0"
129
- version:
130
157
  requirements: []
131
158
 
132
159
  rubyforge_project:
133
- rubygems_version: 1.3.5
160
+ rubygems_version: 1.3.7
134
161
  signing_key:
135
162
  specification_version: 3
136
163
  summary: Get your iTunes Connect Reports