amazon-associates 0.6.3
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +3 -0
- data/CHANGELOG +29 -0
- data/LICENSE +21 -0
- data/README.rdoc +84 -0
- data/Rakefile +62 -0
- data/VERSION.yml +5 -0
- data/amazon-associates.gemspec +118 -0
- data/lib/amazon-associates.rb +90 -0
- data/lib/amazon-associates/caching/filesystem_cache.rb +121 -0
- data/lib/amazon-associates/errors.rb +23 -0
- data/lib/amazon-associates/extensions/core.rb +31 -0
- data/lib/amazon-associates/extensions/hpricot.rb +115 -0
- data/lib/amazon-associates/request.rb +143 -0
- data/lib/amazon-associates/requests/browse_node.rb +10 -0
- data/lib/amazon-associates/requests/cart.rb +81 -0
- data/lib/amazon-associates/requests/item.rb +13 -0
- data/lib/amazon-associates/responses/browse_node_lookup_response.rb +10 -0
- data/lib/amazon-associates/responses/cart_responses.rb +26 -0
- data/lib/amazon-associates/responses/item_lookup_response.rb +16 -0
- data/lib/amazon-associates/responses/item_search_response.rb +20 -0
- data/lib/amazon-associates/responses/response.rb +27 -0
- data/lib/amazon-associates/responses/similarity_lookup_response.rb +9 -0
- data/lib/amazon-associates/types/api_result.rb +8 -0
- data/lib/amazon-associates/types/browse_node.rb +48 -0
- data/lib/amazon-associates/types/cart.rb +87 -0
- data/lib/amazon-associates/types/customer_review.rb +15 -0
- data/lib/amazon-associates/types/editorial_review.rb +8 -0
- data/lib/amazon-associates/types/error.rb +8 -0
- data/lib/amazon-associates/types/image.rb +37 -0
- data/lib/amazon-associates/types/image_set.rb +11 -0
- data/lib/amazon-associates/types/item.rb +156 -0
- data/lib/amazon-associates/types/listmania_list.rb +9 -0
- data/lib/amazon-associates/types/measurement.rb +47 -0
- data/lib/amazon-associates/types/offer.rb +10 -0
- data/lib/amazon-associates/types/ordinal.rb +24 -0
- data/lib/amazon-associates/types/price.rb +29 -0
- data/lib/amazon-associates/types/requests.rb +50 -0
- data/spec/requests/browse_node_lookup_spec.rb +41 -0
- data/spec/requests/item_search_spec.rb +27 -0
- data/spec/spec_helper.rb +8 -0
- data/spec/types/cart_spec.rb +294 -0
- data/spec/types/item_spec.rb +55 -0
- data/spec/types/measurement_spec.rb +43 -0
- data/test/amazon/browse_node_test.rb +34 -0
- data/test/amazon/cache_test.rb +33 -0
- data/test/amazon/caching/filesystem_cache_test.rb +198 -0
- data/test/amazon/item_test.rb +397 -0
- data/test/test_helper.rb +9 -0
- data/test/utilities/filesystem_test_helper.rb +35 -0
- 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,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,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
|