turbovax 0.0.2pre

Sign up to get free protection for your applications and to get access to all the features.
data/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # Turbovax
2
+
3
+ Turbovax gem helps you quickly stand up bots that can:
4
+ 1) fetch data from vaccine websites
5
+ 2) tweet appointment data
6
+ 3) return structured appointment data
7
+
8
+ It does not provide any data storage or web server layers. You can build that functionality on top of the gem by yourself.
9
+
10
+ ## Installation
11
+
12
+ Add this line to your application's Gemfile:
13
+
14
+ ```ruby
15
+ gem 'turbovax'
16
+ ```
17
+
18
+ And then execute:
19
+
20
+ $ bundle install
21
+
22
+ Or install it yourself as:
23
+
24
+ $ gem install turbovax
25
+
26
+ ## Usage
27
+
28
+ Initialize configuration (optional):
29
+
30
+ Turbovax.configure do |config|
31
+ config.logger = Logger.new($stdout, level: Logger::DEBUG)
32
+ config.twitter_enabled = true
33
+ config.twitter_credentials = {
34
+ consumer_key: "CONSUMER_KEY",
35
+ consumer_secret: "CONSUMER_SECRET",
36
+ access_token: "ACCESS_TOKEN",
37
+ access_token_secret: "ACCESS_TOKEN_SECRET"
38
+ }
39
+
40
+ config.faraday_logging_config = {
41
+ headers: true,
42
+ bodies: true,
43
+ log_level: :info
44
+ }
45
+ end
46
+
47
+ Create test portal:
48
+
49
+ class TestPortal < Turbovax::Portal
50
+ name "Gotham City Clinic"
51
+ key "gotham_city"
52
+ public_url "https://www.turbovax.info/"
53
+ api_url "http://api.turbovax.info/v1/test.json"
54
+ request_http_method Turbovax::Constants::GET_REQUEST_METHOD
55
+
56
+ parse_response do |response|
57
+ response_json = JSON.parse(response)
58
+ Array(response_json["appointments"]).map do |location_json|
59
+ appointments = Array(location_json["slots"]).map do |appointment_string|
60
+ {
61
+ time: DateTime.parse(appointment_string)
62
+ }
63
+ end
64
+
65
+ Turbovax::Location.new(
66
+ id: "ID",
67
+ name: location_json["clinic_name"],
68
+ full_address: location_json["area"],
69
+ time_zone: "America/New_York",
70
+ data: {
71
+ vaccine_types: [location_json["vaccine"]],
72
+ appointments: appointments,
73
+ }
74
+ )
75
+ end
76
+ end
77
+ end
78
+
79
+ Execute operation:
80
+
81
+ locations = Turbovax::DataFetcher.new(TestPortal, twitter_handler: Turbovax::Handlers::LocationHandler).execute!
82
+
83
+ ## Development
84
+
85
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
86
+
87
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
88
+
89
+ ## Contributing
90
+
91
+ Bug reports and pull requests are welcome on GitHub at https://github.com/hugem/turbovax-gem.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/bin/console ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "pry"
6
+ require "turbovax"
7
+
8
+ # You can add fixtures and/or initialization code here to make experimenting
9
+ # with your gem easier. You can also use a different console, if you like.
10
+
11
+ def reload!
12
+ reload_paths("turbovax")
13
+ end
14
+
15
+ def reload_paths(require_regex)
16
+ $LOADED_FEATURES.grep(/#{require_regex}/).each { |e| $LOADED_FEATURES.delete(e) && require(e) }
17
+ end
18
+
19
+ # (If you use this, don't forget to add pry to your Gemfile!)
20
+ Pry.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/all"
4
+
5
+ module Turbovax
6
+ # Class that encapsulates a singular appointment
7
+ class Appointment
8
+ # @return [DateTime]
9
+ attr_accessor :time
10
+ # @return [String]
11
+ # Can automatically be set by Turbovax::Location instance
12
+ attr_accessor :time_zone
13
+ # @return [Boolean]
14
+ attr_accessor :is_second_dose
15
+ # @return [String]
16
+ attr_accessor :vaccine_type
17
+
18
+ # @param params hash mapping of attribute => value
19
+ def initialize(**params)
20
+ params.each do |attribute, value|
21
+ value_to_save =
22
+ if attribute.to_s == "time"
23
+ if value.is_a?(DateTime) || value.is_a?(Time)
24
+ value
25
+ else
26
+ DateTime.parse(value)
27
+ end
28
+ else
29
+ value
30
+ end
31
+
32
+ send("#{attribute}=", value_to_save)
33
+ end
34
+ end
35
+
36
+ # If time_zone is set on instance, returns appointment time in time zone
37
+ # @return [DateTime]
38
+ def time_in_time_zone
39
+ time_zone ? time.in_time_zone(time_zone) : time
40
+ end
41
+
42
+ private
43
+
44
+ def <=>(other)
45
+ time <=> other.time
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turbovax
4
+ module Constants
5
+ GET_REQUEST_METHOD = :get
6
+ POST_REQUEST_METHOD = :post
7
+ end
8
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "faraday"
5
+
6
+ module Turbovax
7
+ # This class, given a portal and a twitter handler:
8
+ # 1) executes request for data
9
+ # 2) passes structured appointment data to twitter handler
10
+ # 3) returns appointment data
11
+ class DataFetcher
12
+ # @param [Turbovax::Portal] portal
13
+ # @param [TurboVax::Twitter::Handler] twitter_handler a class handles if appointments are found
14
+ # @param [Hash] extra_params other info that can be provided to portal when executing blocks
15
+ def initialize(portal, twitter_handler: nil, extra_params: {})
16
+ @portal = portal
17
+ @extra_params = { date: DateTime.now }.merge(extra_params)
18
+ @conn = create_request_connection
19
+ @twitter_handler = twitter_handler
20
+ end
21
+
22
+ # @return [Array<Turbovax::Location>] List of locations and appointments
23
+ def execute!
24
+ response = make_request
25
+ log("make request [DONE]")
26
+ locations = @portal.parse_response_with_portal(response.body, @extra_params)
27
+ log("parse response [DONE]")
28
+
29
+ send_to_twitter_handler(locations)
30
+
31
+ locations
32
+ end
33
+
34
+ private
35
+
36
+ def send_to_twitter_handler(locations)
37
+ if !Turbovax.twitter_enabled
38
+ log("twitter handler [SKIP] not enabled")
39
+ elsif !locations.size.positive?
40
+ log("twitter handler [SKIP]: no location data")
41
+ else
42
+ @twitter_handler&.new(locations)&.execute!
43
+ log("twitter handler [DONE]")
44
+ end
45
+ end
46
+
47
+ def create_request_connection
48
+ Faraday.new(
49
+ url: @portal.api_base_url,
50
+ headers: @portal.request_headers,
51
+ ssl: { verify: false }
52
+ ) do |faraday|
53
+ faraday.response :logger, Turbovax.logger,
54
+ Turbovax.faraday_logging_config
55
+ faraday.adapter Faraday.default_adapter
56
+ end
57
+ end
58
+
59
+ def make_request
60
+ request_type = @portal.request_http_method
61
+ path = @portal.api_path
62
+ query_params = @portal.api_query_params(@extra_params)
63
+
64
+ case request_type
65
+ when Turbovax::Constants::GET_REQUEST_METHOD
66
+ make_get_request(path, query_params)
67
+ when Turbovax::Constants::POST_REQUEST_METHOD
68
+ make_post_request(path, query_params)
69
+ else
70
+ raise Turbovax::InvalidRequestTypeError
71
+ end
72
+ end
73
+
74
+ def make_get_request(path, query_params)
75
+ @conn.get(path) do |req|
76
+ # only set params if they are present, otherwise this will overwrite any string query
77
+ # param values that are existing in the url path
78
+ req.params = query_params if query_params.nil? || query_params != {}
79
+ end
80
+ end
81
+
82
+ def make_post_request(path, query_params)
83
+ @conn.post(path) do |req|
84
+ # only set params if they are present, otherwise this will overwrite any string query
85
+ # param values that are existing in the url path
86
+ req.params = query_params if query_params.nil? || query_params != {}
87
+ req.body = @portal.request_body(@extra_params)
88
+ end
89
+ end
90
+
91
+ def log(message)
92
+ Turbovax.logger.info("[#{self.class}] #{message}")
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "twitter"
4
+
5
+ module Turbovax
6
+ module Handlers
7
+ # Given a list of locations, tweet appointment info for each location
8
+ class LocationHandler
9
+ def initialize(locations)
10
+ @locations = locations
11
+ end
12
+
13
+ def execute!
14
+ count = 0
15
+ @locations.each do |location|
16
+ next if count >= max_location_limit
17
+
18
+ count += 1 if handle_location(location)
19
+ end
20
+ end
21
+
22
+ # Max locations to tweet at a given time
23
+ def max_location_limit
24
+ 2
25
+ end
26
+
27
+ # Max number of days included in a tweet
28
+ def day_limit
29
+ 3
30
+ end
31
+
32
+ # Max number of appointment times included per day
33
+ def daily_appointment_limit
34
+ 3
35
+ end
36
+
37
+ # Format of each individual date. See APIdoc for format
38
+ # https://apidock.com/ruby/DateTime/strftime
39
+ # @example Datetime to default time format
40
+ # Wed, 21 Apr 2021 09:23:15 -0400 => Apr 21
41
+ def date_format
42
+ "%b %-e"
43
+ end
44
+
45
+ # Format of each individual appointment time. See APIdoc for format
46
+ # https://apidock.com/ruby/DateTime/strftime
47
+ # @example Datetime to default time format
48
+ # Wed, 21 Apr 2021 09:23:15 -0400 => 9:23AM
49
+ def appointment_time_format
50
+ "%-l:%M%p"
51
+ end
52
+
53
+ # @return [Boolean]
54
+ # Override this method to to add caching logic
55
+ def should_tweet_for_location(location)
56
+ location.available
57
+ end
58
+
59
+ private
60
+
61
+ def handle_location(location)
62
+ return false unless should_tweet_for_location(location)
63
+
64
+ text = format_tweet(location)
65
+
66
+ send_tweet(text)
67
+ true
68
+ end
69
+
70
+ def send_tweet(text)
71
+ Turbovax::TwitterClient.send_tweet(text)
72
+ end
73
+
74
+ def format_tweet(location)
75
+ to_join = []
76
+
77
+ portal = location.portal
78
+ appointment_count = location.appointment_count ? "#{location.appointment_count} appts" : nil
79
+
80
+ summary_string = "[#{join(portal.name, location.area, delimiter: " · ")}] "
81
+ summary_string += join(location.name, appointment_count, delimiter: ": ")
82
+ to_join << summary_string
83
+
84
+ to_join << format_appointments(location)
85
+ to_join << portal.public_url
86
+
87
+ to_join.join("\n\n")
88
+ end
89
+
90
+ def format_appointments(location)
91
+ to_join = []
92
+
93
+ appointments_by_day =
94
+ group_appointments_by_day(location.appointments.sort)
95
+
96
+ appointments_by_day.each.with_index do |(day, appointments), index|
97
+ next if index >= day_limit
98
+
99
+ to_join << format_appointments_for_day(day, appointments)
100
+ end
101
+
102
+ to_join.join("\n")
103
+ end
104
+
105
+ def group_appointments_by_day(appointments)
106
+ appointments.each_with_object({}) do |appointment, memo|
107
+ day = appointment.time_in_time_zone.strftime(date_format)
108
+
109
+ memo[day] ||= []
110
+ memo[day] << appointment
111
+ end
112
+ end
113
+
114
+ def format_appointments_for_day(day_string, appointments)
115
+ use_extra_appointment_count = appointments.size > daily_appointment_limit
116
+ extra_appointment_count = appointments.size - daily_appointment_limit
117
+
118
+ sorted_times = appointments.first(daily_appointment_limit).sort.map do |appointment|
119
+ appointment.time_in_time_zone.strftime(appointment_time_format)
120
+ end
121
+
122
+ time_string = sorted_times.join(", ")
123
+ time_string += " + #{extra_appointment_count}" if use_extra_appointment_count
124
+
125
+ "#{day_string}: #{time_string}"
126
+ end
127
+
128
+ def join(*args, delimiter:)
129
+ args.compact.join(delimiter)
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turbovax
4
+ # Representation of an individual vaccination site
5
+ class Location
6
+ # @return [String]
7
+ # Turbovax-specific unique ID for identification purposes
8
+ attr_accessor :id
9
+ # @return [String]
10
+ # Human readable name
11
+ attr_accessor :name
12
+ # @return [Turbovax::Portal]
13
+ attr_accessor :portal
14
+ # @return [String]
15
+ # Portal specific ID
16
+ attr_accessor :portal_id
17
+ # @return [String]
18
+ attr_accessor :full_address
19
+ # @return [String]
20
+ attr_accessor :area
21
+ # @return [String]
22
+ attr_accessor :zipcode
23
+ # @return [String]
24
+ attr_accessor :latitude
25
+ # @return [String]
26
+ attr_accessor :longitude
27
+ # @return [String]
28
+ # Valid values in https://api.rubyonrails.org/classes/ActiveSupport/TimeZone.html
29
+ attr_accessor :time_zone
30
+ # @return [Hash]
31
+ # Use this nested hash to specify appointments, appointment_count, available, vaccine_types
32
+ attr_accessor :data
33
+ # @return [Hash]
34
+ # Use this attribute to add any metadata
35
+ attr_accessor :metadata
36
+
37
+ def initialize(**params)
38
+ params.each do |attribute, value|
39
+ send("#{attribute}=", value)
40
+ end
41
+ end
42
+
43
+ # @return [Boolean]
44
+ # This can be manually specified via data hash or automatically calculated if
45
+ # appointment_count > 0
46
+ def available
47
+ data_hash[:available] || appointment_count.positive?
48
+ end
49
+
50
+ # This can be manually specified via data hash or automatically calculated
51
+ def vaccine_types
52
+ data_hash[:vaccine_types] || appointments.map(&:vaccine_type).uniq
53
+ end
54
+
55
+ # Returns a list of appointment instances (which are defined via) data hash
56
+ def appointments
57
+ Array(data_hash[:appointments]).map do |appointment|
58
+ if appointment.is_a?(Turbovax::Appointment)
59
+ appointment.time_zone = time_zone
60
+ appointment
61
+ else
62
+ Turbovax::Appointment.new(appointment.merge(time_zone: time_zone))
63
+ end
64
+ end
65
+ end
66
+
67
+ def appointment_count
68
+ data_hash[:appointment_count] || appointments.size
69
+ end
70
+
71
+ private
72
+
73
+ def data_hash
74
+ data || {}
75
+ end
76
+ end
77
+ end