extraspace 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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