extraspace 0.3.0 → 0.5.0

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
  SHA256:
3
- metadata.gz: e31518705b665cfcff3914a8553164f1463a3c66140cb41ea14be9154abf44fa
4
- data.tar.gz: bf86b8674cf0557e239b286acaca2f77d1688bd3f431434ad15914db9624a143
3
+ metadata.gz: a6b9f72cb5e07f8752d98b4f9a03ae259d29e434bc136a83c131355ca805303c
4
+ data.tar.gz: 42db36f6586bf84339fd09b9acacdba161886a23087932a9dd7d392bb30bd5bd
5
5
  SHA512:
6
- metadata.gz: 6204e8de2f7349debac7049c5ca1ade4fc0b5328be5faaf61eefb42d982e735897c28fc6292ac98c6a40e36dadf94550fcd30f5a066b3eb58ba8de7db3c2c20d
7
- data.tar.gz: edd94f3a7d6dadc415f112f7dd3fa1ff7e8ae5fd8592794e3a218a3806e5ec7777659eeaee3bde305af51eee67f23fb5b5862925f51fc54d22ff5aa5ad863f34
6
+ metadata.gz: eaa2e29a14042f4609f6aa3e7534a7e281fa7831cfd9254b8dc57309ded56fe14063fe8ce730d11a4f569e7068617eb1ab3bfdb541689de57a21d236313e751f
7
+ data.tar.gz: 2d15bad80e64d83162ab01665752d16abc198ccc65299a815c550e9eed5f121810c120ae46a0f560dc59bf8d4a0291e0de85c3470acd3bf9d9c026d34d823754
data/README.md CHANGED
@@ -12,6 +12,17 @@
12
12
  gem install extrapsace
13
13
  ```
14
14
 
15
+ ## Configuration
16
+
17
+ ```ruby
18
+ require 'extraspace'
19
+
20
+ ExtraSpace.configure do |config|
21
+ config.user_agent = '../..' # ENV['EXTRASPACE_USER_AGENT']
22
+ config.timeout = 30 # ENV['EXTRASPACE_TIMEOUT']
23
+ end
24
+ ```
25
+
15
26
  ## Usage
16
27
 
17
28
  ```ruby
@@ -19,6 +19,20 @@ module ExtraSpace
19
19
  # @return [String]
20
20
  attr_accessor :zip
21
21
 
22
+ # @param data [Hash]
23
+ #
24
+ # @return [Address]
25
+ def self.parse(data:)
26
+ lines = %w[line1 line2 line3 line4].map { |key| data[key] }
27
+
28
+ new(
29
+ street: lines.compact.reject(&:empty?).join(' '),
30
+ city: data['city'],
31
+ state: data['stateName'],
32
+ zip: data['postalCode']
33
+ )
34
+ end
35
+
22
36
  # @param street [String]
23
37
  # @param city [String]
24
38
  # @param state [String]
@@ -45,19 +59,5 @@ module ExtraSpace
45
59
  def text
46
60
  "#{street}, #{city}, #{state} #{zip}"
47
61
  end
48
-
49
- # @param data [Hash]
50
- #
51
- # @return [Address]
52
- def self.parse(data:)
53
- lines = %w[line1 line2 line3 line4].map { |key| data[key] }
54
-
55
- new(
56
- street: lines.compact.reject(&:empty?).join(' '),
57
- city: data['city'],
58
- state: data['stateName'],
59
- zip: data['postalCode']
60
- )
61
- end
62
62
  end
63
63
  end
@@ -31,7 +31,7 @@ module ExtraSpace
31
31
  private
32
32
 
33
33
  def crawl
34
- ExtraSpace::Facility.crawl
34
+ Crawl.run
35
35
  exit(Code::OK)
36
36
  end
37
37
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExtraSpace
4
+ # The core configuration.
5
+ class Config
6
+ # @attribute [rw] accept_language
7
+ # @return [String]
8
+ attr_accessor :accept_language
9
+
10
+ # @attribute [rw] user_agent
11
+ # @return [String]
12
+ attr_accessor :user_agent
13
+
14
+ # @attribute [rw] timeout
15
+ # @return [Integer]
16
+ attr_accessor :timeout
17
+
18
+ # @attribute [rw] proxy_url
19
+ # @return [String]
20
+ attr_accessor :proxy_url
21
+
22
+ def initialize
23
+ @accept_language = ENV.fetch('EXTRASPACE_ACCEPT_LANGUAGE', 'en-US,en;q=0.9')
24
+ @user_agent = ENV.fetch('EXTRASPACE_USER_AGENT', "extraspace.rb/#{VERSION}")
25
+ @timeout = Integer(ENV.fetch('EXTRASPACE_TIMEOUT', 60))
26
+ @proxy_url = ENV.fetch('EXTRASPACE_PROXY_URL', nil)
27
+ end
28
+
29
+ # @return [Boolean]
30
+ def headers?
31
+ !@user_agent.nil?
32
+ end
33
+
34
+ # @return [Boolean]
35
+ def timeout?
36
+ !@timeout.zero?
37
+ end
38
+
39
+ # @return [Boolean]
40
+ def proxy?
41
+ !@proxy_url.nil?
42
+ end
43
+
44
+ # @return [Hash<String, String>] e.g { 'User-Agent' => 'extraspace.rb/1.0.0' }
45
+ def headers
46
+ {
47
+ 'Accept-Language' => @accept_language,
48
+ 'User-Agent' => @user_agent
49
+ }
50
+ end
51
+
52
+ # @return [Array] e.g. ['proxy.example.com', 8080, 'user', 'pass']
53
+ def via
54
+ proxy_uri = URI.parse(@proxy_url)
55
+ [proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password]
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExtraSpace
4
+ # Handles the crawl command via CLI.
5
+ class Crawl
6
+ def self.run(...)
7
+ new(...).run
8
+ end
9
+
10
+ # @param stdout [IO] optional
11
+ # @param stderr [IO] optional
12
+ # @param options [Hash] optional
13
+ def initialize(stdout: $stdout, stderr: $stderr, options: {})
14
+ @stdout = stdout
15
+ @stderr = stderr
16
+ @options = options
17
+ end
18
+
19
+ def run
20
+ sitemap = Facility.sitemap
21
+ @stdout.puts("count=#{sitemap.links.count}")
22
+ @stdout.puts
23
+
24
+ sitemap.links.each { |link| process(url: link.loc) }
25
+ end
26
+
27
+ def process(url:)
28
+ @stdout.puts(url)
29
+ facility = Facility.fetch(url: url)
30
+ @stdout.puts(facility.text)
31
+ facility.prices.each { |price| @stdout.puts(price.text) }
32
+ @stdout.puts
33
+ rescue FetchError => e
34
+ @stderr.puts("url=#{url} error=#{e.message}")
35
+ end
36
+ end
37
+ end
@@ -3,6 +3,8 @@
3
3
  module ExtraSpace
4
4
  # Used to fetch and parse either HTML or XML via a URL.
5
5
  class Crawler
6
+ HOST = 'https://www.extraspace.com'
7
+
6
8
  # Raised for unexpected HTTP responses.
7
9
  class FetchError < StandardError
8
10
  # @param url [String]
@@ -26,10 +28,24 @@ module ExtraSpace
26
28
  new.xml(url:)
27
29
  end
28
30
 
31
+ # @return [HTTP::Client]
32
+ def connection
33
+ @connection ||= begin
34
+ config = ExtraSpace.config
35
+
36
+ connection = HTTP.use(:auto_deflate).use(:auto_inflate).persistent(HOST)
37
+ connection = connection.headers('User-Agent' => config.user_agent) if config.user_agent
38
+ connection = connection.timeout(config.timeout) if config.timeout
39
+ connection = connection.via(*config.via) if config.proxy?
40
+
41
+ connection
42
+ end
43
+ end
44
+
29
45
  # @param url [String]
30
46
  # @return [HTTP::Response]
31
47
  def fetch(url:)
32
- response = HTTP.get(url)
48
+ response = connection.get(url)
33
49
  raise FetchError.new(url:, response: response.flush) unless response.status.ok?
34
50
 
35
51
  response
@@ -3,25 +3,34 @@
3
3
  module ExtraSpace
4
4
  # The dimensions (width + depth + sqft) of a price.
5
5
  class Dimensions
6
+ DEFAULT_HEIGHT = 8.0 # feet
7
+
6
8
  # @attribute [rw] depth
7
- # @return [Integer]
9
+ # @return [Float]
8
10
  attr_accessor :depth
9
11
 
10
12
  # @attribute [rw] width
11
- # @return [Integer]
13
+ # @return [Float]
12
14
  attr_accessor :width
13
15
 
14
- # @attribute [rw] sqft
15
- # @return [Integer]
16
- attr_accessor :sqft
16
+ # @attribute [rw] height
17
+ # @return [Float]
18
+ attr_accessor :height
17
19
 
18
- # @param depth [Integer]
19
- # @param width [Integer]
20
- # @param sqft [Integer]
21
- def initialize(depth:, width:, sqft:)
20
+ # @param data [Hash]
21
+ #
22
+ # @return [Dimensions]
23
+ def self.parse(data:)
24
+ new(depth: data['depth'], width: data['width'], height: DEFAULT_HEIGHT)
25
+ end
26
+
27
+ # @param depth [Float]
28
+ # @param width [Float]
29
+ # @param height [Float]
30
+ def initialize(depth:, width:, height: DEFAULT_HEIGHT)
22
31
  @depth = depth
23
32
  @width = width
24
- @sqft = sqft
33
+ @height = height
25
34
  end
26
35
 
27
36
  # @return [String]
@@ -29,21 +38,24 @@ module ExtraSpace
29
38
  props = [
30
39
  "depth=#{@depth.inspect}",
31
40
  "width=#{@width.inspect}",
32
- "sqft=#{@sqft.inspect}"
41
+ "height=#{@height.inspect}"
33
42
  ]
34
43
  "#<#{self.class.name} #{props.join(' ')}>"
35
44
  end
36
45
 
37
- # @return [String] e.g. "10' × 10' (100 sqft)"
38
- def text
39
- "#{format('%g', @width)}' × #{format('%g', @depth)}' (#{@sqft} sqft)"
46
+ # @return [Integer]
47
+ def sqft
48
+ Integer(@width * @depth)
40
49
  end
41
50
 
42
- # @param data [Hash]
43
- #
44
- # @return [Dimensions]
45
- def self.parse(data:)
46
- new(depth: data['depth'], width: data['width'], sqft: data['squareFoot'])
51
+ # @return [Integer]
52
+ def cuft
53
+ Integer(@width * @depth * @height)
54
+ end
55
+
56
+ # @return [String] e.g. "10' × 10' (100 sqft)"
57
+ def text
58
+ "#{format('%g', @width)}' × #{format('%g', @depth)}' (#{sqft} sqft)"
47
59
  end
48
60
  end
49
61
  end
@@ -5,16 +5,31 @@ module ExtraSpace
5
5
  #
6
6
  # e.g. https://www.extraspace.com/storage/facilities/us/alabama/auburn/3264/
7
7
  class Facility
8
+ DEFAULT_EMAIL = 'info@extraspace.com'
9
+ DEFAULT_PHONE = '1-855-518-1443'
10
+
8
11
  SITEMAP_URL = 'https://www.extraspace.com/facility-sitemap.xml'
9
12
 
10
13
  # @attribute [rw] id
11
14
  # @return [String]
12
15
  attr_accessor :id
13
16
 
17
+ # @attribute [rw] url
18
+ # @return [String]
19
+ attr_accessor :url
20
+
14
21
  # @attribute [rw] name
15
22
  # @return [String]
16
23
  attr_accessor :name
17
24
 
25
+ # @attribute [rw] phone
26
+ # @return [String]
27
+ attr_accessor :phone
28
+
29
+ # @attribute [rw] email
30
+ # @return [String]
31
+ attr_accessor :email
32
+
18
33
  # @attribute [rw] address
19
34
  # @return [Address]
20
35
  attr_accessor :address
@@ -37,14 +52,15 @@ module ExtraSpace
37
52
  # @return [Facility]
38
53
  def self.fetch(url:)
39
54
  document = Crawler.html(url:)
40
- data = JSON.parse(document.at('#__NEXT_DATA__').text)
41
- parse(data:)
55
+ parse(url:, document:)
42
56
  end
43
57
 
44
- # @param data [Hash]
58
+ # @param url [String]
59
+ # @param document [Nokogiri::HTML::Document]
45
60
  #
46
61
  # @return [Facility]
47
- def self.parse(data:)
62
+ def self.parse(url:, document:)
63
+ data = parse_next_data(document: document)
48
64
  page_data = data.dig('props', 'pageProps', 'pageData', 'data')
49
65
  store_data = page_data.dig('facilityData', 'data', 'store')
50
66
  unit_classes = page_data.dig('unitClasses', 'data', 'unitClasses')
@@ -55,7 +71,16 @@ module ExtraSpace
55
71
  geocode = Geocode.parse(data: store_data['geocode'])
56
72
  prices = unit_classes.map { |price_data| Price.parse(data: price_data) }
57
73
 
58
- new(id:, name:, address:, geocode:, prices:)
74
+ new(id:, url:, name:, address:, geocode:, prices:)
75
+ end
76
+
77
+ # @param document [Nokogiri::HTML::Document]
78
+ #
79
+ # @raise [ParseError]
80
+ #
81
+ # @return [Hash]
82
+ def self.parse_next_data(document:)
83
+ JSON.parse(document.at('#__NEXT_DATA__').text)
59
84
  end
60
85
 
61
86
  def self.crawl
@@ -74,15 +99,21 @@ module ExtraSpace
74
99
  end
75
100
 
76
101
  # @param id [String]
102
+ # @param url [String]
77
103
  # @param name [String]
78
104
  # @param address [Address]
79
105
  # @param geocode [Geocode]
106
+ # @param phone [String]
107
+ # @param email [String]
80
108
  # @param prices [Array<Price>]
81
- def initialize(id:, name:, address:, geocode:, prices:)
109
+ def initialize(id:, url:, name:, address:, geocode:, phone: DEFAULT_PHONE, email: DEFAULT_EMAIL, prices: [])
82
110
  @id = id
111
+ @url = url
83
112
  @name = name
84
113
  @address = address
85
114
  @geocode = geocode
115
+ @phone = phone
116
+ @email = email
86
117
  @prices = prices
87
118
  end
88
119
 
@@ -90,16 +121,19 @@ module ExtraSpace
90
121
  def inspect
91
122
  props = [
92
123
  "id=#{@id.inspect}",
124
+ "url=#{@url.inspect}",
93
125
  "address=#{@address.inspect}",
94
126
  "geocode=#{@geocode.inspect}",
127
+ "phone=#{@phone.inspect}",
128
+ "email=#{@email.inspect}",
95
129
  "prices=#{@prices.inspect}"
96
130
  ]
97
131
  "#<#{self.class.name} #{props.join(' ')}>"
98
132
  end
99
133
 
100
- # @return [String] e.g. "123 Main St, Springfield, IL 62701"
134
+ # @return [String]
101
135
  def text
102
- "#{@id} | #{@name} | #{@address.text} | #{@geocode.text}"
136
+ "#{@id} | #{@name} | #{@phone} | #{@email} | #{@address.text} | #{@geocode.text}"
103
137
  end
104
138
  end
105
139
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExtraSpace
4
+ # The features (e.g. climate-controlled, inside-drive-up-access, outside-drive-up-access, etc) of a price.
5
+ class Features
6
+ FIRST_FLOOR_ACCESS_NAME = '1stFloorAccess'
7
+ CLIMATE_CONTROLLED_NAME = 'ClimateControlled'
8
+ DRIVE_UP_ACCESS_NAME = 'DriveUpAccess'
9
+ ELEVATOR_ACCESS_NAME = 'ElevatorAccess'
10
+
11
+ # @param data [Array<Hash>]
12
+ #
13
+ # @return [Features]
14
+ def self.parse(data:)
15
+ new(
16
+ climate_controlled: data.any? { |feature| feature['name'].eql?(CLIMATE_CONTROLLED_NAME) },
17
+ drive_up_access: data.any? { |feature| feature['name'].eql?(DRIVE_UP_ACCESS_NAME) },
18
+ elevator_access: data.any? { |feature| feature['name'].eql?(ELEVATOR_ACCESS_NAME) },
19
+ first_floor_access: data.any? { |feature| feature['name'].eql?(FIRST_FLOOR_ACCESS_NAME) }
20
+ )
21
+ end
22
+
23
+ # @param climate_controlled [Boolean]
24
+ # @param drive_up_access [Boolean]
25
+ # @param first_floor_access [Boolean]
26
+ def initialize(climate_controlled:, drive_up_access:, elevator_access:, first_floor_access:)
27
+ @climate_controlled = climate_controlled
28
+ @drive_up_access = drive_up_access
29
+ @elevator_access = elevator_access
30
+ @first_floor_access = first_floor_access
31
+ end
32
+
33
+ # @return [String]
34
+ def inspect
35
+ props = [
36
+ "climate_controlled=#{@climate_controlled}",
37
+ "drive_up_access=#{@drive_up_access}",
38
+ "elevator_access=#{@elevator_access}",
39
+ "first_floor_access=#{@first_floor_access}"
40
+ ]
41
+
42
+ "#<#{self.class.name} #{props.join(' ')}>"
43
+ end
44
+
45
+ # @return [String] e.g. "Climate Controlled + First Floor Access"
46
+ def text
47
+ amenities.join(' + ')
48
+ end
49
+
50
+ # @return [Array<String>]
51
+ def amenities
52
+ [].tap do |amenities|
53
+ amenities << 'Climate Controlled' if climate_controlled?
54
+ amenities << 'Drive-Up Access' if drive_up_access?
55
+ amenities << 'Elevator Access' if elevator_access?
56
+ amenities << 'First Floor Access' if first_floor_access?
57
+ end
58
+ end
59
+
60
+ # @return [Boolean]
61
+ def climate_controlled?
62
+ @climate_controlled
63
+ end
64
+
65
+ # @return [Boolean]
66
+ def drive_up_access?
67
+ @drive_up_access
68
+ end
69
+
70
+ # @return [Boolean]
71
+ def elevator_access?
72
+ @elevator_access
73
+ end
74
+
75
+ # @return [Boolean]
76
+ def first_floor_access?
77
+ @first_floor_access
78
+ end
79
+ end
80
+ end
@@ -11,6 +11,16 @@ module ExtraSpace
11
11
  # @return [Float]
12
12
  attr_accessor :longitude
13
13
 
14
+ # @param data [Hash]
15
+ #
16
+ # @return [Geocode]
17
+ def self.parse(data:)
18
+ new(
19
+ latitude: data['latitude'],
20
+ longitude: data['longitude']
21
+ )
22
+ end
23
+
14
24
  # @param latitude [Float]
15
25
  # @param longitude [Float]
16
26
  def initialize(latitude:, longitude:)
@@ -31,15 +41,5 @@ module ExtraSpace
31
41
  def text
32
42
  "#{@latitude},#{@longitude}"
33
43
  end
34
-
35
- # @param data [Hash]
36
- #
37
- # @return [Geocode]
38
- def self.parse(data:)
39
- new(
40
- latitude: data['latitude'],
41
- longitude: data['longitude']
42
- )
43
- end
44
44
  end
45
45
  end
@@ -11,16 +11,34 @@ module ExtraSpace
11
11
  # @return [Dimensions]
12
12
  attr_accessor :dimensions
13
13
 
14
+ # @attribute [rw] features
15
+ # @return [Features]
16
+ attr_accessor :features
17
+
14
18
  # @attribute [rw] rates
15
19
  # @return [Rates]
16
20
  attr_accessor :rates
17
21
 
22
+ # @param data [Hash]
23
+ #
24
+ # @return [Price]
25
+ def self.parse(data:)
26
+ new(
27
+ id: data['uid'],
28
+ dimensions: Dimensions.parse(data: data['dimensions']),
29
+ features: Features.parse(data: data['features']),
30
+ rates: Rates.parse(data: data['rates'])
31
+ )
32
+ end
33
+
18
34
  # @param id [String]
19
35
  # @param dimensions [Dimensions]
36
+ # @param features [Features]
20
37
  # @param rates [Rates]
21
- def initialize(id:, dimensions:, rates:)
38
+ def initialize(id:, dimensions:, features:, rates:)
22
39
  @id = id
23
40
  @dimensions = dimensions
41
+ @features = features
24
42
  @rates = rates
25
43
  end
26
44
 
@@ -29,6 +47,7 @@ module ExtraSpace
29
47
  props = [
30
48
  "id=#{@id.inspect}",
31
49
  "dimensions=#{@dimensions.inspect}",
50
+ "features=#{@features.inspect}",
32
51
  "rates=#{@rates.inspect}"
33
52
  ]
34
53
  "#<#{self.class.name} #{props.join(' ')}>"
@@ -38,18 +57,5 @@ module ExtraSpace
38
57
  def text
39
58
  "#{@id} | #{@dimensions.text} | #{@rates.text}"
40
59
  end
41
-
42
- # @param data [Hash]
43
- #
44
- # @return [Price]
45
- def self.parse(data:)
46
- dimensions = Dimensions.parse(data: data['dimensions'])
47
- rates = Rates.parse(data: data['rates'])
48
- new(
49
- id: data['uid'],
50
- dimensions: dimensions,
51
- rates: rates
52
- )
53
- end
54
60
  end
55
61
  end
@@ -11,6 +11,16 @@ module ExtraSpace
11
11
  # @return [Integer]
12
12
  attr_accessor :web
13
13
 
14
+ # @param data [Hash]
15
+ #
16
+ # @return [Rates]
17
+ def self.parse(data:)
18
+ new(
19
+ street: data['street'],
20
+ web: data['web']
21
+ )
22
+ end
23
+
14
24
  # @param street [Integer]
15
25
  # @param web [Integer]
16
26
  def initialize(street:, web:)
@@ -31,15 +41,5 @@ module ExtraSpace
31
41
  def text
32
42
  "$#{@street} (street) | $#{@web} (web)"
33
43
  end
34
-
35
- # @param data [Hash]
36
- #
37
- # @return [Rates]
38
- def self.parse(data:)
39
- new(
40
- street: data['street'],
41
- web: data['web']
42
- )
43
- end
44
44
  end
45
45
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ExtraSpace
4
- VERSION = '0.3.0'
4
+ VERSION = '0.5.0'
5
5
  end
data/lib/extraspace.rb CHANGED
@@ -9,6 +9,18 @@ loader.inflector.inflect 'extraspace' => 'ExtraSpace'
9
9
  loader.inflector.inflect 'cli' => 'CLI'
10
10
  loader.setup
11
11
 
12
+ # An interface for ExtraSpace.
12
13
  module ExtraSpace
13
14
  class Error < StandardError; end
15
+
16
+ # @return [Config]
17
+ def self.config
18
+ @config ||= Config.new
19
+ end
20
+
21
+ # @yield [config]
22
+ # @yieldparam config [Config]
23
+ def self.configure
24
+ yield config
25
+ end
14
26
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: extraspace
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kevin Sylvestre
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-11-27 00:00:00.000000000 Z
11
+ date: 2024-12-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: http
@@ -96,9 +96,12 @@ files:
96
96
  - lib/extraspace.rb
97
97
  - lib/extraspace/address.rb
98
98
  - lib/extraspace/cli.rb
99
+ - lib/extraspace/config.rb
100
+ - lib/extraspace/crawl.rb
99
101
  - lib/extraspace/crawler.rb
100
102
  - lib/extraspace/dimensions.rb
101
103
  - lib/extraspace/facility.rb
104
+ - lib/extraspace/features.rb
102
105
  - lib/extraspace/geocode.rb
103
106
  - lib/extraspace/link.rb
104
107
  - lib/extraspace/price.rb