guesty-client 0.1.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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +34 -0
  3. data/LICENSE +21 -0
  4. data/README.md +178 -0
  5. data/lib/guesty/access_tokens/upsert.rb +61 -0
  6. data/lib/guesty/api_error.rb +73 -0
  7. data/lib/guesty/client.rb +138 -0
  8. data/lib/guesty/config.rb +29 -0
  9. data/lib/guesty/conversations/attachments/index.rb +42 -0
  10. data/lib/guesty/conversations/index.rb +58 -0
  11. data/lib/guesty/conversations/messages/reply.rb +55 -0
  12. data/lib/guesty/conversations/posts/index.rb +121 -0
  13. data/lib/guesty/conversations/posts/since.rb +37 -0
  14. data/lib/guesty/conversations/search.rb +31 -0
  15. data/lib/guesty/conversations/show.rb +25 -0
  16. data/lib/guesty/conversations.rb +15 -0
  17. data/lib/guesty/error.rb +32 -0
  18. data/lib/guesty/guest_folio/invoice_items/index.rb +61 -0
  19. data/lib/guesty/guests/profiles/create.rb +25 -0
  20. data/lib/guesty/guests/profiles/show.rb +23 -0
  21. data/lib/guesty/guests/profiles/update.rb +36 -0
  22. data/lib/guesty/guests.rb +5 -0
  23. data/lib/guesty/homes/calendars/show.rb +134 -0
  24. data/lib/guesty/homes/search.rb +46 -0
  25. data/lib/guesty/memory_rate_limiter.rb +28 -0
  26. data/lib/guesty/memory_token_store.rb +33 -0
  27. data/lib/guesty/operation.rb +37 -0
  28. data/lib/guesty/rate_limiter_middleware.rb +23 -0
  29. data/lib/guesty/reservations/accommodation_fare/update.rb +44 -0
  30. data/lib/guesty/reservations/approve.rb +23 -0
  31. data/lib/guesty/reservations/create.rb +50 -0
  32. data/lib/guesty/reservations/custom_fields/update.rb +26 -0
  33. data/lib/guesty/reservations/dates/update.rb +41 -0
  34. data/lib/guesty/reservations/decline.rb +30 -0
  35. data/lib/guesty/reservations/flags/update.rb +33 -0
  36. data/lib/guesty/reservations/payments/create.rb +72 -0
  37. data/lib/guesty/reservations/pre_approve.rb +23 -0
  38. data/lib/guesty/reservations/recover.rb +27 -0
  39. data/lib/guesty/reservations/reviews/extractors/airbnb.rb +66 -0
  40. data/lib/guesty/reservations/reviews/extractors/base.rb +36 -0
  41. data/lib/guesty/reservations/reviews/extractors/booking_com.rb +35 -0
  42. data/lib/guesty/reservations/reviews/extractors/vrbo.rb +46 -0
  43. data/lib/guesty/reservations/reviews/index.rb +67 -0
  44. data/lib/guesty/reservations/search.rb +63 -0
  45. data/lib/guesty/reservations/show.rb +28 -0
  46. data/lib/guesty/reservations/status/cancel.rb +34 -0
  47. data/lib/guesty/reservations/status/update.rb +21 -0
  48. data/lib/guesty/reservations/stays/update.rb +42 -0
  49. data/lib/guesty/reservations/update.rb +19 -0
  50. data/lib/guesty/reservations.rb +13 -0
  51. data/lib/guesty/reviews/respond.rb +19 -0
  52. data/lib/guesty/version.rb +3 -0
  53. data/lib/guesty/views/create.rb +19 -0
  54. data/lib/guesty/views/update.rb +33 -0
  55. data/lib/guesty/webhooks/create.rb +21 -0
  56. data/lib/guesty/webhooks/index.rb +18 -0
  57. data/lib/guesty/webhooks/signatures/verify.rb +76 -0
  58. data/lib/guesty/webhooks/sync.rb +36 -0
  59. data/lib/guesty/webhooks/update.rb +22 -0
  60. data/lib/guesty-client.rb +1 -0
  61. data/lib/guesty.rb +78 -0
  62. data/sig/guesty.rbs +427 -0
  63. metadata +191 -0
@@ -0,0 +1,33 @@
1
+ module Guesty
2
+ # The default token store: keeps one token per credential slot in memory for
3
+ # the life of the process. Guesty mints only 5 tokens per client id per 24
4
+ # hours, so a multi-process host must inject a shared, persistent store, such
5
+ # as a database table behind a cache.
6
+ class MemoryTokenStore
7
+ Token = Struct.new(:access_token, :expires_at, keyword_init: true) do
8
+ def expired?
9
+ expires_at.nil? || expires_at < Time.now
10
+ end
11
+ end
12
+
13
+ def initialize
14
+ @tokens = {}
15
+ end
16
+
17
+ # @rbs (::String slot_key) -> Token?
18
+ def read(slot_key)
19
+ token = @tokens[slot_key]
20
+
21
+ token if token && !token.expired?
22
+ end
23
+
24
+ # @rbs (slot_key: ::String, access_token: ::String, expires_at: ::Time) -> Token
25
+ def save(slot_key:, access_token:, expires_at:)
26
+ @tokens[slot_key] = Token.new(access_token:, expires_at:)
27
+ end
28
+
29
+ def clear
30
+ @tokens = {}
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,37 @@
1
+ module Guesty
2
+ # Shared monadic contract for the client operations: #call returns a Result,
3
+ # #call! unwraps it and raises the underlying failure. An error raised inside
4
+ # #call becomes Failure(error), mirroring the host's operation base.
5
+ module Operation
6
+ def self.included(base)
7
+ base.include Dry::Monads[:result]
8
+ base.prepend(Call)
9
+ end
10
+
11
+ module Call
12
+ def call(*, **, &)
13
+ super
14
+ rescue StandardError => e
15
+ Failure(e)
16
+ end
17
+ end
18
+
19
+ def call!(...)
20
+ result = call(...)
21
+ raise result.failure if result.failure?
22
+
23
+ result
24
+ end
25
+
26
+ private
27
+
28
+ def argument!(fatal: false, **attrs)
29
+ key, reason = attrs.first
30
+ raise Guesty::ArgumentError.new("#{key}: #{reason}", fatal:)
31
+ end
32
+
33
+ def error!(message, fatal: false)
34
+ raise Guesty::Error.new(message, fatal:)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,23 @@
1
+ module Guesty
2
+ # Faraday request middleware delegating throttling to the injected limiter
3
+ # (Guesty.config.rate_limiter), so a host can share the windows across
4
+ # processes. Registered as :guesty_rate_limiter.
5
+ class RateLimiterMiddleware < Faraday::Middleware
6
+ # @rbs (untyped app, ?::Hash[::Symbol, untyped] options) -> void
7
+ def initialize(app, options = {})
8
+ super
9
+
10
+ @key = options[:key]
11
+ @threshold = options[:threshold]
12
+ @interval = options[:interval]
13
+ end
14
+
15
+ def on_request(_env)
16
+ key = @key.is_a?(Proc) ? @key.call : @key
17
+
18
+ Guesty.config.rate_limiter.throttle!(key:, threshold: @threshold, interval: @interval)
19
+ end
20
+ end
21
+
22
+ Faraday::Request.register_middleware(guesty_rate_limiter: RateLimiterMiddleware)
23
+ end
@@ -0,0 +1,44 @@
1
+ module Guesty
2
+ module Reservations
3
+ module AccommodationFare
4
+ class Update
5
+ include Guesty::Operation
6
+
7
+ FARE_TYPES = %w[AF AFA AFD].freeze #: ::Array[::String]
8
+ ADJUSTED_TYPE = 'AF'.freeze #: ::String
9
+
10
+ # @rbs (reservation_id: ::String, amount: ::Integer | ::Float | ::BigDecimal) -> Dry::Monads::Result[::BigDecimal]
11
+ def call(reservation_id:, amount:)
12
+ argument! reservation_id: :required if reservation_id.blank?
13
+ argument! amount: :negative, fatal: true if amount.negative?
14
+
15
+ adjustment = amount.to_d - current_fare(reservation_id)
16
+ return Success(adjustment) if adjustment.zero?
17
+
18
+ response = Guesty.do_request :post, 'price-adjustments/manual-total-amount', payload(reservation_id, adjustment), retries: 0
19
+ return response unless response.success?
20
+
21
+ Success(adjustment)
22
+ end
23
+
24
+ private
25
+
26
+ # @rbs (::String reservation_id) -> ::BigDecimal
27
+ def current_fare(reservation_id)
28
+ reservation = Show.new.call!(reservation_id:, fields: %w[money.invoiceItems]).value!
29
+ items = reservation.dig(:money, :invoiceItems)
30
+
31
+ items.select { |item| FARE_TYPES.include?(item[:normalType]) }.sum(BigDecimal('0')) { |item| item[:amount].to_d }
32
+ end
33
+
34
+ # @rbs (::String reservation_id, ::BigDecimal adjustment) -> ::Hash[::Symbol, untyped]
35
+ def payload(reservation_id, adjustment)
36
+ {
37
+ reservationId: reservation_id,
38
+ priceAdjustments: [{ normalType: ADJUSTED_TYPE, amount: adjustment.to_f }]
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,23 @@
1
+ module Guesty
2
+ module Reservations
3
+ # Approves a request to book (a reservation in status `reserved`). Runs
4
+ # asynchronously on Guesty's side; a repeat answers 400 DUPLICATE.
5
+ class Approve
6
+ include Guesty::Operation
7
+
8
+ # @rbs (reservation_id: ::String) -> Dry::Monads::Result[bool]
9
+ def call(reservation_id:)
10
+ argument! reservation_id: :required if reservation_id.blank?
11
+
12
+ response = Guesty.do_request(
13
+ :post,
14
+ "reservations-v3/#{reservation_id}/approve",
15
+ retries: 0
16
+ )
17
+ return response unless response.success?
18
+
19
+ Success(true)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,50 @@
1
+ module Guesty
2
+ module Reservations
3
+ class Create
4
+ include Guesty::Operation
5
+
6
+ REQUIRED_ATTRIBUTE_KEYS = %i[
7
+ guestId
8
+ listingId
9
+ source
10
+ status
11
+ ignoreCalendar
12
+ ignoreTerms
13
+ checkInDateLocalized
14
+ checkOutDateLocalized
15
+ ].freeze #: ::Array[::Symbol]
16
+ GUEST_COUNT_KEYS = %i[numberOfAdults numberOfChildren].freeze #: ::Array[::Symbol]
17
+
18
+ # @rbs (attributes: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[::String]
19
+ def call(attributes:)
20
+ validate_attributes!(attributes)
21
+
22
+ response = Guesty.do_request :post, 'reservations-v3', attributes, retries: 0, timeout: 60
23
+ Success(handle_response(response))
24
+ end
25
+
26
+ private
27
+
28
+ def validate_attributes!(attributes)
29
+ argument! attributes: :required if attributes.blank?
30
+
31
+ REQUIRED_ATTRIBUTE_KEYS.each do |key|
32
+ argument! key => :required if attributes[key].nil?
33
+ end
34
+
35
+ guests = attributes[:numberOfGuests]
36
+ return unless guests
37
+
38
+ GUEST_COUNT_KEYS.each do |key|
39
+ argument! key => :required if guests[key].blank?
40
+ end
41
+ end
42
+
43
+ def handle_response(response)
44
+ raise response.failure unless response.success?
45
+
46
+ response.value!.body.fetch(:reservationId)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,26 @@
1
+ module Guesty
2
+ module Reservations
3
+ module CustomFields
4
+ class Update
5
+ include Guesty::Operation
6
+
7
+ # @rbs (reservation_id: ::String, custom_fields: ::Array[::Hash[::Symbol, untyped]]) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
8
+ def call(reservation_id:, custom_fields:)
9
+ argument! reservation_id: :required if reservation_id.blank?
10
+ argument! custom_fields: :required if custom_fields.blank?
11
+
12
+ response = Guesty.do_request :put, "reservations-v3/#{reservation_id}/custom-fields", { customFields: custom_fields }
13
+ Success(handle_response(response))
14
+ end
15
+
16
+ private
17
+
18
+ def handle_response(response)
19
+ raise response.failure unless response.success?
20
+
21
+ response.value!.body
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,41 @@
1
+ module Guesty
2
+ module Reservations
3
+ module Dates
4
+ class Update
5
+ include Guesty::Operation
6
+
7
+ # @rbs (reservation_id: ::String, attributes: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[bool]
8
+ def call(reservation_id:, attributes:)
9
+ argument! reservation_id: :required if reservation_id.blank?
10
+ argument! attributes: :required if attributes.blank?
11
+
12
+ payload = date_attributes(attributes)
13
+ response = Guesty.do_request(
14
+ :put,
15
+ "reservations-v3/#{reservation_id}/dates",
16
+ payload
17
+ )
18
+
19
+ raise response.failure if response.failure?
20
+
21
+ Success(true)
22
+ end
23
+
24
+ private
25
+
26
+ # @rbs (::Hash[::Symbol, untyped] attributes) -> ::Hash[::Symbol, untyped]
27
+ def date_attributes(attributes)
28
+ {
29
+ checkInDateLocalized: attributes[:start_date]&.iso8601,
30
+ checkOutDateLocalized: attributes[:end_date]&.iso8601,
31
+ plannedArrival: attributes[:check_in]&.strftime('%H:%M'),
32
+ plannedDeparture: attributes[:check_out]&.strftime('%H:%M'),
33
+ ignoreCalendar: attributes[:ignore_calendar],
34
+ ignoreTerms: attributes[:ignore_terms],
35
+ applyRecalculation: false
36
+ }.compact
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,30 @@
1
+ module Guesty
2
+ module Reservations
3
+ # Declines a request to book. Airbnb answers 422 without a reason; Guesty
4
+ # documents only `dates_not_available`.
5
+ class Decline
6
+ include Guesty::Operation
7
+
8
+ # @rbs (reservation_id: ::String, reason: ::String, message: ::String) -> Dry::Monads::Result[bool]
9
+ def call(reservation_id:, reason:, message:)
10
+ argument! reservation_id: :required if reservation_id.blank?
11
+ argument! reason: :required if reason.blank?
12
+ argument! message: :required if message.blank?
13
+
14
+ body = {
15
+ reason:,
16
+ messageToGuest: message
17
+ }
18
+ response = Guesty.do_request(
19
+ :post,
20
+ "reservations-v3/#{reservation_id}/decline",
21
+ body,
22
+ retries: 0
23
+ )
24
+ return response unless response.success?
25
+
26
+ Success(true)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,33 @@
1
+ module Guesty
2
+ module Reservations
3
+ module Flags
4
+ # Sets account custom fields on a reservation. The field ids are
5
+ # account-specific, so the host injects them (Guesty.config.custom_fields,
6
+ # keyed by flag name: blacklisted, grants_created, grants_partial).
7
+ class Update
8
+ include Guesty::Operation
9
+
10
+ # @rbs (reservation_id: ::String, flags: ::Hash[::Symbol, bool]) -> Dry::Monads::Result[bool]
11
+ def call(reservation_id:, flags:)
12
+ custom_fields = flags.map { |name, value| { fieldId: field_ids.fetch(name), value: value.to_s } }
13
+ body = { customFields: custom_fields }
14
+ response = Guesty.do_request(
15
+ :put,
16
+ "reservations-v3/#{reservation_id}/custom-fields",
17
+ body
18
+ )
19
+
20
+ raise response.failure if response.failure?
21
+
22
+ Success(true)
23
+ end
24
+
25
+ private
26
+
27
+ def field_ids
28
+ Guesty.config.custom_fields
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,72 @@
1
+ module Guesty
2
+ module Reservations
3
+ module Payments
4
+ # Records a payment taken outside Guesty. Idempotent per payment intent:
5
+ # a reservation already fully paid, or with a payment noting the intent,
6
+ # is returned without a post.
7
+ class Create
8
+ include Guesty::Operation
9
+
10
+ PAYMENT_METHOD_NAME = 'RECORDED_CASH'.freeze #: ::String
11
+ HISTORY_UNAVAILABLE = 'Guesty has not returned the payment history yet. Try again later.'.freeze #: ::String
12
+
13
+ # @rbs (reservation_id: ::String, attributes: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
14
+ def call(reservation_id:, attributes:)
15
+ validate_arguments!(reservation_id:, attributes:)
16
+
17
+ @reservation_id = reservation_id
18
+
19
+ return Success(reservation) if fully_paid? || recorded?(attributes)
20
+
21
+ response = Guesty.do_request :post, "reservations/#{reservation_id}/payments", payload(attributes), retries: 0
22
+ return response unless response.success?
23
+
24
+ Success(response.value!.body)
25
+ end
26
+
27
+ private
28
+
29
+ def validate_arguments!(reservation_id:, attributes:)
30
+ argument! reservation_id: :required if reservation_id.blank?
31
+ argument! attributes: :required if attributes.blank?
32
+ argument! amount: :required if attributes[:amount].blank?
33
+ argument! amount: :must_be_positive if attributes[:amount].present? && attributes[:amount] <= 0
34
+ argument! paid_at: :required if attributes[:paid_at].blank?
35
+ argument! payment_intent_id: :required if attributes[:payment_intent_id].blank?
36
+ end
37
+
38
+ # @rbs () -> ::Hash[::Symbol, untyped]
39
+ def reservation
40
+ @reservation ||= Show.new.call!(reservation_id: @reservation_id, fields: %w[money.isFullyPaid money.payments money.totalPaid]).value!
41
+ end
42
+
43
+ # @rbs () -> bool
44
+ def fully_paid?
45
+ reservation[:money][:isFullyPaid]
46
+ end
47
+
48
+ # @rbs (::Hash[::Symbol, untyped] attributes) -> bool
49
+ def recorded?(attributes)
50
+ payments = reservation[:money][:payments]
51
+
52
+ return payments.any? { |payment| payment[:note]&.include?(attributes[:payment_intent_id]) } if payments
53
+ return false if reservation[:money][:totalPaid]&.zero?
54
+
55
+ error! HISTORY_UNAVAILABLE
56
+ end
57
+
58
+ # @rbs (::Hash[::Symbol, untyped] attributes) -> ::Hash[::Symbol, untyped]
59
+ def payload(attributes)
60
+ {
61
+ paymentMethod: {
62
+ method: PAYMENT_METHOD_NAME
63
+ },
64
+ amount: attributes[:amount],
65
+ paidAt: attributes[:paid_at].strftime('%Y-%m-%d'),
66
+ note: "Payment recorded in Guestit.se / Stripe #{attributes[:payment_intent_id]}"
67
+ }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,23 @@
1
+ module Guesty
2
+ module Reservations
3
+ # Pre-approves an inquiry (Airbnb only, asynchronous). An inquiry cannot be
4
+ # declined through the API.
5
+ class PreApprove
6
+ include Guesty::Operation
7
+
8
+ # @rbs (reservation_id: ::String) -> Dry::Monads::Result[bool]
9
+ def call(reservation_id:)
10
+ argument! reservation_id: :required if reservation_id.blank?
11
+
12
+ response = Guesty.do_request(
13
+ :post,
14
+ "reservations-v3/#{reservation_id}/pre-approve",
15
+ retries: 0
16
+ )
17
+ return response unless response.success?
18
+
19
+ Success(true)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ module Guesty
2
+ module Reservations
3
+ # Finds the Guesty reservation matching a confirmation code and stay window,
4
+ # skipping canceled ones. Returns nil when none matches.
5
+ class Recover
6
+ include Guesty::Operation
7
+
8
+ # @rbs (confirmation_code: ::String, start_date: ::Date, end_date: ::Date) -> Dry::Monads::Result[::String?]
9
+ def call(confirmation_code:, start_date:, end_date:)
10
+ fields = %w[_id listingId checkInDateLocalized checkOutDateLocalized status confirmationCode]
11
+ attributes = { confirmation_code: }
12
+ result = Guesty::Reservations::Search.new.call!(
13
+ fields:,
14
+ attributes:,
15
+ take: 2
16
+ ).value!
17
+ candidate = result.fetch(:results).find do |reservation|
18
+ reservation[:status] != CANCELLED_STATUS &&
19
+ reservation[:checkInDateLocalized] == start_date.iso8601 &&
20
+ reservation[:checkOutDateLocalized] == end_date.iso8601
21
+ end
22
+
23
+ Success(candidate&.dig(:_id))
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,66 @@
1
+ module Guesty
2
+ module Reservations
3
+ module Reviews
4
+ module Extractors
5
+ # Airbnb raw reviews carry category_ratings[].review_category_tags.
6
+ class Airbnb < Base
7
+ POSITIVE_TAG = 'GUEST_REVIEW_HOST_POSITIVE_'.freeze #: ::String
8
+ NEGATIVE_TAG = 'GUEST_REVIEW_HOST_NEGATIVE_'.freeze #: ::String
9
+
10
+ private
11
+
12
+ # @rbs (::Hash[::Symbol, untyped] raw_review) -> ::Hash[::Symbol, untyped]
13
+ def extract(raw_review)
14
+ codes = tag_codes(raw_review)
15
+
16
+ {
17
+ **super,
18
+ positive_tags: tags(codes, POSITIVE_TAG),
19
+ negative_tags: tags(codes, NEGATIVE_TAG),
20
+ other_tags: codes.reject { it.start_with?(POSITIVE_TAG, NEGATIVE_TAG) }.map(&:downcase)
21
+ }
22
+ end
23
+
24
+ def extract_text(raw_review)
25
+ {
26
+ public: raw_review[:public_review],
27
+ private: raw_review[:private_feedback]
28
+ }.compact_blank
29
+ end
30
+
31
+ def extract_scores(raw_review)
32
+ scores = { rating: raw_review[:overall_rating]&.to_f }
33
+
34
+ category_ratings = raw_review[:category_ratings]
35
+ return unless category_ratings.is_a?(Array)
36
+
37
+ category_ratings.each do |rating_entry|
38
+ next unless rating_entry.is_a?(Hash)
39
+
40
+ category = rating_entry[:category].downcase.to_sym
41
+ rating = rating_entry[:rating]
42
+
43
+ scores[category] = rating.to_f if category.present? && rating.present?
44
+ end
45
+
46
+ scores.compact
47
+ end
48
+
49
+ # @rbs (::Hash[::Symbol, untyped] raw_review) -> ::Array[::String]
50
+ def tag_codes(raw_review)
51
+ category_ratings = raw_review[:category_ratings]
52
+
53
+ return [] unless category_ratings.is_a?(Array)
54
+
55
+ category_ratings.flat_map { it[:review_category_tags] }
56
+ end
57
+
58
+ # @rbs (::Array[::String] codes, ::String prefix) -> ::Array[::String]
59
+ def tags(codes, prefix)
60
+ codes.select { it.start_with?(prefix) }.map { it.delete_prefix(prefix).downcase }
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,36 @@
1
+ module Guesty
2
+ module Reservations
3
+ module Reviews
4
+ module Extractors
5
+ class Base
6
+ include Guesty::Operation
7
+
8
+ # @rbs (raw_review: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
9
+ def call(raw_review:)
10
+ argument! raw_review: :required, fatal: true if raw_review.blank?
11
+
12
+ Success(extract(raw_review))
13
+ end
14
+
15
+ private
16
+
17
+ # @rbs (::Hash[::Symbol, untyped] raw_review) -> ::Hash[::Symbol, untyped]
18
+ def extract(raw_review)
19
+ {
20
+ text: extract_text(raw_review),
21
+ scores: extract_scores(raw_review)
22
+ }
23
+ end
24
+
25
+ def extract_text(raw_review)
26
+ raise NotImplementedError, "#{self.class} must implement #extract_text"
27
+ end
28
+
29
+ def extract_scores(raw_review)
30
+ raise NotImplementedError, "#{self.class} must implement #extract_scores"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,35 @@
1
+ module Guesty
2
+ module Reservations
3
+ module Reviews
4
+ module Extractors
5
+ class BookingCom < Base
6
+ private
7
+
8
+ def extract_text(raw_review)
9
+ {
10
+ public: [
11
+ raw_review.dig(:content, :headline),
12
+ raw_review.dig(:content, :positive),
13
+ raw_review.dig(:content, :negative)
14
+ ].compact_blank.join(' ').presence
15
+ }
16
+ end
17
+
18
+ def extract_scores(raw_review)
19
+ scores = {} #: ::Hash[::Symbol, untyped]
20
+ category_ratings = raw_review[:scoring]
21
+ return unless category_ratings.is_a?(Hash)
22
+
23
+ category_ratings.each do |category, rating|
24
+ scores[category.to_sym] = (rating.to_f / 2).round(2) # Booking.com gives 1-10, we want 0-5
25
+ end
26
+
27
+ scores[:rating] = scores[:review_score]
28
+ scores[:cleanliness] = scores[:clean]
29
+ scores.except(:clean, :review_score).compact
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,46 @@
1
+ module Guesty
2
+ module Reservations
3
+ module Reviews
4
+ module Extractors
5
+ class Vrbo < Base
6
+ private
7
+
8
+ def extract_text(raw_review)
9
+ {
10
+ public: [
11
+ raw_review.dig(:title, :value),
12
+ raw_review.dig(:body, :value)
13
+ ].compact.reject(&:empty?).join(' ').presence
14
+ }
15
+ end
16
+
17
+ def extract_scores(raw_review)
18
+ scores = { rating: raw_review[:starRatingOverall].to_f }
19
+
20
+ raw_ratings = raw_review[:starRatings]
21
+ return unless raw_ratings.is_a?(Array)
22
+
23
+ raw_ratings.each do |rating|
24
+ next unless rating.is_a?(Hash)
25
+
26
+ category_key = normalize_category(rating[:category].downcase.to_sym)
27
+ scores[category_key] ||= rating[:value].to_f
28
+ end
29
+
30
+ scores.compact
31
+ end
32
+
33
+ def normalize_category(category)
34
+ case category
35
+ when :roomcleanliness then :cleanliness
36
+ when :onlinelisting then :accuracy
37
+ when :overall then :rating
38
+ else
39
+ category
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end