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.
- checksums.yaml +7 -0
- data/.github/workflows/main.yml +18 -0
- data/.gitignore +17 -0
- data/.rspec +3 -0
- data/.rubocop.yml +23 -0
- data/CHANGELOG.md +2 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +15 -0
- data/LICENSE +661 -0
- data/README.md +91 -0
- data/Rakefile +12 -0
- data/bin/console +20 -0
- data/bin/setup +8 -0
- data/lib/turbovax/appointment.rb +48 -0
- data/lib/turbovax/constants.rb +8 -0
- data/lib/turbovax/data_fetcher.rb +95 -0
- data/lib/turbovax/handlers/location_handler.rb +133 -0
- data/lib/turbovax/location.rb +77 -0
- data/lib/turbovax/portal.rb +178 -0
- data/lib/turbovax/twitter_client.rb +23 -0
- data/lib/turbovax/version.rb +5 -0
- data/lib/turbovax.rb +63 -0
- data/turbovax.gemspec +36 -0
- metadata +116 -0
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
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,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,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
|