itunes-connect 0.9.0 → 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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