base_scraper_service 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 69eb49ba30aa10adba230399397bc900df7f46b10da8c5ef7e02d00743fd7407
4
+ data.tar.gz: a7534672884d78323aa41838e1048c1cfff7a59d19a7f633034eaf9e8dc680d0
5
+ SHA512:
6
+ metadata.gz: b8957504e31a3a5519538bd7b5fecdb7837f502b5cdccea51c59d3c585bb26f5dc7642449808e3913d361ee778af74e06708dc54b5ed771cb8839434f1e7834c
7
+ data.tar.gz: 6227de3f572c770425145ef074dbbdbec0cebf8cd0c7f95ba4cc1d00a0ef24e60534e3537d5ae305d1a6af41688304b55fea00b4bd78b6a3b0e951ee5f28dea4
@@ -0,0 +1,28 @@
1
+ require_relative 'user_agent'
2
+ require "mechanize"
3
+ require "nokogiri"
4
+
5
+ module BaseScraper
6
+ class Service
7
+ def agent_object
8
+ agent = Mechanize.new
9
+ agent.read_timeout = 5
10
+ agent.open_timeout = 8
11
+ agent.keep_alive = false
12
+ agent.verify_mode = OpenSSL::SSL::VERIFY_NONE
13
+ agent.user_agent = UserAgent.random
14
+ agent.idle_timeout = 5
15
+ agent.pluggable_parser.default = Mechanize::Page
16
+
17
+ # Prevent from <Mechanize::Error: unsupported content-encoding: UTF-8>
18
+ content_encoding_hooks_func = lambda do |a, uri, resp, body_io|
19
+ if resp['Content-Encoding'].to_s == 'UTF-8'
20
+ resp['Content-Encoding'] = 'gzip'
21
+ end
22
+ end
23
+ agent.content_encoding_hooks << content_encoding_hooks_func
24
+
25
+ agent
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,94 @@
1
+ require 'sinatra'
2
+ require 'dotenv'
3
+ require "./#{ENV['SERVICE_NAME']}_offers_service"
4
+
5
+ configure { set :server, :puma }
6
+
7
+ class Base < Sinatra::Base
8
+
9
+ get "/#{ENV['SERVICE_NAME']}/isbn_offers" do
10
+ isbn = params[:isbn]
11
+ uuid = SecureRandom.uuid
12
+
13
+ @res = {
14
+ isbn: isbn,
15
+ uuid: uuid,
16
+ item_condition: params[:item_condition],
17
+ ship_type: params[:ship_type]
18
+ }
19
+
20
+ if valid_params?
21
+ service_response = Object.const_get(get_service_name).new(isbn, uuid).scrape_offers
22
+ offers = service_response[:offers]
23
+
24
+ offers = filter_offers(offers)
25
+ offers.sort_by! { |offer| offer[:total_price] }
26
+ offers_count = offers.count
27
+
28
+ @res[:status] = service_response[:status]
29
+ @res[:short_message] = service_response[:short_message]
30
+ @res[:long_message] = service_response[:long_message]
31
+ if @res[:status] == 200
32
+ @res[:offers_count] = offers.count
33
+ @res[:data] = { offers: offers }
34
+ end
35
+ else
36
+ @res[:status] = 400
37
+ @res[:short_message] = 'error'
38
+ end
39
+
40
+ halt @res[:status], @res.to_json
41
+ end
42
+
43
+ not_found do
44
+ send_file( 'public/404.html', { status: 404 })
45
+ end
46
+
47
+ def get_service_name
48
+ ENV['SERVICE_NAME'].capitalize + 'OffersService'
49
+ end
50
+
51
+ def send_sinatra_file(path)
52
+ file_path = File.join(File.dirname(__FILE__), 'public', path)
53
+ file_path = File.join(file_path, 'index.html') unless file_path =~ /\.[a-z]+$/i and !File.directory?(file_path)
54
+ File.exist?(file_path) ? send_file(file_path) : not_found
55
+ end
56
+
57
+ def filter_offers(offers)
58
+ offers = offers.select{ |offer| offer[:Item_Condition].downcase == params[:item_condition].downcase} if params[:item_condition].present?
59
+ offers = offers.select{ |offer| offer[:ship_type].downcase == params[:ship_type].downcase} if params[:ship_type].present?
60
+
61
+ offers
62
+ end
63
+
64
+ def constantize(lower_case_string)
65
+ "#{lower_case_string.split('_').collect(&:capitalize).join + 'OffersService'}".constantize
66
+ end
67
+
68
+ def valid_params?
69
+ valid_isbn?(params[:isbn]) &&
70
+ valid_book_condition?(params[:item_condition].to_s) &&
71
+ valid_ship_type?(params[:ship_type].to_s)
72
+ end
73
+
74
+ def valid_isbn?(isbn)
75
+ return true if StdNum::ISBN.valid?(isbn) && [10, 13].include?(isbn.size)
76
+
77
+ @res[:long_message] = 'invalid isbn'
78
+ false
79
+ end
80
+
81
+ def valid_book_condition?(item_condition)
82
+ return true if ['', 'used', 'new'].include?(item_condition.downcase)
83
+
84
+ @res[:long_message] = 'invalid item condition'
85
+ false
86
+ end
87
+
88
+ def valid_ship_type?(ship_type)
89
+ return true if ['', 'standard', 'expedited'].include?(ship_type.downcase)
90
+
91
+ @res[:long_message] = 'invalid ship type'
92
+ false
93
+ end
94
+ end
@@ -0,0 +1,90 @@
1
+ require 'mechanize'
2
+ require 'nokogiri'
3
+ require 'thread'
4
+ require 'fileutils'
5
+ require 'logger'
6
+ require "dotenv"
7
+ require 'csv'
8
+ require 'date'
9
+ require 'active_support/time'
10
+ require 'library_stdnums'
11
+ require 'base_scraper_service'
12
+ require_relative 'user_agent'
13
+ require_relative 'agent_object'
14
+
15
+ module BaseScraper
16
+ class Service
17
+ def send_request(agent, uri, params = nil, headers = {}, _optoins = {})
18
+ tries = 0
19
+ max_tries = 5
20
+ page = nil
21
+
22
+ refresh_agent(agent)
23
+ change_proxy(agent)
24
+ begin
25
+ page = params.nil? ? agent.get(uri, [], nil, headers) : agent.post(uri, params, headers)
26
+
27
+ rescue Mechanize::ResponseCodeError => e
28
+ change_proxy(agent)
29
+
30
+ tries += 1
31
+
32
+ agent.set_proxy(ENV["LPM_SERVER_URL"], ENV["LPM_SERVER_PORT"]) if tries >= 4
33
+
34
+ retry if tries < max_tries
35
+ rescue Exception => e
36
+ refresh_agent(agent)
37
+ change_proxy(agent)
38
+
39
+ tries += 1
40
+
41
+ agent.set_proxy(ENV["LPM_SERVER_URL"], ENV["LPM_SERVER_PORT"]) if tries >= 4
42
+
43
+ retry if tries < max_tries
44
+ puts "Message: #{e.message} from #{uri}, proxy: #{agent.proxy_addr}" if logs_enable?
45
+ end
46
+
47
+ page
48
+ end
49
+
50
+ def format_price(price)
51
+ return unless price.present?
52
+ price.to_s.strip.gsub("$", "").strip.to_f
53
+ end
54
+
55
+ def format_isbn(isbn)
56
+ StdNum::ISBN.convert_to_13(isbn)
57
+ end
58
+
59
+ def with_exception(long_message: "", status: 500, short_message: "exception")
60
+ return { offers: [], short_message: short_message, long_message: long_message, status: status }
61
+ end
62
+
63
+ def logs_enable?
64
+ ENV["ENABLE_LOGS"] == "true"
65
+ end
66
+
67
+ private
68
+
69
+ def refresh_agent(agent)
70
+ agent.cookie_jar.clear!
71
+ agent.history.clear
72
+ agent.user_agent = UserAgent.random
73
+ end
74
+
75
+ def get_proxy_index
76
+ (0..(@proxy_array.count - 1)).to_a.sample
77
+ end
78
+
79
+ def change_proxy(agent)
80
+ if @proxy_array.count > 0
81
+ cur_proxy_index = get_proxy_index
82
+ if @proxy_array[cur_proxy_index].present?
83
+ agent.set_proxy(@proxy_array[cur_proxy_index][0], @proxy_array[cur_proxy_index][1])
84
+ end
85
+ else
86
+ agent.set_proxy(ENV['LPM_SERVER_URL'], ENV['LPM_SERVER_PORT'])
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,348 @@
1
+ module BaseScraper
2
+ class Service
3
+ class LocationService
4
+ def initialize(address)
5
+ @address = address.to_s.gsub('.', '')
6
+ end
7
+
8
+ def extract_state
9
+ # 1- return state abbreviation if matched with state abbreviations
10
+ # 2- return state abbreviation if matched with state name
11
+ # 3- otherwise return empty string
12
+ state = us_states_hash.keys.select { |state_abbreviation| @address.upcase.include?(state_abbreviation) }.first
13
+ return state if state.present? && @address.length != 3
14
+ state = us_states_hash.select { |_, state| @address.include?(state) }.first&.first
15
+
16
+ return state.to_s
17
+ end
18
+
19
+ def extract_country
20
+ # 1- return country abbreviation if matched with country abbreviations
21
+ # 2- return country abbreviation if matched with country name
22
+ # 3- return USA if address contains a USA state
23
+ # 4- otherwise return empty string
24
+ country = countries_hash.keys.select { |country_abbreviation| @address.include?(country_abbreviation) }.first
25
+ puts country, @address
26
+ return country if country.present?
27
+ country = countries_hash.select { |_, country_name| @address.include?(country_name) }.first&.first
28
+ return country if country.present?
29
+
30
+ return 'USA' if extract_state.present?
31
+
32
+ return country.to_s
33
+ end
34
+
35
+ private
36
+
37
+ def us_states_hash
38
+ {
39
+ 'AL' => 'Alabama',
40
+ 'AK' => 'Alaska',
41
+ 'AZ' => 'Arizona',
42
+ 'AR' => 'Arkansas',
43
+ 'CA' => 'California',
44
+ 'CO' => 'Colorado',
45
+ 'CT' => 'Connecticut',
46
+ 'DE' => 'Delaware',
47
+ 'DC' => 'District Of Columbia',
48
+ 'FL' => 'Florida',
49
+ 'GA' => 'Georgia',
50
+ 'HI' => 'Hawaii',
51
+ 'ID' => 'Idaho',
52
+ 'IL' => 'Illinois',
53
+ 'IN' => 'Indiana',
54
+ 'IA' => 'Iowa',
55
+ 'KS' => 'Kansas',
56
+ 'KY' => 'Kentucky',
57
+ 'LA' => 'Louisiana',
58
+ 'ME' => 'Maine',
59
+ 'MD' => 'Maryland',
60
+ 'MA' => 'Massachusetts',
61
+ 'MI' => 'Michigan',
62
+ 'MN' => 'Minnesota',
63
+ 'MS' => 'Mississippi',
64
+ 'MO' => 'Missouri',
65
+ 'MT' => 'Montana',
66
+ 'NE' => 'Nebraska',
67
+ 'NV' => 'Nevada',
68
+ 'NH' => 'New Hampshire',
69
+ 'NJ' => 'New Jersey',
70
+ 'NM' => 'New Mexico',
71
+ 'NY' => 'New York',
72
+ 'NC' => 'North Carolina',
73
+ 'ND' => 'North Dakota',
74
+ 'OH' => 'Ohio',
75
+ 'OK' => 'Oklahoma',
76
+ 'OR' => 'Oregon',
77
+ 'PA' => 'Pennsylvania',
78
+ 'RI' => 'Rhode Island',
79
+ 'SC' => 'South Carolina',
80
+ 'SD' => 'South Dakota',
81
+ 'TN' => 'Tennessee',
82
+ 'TX' => 'Texas',
83
+ 'UT' => 'Utah',
84
+ 'VT' => 'Vermont',
85
+ 'VA' => 'Virginia',
86
+ 'WA' => 'Washington',
87
+ 'WV' => 'West Virginia',
88
+ 'WI' => 'Wisconsin',
89
+ 'WY' => 'Wyoming'
90
+ }
91
+ end
92
+
93
+ def countries_hash
94
+ {
95
+ 'AFG' => 'Afghanistan',
96
+ 'ALB' => 'Albania',
97
+ 'DZA' => 'Algeria',
98
+ 'ASM' => 'American Samoa',
99
+ 'AND' => 'Andorra',
100
+ 'AGO' => 'Angola',
101
+ 'AIA' => 'Anguilla',
102
+ 'ATA' => 'Antarctica',
103
+ 'ATG' => 'Antigua and Barbuda',
104
+ 'ARG' => 'Argentina',
105
+ 'ARM' => 'Armenia',
106
+ 'ABW' => 'Aruba',
107
+ 'AUS' => 'Australia',
108
+ 'AUT' => 'Austria',
109
+ 'AZE' => 'Azerbaijan',
110
+ 'BHS' => 'Bahamas',
111
+ 'BHR' => 'Bahrain',
112
+ 'BGD' => 'Bangladesh',
113
+ 'BRB' => 'Barbados',
114
+ 'BLR' => 'Belarus',
115
+ 'BEL' => 'Belgium',
116
+ 'BLZ' => 'Belize',
117
+ 'BEN' => 'Benin',
118
+ 'BMU' => 'Bermuda',
119
+ 'BTN' => 'Bhutan',
120
+ 'BOL' => 'Bolivia',
121
+ 'BES' => 'Bonaire, Sint Eustatius and Saba',
122
+ 'BIH' => 'Bosnia and Herzegovina',
123
+ 'BWA' => 'Botswana',
124
+ 'BVT' => 'Bouvet Island',
125
+ 'BRA' => 'Brazil',
126
+ 'IOT' => 'British Indian Ocean Territory',
127
+ 'BRN' => 'Brunei Darussalam',
128
+ 'BGR' => 'Bulgaria',
129
+ 'BFA' => 'Burkina Faso',
130
+ 'BDI' => 'Burundi',
131
+ 'CPV' => 'Cabo Verde',
132
+ 'KHM' => 'Cambodia',
133
+ 'CMR' => 'Cameroon',
134
+ 'CAN' => 'Canada',
135
+ 'CYM' => 'Cayman Islands',
136
+ 'CAF' => 'Central African Republic',
137
+ 'TCD' => 'Chad',
138
+ 'CHL' => 'Chile',
139
+ 'CHN' => 'China',
140
+ 'CXR' => 'Christmas Island',
141
+ 'CCK' => 'Cocos (Keeling) Islands',
142
+ 'COL' => 'Colombia',
143
+ 'COM' => 'Comoros',
144
+ 'COD' => 'Congo',
145
+ 'COG' => 'Congo',
146
+ 'COK' => 'Cook Islands',
147
+ 'CRI' => 'Costa Rica',
148
+ 'HRV' => 'Croatia',
149
+ 'CUB' => 'Cuba',
150
+ 'CUW' => 'Curaçao',
151
+ 'CYP' => 'Cyprus',
152
+ 'CZE' => 'Czechia',
153
+ 'CIV' => "Côte d'Ivoire",
154
+ 'DNK' => 'Denmark',
155
+ 'DJI' => 'Djibouti',
156
+ 'DMA' => 'Dominica',
157
+ 'DOM' => 'Dominican Republic',
158
+ 'ECU' => 'Ecuador',
159
+ 'EGY' => 'Egypt',
160
+ 'SLV' => 'El Salvador',
161
+ 'GNQ' => 'Equatorial Guinea',
162
+ 'ERI' => 'Eritrea',
163
+ 'EST' => 'Estonia',
164
+ 'SWZ' => 'Eswatini',
165
+ 'ETH' => 'Ethiopia',
166
+ 'FLK' => 'Falkland Islands',
167
+ 'FRO' => 'Faroe Islands',
168
+ 'FJI' => 'Fiji',
169
+ 'FIN' => 'Finland',
170
+ 'FRA' => 'France',
171
+ 'GUF' => 'French Guiana',
172
+ 'PYF' => 'French Polynesia',
173
+ 'ATF' => 'French Southern Territories',
174
+ 'GAB' => 'Gabon',
175
+ 'GMB' => 'Gambia',
176
+ 'GEO' => 'Georgia',
177
+ 'DEU' => 'Germany',
178
+ 'GHA' => 'Ghana',
179
+ 'GIB' => 'Gibraltar',
180
+ 'GRC' => 'Greece',
181
+ 'GRL' => 'Greenland',
182
+ 'GRD' => 'Grenada',
183
+ 'GLP' => 'Guadeloupe',
184
+ 'GUM' => 'Guam',
185
+ 'GTM' => 'Guatemala',
186
+ 'GGY' => 'Guernsey',
187
+ 'GIN' => 'Guinea',
188
+ 'GNB' => 'Guinea-Bissau',
189
+ 'GUY' => 'Guyana',
190
+ 'HTI' => 'Haiti',
191
+ 'HMD' => 'Heard Island and McDonald Islands',
192
+ 'VAT' => 'Holy See',
193
+ 'HND' => 'Honduras',
194
+ 'HKG' => 'Hong Kong',
195
+ 'HUN' => 'Hungary',
196
+ 'ISL' => 'Iceland',
197
+ 'IND' => 'India',
198
+ 'IDN' => 'Indonesia',
199
+ 'IRN' => 'Iran',
200
+ 'IRQ' => 'Iraq',
201
+ 'IRL' => 'Ireland',
202
+ 'IMN' => 'Isle of Man',
203
+ 'ISR' => 'Israel',
204
+ 'ITA' => 'Italy',
205
+ 'JAM' => 'Jamaica',
206
+ 'JPN' => 'Japan',
207
+ 'JEY' => 'Jersey',
208
+ 'JOR' => 'Jordan',
209
+ 'KAZ' => 'Kazakhstan',
210
+ 'KEN' => 'Kenya',
211
+ 'KIR' => 'Kiribati',
212
+ 'PRK' => "Korea",
213
+ 'KOR' => 'Korea',
214
+ 'KWT' => 'Kuwait',
215
+ 'KGZ' => 'Kyrgyzstan',
216
+ 'LAO' => "Lao People's Democratic Republic",
217
+ 'LVA' => 'Latvia',
218
+ 'LBN' => 'Lebanon',
219
+ 'LSO' => 'Lesotho',
220
+ 'LBR' => 'Liberia',
221
+ 'LBY' => 'Libya',
222
+ 'LIE' => 'Liechtenstein',
223
+ 'LTU' => 'Lithuania',
224
+ 'LUX' => 'Luxembourg',
225
+ 'MAC' => 'Macao',
226
+ 'MDG' => 'Madagascar',
227
+ 'MWI' => 'Malawi',
228
+ 'MYS' => 'Malaysia',
229
+ 'MDV' => 'Maldives',
230
+ 'MLI' => 'Mali',
231
+ 'MLT' => 'Malta',
232
+ 'MHL' => 'Marshall Islands',
233
+ 'MTQ' => 'Martinique',
234
+ 'MRT' => 'Mauritania',
235
+ 'MUS' => 'Mauritius',
236
+ 'MYT' => 'Mayotte',
237
+ 'MEX' => 'Mexico',
238
+ 'FSM' => 'Micronesia',
239
+ 'MDA' => 'Moldova',
240
+ 'MCO' => 'Monaco',
241
+ 'MNG' => 'Mongolia',
242
+ 'MNE' => 'Montenegro',
243
+ 'MSR' => 'Montserrat',
244
+ 'MAR' => 'Morocco',
245
+ 'MOZ' => 'Mozambique',
246
+ 'MMR' => 'Myanmar',
247
+ 'NAM' => 'Namibia',
248
+ 'NRU' => 'Nauru',
249
+ 'NPL' => 'Nepal',
250
+ 'NLD' => 'Netherlands',
251
+ 'NCL' => 'New Caledonia',
252
+ 'NZL' => 'New Zealand',
253
+ 'NIC' => 'Nicaragua',
254
+ 'NER' => 'Niger',
255
+ 'NGA' => 'Nigeria',
256
+ 'NIU' => 'Niue',
257
+ 'NFK' => 'Norfolk Island',
258
+ 'MNP' => 'Northern Mariana Islands',
259
+ 'NOR' => 'Norway',
260
+ 'OMN' => 'Oman',
261
+ 'PAK' => 'Pakistan',
262
+ 'PLW' => 'Palau',
263
+ 'PSE' => 'Palestine, State of',
264
+ 'PAN' => 'Panama',
265
+ 'PNG' => 'Papua New Guinea',
266
+ 'PRY' => 'Paraguay',
267
+ 'PER' => 'Peru',
268
+ 'PHL' => 'Philippines',
269
+ 'PCN' => 'Pitcairn',
270
+ 'POL' => 'Poland',
271
+ 'PRT' => 'Portugal',
272
+ 'PRI' => 'Puerto Rico',
273
+ 'QAT' => 'Qatar',
274
+ 'MKD' => 'Republic of North Macedonia',
275
+ 'ROU' => 'Romania',
276
+ 'RUS' => 'Russian Federation',
277
+ 'RWA' => 'Rwanda',
278
+ 'REU' => 'Réunion',
279
+ 'BLM' => 'Saint Barthélemy',
280
+ 'SHN' => 'Saint Helena, Ascension and Tristan da Cunha',
281
+ 'KNA' => 'Saint Kitts and Nevis',
282
+ 'LCA' => 'Saint Lucia',
283
+ 'MAF' => 'Saint Martin',
284
+ 'SPM' => 'Saint Pierre and Miquelon',
285
+ 'VCT' => 'Saint Vincent and Grenadines',
286
+ 'WSM' => 'Samoa',
287
+ 'SMR' => 'San Marino',
288
+ 'STP' => 'Sao Tome and Principe',
289
+ 'SAU' => 'Saudi Arabia',
290
+ 'SEN' => 'Senegal',
291
+ 'SRB' => 'Serbia',
292
+ 'SYC' => 'Seychelles',
293
+ 'SLE' => 'Sierra Leone',
294
+ 'SGP' => 'Singapore',
295
+ 'SXM' => 'Sint Maarten',
296
+ 'SVK' => 'Slovakia',
297
+ 'SVN' => 'Slovenia',
298
+ 'SLB' => 'Solomon Islands',
299
+ 'SOM' => 'Somalia',
300
+ 'ZAF' => 'South Africa',
301
+ 'SGS' => 'South Georgia and South Sandwich Islands',
302
+ 'SSD' => 'South Sudan',
303
+ 'ESP' => 'Spain',
304
+ 'LKA' => 'Sri Lanka',
305
+ 'SDN' => 'Sudan',
306
+ 'SUR' => 'Suriname',
307
+ 'SJM' => 'Svalbard and Jan Mayen',
308
+ 'SWE' => 'Sweden',
309
+ 'CHE' => 'Switzerland',
310
+ 'SYR' => 'Syrian Arab Republic',
311
+ 'TWN' => 'Taiwan',
312
+ 'TJK' => 'Tajikistan',
313
+ 'TZA' => 'Tanzania, United Republic of',
314
+ 'THA' => 'Thailand',
315
+ 'TLS' => 'Timor-Leste',
316
+ 'TGO' => 'Togo',
317
+ 'TKL' => 'Tokelau',
318
+ 'TON' => 'Tonga',
319
+ 'TTO' => 'Trinidad and Tobago',
320
+ 'TUN' => 'Tunisia',
321
+ 'TUR' => 'Turkey',
322
+ 'TKM' => 'Turkmenistan',
323
+ 'TCA' => 'Turks and Caicos Islands',
324
+ 'TUV' => 'Tuvalu',
325
+ 'UGA' => 'Uganda',
326
+ 'UKR' => 'Ukraine',
327
+ 'ARE' => 'United Arab Emirates',
328
+ 'GBR' => 'United Kingdom of Great Britain and Northern Ireland',
329
+ 'UMI' => 'United States Minor Outlying Islands',
330
+ 'USA' => 'United States of America',
331
+ 'URY' => 'Uruguay',
332
+ 'UZB' => 'Uzbekistan',
333
+ 'VUT' => 'Vanuatu',
334
+ 'VEN' => 'Venezuela',
335
+ 'VNM' => 'Viet Nam',
336
+ 'VGB' => 'Virgin Islands',
337
+ 'VIR' => 'Virgin Islands',
338
+ 'WLF' => 'Wallis and Futuna',
339
+ 'ESH' => 'Western Sahara',
340
+ 'YEM' => 'Yemen',
341
+ 'ZMB' => 'Zambia',
342
+ 'ZWE' => 'Zimbabwe',
343
+ 'ALA' => 'Åland Islands'
344
+ }
345
+ end
346
+ end
347
+ end
348
+ end