amazon-associates 0.6.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. data/.gitignore +3 -0
  2. data/CHANGELOG +29 -0
  3. data/LICENSE +21 -0
  4. data/README.rdoc +84 -0
  5. data/Rakefile +62 -0
  6. data/VERSION.yml +5 -0
  7. data/amazon-associates.gemspec +118 -0
  8. data/lib/amazon-associates.rb +90 -0
  9. data/lib/amazon-associates/caching/filesystem_cache.rb +121 -0
  10. data/lib/amazon-associates/errors.rb +23 -0
  11. data/lib/amazon-associates/extensions/core.rb +31 -0
  12. data/lib/amazon-associates/extensions/hpricot.rb +115 -0
  13. data/lib/amazon-associates/request.rb +143 -0
  14. data/lib/amazon-associates/requests/browse_node.rb +10 -0
  15. data/lib/amazon-associates/requests/cart.rb +81 -0
  16. data/lib/amazon-associates/requests/item.rb +13 -0
  17. data/lib/amazon-associates/responses/browse_node_lookup_response.rb +10 -0
  18. data/lib/amazon-associates/responses/cart_responses.rb +26 -0
  19. data/lib/amazon-associates/responses/item_lookup_response.rb +16 -0
  20. data/lib/amazon-associates/responses/item_search_response.rb +20 -0
  21. data/lib/amazon-associates/responses/response.rb +27 -0
  22. data/lib/amazon-associates/responses/similarity_lookup_response.rb +9 -0
  23. data/lib/amazon-associates/types/api_result.rb +8 -0
  24. data/lib/amazon-associates/types/browse_node.rb +48 -0
  25. data/lib/amazon-associates/types/cart.rb +87 -0
  26. data/lib/amazon-associates/types/customer_review.rb +15 -0
  27. data/lib/amazon-associates/types/editorial_review.rb +8 -0
  28. data/lib/amazon-associates/types/error.rb +8 -0
  29. data/lib/amazon-associates/types/image.rb +37 -0
  30. data/lib/amazon-associates/types/image_set.rb +11 -0
  31. data/lib/amazon-associates/types/item.rb +156 -0
  32. data/lib/amazon-associates/types/listmania_list.rb +9 -0
  33. data/lib/amazon-associates/types/measurement.rb +47 -0
  34. data/lib/amazon-associates/types/offer.rb +10 -0
  35. data/lib/amazon-associates/types/ordinal.rb +24 -0
  36. data/lib/amazon-associates/types/price.rb +29 -0
  37. data/lib/amazon-associates/types/requests.rb +50 -0
  38. data/spec/requests/browse_node_lookup_spec.rb +41 -0
  39. data/spec/requests/item_search_spec.rb +27 -0
  40. data/spec/spec_helper.rb +8 -0
  41. data/spec/types/cart_spec.rb +294 -0
  42. data/spec/types/item_spec.rb +55 -0
  43. data/spec/types/measurement_spec.rb +43 -0
  44. data/test/amazon/browse_node_test.rb +34 -0
  45. data/test/amazon/cache_test.rb +33 -0
  46. data/test/amazon/caching/filesystem_cache_test.rb +198 -0
  47. data/test/amazon/item_test.rb +397 -0
  48. data/test/test_helper.rb +9 -0
  49. data/test/utilities/filesystem_test_helper.rb +35 -0
  50. metadata +216 -0
@@ -0,0 +1,16 @@
1
+ module Amazon
2
+ module Associates
3
+ class ItemLookupResponse < Response
4
+ xml_name 'ItemLookupResponse'
5
+ xml_reader :items, :as => [Item]
6
+ xml_reader :current_page, :from => 'xmlns:ItemPage', :as => Integer, :else => 1
7
+ xml_reader :request, :as => Request, :in => 'xmlns:Items', :required => true
8
+ xml_reader :request_query, :as => ItemLookupRequest, :from => 'xmlns:SimilarityLookupRequest', :in => 'xmlns:Items/xmlns:Request'
9
+
10
+ def item
11
+ raise IndexError, "more than one item" if items.size > 1
12
+ items.first
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,20 @@
1
+ module Amazon
2
+ module Associates
3
+ class SearchResponse < Response
4
+ attr_accessor :url # TODO would be just a reader if we can figure out xml construction better
5
+ xml_reader :operation_request, :as => OperationRequest, :required => true
6
+ xml_reader :request, :as => Request, :in => 'xmlns:Items'
7
+ delegate :current_page, :to => :request_query
8
+
9
+ xml_reader :items, :as => [Item]
10
+ xml_reader :total_results, :in => 'xmlns:Items', :as => Integer
11
+ xml_reader :total_pages, :in => 'xmlns:Items', :as => Integer
12
+ end
13
+
14
+ class ItemSearchResponse < SearchResponse
15
+ xml_name 'ItemSearchResponse'
16
+
17
+ xml_reader :request_query, :as => ItemSearchRequest, :in => 'xmlns:Items/xmlns:Request', :required => true
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,27 @@
1
+ module Amazon
2
+ module Associates
3
+ class Response < ApiResult
4
+ attr_reader :url
5
+
6
+ def errors
7
+ request.errors
8
+ end
9
+
10
+ def initialize(url)
11
+ @url = url
12
+ end
13
+
14
+ def ==(other)
15
+ (instance_variables.sort == other.instance_variables.sort) && instance_variables.all? do |v|
16
+ instance_variable_get(v) == other.instance_variable_get(v)
17
+ end
18
+ end
19
+
20
+ private
21
+ def after_parse
22
+ # these can't be done as blocks because we need @url available
23
+ raise errors.first unless errors.empty?
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,9 @@
1
+ module Amazon
2
+ module Associates
3
+ class SimilarityLookupResponse < SearchResponse
4
+ xml_name 'SimilarityLookupResponse'
5
+
6
+ # xml_reader :request_query, :as => SimilarityLookupRequest, :in => 'Items/Request', :required => true
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ module Amazon
2
+ module Associates
3
+ class ApiResult
4
+ include ROXML
5
+ xml_convention :camelcase
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,48 @@
1
+ module Amazon
2
+ module Associates
3
+ class Item < ApiResult
4
+ # forward-declaration...
5
+ end
6
+
7
+ class BrowseNode < ApiResult
8
+ xml_reader :id, :from => 'xmlns:BrowseNodeId', :required => true
9
+ xml_reader :name, :from => 'Name'
10
+ xml_reader :parent, :as => BrowseNode, :from => 'xmlns:BrowseNode', :in => 'xmlns:Ancestors'
11
+ xml_reader :children, :as => [BrowseNode]
12
+ xml_reader :top_sellers, :as => [Item]
13
+
14
+ def initialize(id = nil, name = nil, parent = nil)
15
+ @id = id
16
+ @name = name
17
+ @parent = parent
18
+ end
19
+
20
+ def to_s
21
+ "#{@name}#{' : ' + @parent.to_s if @parent}"
22
+ end
23
+
24
+ def inspect
25
+ "#<#{self.class}:#{@id} #{self}>"
26
+ end
27
+
28
+ def ==(other)
29
+ return false unless other.respond_to?(:name, :id)
30
+ @name == other.name and @id == other.id
31
+ end
32
+
33
+ {:brand => [:manufacturers, :custom_brands], :type => [:categories]}.each_pair do |name, aliases|
34
+ define_method("#{name}?") do
35
+ markers = ([name] + aliases).map {|n| n.to_s.titleize}
36
+ return true if markers.include? instance_variable_get(:@name)
37
+
38
+ parent = instance_variable_get :@parent
39
+ while parent
40
+ return true if markers.include? parent.name
41
+ parent = parent.parent
42
+ end
43
+ false
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,87 @@
1
+ require 'roxml'
2
+
3
+ module Amazon
4
+ module Associates
5
+ class Cart < ApiResult
6
+ xml_reader :items, :as => [CartItem], :from => 'xmlns:CartItem', :in => 'xmlns:CartItems', :frozen => true
7
+ xml_reader :purchase_url, :from => 'xmlns:PurchaseURL'
8
+ xml_reader :id, :from => 'xmlns:CartId', :required => true
9
+ xml_reader :hmac, :from => 'xmlns:HMAC', :required => true
10
+
11
+ delegate :empty?, :include?, :to => :items
12
+
13
+ def to_hash
14
+ {:id => id,
15
+ :hmac => hmac}
16
+ end
17
+
18
+ # CartCreate
19
+ # defaults:
20
+ # response_group: Cart
21
+ # merge_cart: false
22
+ # list_item_id: nil
23
+ def self.create(items, args = nil)
24
+ if items.is_a?(Array)
25
+ items = items.inject({}) do |all, (item, count)|
26
+ all[item] = count || 1
27
+ all
28
+ end
29
+ end
30
+ Amazon::Associates.cart_create(items, args).cart
31
+ end
32
+
33
+ # CartGet
34
+ def self.get(args)
35
+ Amazon::Associates.cart_get(args).cart
36
+ end
37
+
38
+ def save
39
+ @changes.each do |action, *args|
40
+ cart = Amazon::Associates.send(action, *args).cart
41
+ @items = cart.items
42
+ @id = cart.id
43
+ @hmac = cart.hmac
44
+ end
45
+ @changes.clear
46
+ self
47
+ end
48
+
49
+ def changed?
50
+ !@changes.empty?
51
+ end
52
+
53
+ def add(item, count = 1)
54
+ raise ArgumentError, "item is nil" if item.nil?
55
+ raise ArgumentError, "count isn't positive" if count <= 0
56
+
57
+ if @items.include? item
58
+ action = :cart_modify
59
+ item = @items.find {|i| i == item } # we need the CartItemId for CartModify
60
+ count += item.quantity
61
+ else
62
+ action = :cart_add
63
+ end
64
+ # TODO: This could be much more sophisticated, collapsing operations and such
65
+ @changes << [action, self, {:items => {item => count}}]
66
+ end
67
+ alias_method :<<, :add
68
+
69
+ def clear
70
+ @changes << [:cart_clear, self]
71
+ end
72
+
73
+ def quantity
74
+ items.sum(&:quantity)
75
+ end
76
+
77
+ def ==(other)
78
+ id == other.id
79
+ end
80
+
81
+ private
82
+ def initialize
83
+ @changes = []
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,15 @@
1
+ module Amazon
2
+ module Associates
3
+ class CustomerReview < ApiResult
4
+ xml_name 'Review'
5
+ xml_reader :asin, :from => 'ASIN'
6
+ xml_reader :rating, :as => Integer
7
+ xml_reader :helpful_votes, :as => Integer
8
+ xml_reader :total_votes, :as => Integer
9
+ xml_reader :summary
10
+ xml_reader :content
11
+ xml_reader :customer_id
12
+ xml_reader :date, :as => Date
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,8 @@
1
+ module Amazon
2
+ module Associates
3
+ class EditorialReview < ApiResult
4
+ xml_reader :source
5
+ xml_reader :content
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module Amazon
2
+ module Associates
3
+ class Error < ApiResult
4
+ xml_reader :code
5
+ xml_reader :message
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,37 @@
1
+ module Amazon
2
+ module Associates
3
+ class Image < ApiResult
4
+ xml_reader :url, :from => 'URL'
5
+ xml_reader :width, :as => Measurement
6
+ xml_reader :height, :as => Measurement
7
+
8
+ def initialize(url = nil, width = nil, height = nil)
9
+ @url = url
10
+ @width = to_measurement(width)
11
+ @height = to_measurement(height)
12
+ end
13
+
14
+ def ==(other)
15
+ return nil unless other.is_a? Image
16
+ url == other.url and width == other.width and height == other.height
17
+ end
18
+
19
+ def size
20
+ unless height.units == 'pixels' and width.units == 'pixels'
21
+ raise 'size not available for images not denominated in pixels'
22
+ end
23
+
24
+ "#{width.value.round}x#{height.value.round}"
25
+ end
26
+
27
+ def inspect
28
+ "#<#{self.class}: #{url},#{width}x#{height}>"
29
+ end
30
+
31
+ private
32
+ def to_measurement(arg)
33
+ arg && (arg.is_a?(Measurement) ? arg : Measurement.new(arg, 'pixels'))
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,11 @@
1
+ module Amazon
2
+ module Associates
3
+ class ImageSet < ApiResult
4
+ xml_reader :category, :from => :attr, :required => true
5
+ xml_reader :small, :as => Image, :from => 'SmallImage'
6
+ xml_reader :medium, :as => Image, :from => 'MediumImage'
7
+ xml_reader :large, :as => Image, :from => 'LargeImage'
8
+ xml_reader :swatch, :as => Image, :from => 'SwatchImage'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,156 @@
1
+ require 'will_paginate/collection'
2
+
3
+ module Amazon
4
+ module Associates
5
+ class Item < ApiResult
6
+ xml_name 'Item'
7
+
8
+ xml_reader :asin, :from => 'ASIN'
9
+ xml_reader :detail_page_url
10
+ xml_reader :list_price, :as => Price, :in => 'xmlns:ItemAttributes'
11
+ xml_reader :attributes, :as => {:key => :name,
12
+ :value => :content}, :in => 'xmlns:ItemAttributes'
13
+ xml_reader :small_image, :as => Image
14
+ xml_reader :medium_image, :as => Image
15
+ #TODO: would be nice to have :key => '@category' and :value => {[Image] => 'SwatchImage'}
16
+ xml_reader :image_sets, :as => [ImageSet] do |sets|
17
+ sets.index_by(&:category)
18
+ end
19
+ xml_reader :listmania_lists, :as => [ListmaniaList]
20
+ xml_reader :browse_nodes, :as => [BrowseNode]
21
+ xml_reader :offers, :as => [Offer]
22
+ # TODO: This should be offers.total_new
23
+ xml_reader :total_new_offers, :from => 'TotalNew', :in => 'xmlns:OfferSummary', :as => Integer
24
+ # TODO: This should be offers.total
25
+ xml_reader :total_offers, :in => 'xmlns:Offers', :as => Integer
26
+
27
+ xml_reader :creators, :as => {:key => '@Role', :value => :content}, :in => 'xmlns:ItemAttributes'
28
+ xml_reader :authors, :as => [], :in => 'xmlns:ItemAttributes'
29
+ xml_reader :edition, :as => Ordinal, :in => 'xmlns:ItemAttributes'
30
+ xml_reader :lowest_new_price, :as => Price, :in => 'xmlns:OfferSummary'
31
+ xml_reader :publisher, :studio, :batteries_included?, :label, :brand, :in => 'xmlns:ItemAttributes'
32
+
33
+ xml_reader :editorial_reviews, :as => [EditorialReview]
34
+ xml_reader :customer_reviews, :as => [CustomerReview]
35
+
36
+ def ==(other)
37
+ asin == other.asin
38
+ end
39
+
40
+ def eql?(other)
41
+ asin == other.asin
42
+ end
43
+
44
+ def author
45
+ authors.only
46
+ end
47
+
48
+ alias_method :offer_price, :lowest_new_price
49
+
50
+ def title
51
+ root_title || attr_title
52
+ end
53
+
54
+ def inspect
55
+ "#<#{self.class}: #{asin} #{attributes.inspect}>"
56
+ end
57
+
58
+ PER_PAGE = 10
59
+
60
+ def self.find(scope, opts = {})
61
+ opts = opts.dup # it seems this hash picks up some internal amazon stuff if we don't copy it
62
+
63
+ if scope.is_a? String
64
+ opts.merge!(:item_id => scope, :item_type => 'ASIN')
65
+ scope = :one
66
+ end
67
+
68
+ case scope
69
+ when :top_sellers then top_sellers(opts)
70
+ when :similar then similar(opts)
71
+ when :all then all(opts)
72
+ when :first then first(opts)
73
+ when :one then one(opts)
74
+ else
75
+ raise ArgumentError, "scope should be :all, :first, :one, or an item id string"
76
+ end
77
+ end
78
+
79
+ def self.top_sellers(opts)
80
+ opts = opts.dup.to_options!
81
+ opts.merge!(:response_group => 'TopSellers')
82
+ opts[:browse_node_id] = opts.delete(:browse_node).id if opts[:browse_node]
83
+ items = Amazon::Associates.browse_node_lookup(opts).top_sellers.map {|i| i.text_at('asin') }
84
+ Amazon::Associates.item_lookup(:item_id => items * ',', :response_group => SMALL_RESPONSE_GROUPS).items
85
+ end
86
+
87
+ def self.similar(*ids)
88
+ opts = ids.extract_options!
89
+ opts.reverse_merge!(:response_group => SMALL_RESPONSE_GROUPS)
90
+ Amazon::Associates.similarity_lookup(ids, opts).items
91
+ end
92
+
93
+ def self.top_sellers(opts)
94
+ opts = opts.dup.to_options!
95
+ opts.merge!(:response_group => 'TopSellers')
96
+ opts[:browse_node_id] = opts.delete(:browse_node).id if opts[:browse_node]
97
+ items = Amazon::Associates.browse_node_lookup(opts).browse_nodes.map(&:top_sellers).flatten.map(&:asin)
98
+ Amazon::Associates.item_lookup(:item_id => items * ',', :response_group => SMALL_RESPONSE_GROUPS).items
99
+ end
100
+
101
+ def self.first(opts)
102
+ all(opts).first
103
+ end
104
+
105
+ def self.all(opts)
106
+ opts = opts.dup.to_options!
107
+ unless %w[All Blended Merchants].include? opts[:search_index]
108
+ opts.reverse_merge!(:merchant_id => 'Amazon',
109
+ :condition => 'All')
110
+ end
111
+ opts[:availability] ||= 'Available' unless opts[:condition].nil? or opts[:condition] == 'New'
112
+ opts[:item_page] ||= (opts.delete(:page) || 1)
113
+ prep_responses(opts)
114
+
115
+ response = Amazon::Associates.item_search(opts)
116
+
117
+ # TODO: Max count is different for different indexes, for example, All only returns 5 pages
118
+ WillPaginate::Collection.create(response.current_page, PER_PAGE, response.total_results) do |pager|
119
+ # TODO: Some of the returned items may not include offers, we may need something like this:
120
+ #.reject {|i| i.offers[:totaloffers] == '0' }
121
+ pager.replace response.items
122
+ end
123
+ end
124
+
125
+ def self.one(opts)
126
+ prep_responses(opts)
127
+ Amazon::Associates.item_lookup(opts.delete(:item_id), opts).items.first
128
+ end
129
+
130
+ def similar
131
+ Item.similar(asin)
132
+ end
133
+
134
+ private
135
+ xml_reader :attr_title, :from => 'xmlns:Title', :in => 'xmlns:ItemAttributes'
136
+ xml_reader :root_title, :from => 'xmlns:Title'
137
+
138
+ SMALL_RESPONSE_GROUPS = %w{Small ItemAttributes Images}
139
+ DEFAULT_RESPONSE_GROUPS = SMALL_RESPONSE_GROUPS + %w{Offers VariationSummary BrowseNodes}
140
+
141
+ def self.prep_responses(opts)
142
+ opts[:response_group] ||= []
143
+ unless opts[:response_group].is_a? Array
144
+ raise ArgumentError, "Response groups are required to be in array form"
145
+ end
146
+ opts[:response_group] += DEFAULT_RESPONSE_GROUPS
147
+ end
148
+ end
149
+
150
+ class CartItem < Item
151
+ # TODO: This could probably just be #id
152
+ xml_reader :cart_item_id
153
+ xml_reader :quantity, :as => Integer, :required => true
154
+ end
155
+ end
156
+ end