skimlinks 1.0.0 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 3e74941ec6e62b0229bfd956be8cfa643d4d4f02
4
- data.tar.gz: ca029f6b9c348538b3298f8a893cf767c0455e5c
3
+ metadata.gz: 5d176c89122667e84f50c2a3e4aadc7e7b308fec
4
+ data.tar.gz: 8f2a3b4dfedc721dd626adcd4f67659d5496d621
5
5
  SHA512:
6
- metadata.gz: 020fe91831e6054caa63c00da23493843f2bb35511a228daf90e976ade1dda5e87025ae7ae4cbef6812fcfc2152021806cea9dd77c30d36df4631bdc3ec787e5
7
- data.tar.gz: f65b5b136dd0d69566ad5b576e4c106e1322f520b456487b6572380a09a0aa77adb9a886688b34b7aed2f94cfdf383dcc3011b949bfb5c78713f81074212d223
6
+ metadata.gz: bb8c3bafe621d4f58ceb6536350a40b390ab81ac347703d861931fa0d3ca652651545fedd379a3bfee8f0ed9be202dafe932be94cf63fcf6505fee678b907cc9
7
+ data.tar.gz: d5609beacaba2147aaa5d63c453b91576a226953430e958ead5e1b2cbf54117650d4e71bfef2a930322f1594be803b63d0763753c8b49a66e11e66dcf3626c7a
@@ -1,4 +1,4 @@
1
- Copyright (c) 2012 kraut computing UG (haftungsbeschränkt)
1
+ Copyright (c) 2013 kraut computing UG (haftungsbeschränkt)
2
2
 
3
3
  MIT License
4
4
 
data/README.md CHANGED
@@ -1,5 +1,193 @@
1
1
  # Skimlinks
2
2
 
3
- This gem is no longer public.
3
+ [![Gem Version](https://badge.fury.io/rb/skimlinks.png)](http://badge.fury.io/rb/skimlinks)
4
+ [![Build Status](https://secure.travis-ci.org/krautcomputing/skimlinks.png)](http://travis-ci.org/krautcomputing/skimlinks)
5
+ [![Dependency Status](https://gemnasium.com/krautcomputing/skimlinks.png)](https://gemnasium.com/krautcomputing/skimlinks)
6
+ [![Code Climate](https://codeclimate.com/github/krautcomputing/skimlinks.png)](https://codeclimate.com/github/krautcomputing/skimlinks)
4
7
 
5
- If you want to use it, please get in touch via info@krautcomputing.com
8
+ A simple wrapper around the [Skimlinks APIs](http://skimlinks.com/apis)
9
+
10
+ Read the accompanying [blog post](http://www.krautcomputing.com/blog/2013/01/11/new-gem-skimlinks/).
11
+
12
+ ## Requirements
13
+
14
+ Requires Ruby 1.9.2 or higher
15
+
16
+ ## Installation
17
+
18
+ Add this line to your application's Gemfile:
19
+
20
+ gem 'skimlinks'
21
+
22
+ And then execute:
23
+
24
+ $ bundle
25
+
26
+ Or install it yourself as:
27
+
28
+ $ gem install skimlinks
29
+
30
+ ## APIs
31
+
32
+ Skimlinks offers the following APIs:
33
+
34
+ * The [Product API](http://api-products.skimlinks.com/doc/) to search for products and product categories
35
+ * The [Merchant API](http://api-merchants.skimlinks.com/doc/) to search for merchants and merchant categories
36
+ * The [Link API](http://go.redirectingat.com/doc/) to convert regular URLs into affiliate URLs
37
+ * The [Reporting API](https://api-reports.skimlinks.com/doc/) to receive a history of your earned commissions
38
+
39
+ This gem currently only implements access to the Product API and Merchant API.
40
+
41
+ ## Usage
42
+
43
+ ### Configuration
44
+
45
+ Add configurations to `config/initializers/skimlinks.rb`:
46
+
47
+ ```ruby
48
+ Skimlinks.configure do |config|
49
+ config.api_key = 'foobar' # Your API key (get it here: https://accounts.skimlinks.com/productapi) (mandatory)
50
+ config.cache = Rails.cache # Set to an instance of ActiveSupport::Cache::Store to cache the API requests. (optional, defaults to nil)
51
+ config.format = :json # Currently no other setting is supported. In the future it will be possible to set this to :xml to communicate with the API via XML. (optional, defaults to :json)
52
+ config.cache_ttl = 10.minutes # Set to higher/lower value to cache requests shorter/longer. (optional, defaults to 1 day)
53
+ end
54
+ ```
55
+
56
+ ### Product API
57
+
58
+ #### Get a list of product categories
59
+
60
+ ```ruby
61
+ Skimlinks::ProductSearch.new.categories
62
+
63
+ => {
64
+ => "Animals" => 1, # Category name => category ID
65
+ => "Animals > Live Animals" => 2,
66
+ => "Animals > Pet Supplies" => 3,
67
+ => "Animals > Pet Supplies > Bird Supplies" => 4,
68
+ => "Animals > Pet Supplies > Bird Supplies > Bird Cages & Stands" => 5,
69
+ => "Animals > Pet Supplies > Bird Supplies > Bird Food" => 6,
70
+ => ...
71
+ => }
72
+ ```
73
+
74
+ #### Get a nested list of product categories
75
+
76
+ ```ruby
77
+ Skimlinks::ProductSearch.new.nested_categories
78
+
79
+ => {
80
+ => "Animals" => {
81
+ => "Live Animals" => nil,
82
+ => "Pet Supplies" => {
83
+ => "Bird Supplies" => {
84
+ => "Bird Cages & Stands" => nil,
85
+ => "Bird Food" => nil,
86
+ => ...
87
+ => }
88
+ => }
89
+ => }
90
+ => }
91
+ ```
92
+
93
+ #### Search for products
94
+
95
+ ```ruby
96
+ Skimlinks::ProductSearch.new(
97
+ query: 'justin bieber', # Search query (mandatory)
98
+ page: 1, # Page (optional, defaults to 1)
99
+ rows: 10, # Number of rows to return (optional, max. 300, defaults to 10)
100
+ min_price: 100, # Minimum price (including decimal digits, i.e. 100 = $1.00) (optional)
101
+ max_price: 500, # Maximum price (including decimal digits, i.e. 500 = $5.00) (optional)
102
+ country: 'uk', # Restrict search to products of a specific country (optional)
103
+ merchant_id: 8286, # Restrict search to products of a specific merchant (optional)
104
+ category: 'Toys & Games' # Restrict search to products of a specific category (optional)
105
+ ).products
106
+
107
+ => [
108
+ => #<Skimlinks::Product:0x007ff694913fb0 @description="Justin Bieber (Boyfriend)", @name="Justin Bieber Boyfriend Poster", @currency="gbp", @product_id="8286|33428272", @id="8116651", @merchant_id=8286, @price=219, @url="http://www.play.com/Product.aspx?r=GADG&title=33428272", @category="Toys & Games", @image_urls=[#<URI::HTTP:0x007ff694910540 URL:http://images.productserve.com/preview/1418/569238291.jpg>], @country="UK", @merchant_name="Play.com">,
109
+ => #<Skimlinks::Product:0x007ff694cb98e0 @description="Justin Bieber (Live) Poster", @name="Justin Bieber (Live) Poster", @currency="gbp", @product_id="8286|20468288", @id="7979941", @merchant_id=8286, @price=219, @url="http://www.play.com/Product.aspx?r=GADG&title=20468288", @category="Toys & Games", @image_urls=[#<URI::HTTP:0x007ff694cb9cc8 URL:http://images.productserve.com/preview/1418/153122801.jpg>], @country="UK", @merchant_name="Play.com">,
110
+ => #<Skimlinks::Product:0x007ff694f60fd0 @description="Justin Bieber (Hoodie) Mini Poster", @name="Justin Bieber (Hoodie) Mini Poster", @currency="gbp", @product_id="8286|20418241", @id="7975531", @merchant_id=8286, @price=219, @url="http://www.play.com/Product.aspx?r=GADG&title=20418241", @category="Toys & Games", @image_urls=[#<URI::HTTP:0x007ff694f610c0 URL:http://images.productserve.com/preview/1418/148546541.jpg>], @country="UK", @merchant_name="Play.com">,
111
+ => ...
112
+ => ]
113
+ ```
114
+
115
+ ### Merchant API
116
+
117
+ #### Search for merchants
118
+
119
+ ```ruby
120
+ Skimlinks::MerchantSearch.new(
121
+ category_ids: [1, 2, 3] # Return only merchants in the specificed categories (optional)
122
+ ).merchants
123
+
124
+ => [
125
+ => #<Skimlinks::Merchant:0x00000103d33b60 @id=17738, @name="*NEW!* High Commission Payout!", @preferred={}, @updated_at=2012-12-16 01:02:00 +0100, @average_conversion_rate="0", @average_commission="0", @logo_url="http://s.skimresources.com/logos/17738.jpg", @domains={"9682"=>"mykegelsecret.com", "45143"=>"kegelmasters.com"}, @categories={"37"=>"health & beauty", "1"=>"adult & mature", "38"=>"health & beauty;cosmetics", "39"=>"health & beauty;health products"}, @countries=["united states"], @product_count=0>,
126
+ => #<Skimlinks::Merchant:0x00000103cd7428 @id=41004, @name="Adultsextoys.com - A Huge Range Of Adult Products", @preferred={}, @updated_at=2012-12-16 01:02:00 +0100, @average_conversion_rate="0", @average_commission="0", @logo_url="http://s.skimresources.com/logos/41004.jpg", @domains={"40457"=>"adultsextoys.com.au"}, @categories={"1"=>"adult & mature"}, @countries=["australia"], @product_count=0>,
127
+ => #<Skimlinks::Merchant:0x00000103cb2dd0 @id=68079, @name="Adultshop", @preferred={}, @updated_at=2012-12-16 01:02:00 +0100, @average_conversion_rate="0", @average_commission="0", @logo_url="http://s.skimresources.com/logos/68079.jpg", @domains={"68133"=>"shop.adultshop.de"}, @categories={"1"=>"adult & mature"}, @countries=["germany"], @product_count=0>,
128
+ => ...
129
+ => ]
130
+ ```
131
+
132
+ #### Get a single merchant
133
+
134
+ ```ruby
135
+ Skimlinks::MerchantSearch.new.merchant(
136
+ 12678 # Merchant ID, get it from calling Skimlinks::MerchantSearch.merchants first (mandatory)
137
+ )
138
+
139
+ => #<Skimlinks::Merchant:0x00000105204f30 @id=12678, @name="Amazon US", @preferred={"commission"=>"8.5% General products\r\n4% Electronics", "commissionDetails"=>"Was 6% --&gt; NOW 8.5% General products!\r\nWas 3% --&gt; NOW 4% Electronics!", "description"=>"Amazon.com is the global leader in e-commerce. They launch new product categories and stores around the world as it offers customers greater selection, lower prices, more in-stock merchandise, and a best-in-class shopping experience.", "ecpc"=>"0.00", "featured_commission"=>nil, "pp_enabled"=>"1"}, @updated_at=2012-12-07 01:02:00 +0100, @average_conversion_rate="5.42%", @average_commission="6.36%", @logo_url="http://s.skimresources.com/logos/12678.jpeg", @domains={"6309"=>"amazon.com", "47172"=>"wireless.amazon.com", "119814"=>"amazonsupply.com"}, @categories={"12"=>"consumer electronics;mobiles, pdas & satnav", "50"=>"phones, tv & broadband subscriptions", "8"=>"consumer electronics", "9"=>"consumer electronics;audio, tv & home theatre", "10"=>"consumer electronics;cameras & photos", "11"=>"consumer electronics;gadgets & geeks", "13"=>"consumer electronics;mp3 players & accessories", "18"=>"fashion & accessories", "19"=>"fashion & accessories;belts & bags", "20"=>"fashion & accessories;children's clothing", "21"=>"fashion & accessories;jewelry", "22"=>"fashion & accessories;lingerie & sleepwear", "23"=>"fashion & accessories;men's clothing", "24"=>"fashion & accessories;shoes", "25"=>"fashion & accessories;women's clothing", "33"=>"gifts", "34"=>"gifts;chocolate", "35"=>"gifts;flowers", "36"=>"gifts;novelty", "40"=>"home & garden", "41"=>"home & garden;bed & bath", "42"=>"home & garden;diy", "43"=>"home & garden;furniture & interior design", "44"=>"home & garden;garden", "45"=>"home & garden;home appliances", "37"=>"health & beauty", "38"=>"health & beauty;cosmetics", "39"=>"health & beauty;health products"}, @countries=["united states"], @product_count=0>
140
+ ```
141
+
142
+ #### Get a list of merchant categories
143
+
144
+ ```ruby
145
+ Skimlinks::MerchantSearch.new.categories
146
+
147
+ => {
148
+ => "adult & mature" => 1, # Category name => category ID
149
+ => "arts, crafts & hobbies" => 2,
150
+ => "automotive, cars & bikes" => 3,
151
+ => "baby & parenting supplies" => 4,
152
+ => "books & magazines" => 5,
153
+ => "charities & non-profit" => 6,
154
+ => "computers & software" => 7,
155
+ => "consumer electronics" => 8,
156
+ => "consumer electronics > audio, tv & home theatre" => 9,
157
+ => "consumer electronics > cameras & photos" => 10,
158
+ => ...
159
+ => }
160
+ ```
161
+
162
+ #### Get a nested list of merchant categories
163
+
164
+ ```ruby
165
+ Skimlinks::MerchantSearch.new.nested_categories
166
+
167
+ => {
168
+ => "adult & mature" => nil,
169
+ => "arts, crafts & hobbies" => nil,
170
+ => "automotive, cars & bikes" => nil,
171
+ => "baby & parenting supplies" => nil,
172
+ => "books & magazines" => nil,
173
+ => "charities & non-profit" => nil,
174
+ => "computers & software" => nil,
175
+ => "consumer electronics" => {
176
+ => "audio, tv & home theatre" => nil,
177
+ => "cameras & photos" => nil,
178
+ => ...
179
+ => }
180
+ => }
181
+ ```
182
+
183
+ ## TODO
184
+
185
+ * Implement access to Link API and Reporting API
186
+
187
+ ## Contributing
188
+
189
+ 1. Fork it
190
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
191
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
192
+ 4. Push to the branch (`git push origin my-new-feature`)
193
+ 5. Create new Pull Request
@@ -1,4 +1,24 @@
1
+ require 'gem_config'
2
+ require 'active_support/cache'
3
+
1
4
  module Skimlinks
5
+ include GemConfig::Base
6
+
7
+ ApiError = Class.new(StandardError)
8
+ InvalidParameters = Class.new(StandardError)
9
+
10
+ with_configuration do
11
+ has :api_key, classes: String
12
+ has :format, values: :json, default: :json
13
+ has :cache, classes: ActiveSupport::Cache::Store
14
+ has :cache_ttl, classes: Numeric, default: 1.day
15
+ end
2
16
  end
3
17
 
4
18
  require 'skimlinks/version'
19
+ require 'skimlinks/client'
20
+ require 'skimlinks/merchant'
21
+ require 'skimlinks/product'
22
+ require 'skimlinks/search_helpers'
23
+ require 'skimlinks/merchant_search'
24
+ require 'skimlinks/product_search'
@@ -0,0 +1,208 @@
1
+ require 'json'
2
+ require 'rest-client'
3
+ require 'mechanize'
4
+
5
+ module Skimlinks
6
+ class Client
7
+ API_ENDPOINTS = {
8
+ product_api: 'http://api-product.skimlinks.com/',
9
+ merchant_api: 'http://api-merchants.skimlinks.com/merchants/',
10
+ link_api: 'http://go.productwidgets.com/'
11
+ }
12
+
13
+ attr_accessor *Skimlinks.configuration.rules.keys
14
+
15
+ def initialize(args = {})
16
+ config = Skimlinks.configuration.current.merge(args)
17
+
18
+ Skimlinks.configuration.rules.keys.each do |key|
19
+ self.send "#{key}=", config[key]
20
+ end
21
+
22
+ @product_api = RestClient::Resource.new(API_ENDPOINTS[:product_api])
23
+ @merchant_api = RestClient::Resource.new(API_ENDPOINTS[:merchant_api])
24
+ @mechanize = Mechanize.new do |m|
25
+ m.agent.redirect_ok = false
26
+ m.agent.http.verify_mode = OpenSSL::SSL::VERIFY_NONE
27
+ end
28
+ end
29
+
30
+ # Product API
31
+
32
+ def product_search(args)
33
+ product_count_and_products(args).last
34
+ end
35
+
36
+ def product_count(args)
37
+ product_count_and_products(args.merge(rows: 0)).first
38
+ end
39
+
40
+ def product_categories
41
+ @product_categories ||= product_api('categories')['skimlinksProductAPI']['categories']
42
+ end
43
+
44
+ # Merchant API
45
+
46
+ def merchant_categories
47
+ @merchant_categories ||= merchant_api('categories')
48
+ end
49
+
50
+ def merchant_category_ids
51
+ @merchant_category_ids ||= flatten(self.merchant_categories).grep(/^\d+$/).uniq.map(&:to_i)
52
+ end
53
+
54
+ def merchants_in_category(category_id)
55
+ [].tap do |merchants|
56
+ start, found = 0, nil
57
+
58
+ while found.nil? || start < found
59
+ data = merchant_api('category', category_id, 'limit', 200, 'start', start)
60
+
61
+ merchants.concat data['merchants'] if data['merchants'].present?
62
+
63
+ start = data['numStarted'].to_i + data['numReturned'].to_i
64
+ found = data['numFound']
65
+ end
66
+ end
67
+ end
68
+
69
+ def merchant_search(query, preferred = false)
70
+ [].tap do |merchants|
71
+ start, found = 0, nil
72
+
73
+ while found.nil? || start < found
74
+ data = merchant_api('search', query, 'limit', 200, 'start', start, preferred ? '?filter_by=preferred' : nil)
75
+
76
+ merchants.concat data['merchants'] if data['merchants'].present?
77
+
78
+ start = data['numStarted'].to_i + data['numReturned'].to_i
79
+ found = data['numFound']
80
+ end
81
+ end
82
+ end
83
+
84
+ # Link API
85
+
86
+ def affiliate(url, publisher_id)
87
+ link_api url, publisher_id
88
+ end
89
+
90
+ private
91
+
92
+ def flatten(object)
93
+ case object
94
+ when Hash
95
+ object.to_a.map { |v| flatten(v) }.flatten
96
+ when Array
97
+ object.flatten.map { |v| flatten(v) }
98
+ else
99
+ object
100
+ end
101
+ end
102
+
103
+ def returning_json(&block)
104
+ JSON.parse block.call
105
+ end
106
+
107
+ def returning_xml(&block)
108
+ Nokogiri::XML block.call
109
+ end
110
+
111
+ def product_count_and_products(args)
112
+ api_query = []
113
+ api_query << %(id:(#{args[:ids].join(' ')})) if args[:ids].present?
114
+ api_query << %((#{%w(title description).map { |field| %(#{field}:"#{args[:query]}") }.join(' OR ')})) if args[:query].present?
115
+ api_query << %(price:[#{args[:min_price].presence || '*'} TO #{args[:max_price].presence || '*'}]) if args[:min_price].present? || args[:max_price].present?
116
+ api_query << %(categoryId:(#{args[:category_ids].join(' ')})) if args[:category_ids].present?
117
+ api_query << %(merchantId:"#{args[:merchant_id]}") if args[:merchant_id].present?
118
+ api_query << %(country:"#{args[:country]}") if args[:country].present?
119
+ api_query << %(currency:"#{args[:currency]}") if args[:currency].present?
120
+
121
+ # TODO: Check for categoryId 0, '' or nil, missing categoryId
122
+
123
+ query_params = {
124
+ q: api_query.join(' AND ')
125
+ }
126
+ query_params[:rows] = args[:rows] if args[:rows].present?
127
+ query_params[:start] = args[:start] if args[:start].present?
128
+
129
+ product_data = product_api('query', query_params)['skimlinksProductAPI']
130
+
131
+ [product_data['numFound'], product_data['products']]
132
+ end
133
+
134
+ def get(api, path, params = {})
135
+ raise Skimlinks::InvalidParameters, 'Only JSON format is supported right now.' unless Skimlinks.configuration.format == :json
136
+
137
+ do_get = lambda do
138
+ returning_json do
139
+ api[URI.escape(path)].get params: params
140
+ end
141
+ end
142
+
143
+ if Skimlinks.configuration.cache.nil?
144
+ do_get.call
145
+ else
146
+ cache_key = [
147
+ 'skimlinks',
148
+ 'api',
149
+ Digest::MD5.hexdigest(api.to_s + path + params.to_s)
150
+ ].join(':')
151
+ cache_options = Skimlinks.configuration.cache_ttl > 0 ? { expires_in: Skimlinks.configuration.cache_ttl } : {}
152
+ Skimlinks.configuration.cache.fetch cache_key, cache_options do
153
+ do_get.call
154
+ end
155
+ end
156
+ rescue RestClient::Exception => e
157
+ message = [e.message].tap do |message_parts|
158
+ error = JSON.parse(e.response)['skimlinksProductAPI']['message'] rescue nil
159
+ message_parts << error if error.present?
160
+ end.join(' - ')
161
+ raise Skimlinks::ApiError, message
162
+ end
163
+
164
+ def product_api(method, params = {})
165
+ raise Skimlinks::InvalidParameters, 'API key not configured' if Skimlinks.configuration.api_key.blank?
166
+
167
+ params = params.reverse_merge(
168
+ format: Skimlinks.configuration.format,
169
+ key: Skimlinks.configuration.api_key
170
+ )
171
+
172
+ get(@product_api, method, params).tap do |response|
173
+ raise Skimlinks::InvalidParameters, 'API key is invalid' if response.is_a?(Array) && response.first =~ /^Invalid API key/
174
+ end
175
+ end
176
+
177
+ def merchant_api(method, *params)
178
+ raise Skimlinks::InvalidParameters, 'API key not configured' if Skimlinks.configuration.api_key.blank?
179
+
180
+ path = [
181
+ Skimlinks.configuration.format,
182
+ Skimlinks.configuration.api_key,
183
+ method,
184
+ *params.compact
185
+ ].join('/')
186
+
187
+ get(@merchant_api, path).tap do |response|
188
+ raise Skimlinks::InvalidParameters, 'API key is invalid' if response.is_a?(Array) && response.first =~ /^Invalid API key/
189
+ end
190
+ end
191
+
192
+ def link_api(url, publisher_id)
193
+ query_params = { url: CGI.escape(url), id: publisher_id, xs: 1 }
194
+ path = [API_ENDPOINTS[:link_api], URI.encode_www_form(query_params)].join('?')
195
+ response = @mechanize.head(path)
196
+
197
+ raise Skimlinks::ApiError, "Unexpected response code: #{response.code}" unless response.code == '302'
198
+
199
+ redirect_location = response['location']
200
+ case redirect_location
201
+ when url, %r(http://www\.google\.com/search\?q=)
202
+ nil
203
+ else
204
+ redirect_location
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,35 @@
1
+ require 'active_support/hash_with_indifferent_access'
2
+
3
+ module Skimlinks
4
+ class Merchant
5
+ attr_accessor :id, :name, :preferred, :updated_at, :average_conversion_rate, :average_commission, :logo_url, :domains, :categories, :countries, :product_count
6
+
7
+ class << self
8
+ def build_from_api_response(merchant_data)
9
+ merchant_data.map do |merchant|
10
+ self.new \
11
+ id: merchant['merchantID'].to_i,
12
+ name: merchant['merchantName'].presence,
13
+ preferred: HashWithIndifferentAccess.new(merchant['preferred']),
14
+ updated_at: merchant['dateUpdated'].present? ? Time.parse(merchant['dateUpdated']) : nil,
15
+ average_conversion_rate: merchant['averageConversionRate'].presence,
16
+ average_commission: merchant['averageCommission'].presence,
17
+ logo_url: merchant['logo'].presence,
18
+ domains: HashWithIndifferentAccess.new(merchant['domains']),
19
+ categories: HashWithIndifferentAccess.new(merchant['categories']),
20
+ countries: Array(merchant['countries'].presence)
21
+ end.sort_by(&:name)
22
+ end
23
+ end
24
+
25
+ def initialize(args = {})
26
+ args.each do |k, v|
27
+ self.send "#{k}=", v
28
+ end
29
+ end
30
+
31
+ def preferred?
32
+ self.preferred.present?
33
+ end
34
+ end
35
+ end