turbovax 0.0.2pre

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.
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