amazon_associate 0.7.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.
@@ -0,0 +1,4 @@
1
+ module AmazonAssociate
2
+ class ConfigurationError < RuntimeError
3
+ end
4
+ end
@@ -0,0 +1,100 @@
1
+ # Internal wrapper class to provide convenient method to access Hpricot element value.
2
+ module AmazonAssociate
3
+ class Element
4
+ # Pass Hpricot::Elements object
5
+ def initialize(element)
6
+ @element = element
7
+ end
8
+
9
+ # Returns Hpricot::Elments object
10
+ def elem
11
+ @element
12
+ end
13
+
14
+ # Find Hpricot::Elements matching the given path. Example: element/"author".
15
+ def /(path)
16
+ elements = @element/path
17
+ return nil if elements.size == 0
18
+ elements
19
+ end
20
+
21
+ # Find Hpricot::Elements matching the given path, and convert to AmazonAssociate::Element.
22
+ # Returns an array AmazonAssociate::Elements if more than Hpricot::Elements size is greater than 1.
23
+ def search_and_convert(path)
24
+ elements = self./(path)
25
+ return unless elements
26
+ elements = elements.map{|element| Element.new(element)}
27
+ return elements.first if elements.size == 1
28
+ elements
29
+ end
30
+
31
+ # Get the text value of the given path, leave empty to retrieve current element value.
32
+ def get(path="")
33
+ Element.get(@element, path)
34
+ end
35
+
36
+ # Get the unescaped HTML text of the given path.
37
+ def get_unescaped(path="")
38
+ Element.get_unescaped(@element, path)
39
+ end
40
+
41
+ # Get the array values of the given path.
42
+ def get_array(path="")
43
+ Element.get_array(@element, path)
44
+ end
45
+
46
+ # Get the children element text values in hash format with the element names as the hash keys.
47
+ def get_hash(path="")
48
+ Element.get_hash(@element, path)
49
+ end
50
+
51
+ # Similar to #get, except an element object must be passed-in.
52
+ def self.get(element, path="")
53
+ return unless element
54
+ result = element.at(path)
55
+ result = result.inner_html if result
56
+ result
57
+ end
58
+
59
+ # Similar to #get_unescaped, except an element object must be passed-in.
60
+ def self.get_unescaped(element, path="")
61
+ result = get(element, path)
62
+ CGI::unescapeHTML(result) if result
63
+ end
64
+
65
+ # Similar to #get_array, except an element object must be passed-in.
66
+ def self.get_array(element, path="")
67
+ return unless element
68
+
69
+ result = element/path
70
+ if (result.is_a? Hpricot::Elements) || (result.is_a? Array)
71
+ parsed_result = []
72
+ result.each {|item|
73
+ parsed_result << Element.get(item)
74
+ }
75
+ parsed_result
76
+ else
77
+ [Element.get(result)]
78
+ end
79
+ end
80
+
81
+ # Similar to #get_hash, except an element object must be passed-in.
82
+ def self.get_hash(element, path="")
83
+ return unless element
84
+
85
+ result = element.at(path)
86
+ if result
87
+ hash = {}
88
+ result = result.children
89
+ result.each do |item|
90
+ hash[item.name.to_sym] = item.inner_html
91
+ end
92
+ hash
93
+ end
94
+ end
95
+
96
+ def to_s
97
+ elem.to_s if elem
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,354 @@
1
+ require "net/http"
2
+ require "hpricot"
3
+ require "cgi"
4
+
5
+ begin
6
+ require 'md5'
7
+ rescue LoadError
8
+ require 'digest/md5'
9
+ end
10
+
11
+ #--
12
+ # Copyright (c) 2009 Dan Pickett, Enlight Solutions
13
+ #
14
+ # Permission is hereby granted, free of charge, to any person obtaining
15
+ # a copy of this software and associated documentation files (the
16
+ # "Software"), to deal in the Software without restriction, including
17
+ # without limitation the rights to use, copy, modify, merge, publish,
18
+ # distribute, sublicense, and/or sell copies of the Software, and to
19
+ # permit persons to whom the Software is furnished to do so, subject to
20
+ # the following conditions:
21
+ #
22
+ # The above copyright notice and this permission notice shall be
23
+ # included in all copies or substantial portions of the Software.
24
+ #
25
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
26
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
27
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
28
+ # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
29
+ # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
30
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
31
+ # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
32
+ #++
33
+ module AmazonAssociate
34
+ class Request
35
+
36
+ SERVICE_URLS = {:us => "http://webservices.amazon.com",
37
+ :uk => "http://webservices.amazon.co.uk",
38
+ :ca => "http://webservices.amazon.ca",
39
+ :de => "http://webservices.amazon.de",
40
+ :jp => "http://webservices.amazon.co.jp",
41
+ :fr => "http://webservices.amazon.fr"
42
+ }
43
+
44
+ # The sort types available to each product search index.
45
+ SORT_TYPES = {
46
+ "Apparel" => %w[relevancerank salesrank pricerank inverseprice -launch-date sale-flag],
47
+ "Automotive" => %w[salesrank price -price titlerank -titlerank],
48
+ "Baby" => %w[psrank salesrank price -price titlerank],
49
+ "Beauty" => %w[pmrank salesrank price -price -launch-date sale-flag],
50
+ "Books" => %w[relevancerank salesrank reviewrank pricerank inverse-pricerank daterank titlerank -titlerank],
51
+ "Classical" => %w[psrank salesrank price -price titlerank -titlerank orig-rel-date],
52
+ "DigitalMusic" => %w[songtitlerank uploaddaterank],
53
+ "DVD" => %w[relevancerank salesrank price -price titlerank -video-release-date],
54
+ "Electronics" => %w[pmrank salesrank reviewrank price -price titlerank],
55
+ "GourmetFood" => %w[relevancerank salesrank pricerank inverseprice launch-date sale-flag],
56
+ "HealthPersonalCare" => %w[pmrank salesrank pricerank inverseprice launch-date sale-flag],
57
+ "Jewelry" => %w[pmrank salesrank pricerank inverseprice launch-date],
58
+ "Kitchen" => %w[pmrank salesrank price -price titlerank -titlerank],
59
+ "Magazines" => %w[subslot-salesrank reviewrank price -price daterank titlerank -titlerank],
60
+ "Merchants" => %w[relevancerank salesrank pricerank inverseprice launch-date sale-flag],
61
+ "Miscellaneous" => %w[pmrank salesrank price -price titlerank -titlerank],
62
+ "Music" => %w[psrank salesrank price -price titlerank -titlerank artistrank orig-rel-date release-date],
63
+ "MusicalInstruments" => %w[pmrank salesrank price -price -launch-date sale-flag],
64
+ "MusicTracks" => %w[titlerank -titlerank],
65
+ "OfficeProducts" => %w[pmrank salesrank reviewrank price -price titlerank],
66
+ "OutdoorLiving" => %w[psrank salesrank price -price titlerank -titlerank],
67
+ "PCHardware" => %w[psrank salesrank price -price titlerank],
68
+ "PetSupplies" => %w[+pmrank salesrank price -price titlerank -titlerank],
69
+ "Photo" => %w[pmrank salesrank titlerank -titlerank],
70
+ "Restaurants" => %w[relevancerank titlerank],
71
+ "Software" => %w[pmrank salesrank titlerank price -price],
72
+ "SportingGoods" => %w[relevancerank salesrank pricerank inverseprice launch-date sale-flag],
73
+ "Tools" => %w[pmrank salesrank titlerank -titlerank price -price],
74
+ "Toys" => %w[pmrank salesrank price -price titlerank -age-min],
75
+ "VHS" => %w[relevancerank salesrank price -price titlerank -video-release-date],
76
+ "Video" => %w[relevancerank salesrank price -price titlerank -video-release-date],
77
+ "VideoGames" => %w[pmrank salesrank price -price titlerank],
78
+ "Wireless" => %w[daterank pricerank invers-pricerank reviewrank salesrank titlerank -titlerank],
79
+ "WirelessAccessories" => %w[psrank salesrank titlerank -titlerank]
80
+ }
81
+
82
+ # Returns an Array of valid sort types for _search_index_, or +nil+ if _search_index_ is invalid.
83
+ def self.sort_types(search_index)
84
+ SORT_TYPES.has_key?(search_index) ? SORT_TYPES[search_index] : nil
85
+ end
86
+
87
+ # Performs BrowseNodeLookup request, defaults to TopSellers ResponseGroup
88
+ def self.browse_node_lookup(browse_node_id, opts = {})
89
+ opts = self.options.merge(opts) if self.options
90
+ opts[:operation] = "BrowseNodeLookup"
91
+ opts[:browse_node_id] = browse_node_id
92
+
93
+ self.send_request(opts)
94
+ end
95
+
96
+ # Cart operations build the Item tags from the ASIN
97
+ # Item.ASIN.Quantity defaults to 1, unless otherwise specified in _opts_
98
+
99
+ # Creates remote shopping cart containing _asin_
100
+ def self.cart_create(items, opts = {})
101
+ opts = self.options.merge(opts) if self.options
102
+ opts[:operation] = "CartCreate"
103
+
104
+ if items.is_a?(String)
105
+ asin = items
106
+ opts["Item.#{asin}.Quantity"] = opts[:quantity] || 1
107
+ opts["Item.#{asin}.ASIN"] = asin
108
+ else
109
+ items.each do |item|
110
+ (item[:offer_listing_id].nil? || item[:offer_listing_id].empty?) ? opts["Item.#{item[:asin]}.ASIN"] = item[:asin] : opts["Item.#{item[:asin]}.OfferListingId"] = item[:offer_listing_id]
111
+ opts["Item.#{item[:asin]}.Quantity"] = item[:quantity] || 1
112
+ end
113
+ end
114
+
115
+ self.send_request(opts)
116
+ end
117
+
118
+ # Adds items to remote shopping cart
119
+ def self.cart_add(items, cart_id, hmac, opts = {})
120
+ opts = self.options.merge(opts) if self.options
121
+ opts[:operation] = "CartAdd"
122
+
123
+ if items.is_a?(String)
124
+ asin = items
125
+ opts["Item.#{asin}.Quantity"] = opts[:quantity] || 1
126
+ opts["Item.#{asin}.ASIN"] = asin
127
+ else
128
+ items.each do |item|
129
+ (item[:offer_listing_id].nil? || item[:offer_listing_id].empty?) ? opts["Item.#{item[:asin]}.ASIN"] = item[:asin] : opts["Item.#{item[:asin]}.OfferListingId"] = item[:offer_listing_id]
130
+ opts["Item.#{item[:asin]}.Quantity"] = item[:quantity] || 1
131
+ end
132
+ end
133
+
134
+ opts[:cart_id] = cart_id
135
+ opts[:hMAC] = hmac
136
+
137
+ self.send_request(opts)
138
+ end
139
+
140
+ # Retrieve a remote shopping cart
141
+ def self.cart_get(cart_id, hmac, opts = {})
142
+ opts = self.options.merge(opts) if self.options
143
+ opts[:operation] = "CartGet"
144
+ opts[:cart_id] = cart_id
145
+ opts[:hMAC] = hmac
146
+
147
+ self.send_request(opts)
148
+ end
149
+
150
+ # modifies _cart_item_id_ in remote shopping cart
151
+ # _quantity_ defaults to 0 to remove the given _cart_item_id_
152
+ # specify _quantity_ to update cart contents
153
+ def self.cart_modify(cart_item_id, cart_id, hmac, quantity=0, opts = {})
154
+ opts = self.options.merge(opts) if self.options
155
+ opts[:operation] = "CartModify"
156
+ opts["Item.1.CartItemId"] = cart_item_id
157
+ opts["Item.1.Quantity"] = quantity
158
+ opts[:cart_id] = cart_id
159
+ opts[:hMAC] = hmac
160
+
161
+ self.send_request(opts)
162
+ end
163
+
164
+ # clears contents of remote shopping cart
165
+ def self.cart_clear(cart_id, hmac, opts = {})
166
+ opts = self.options.merge(opts) if self.options
167
+ opts[:operation] = "CartClear"
168
+ opts[:cart_id] = cart_id
169
+ opts[:hMAC] = hmac
170
+
171
+ self.send_request(opts)
172
+ end
173
+ @@options = {}
174
+ @@debug = false
175
+
176
+ # Default search options
177
+ def self.options
178
+ @@options
179
+ end
180
+
181
+ # Set default search options
182
+ def self.options=(opts)
183
+ @@options = opts
184
+ end
185
+
186
+ # Get debug flag.
187
+ def self.debug
188
+ @@debug
189
+ end
190
+
191
+ # Set debug flag to true or false.
192
+ def self.debug=(dbg)
193
+ @@debug = dbg
194
+ end
195
+
196
+ def self.configure(&proc)
197
+ raise ArgumentError, "Block is required." unless block_given?
198
+
199
+ yield @@options
200
+ if !@@options[:caching_strategy].nil?
201
+ @@options.merge!(CacheFactory.initialize_options(@@options))
202
+ end
203
+ end
204
+
205
+ # Search amazon items with search terms. Default search index option is "Books".
206
+ # For other search type other than keywords, please specify :type => [search type param name].
207
+ def self.item_search(terms, opts = {})
208
+ opts[:operation] = "ItemSearch"
209
+ opts[:search_index] = opts[:search_index] || "Books"
210
+
211
+ type = opts.delete(:type)
212
+ if type
213
+ opts[type.to_sym] = terms
214
+ else
215
+ opts[:keywords] = terms
216
+ end
217
+
218
+ self.send_request(opts)
219
+ end
220
+
221
+ # Search an item by ASIN no.
222
+ def self.item_lookup(item_id, opts = {})
223
+ opts[:operation] = "ItemLookup"
224
+ opts[:item_id] = item_id
225
+
226
+ self.send_request(opts)
227
+ end
228
+
229
+ # Generic send request to ECS REST service. You have to specify the :operation parameter.
230
+ def self.send_request(opts)
231
+ opts = self.options.merge(opts) if self.options
232
+ unsigned_url = prepare_unsigned_url(opts)
233
+ response = nil
234
+
235
+ if caching_enabled?
236
+ AmazonAssociate::CacheFactory.sweep(self.options[:caching_strategy])
237
+
238
+ res = AmazonAssociate::CacheFactory.get(unsigned_url, self.options[:caching_strategy])
239
+ response = Response.new(res, unsigned_url) unless res.nil?
240
+ end
241
+
242
+ if !caching_enabled? || response.nil?
243
+ request_url = prepare_signed_url(opts)
244
+ log "Request URL: #{request_url}"
245
+ res = Net::HTTP.get_response(URI::parse(request_url))
246
+
247
+ unless res.kind_of? Net::HTTPSuccess
248
+ raise AmazonAssociate::RequestError, "HTTP Response: #{res.code} #{res.message}"
249
+ end
250
+
251
+ response = Response.new(res.body, request_url)
252
+ response.unsigned_url = unsigned_url
253
+
254
+ if caching_enabled?
255
+ cache_response(unsigned_url, response, self.options[:caching_strategy])
256
+ end
257
+ end
258
+
259
+ response
260
+ end
261
+
262
+ attr_accessor :request_url, :unsigned_url
263
+
264
+ protected
265
+ def self.log(s)
266
+ return unless self.debug
267
+ if defined? RAILS_DEFAULT_LOGGER
268
+ RAILS_DEFAULT_LOGGER.error(s)
269
+ elsif defined? LOGGER
270
+ LOGGER.error(s)
271
+ else
272
+ puts s
273
+ end
274
+ end
275
+
276
+ private
277
+ def self.get_service_url(opts)
278
+ country = opts.delete(:country)
279
+ country = (country.nil?) ? "us" : country
280
+ url = SERVICE_URLS[country.to_sym]
281
+
282
+ raise AmazonAssociate::RequestError, "Invalid country \"#{country}\"" unless url
283
+ url
284
+ end
285
+
286
+ def self.prepare_unsigned_url(opts)
287
+ url = get_service_url(opts) + "/onca/xml"
288
+
289
+ qs = ""
290
+ opts.each {|k,v|
291
+ next unless v
292
+ next if [:caching_options, :caching_strategy, :secret_key].include?(k)
293
+ v = v.join(",") if v.is_a? Array
294
+ qs << "&#{camelize(k.to_s)}=#{URI.encode(v.to_s)}"
295
+ }
296
+
297
+ @unsigned_url = "#{url}#{qs}"
298
+ end
299
+
300
+ def self.prepare_signed_url(opts)
301
+ url = get_service_url(opts) + "/onca/xml"
302
+
303
+ unencoded_key_value_strings = []
304
+ encoded_key_value_strings = []
305
+ opts[:timestamp] = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S") + ".000Z"
306
+ opts[:service] = "AWSECommerceService"
307
+ opts[:version] = "2009-01-01"
308
+ sort_parameters(opts).each do |p|
309
+ next if p[1].nil?
310
+ next if [:caching_options, :caching_strategy, :secret_key].include?(p[0])
311
+
312
+
313
+ encoded_value = CGI.escape(p[1].to_s)
314
+
315
+ encoded_key_value_strings << camelize(p[0].to_s ) + "=" + encoded_value
316
+ end
317
+
318
+ string_to_sign =
319
+ "GET
320
+ #{get_service_url(opts).gsub("http://", "")}
321
+ /onca/xml
322
+ #{encoded_key_value_strings.join("&")}"
323
+
324
+ signature = sign_string(string_to_sign)
325
+ encoded_key_value_strings << "Signature=" + signature
326
+
327
+ "#{url}?#{encoded_key_value_strings.join("&")}"
328
+ end
329
+
330
+ def self.sort_parameters(opts)
331
+ key_value_strings = []
332
+ opts.sort {|a, b| camelize(a) <=> camelize(b) }
333
+ end
334
+
335
+ def self.sign_string(string_to_sign)
336
+ sha1 = HMAC::SHA256.digest(self.options[:secret_key], string_to_sign)
337
+
338
+ #Base64 encoding adds a linefeed to the end of the string so chop the last character!
339
+ CGI.escape(Base64.encode64(sha1).chomp)
340
+ end
341
+
342
+ def self.camelize(s)
343
+ s.to_s.gsub(/\/(.?)/) { "::" + $1.upcase }.gsub(/(^|_)(.)/) { $2.upcase }
344
+ end
345
+
346
+ def self.caching_enabled?
347
+ !self.options[:caching_strategy].nil?
348
+ end
349
+
350
+ def self.cache_response(request, response, options)
351
+ AmazonAssociate::CacheFactory.cache(request, response, options)
352
+ end
353
+ end
354
+ end