nsastorage 0.1.0 → 0.2.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: ecae367c1dc75c720c89af10a90e6325bfe10c03dbfee675a125a45c70fcc6f5
4
- data.tar.gz: dbd35a90238e4a806a9e569bae4c99f474f101d92e906eef91cec2a7c85867b2
3
+ metadata.gz: 6bf4dc5114c043fc32d886eb98d630f24bfff9d3ccefbdd3a92b006c3cb967d4
4
+ data.tar.gz: c81742bdf7d0d35c5dc23b2584ad10d47b370a1de6e564e08233ed9db7b21538
5
5
  SHA512:
6
- metadata.gz: 3fa08993d81f797354e095db5f367e650a4c58c67763fa147f65de1cc4794113c7b4394bb5656219484ac62dd530c4dc70fc5b2aa86a6b826140b0c1cd767575
7
- data.tar.gz: 2b0dbea4c879f0d44d0716772830b330d497ee1c8170969909db7dcdb375203b50672cba4d1f4611e29e3cd800e183aeee405e4f90b040b5fc7994a936691695
6
+ metadata.gz: 801e52c62bdac13fcc9338496e47d866bb8392cf7b988b2535596963c5030bc90a5400ce7ad173d71e4a7cac61a89c0a196f0d064115431219fe5301167b9f2e
7
+ data.tar.gz: ad2da5dfc46c674e85bc310312f6e30feb65f19df3036ce0ba91ee16bd5676c30fe1e5d75241a2e0bbaf7fcd79d67034360455cd00f95ebcf3f31294feff0d94
@@ -3,6 +3,8 @@
3
3
  module NSAStorage
4
4
  # The address (street + city + state + zip) of a facility.
5
5
  class Address
6
+ ADDRESS_SELECTOR = '.item-des-box .text-box i'
7
+ ADDRESS_REGEX = /(?<street>.+),\s+(?<city>.+),\s+(?<state>.+)\s+(?<zip>\d{5})/
6
8
  # @attribute [rw] street
7
9
  # @return [String]
8
10
  attr_accessor :street
@@ -46,15 +48,17 @@ module NSAStorage
46
48
  "#{street}, #{city}, #{state} #{zip}"
47
49
  end
48
50
 
49
- # @param data [Hash]
51
+ # @param document [Nokogiri::HTML::Document]
50
52
  #
51
53
  # @return [Address]
52
- def self.parse(data:)
54
+ def self.parse(document:)
55
+ element = document.at_css(ADDRESS_SELECTOR)
56
+ match = element.text.match(ADDRESS_REGEX)
53
57
  new(
54
- street: data['streetAddress'],
55
- city: data['addressLocality'],
56
- state: data['addressRegion'],
57
- zip: data['postalCode']
58
+ street: match[:street],
59
+ city: match[:city],
60
+ state: match[:state],
61
+ zip: match[:zip]
58
62
  )
59
63
  end
60
64
  end
@@ -5,6 +5,13 @@ module NSAStorage
5
5
  class Crawler
6
6
  HOST = 'https://www.nsastorage.com'
7
7
 
8
+ # @attribute url [String]
9
+ # @raise [FetchError]
10
+ # @return [Hash]
11
+ def self.json(url:)
12
+ new.json(url:)
13
+ end
14
+
8
15
  # @param url [String]
9
16
  # @raise [FetchError]
10
17
  # @return [Nokogiri::HTML::Document]
@@ -42,6 +49,13 @@ module NSAStorage
42
49
  response
43
50
  end
44
51
 
52
+ # @param url [String]
53
+ # @raise [FetchError]
54
+ # @return [Hash]
55
+ def json(url:)
56
+ JSON.parse(String(fetch(url:).body))
57
+ end
58
+
45
59
  # @param url [String]
46
60
  # @raise [FetchError]
47
61
  # @return [Nokogiri::XML::Document]
@@ -5,6 +5,8 @@ module NSAStorage
5
5
  class Dimensions
6
6
  DEFAULT_HEIGHT = 8.0 # feet
7
7
 
8
+ DIMENSIONS_REGEX = /(?<width>[\d\.]+) x (?<depth>[\d\.]+)/
9
+
8
10
  # @attribute [rw] depth
9
11
  # @return [Float]
10
12
  attr_accessor :depth
@@ -55,9 +57,8 @@ module NSAStorage
55
57
  #
56
58
  # @return [Dimensions]
57
59
  def self.parse(element:)
58
- text = element.text
59
- match = text.match(/(?<width>[\d\.]+)'x(?<depth>[\d\.]+)'/)
60
- raise text.inspect if match.nil?
60
+ text = element.at_css('.unit-select-item-detail').text
61
+ match = DIMENSIONS_REGEX.match(text)
61
62
 
62
63
  width = Float(match[:width])
63
64
  depth = Float(match[:depth])
@@ -7,13 +7,11 @@ module NSAStorage
7
7
  class Facility
8
8
  class ParseError < StandardError; end
9
9
 
10
- DEFAULT_EMAIL = 'TODO'
11
- DEFAULT_PHONE = 'TODO'
10
+ DEFAULT_EMAIL = 'customerservice@nsabrands.com'
11
+ DEFAULT_PHONE = '+1-844-434-1150'
12
12
 
13
13
  SITEMAP_URL = 'https://www.nsastorage.com/sitemap.xml'
14
14
 
15
- ID_REGEX = %r{/(?<id>\d+)}
16
-
17
15
  # @attribute [rw] id
18
16
  # @return [String]
19
17
  attr_accessor :id
@@ -64,13 +62,14 @@ module NSAStorage
64
62
  #
65
63
  # @return [Facility]
66
64
  def self.parse(url:, document:)
67
- data = parse_json_ld(document: document)
68
- id = ID_REGEX.match(url)[:id]
65
+ id = Integer(document.at_css('[data-facility-id]')['data-facility-id'])
66
+ name = document.at_css('.section-title').text.strip
69
67
 
70
- address = Address.parse(data: data['address'])
71
- geocode = Geocode.parse(data: data['address'])
68
+ geocode = Geocode.parse(document:)
69
+ address = Address.parse(document:)
70
+ prices = Price.fetch(facility_id: id)
72
71
 
73
- new(id:, url:, name: data['name'], address:, geocode:)
72
+ new(id:, url:, name:, address:, geocode:, prices:)
74
73
  end
75
74
 
76
75
  # @param document [Nokogiri::HTML::Document]
@@ -78,11 +77,19 @@ module NSAStorage
78
77
  # @raise [ParseError]
79
78
  #
80
79
  # @return [Hash]
81
- def self.parse_json_ld(document:)
82
- document
83
- .xpath('//script[@type="application/ld+json"]')
84
- .map { |script| JSON.parse(script.text) }
85
- .find { |data| data['@type'] == 'SelfStorage' }
80
+ def self.parse_ld_json_script(document:)
81
+ parse_ld_json_scripts(document:).find do |data|
82
+ data['@type'] == 'SelfStorage'
83
+ end || raise(ParseError, 'missing ld+json')
84
+ end
85
+
86
+ # @param document [Nokogiri::HTML::Document]
87
+ #
88
+ # @return [Array<Hash>]
89
+ def self.parse_ld_json_scripts(document:)
90
+ elements = document.xpath('//script[@type="application/ld+json"]')
91
+
92
+ elements.map { |element| element.text.empty? ? {} : JSON.parse(element.text) }
86
93
  end
87
94
 
88
95
  # @param id [String]
@@ -11,20 +11,17 @@ module NSAStorage
11
11
 
12
12
  new(
13
13
  climate_controlled: text.include?('Climate controlled'),
14
- inside_drive_up_access: text.include?('Inside drive-up access'),
15
- outside_drive_up_access: text.include?('Outside drive-up access'),
16
- first_floor_access: text.include?('1st floor access')
14
+ drive_up_access: text.include?('Drive Up Access'),
15
+ first_floor_access: text.include?('1st Floor')
17
16
  )
18
17
  end
19
18
 
20
19
  # @param climate_controlled [Boolean]
21
- # @param inside_drive_up_access [Boolean]
22
- # @param outside_drive_up_access [Boolean]
20
+ # @param drive_up_access [Boolean]
23
21
  # @param first_floor_access [Boolean]
24
- def initialize(climate_controlled:, inside_drive_up_access:, outside_drive_up_access:, first_floor_access:)
22
+ def initialize(climate_controlled:, drive_up_access:, first_floor_access:)
25
23
  @climate_controlled = climate_controlled
26
- @inside_drive_up_access = inside_drive_up_access
27
- @outside_drive_up_access = outside_drive_up_access
24
+ @drive_up_access = drive_up_access
28
25
  @first_floor_access = first_floor_access
29
26
  end
30
27
 
@@ -32,8 +29,7 @@ module NSAStorage
32
29
  def inspect
33
30
  props = [
34
31
  "climate_controlled=#{@climate_controlled}",
35
- "inside_drive_up_access=#{@inside_drive_up_access}",
36
- "outside_drive_up_access=#{@outside_drive_up_access}",
32
+ "drive_up_access=#{@drive_up_access}",
37
33
  "first_floor_access=#{@first_floor_access}"
38
34
  ]
39
35
 
@@ -49,8 +45,7 @@ module NSAStorage
49
45
  def amenities
50
46
  [].tap do |amenities|
51
47
  amenities << 'Climate Controlled' if climate_controlled?
52
- amenities << 'Inside Drive-Up Access' if inside_drive_up_access?
53
- amenities << 'Outside Drive-Up Access' if outside_drive_up_access?
48
+ amenities << 'Drive-Up Access' if drive_up_access?
54
49
  amenities << 'First Floor Access' if first_floor_access?
55
50
  end
56
51
  end
@@ -60,19 +55,9 @@ module NSAStorage
60
55
  @climate_controlled
61
56
  end
62
57
 
63
- # @return [Boolean]
64
- def inside_drive_up_access?
65
- @inside_drive_up_access
66
- end
67
-
68
- # @return [Boolean]
69
- def outside_drive_up_access?
70
- @outside_drive_up_access
71
- end
72
-
73
58
  # @return [Boolean]
74
59
  def drive_up_access?
75
- inside_drive_up_access? || outside_drive_up_access?
60
+ @drive_up_access
76
61
  end
77
62
 
78
63
  # @return [Boolean]
@@ -3,6 +3,9 @@
3
3
  module NSAStorage
4
4
  # The geocode (latitude + longitude) of a facility.
5
5
  class Geocode
6
+ LATITUDE_REGEX = /\\u0022lat\\u0022:(?<latitude>[\+\-\d\.]+)/
7
+ LONGITUDE_REGEX = /\\u0022long\\u0022:(?<longitude>[\+\-\d\.]+)/
8
+
6
9
  # @attribute [rw] latitude
7
10
  # @return [Float]
8
11
  attr_accessor :latitude
@@ -11,14 +14,14 @@ module NSAStorage
11
14
  # @return [Float]
12
15
  attr_accessor :longitude
13
16
 
14
- # @param data [Hash]
17
+ # @param document [Nokogiri::HTML::Document]
15
18
  #
16
19
  # @return [Geocode]
17
- def self.parse(data:)
18
- new(
19
- latitude: data['latitude'],
20
- longitude: data['longitude']
21
- )
20
+ def self.parse(document:)
21
+ latitude = LATITUDE_REGEX.match(document.text)[:latitude]
22
+ longitude = LONGITUDE_REGEX.match(document.text)[:longitude]
23
+
24
+ new(latitude: Float(latitude), longitude: Float(longitude))
22
25
  end
23
26
 
24
27
  # @param latitude [Float]
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NSAStorage
4
- # The price (id + dimensions + rate) for a facility
4
+ # The price (id + dimensions + rate) for a facility.
5
5
  class Price
6
+ ID_REGEX = %r{(?<id>\d+)/rent/}
6
7
  # @attribute [rw] id
7
8
  # @return [String]
8
9
  attr_accessor :id
@@ -19,6 +20,15 @@ module NSAStorage
19
20
  # @return [Rates]
20
21
  attr_accessor :rates
21
22
 
23
+ # @param facility_id [Integer]
24
+ #
25
+ # @return [Array<Price>]
26
+ def self.fetch(facility_id:)
27
+ url = "https://www.nsastorage.com/facility-units/#{facility_id}"
28
+ html = Crawler.json(url:)['data']['html']['units']
29
+ Nokogiri::HTML(html).css('[data-unit-size]').map { |element| parse(element:) }
30
+ end
31
+
22
32
  # @param id [String]
23
33
  # @param dimensions [Dimensions]
24
34
  # @param features [Features]
@@ -50,8 +60,9 @@ module NSAStorage
50
60
  #
51
61
  # @return [Price]
52
62
  def self.parse(element:)
63
+ link = element.at_xpath("//a[contains(text(), 'Rent')]")
53
64
  new(
54
- id: element.attr('id'),
65
+ id: ID_REGEX.match(link['href'])[:id],
55
66
  dimensions: Dimensions.parse(element:),
56
67
  features: Features.parse(element:),
57
68
  rates: Rates.parse(element:)
@@ -3,8 +3,8 @@
3
3
  module NSAStorage
4
4
  # The rates (street + web) for a facility
5
5
  class Rates
6
- STREET_SELECTOR = '.ptOriginalPriceSpan'
7
- WEB_SELECTOR = '.ptDiscountPriceSpan'
6
+ STREET_SELECTOR = '.part_item_old_price'
7
+ WEB_SELECTOR = '.part_item_price'
8
8
  VALUE_REGEX = /(?<value>[\d\.]+)/
9
9
 
10
10
  # @attribute [rw] street
@@ -19,7 +19,7 @@ module NSAStorage
19
19
  Link.new(loc:, lastmod:)
20
20
  end
21
21
 
22
- new(links: links)
22
+ new(links: links.filter { |link| link.loc.match(%r{/storage/}) })
23
23
  end
24
24
 
25
25
  # @param url [String]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NSAStorage
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nsastorage
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kevin Sylvestre
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
11
  date: 2024-12-11 00:00:00.000000000 Z
@@ -117,7 +117,7 @@ metadata:
117
117
  homepage_uri: https://github.com/ksylvest/nsastorage
118
118
  source_code_uri: https://github.com/ksylvest/nsastorage
119
119
  changelog_uri: https://github.com/ksylvest/nsastorage
120
- post_install_message:
120
+ post_install_message:
121
121
  rdoc_options: []
122
122
  require_paths:
123
123
  - lib
@@ -132,8 +132,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
132
132
  - !ruby/object:Gem::Version
133
133
  version: '0'
134
134
  requirements: []
135
- rubygems_version: 3.5.22
136
- signing_key:
135
+ rubygems_version: 3.5.23
136
+ signing_key:
137
137
  specification_version: 4
138
138
  summary: A crawler for NSAStorage.
139
139
  test_files: []