extraspace 0.3.0 → 0.4.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: 5a9b243e73e7e8ded7dec1cb75998fcf2b6cfc3907e9f07ec1c6a26071c10af4
4
+ data.tar.gz: 9b56a1dc1d3da8e3d83a358e1554214a58270f0936167004c29063024570445f
5
5
  SHA512:
6
- metadata.gz: 6204e8de2f7349debac7049c5ca1ade4fc0b5328be5faaf61eefb42d982e735897c28fc6292ac98c6a40e36dadf94550fcd30f5a066b3eb58ba8de7db3c2c20d
7
- data.tar.gz: edd94f3a7d6dadc415f112f7dd3fa1ff7e8ae5fd8592794e3a218a3806e5ec7777659eeaee3bde305af51eee67f23fb5b5862925f51fc54d22ff5aa5ad863f34
6
+ metadata.gz: 44636e3f4b7229189e6a806d14bf440fb565127a952a15911e761e440364f83c2413e7167fc55b51ce145eb11f8fe4446c7f259548a5f0dca3adc25a7cb06a4d
7
+ data.tar.gz: bea8280c5d3506e5358cc69ab1561419667442ca6fc2f78216400bebf60ba2be729fd282e5daee8c79b9dae7dab2f4312fc94d66ed65b5504aeaba67933ebc13
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
@@ -97,7 +97,7 @@ module ExtraSpace
97
97
  "#<#{self.class.name} #{props.join(' ')}>"
98
98
  end
99
99
 
100
- # @return [String] e.g. "123 Main St, Springfield, IL 62701"
100
+ # @return [String]
101
101
  def text
102
102
  "#{@id} | #{@name} | #{@address.text} | #{@geocode.text}"
103
103
  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.4.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.4.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