turbovax 0.0.2pre
Sign up to get free protection for your applications and to get access to all the features.
- 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
|