├── .rspec ├── .gitignore ├── .dockerignore ├── spec ├── spec_helper.rb ├── slack_spec.rb ├── mock_clinic.rb ├── cvs_spec.rb ├── ma_immunizations_spec.rb ├── twitter_spec.rb ├── storage_spec.rb └── base_clinic_spec.rb ├── Gemfile ├── lib ├── multi_logger.rb ├── sites │ ├── pharmacy_clinic.rb │ ├── amesbury.rb │ ├── northampton.rb │ ├── trinity_health.rb │ ├── rutland.rb │ ├── heywood_healthcare.rb │ ├── base_clinic.rb │ ├── baystate_health.rb │ ├── southcoast.rb │ ├── ma_immunizations_registrations.rb │ ├── curative.rb │ ├── lowell_general.rb │ ├── zocdoc.rb │ ├── athena.rb │ ├── holyoke_health.rb │ ├── acuity.rb │ ├── cvs.rb │ ├── rxtouch.rb │ ├── vaccinespotter.rb │ ├── costco.rb │ ├── ma_immunizations.rb │ ├── color.rb │ ├── walgreens.rb │ ├── my_chart.rb │ └── config │ │ └── user_agents.txt ├── sentry_helper.rb ├── user_agents.rb ├── browser.rb ├── storage.rb ├── twitter.rb └── slack.rb ├── docker-compose.yml ├── .github └── workflows │ └── rspec.yml ├── Dockerfile ├── CONTRIBUTING.md ├── Gemfile.lock ├── run.rb ├── CODE_OF_CONDUCT.md ├── README.md └── LICENSE /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | dump.rdb 3 | log/* 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | dump.rdb 3 | log/* 4 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | 3 | ENV['ENVIRONMENT'] = 'test' 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'nokogiri' 4 | gem 'redis' 5 | gem 'rest-client' 6 | gem 'sentry-ruby' 7 | gem 'slack-ruby-client' 8 | gem 'twitter' 9 | 10 | # browser automation 11 | gem 'ferrum' 12 | 13 | group :test do 14 | gem 'rspec' 15 | end 16 | -------------------------------------------------------------------------------- /lib/multi_logger.rb: -------------------------------------------------------------------------------- 1 | class MultiLogger 2 | def initialize(*targets) 3 | @targets = targets 4 | end 5 | 6 | %w[log debug info warn error fatal unknown].each do |m| 7 | define_method(m) do |*args| 8 | @targets.map { |t| t.send(m, *args) } 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | vaccinetime: 4 | build: . 5 | volumes: 6 | - .:/usr/src/app 7 | links: 8 | - redis 9 | environment: 10 | REDIS_URL: redis://redis 11 | IN_DOCKER: 'true' 12 | stdin_open: true 13 | tty: true 14 | 15 | redis: 16 | image: redis 17 | -------------------------------------------------------------------------------- /lib/sites/pharmacy_clinic.rb: -------------------------------------------------------------------------------- 1 | require_relative './base_clinic' 2 | 3 | class PharmacyClinic < BaseClinic 4 | DEFAULT_TWEET_THRESHOLD = ENV['PHARMACY_DEFAULT_TWEET_THRESHOLD']&.to_i || 10 5 | DEFAULT_TWEET_INCREASE_NEEDED = ENV['PHARMACY_DEFAULT_TWEET_INCREASE_NEEDED']&.to_i || 5 6 | DEFAULT_TWEET_COOLDOWN = ENV['PHARMACY_DEFAULT_TWEET_COOLDOWN']&.to_i || 60 * 60 # 1 hour 7 | end 8 | -------------------------------------------------------------------------------- /lib/sentry_helper.rb: -------------------------------------------------------------------------------- 1 | require 'sentry-ruby' 2 | 3 | module SentryHelper 4 | def self.catch_errors(logger, module_name, on_error: []) 5 | yield 6 | rescue => e 7 | logger.error "[#{module_name}] Failed to get appointments: #{e}" 8 | raise e unless ENV['ENVIRONMENT'] == 'production' || ENV['ENVIRONMENT'] == 'staging' 9 | 10 | Sentry.capture_exception(e) 11 | on_error 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /.github/workflows/rspec.yml: -------------------------------------------------------------------------------- 1 | name: RSpec 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: RSpec 6 | runs-on: ubuntu-latest 7 | env: 8 | CI: true 9 | ENVIRONMENT: test 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: ruby/setup-ruby@v1 13 | with: 14 | ruby-version: 3.0 15 | bundler-cache: true 16 | - run: bundle exec rspec 17 | -------------------------------------------------------------------------------- /lib/user_agents.rb: -------------------------------------------------------------------------------- 1 | module UserAgents 2 | USER_AGENTS = [] 3 | 4 | File.open("#{__dir__}/sites/config/user_agents.txt", 'r') do |f| 5 | f.each_line do |line| 6 | USER_AGENTS.append(line.strip) 7 | end 8 | end 9 | 10 | def self.all 11 | USER_AGENTS 12 | end 13 | 14 | def self.random 15 | all.sample 16 | end 17 | 18 | def self.chrome 19 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36' 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.0 2 | 3 | ENV IN_DOCKER=true 4 | 5 | RUN ln -fs /usr/share/zoneinfo/US/Eastern /etc/localtime && dpkg-reconfigure -f noninteractive tzdata 6 | 7 | RUN apt-get update && apt-get install -y less chromium 8 | 9 | # throw errors if Gemfile has been modified since Gemfile.lock 10 | RUN bundle config --global frozen 1 11 | 12 | WORKDIR /usr/src/app 13 | 14 | COPY Gemfile Gemfile.lock ./ 15 | RUN bundle install 16 | 17 | COPY . . 18 | 19 | RUN mkdir -p log 20 | 21 | RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 22 | 23 | CMD ["bundle", "exec", "ruby", "run.rb"] 24 | -------------------------------------------------------------------------------- /lib/browser.rb: -------------------------------------------------------------------------------- 1 | require 'ferrum' 2 | 3 | module Browser 4 | def self.run 5 | browser = if ENV['IN_DOCKER'] == 'true' 6 | Ferrum::Browser.new(browser_options: { 'no-sandbox': nil }) 7 | else 8 | Ferrum::Browser.new 9 | end 10 | 11 | begin 12 | yield browser 13 | ensure 14 | browser.quit 15 | end 16 | end 17 | 18 | def self.wait_for(browser, css) 19 | 10.times do 20 | browser.network.wait_for_idle 21 | 22 | tag = browser.at_css(css) 23 | return tag if tag 24 | 25 | sleep 1 26 | end 27 | nil 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/slack_spec.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require_relative '../lib/slack' 3 | 4 | describe SlackClient do 5 | describe '#send' do 6 | it 'sends a slack' do 7 | mock_slack = double('Slack::Web::Client') 8 | mock_clinic = double('Clinic', title: 'Test clinic', new_appointments: 1) 9 | expect(FakeSlack).to receive(:new).and_return(mock_slack) 10 | expect(mock_slack).to receive(:chat_postMessage).with( 11 | blocks: ['test blocks'], 12 | channel: '#bot-vaccine', 13 | username: 'vaccine-bot', 14 | icon_emoji: ':old-man-yells-at-covid19:' 15 | ) 16 | expect(mock_clinic).to receive(:slack_blocks).and_return('test blocks') 17 | slack = SlackClient.new(Logger.new('/dev/null')) 18 | slack.send([mock_clinic]) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/mock_clinic.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/sites/base_clinic' 2 | 3 | class MockStorage 4 | def initialize(last_posted_time) 5 | @last_posted_time = last_posted_time 6 | end 7 | 8 | def get_post_time(_) 9 | @last_posted_time 10 | end 11 | end 12 | 13 | class MockClinic < BaseClinic 14 | attr_reader :title, :appointments, :new_appointments, :link, :city 15 | 16 | def initialize(title: 'Mock clinic on 01/01/2021', 17 | appointments: 0, 18 | new_appointments: 0, 19 | link: 'clinicsite.com', 20 | last_posted_time: nil, 21 | city: nil) 22 | super(MockStorage.new(last_posted_time)) 23 | @title = title 24 | @appointments = appointments 25 | @new_appointments = new_appointments 26 | @link = link 27 | @last_posted_time = last_posted_time 28 | @city = city 29 | end 30 | 31 | def storage_key 32 | title 33 | end 34 | 35 | def save_tweet_time 36 | nil 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/sites/amesbury.rb: -------------------------------------------------------------------------------- 1 | require 'rest-client' 2 | 3 | require_relative '../sentry_helper' 4 | require_relative './ma_immunizations_registrations' 5 | 6 | module Amesbury 7 | BASE_URL = 'https://www.amesburyma.gov/home/urgent-alerts/covid-19-vaccine-distribution'.freeze 8 | 9 | def self.all_clinics(storage, logger) 10 | logger.info '[Amesbury] Checking site' 11 | SentryHelper.catch_errors(logger, 'Amesbury') do 12 | res = RestClient.get(BASE_URL).body 13 | sites = res.scan(%r{https://www\.(maimmunizations\.org/+reg/\d+)}) 14 | if sites.empty? 15 | logger.info '[Amesbury] No sites found' 16 | [] 17 | else 18 | logger.info "[Amesbury] Scanning #{sites.length} sites" 19 | MaImmunizationsRegistrations.all_clinics( 20 | 'AMESBURY', 21 | BASE_URL, 22 | sites.map { |clinic_url| "https://registrations.#{clinic_url[0]}" }, 23 | storage, 24 | logger, 25 | 'Amesbury' 26 | ) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/sites/northampton.rb: -------------------------------------------------------------------------------- 1 | require 'rest-client' 2 | 3 | require_relative '../sentry_helper' 4 | require_relative './ma_immunizations_registrations' 5 | 6 | module Northampton 7 | BASE_URL = 'https://www.northamptonma.gov/2219/Vaccine-Clinics'.freeze 8 | 9 | def self.all_clinics(storage, logger) 10 | logger.info '[Northampton] Checking site' 11 | SentryHelper.catch_errors(logger, 'Northampton') do 12 | res = RestClient.get(BASE_URL).body 13 | sites = res.scan(%r{https://www\.(maimmunizations\.org//reg/\d+)}) 14 | if sites.empty? 15 | logger.info '[Northampton] No sites found' 16 | [] 17 | else 18 | logger.info "[Northampton] Scanning #{sites.length} sites" 19 | MaImmunizationsRegistrations.all_clinics( 20 | 'NORTHAMPTON', 21 | BASE_URL, 22 | sites.map { |clinic_url| "https://registrations.#{clinic_url[0]}" }, 23 | storage, 24 | logger, 25 | 'Northampton' 26 | ) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/storage.rb: -------------------------------------------------------------------------------- 1 | require 'redis' 2 | require 'json' 3 | 4 | class Storage 5 | APPOINTMENT_KEY = 'slack-vaccine-appt'.freeze 6 | POST_KEY = 'slack-vaccine-post'.freeze 7 | COOKIES_KEY = 'vaccine-cookies'.freeze 8 | 9 | def initialize(redis = Redis.new) 10 | @redis = redis 11 | end 12 | 13 | def with_prefix(prefix, clinic) 14 | prefix + ':' + clinic.storage_key 15 | end 16 | 17 | def set(key, value) 18 | @redis.set(key, value) 19 | end 20 | 21 | def get(key) 22 | @redis.get(key) 23 | end 24 | 25 | def save_appointments(clinic) 26 | set(with_prefix(APPOINTMENT_KEY, clinic), clinic.appointments) 27 | end 28 | 29 | def get_appointments(clinic) 30 | get(with_prefix(APPOINTMENT_KEY, clinic)) 31 | end 32 | 33 | def save_post_time(clinic) 34 | set(with_prefix(POST_KEY, clinic), Time.now) 35 | end 36 | 37 | def get_post_time(clinic) 38 | get(with_prefix(POST_KEY, clinic)) 39 | end 40 | 41 | def save_cookies(site, cookies, expiration) 42 | set("#{COOKIES_KEY}:#{site}", { cookies: cookies, expiration: expiration }.to_json) 43 | end 44 | 45 | def get_cookies(site) 46 | res = get("#{COOKIES_KEY}:#{site}") 47 | res && JSON.parse(res) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/cvs_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/storage' 2 | require_relative '../lib/sites/cvs' 3 | 4 | SIGN_UP_PAGE = 'https://www.cvs.com/immunizations/covid-19-vaccine' 5 | 6 | describe Cvs do 7 | let(:redis) { double('Redis') } 8 | let(:storage) { Storage.new(redis) } 9 | 10 | describe '.twitter_text' do 11 | it 'can tweet for one city' do 12 | clinic = Cvs::StateClinic.new(storage, ['SOMERVILLE'], 'MA') 13 | expect(clinic.twitter_text).to eq([ 14 | 'CVS appointments available in SOMERVILLE. Check eligibility and sign up at https://www.cvs.com/immunizations/covid-19-vaccine' 15 | ]) 16 | end 17 | 18 | it 'splits up tweets that are too long' do 19 | clinic = Cvs::StateClinic.new( 20 | storage, 21 | ['AMHERST', 'BOSTON', 'BROCKTON', 'CAMBRIDGE', 'CARVER', 'CHICOPEE', 'DANVERS', 'DORCHESTER', 'FALL RIVER', 'FALMOUTH', 'FITCHBURG', 'HAVERHILL', 'LEOMINSTER', 'LUNENBURG', 'LYNN', 'MATTAPAN', 'METHUEN', 'SOMERVILLE', 'SPRINGFIELD', 'WOBURN', 'WORCESTER'], 22 | 'MA' 23 | ) 24 | expect(clinic.twitter_text).to eq([ 25 | 'CVS appointments available in AMHERST, BOSTON, BROCKTON, CAMBRIDGE, CARVER, CHICOPEE, DANVERS, DORCHESTER, FALL RIVER, FALMOUTH, FITCHBURG, HAVERHILL, LEOMINSTER, LUNENBURG, LYNN, MATTAPAN, METHUEN, SOMERVILLE, SPRINGFIELD. Check eligibility and sign up at https://www.cvs.com/immunizations/covid-19-vaccine', 26 | 'CVS appointments available in WOBURN, WORCESTER. Check eligibility and sign up at https://www.cvs.com/immunizations/covid-19-vaccine', 27 | ]) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/twitter.rb: -------------------------------------------------------------------------------- 1 | require 'twitter' 2 | 3 | class FakeTwitter 4 | def initialize(logger) 5 | @logger = logger 6 | end 7 | 8 | def update(str) 9 | @logger.info "[FakeTwitter]: #{str}" 10 | end 11 | end 12 | 13 | class TwitterClient 14 | 15 | def initialize(logger) 16 | @logger = logger 17 | @twitter = if ENV['ENVIRONMENT'] != 'test' && env_keys_exist? 18 | Twitter::REST::Client.new do |config| 19 | config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] 20 | config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] 21 | config.access_token = ENV['TWITTER_ACCESS_TOKEN'] 22 | config.access_token_secret = ENV['TWITTER_ACCESS_TOKEN_SECRET'] 23 | end 24 | else 25 | FakeTwitter.new(logger) 26 | end 27 | end 28 | 29 | def env_keys_exist? 30 | ENV['TWITTER_CONSUMER_KEY'] && 31 | ENV['TWITTER_CONSUMER_SECRET'] && 32 | ENV['TWITTER_ACCESS_TOKEN'] && 33 | ENV['TWITTER_ACCESS_TOKEN_SECRET'] 34 | end 35 | 36 | def tweet(clinic) 37 | @logger.info "[TwitterClient] Sending tweet for #{clinic.title} (#{clinic.new_appointments} new appointments)" 38 | text = clinic.twitter_text 39 | if text.is_a?(Array) 40 | text.each { |t| @twitter.update(t) } 41 | else 42 | @twitter.update(text) 43 | end 44 | 45 | rescue => e 46 | @logger.error "[TwitterClient] error: #{e}" 47 | raise e unless ENV['ENVIRONMENT'] == 'production' || ENV['ENVIRONMENT'] == 'staging' 48 | 49 | Sentry.capture_exception(e) 50 | end 51 | 52 | def post(clinics) 53 | clinics.filter(&:should_tweet?).each do |clinic| 54 | tweet(clinic) 55 | clinic.save_tweet_time 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/slack.rb: -------------------------------------------------------------------------------- 1 | require 'slack-ruby-client' 2 | 3 | class FakeSlack 4 | def initialize(logger) 5 | @logger = logger 6 | end 7 | 8 | def chat_postMessage(channel:, blocks:, username:, icon_emoji:) 9 | @logger.info "[FakeSlack]: #{username} (#{icon_emoji}) to #{channel} - #{blocks}" 10 | end 11 | end 12 | 13 | class SlackClient 14 | DEFAULT_SLACK_CHANNEL = '#bot-vaccine'.freeze 15 | DEFAULT_SLACK_USERNAME = 'vaccine-bot'.freeze 16 | DEFAULT_SLACK_ICON = ':old-man-yells-at-covid19:'.freeze 17 | 18 | def initialize(logger) 19 | @logger = logger 20 | @client = if ENV['ENVIRONMENT'] != 'test' && ENV['SLACK_API_TOKEN'] 21 | init_slack_client 22 | else 23 | FakeSlack.new(logger) 24 | end 25 | end 26 | 27 | def init_slack_client 28 | Slack.configure do |config| 29 | config.token = ENV['SLACK_API_TOKEN'] 30 | end 31 | 32 | slack_client = Slack::Web::Client.new 33 | slack_client.auth_test 34 | slack_client 35 | end 36 | 37 | def send(clinics) 38 | clinics.each do |clinic| 39 | @logger.info "[SlackClient] Sending slack for #{clinic.title} (#{clinic.new_appointments} new appointments)" 40 | end 41 | 42 | @client.chat_postMessage( 43 | blocks: clinics.map(&:slack_blocks), 44 | channel: ENV['SLACK_CHANNEL'] || DEFAULT_SLACK_CHANNEL, 45 | username: ENV['SLACK_USERNAME'] || DEFAULT_SLACK_USERNAME, 46 | icon_emoji: ENV['SLACK_ICON'] || DEFAULT_SLACK_ICON 47 | ) 48 | 49 | rescue => e 50 | @logger.error "[SlackClient] error: #{e}" 51 | raise e unless ENV['ENVIRONMENT'] == 'production' || ENV['ENVIRONMENT'] == 'staging' 52 | 53 | Sentry.capture_exception(e) 54 | end 55 | 56 | def should_post?(clinic) 57 | clinic.link && 58 | clinic.appointments.positive? && 59 | clinic.new_appointments.positive? 60 | end 61 | 62 | def post(clinics) 63 | clinics_to_slack = clinics.filter { |clinic| should_post?(clinic) } 64 | send(clinics_to_slack) if clinics_to_slack.any? 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/sites/trinity_health.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'rest-client' 3 | require 'nokogiri' 4 | 5 | require_relative '../sentry_helper' 6 | require_relative './base_clinic' 7 | 8 | module TrinityHealth 9 | BASE_URL = 'https://apps.sphp.com/THofNECOVIDVaccinations'.freeze 10 | 11 | def self.all_clinics(storage, logger) 12 | logger.info '[TrinityHealth] Checking site' 13 | date = Date.today - 1 14 | clinics = [] 15 | 16 | SentryHelper.catch_errors(logger, 'TrinityHealth') do 17 | 8.times do 18 | date += 1 19 | sleep(0.5) 20 | 21 | res = fetch_day(date) 22 | next if /There are no open appointments on this day. Please try another day./ =~ res 23 | 24 | returned_day = Nokogiri::HTML(res).search('#CurrentDay')[0]['data-date'] 25 | 26 | appointments = res.scan(/Reserve Appointment/).size 27 | if appointments.positive? 28 | logger.info "[TrinityHealth] Found #{appointments} appointments on #{returned_day}" 29 | clinics << Clinic.new(storage, returned_day, appointments) 30 | end 31 | end 32 | end 33 | 34 | clinics 35 | end 36 | 37 | def self.fetch_day(date) 38 | day = date.strftime('%m/%d/%Y') 39 | RestClient::Request.execute( 40 | url: "#{BASE_URL}/livesearch.php", 41 | method: :post, 42 | payload: { ScheduleDay: day }, 43 | cookies: { SiteName: 'THOfNE Mercy Medical Center' }, 44 | verify_ssl: false 45 | ).body 46 | end 47 | 48 | class Clinic < BaseClinic 49 | attr_reader :date, :appointments 50 | 51 | NAME = 'Mercy Medical Center, Springfield MA'.freeze 52 | 53 | def initialize(storage, date, appointments) 54 | super(storage) 55 | @date = date 56 | @appointments = appointments 57 | end 58 | 59 | def module_name 60 | 'TRINITY_HEALTH' 61 | end 62 | 63 | def title 64 | "#{NAME} on #{date}" 65 | end 66 | 67 | def link 68 | BASE_URL 69 | end 70 | 71 | def address 72 | '299 Carew Street, Springfield, MA 01104' 73 | end 74 | 75 | def slack_blocks 76 | { 77 | type: 'section', 78 | text: { 79 | type: 'mrkdwn', 80 | text: "*#{title}*\n*Address:* #{address}\n*Available appointments:* #{render_slack_appointments}\n*Link:* #{link}", 81 | }, 82 | } 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/sites/rutland.rb: -------------------------------------------------------------------------------- 1 | require 'rest-client' 2 | 3 | require_relative '../sentry_helper' 4 | require_relative './ma_immunizations_registrations' 5 | 6 | module Rutland 7 | MAIN_URL = 'https://www.rrecc.us/vaccine'.freeze 8 | TEACHER_URL = 'https://www.rrecc.us/k12'.freeze 9 | 10 | def self.all_clinics(storage, logger) 11 | logger.info '[Rutland] Checking site' 12 | main_clinics(storage, logger) #+ teacher_clinics(storage, logger) 13 | end 14 | 15 | def self.main_clinics(storage, logger) 16 | SentryHelper.catch_errors(logger, 'Rutland') do 17 | res = RestClient.get(MAIN_URL).body 18 | 19 | sections = res.split('') 23 | match = />([\w\d\s-]+)[<(]/.match(section) 24 | match && match[1].strip 25 | end 26 | 27 | sites = section.scan(%r{www\.maimmunizations\.org//reg/(\d+)"}) 28 | if sites.empty? 29 | logger.info '[Rutland] No sites found' 30 | [] 31 | else 32 | logger.info "[Rutland] Scanning #{sites.length} sites" 33 | MaImmunizationsRegistrations.all_clinics( 34 | 'RUTLAND', 35 | MAIN_URL, 36 | sites.map { |clinic_num| "https://registrations.maimmunizations.org//reg/#{clinic_num[0]}" }, 37 | storage, 38 | logger, 39 | 'Rutland', 40 | additional_info 41 | ) 42 | end 43 | end 44 | end 45 | end 46 | 47 | def self.teacher_clinics(storage, logger) 48 | SentryHelper.catch_errors(logger, 'Rutland') do 49 | res = RestClient.get(TEACHER_URL).body 50 | sites = res.scan(%r{www\.maimmunizations\.org__reg_(\d+)&}) 51 | if sites.empty? 52 | logger.info '[Rutland] No sites found' 53 | [] 54 | else 55 | logger.info "[Rutland] Scanning #{sites.length} sites" 56 | MaImmunizationsRegistrations.all_clinics( 57 | 'RUTLAND', 58 | TEACHER_URL, 59 | sites.map { |clinic_num| "https://registrations.maimmunizations.org//reg/#{clinic_num[0]}" }, 60 | storage, 61 | logger, 62 | 'Rutland', 63 | 'teachers only' 64 | ) 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/sites/heywood_healthcare.rb: -------------------------------------------------------------------------------- 1 | require 'rest-client' 2 | require 'nokogiri' 3 | 4 | require_relative '../sentry_helper' 5 | require_relative './base_clinic' 6 | 7 | module HeywoodHealthcare 8 | SIGN_UP_URL = 'https://gardnervaccinations.as.me/schedule.php'.freeze 9 | API_URL = 'https://gardnervaccinations.as.me/schedule.php?action=showCalendar&fulldate=1&owner=21588707&template=class'.freeze 10 | SITE_NAME = 'Heywood Healthcare in Gardner, MA'.freeze 11 | 12 | def self.all_clinics(storage, logger) 13 | SentryHelper.catch_errors(logger, 'HeywoodHealthcare') do 14 | logger.info '[HeywoodHealthcare] Checking site' 15 | unless appointments? 16 | logger.info '[HeywoodHealthcare] No appointments available' 17 | return [] 18 | end 19 | 20 | fetch_appointments.map do |date, appointments| 21 | logger.info "[HeywoodHealthcare] Found #{appointments} appointments on #{date}" 22 | Clinic.new(storage, SITE_NAME, date, appointments, SIGN_UP_URL) 23 | end 24 | end 25 | end 26 | 27 | def self.appointments? 28 | res = RestClient.get(SIGN_UP_URL) 29 | if /There are no appointment types available for scheduling/ =~ res 30 | false 31 | else 32 | true 33 | end 34 | end 35 | 36 | def self.fetch_appointments 37 | res = RestClient.post( 38 | API_URL, 39 | { 40 | 'type' => '', 41 | 'calendar' => '', 42 | 'skip' => true, 43 | 'options[qty]' => 1, 44 | 'options[numDays]' => 27, 45 | 'ignoreAppointment' => '', 46 | 'appointmentType' => '', 47 | 'calendarID' => '', 48 | } 49 | ) 50 | html = Nokogiri::HTML(res.body) 51 | html.search('.class-signup-container').each_with_object(Hash.new(0)) do |row, h| 52 | link = row.search('a.btn-class-signup') 53 | next unless link.any? 54 | 55 | date = Date.parse(link[0]['data-readable-date']).strftime('%m/%d/%Y') 56 | row.search('.num-slots-available-container .babel-ignore').each do |appointments| 57 | h[date] += appointments.text.to_i 58 | end 59 | end 60 | end 61 | 62 | class Clinic < BaseClinic 63 | attr_reader :name, :date, :appointments, :link 64 | 65 | def initialize(storage, name, date, appointments, link) 66 | super(storage) 67 | @name = name 68 | @date = date 69 | @appointments = appointments 70 | @link = link 71 | end 72 | 73 | def module_name 74 | 'HEYWOOD_HEALTHCARE' 75 | end 76 | 77 | def title 78 | "#{name} on #{date}" 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/sites/base_clinic.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | 3 | class BaseClinic 4 | DEFAULT_TWEET_THRESHOLD = ENV['DEFAULT_TWEET_THRESHOLD']&.to_i || 20 # minimum number to post 5 | DEFAULT_TWEET_INCREASE_NEEDED = ENV['DEFAULT_TWEET_INCREASE_NEEDED']&.to_i || 10 6 | DEFAULT_TWEET_COOLDOWN = ENV['DEFAULT_TWEET_COOLDOWN']&.to_i || 30 * 60 # 30 minutes 7 | 8 | def initialize(storage) 9 | @storage = storage 10 | end 11 | 12 | def title 13 | raise NotImplementedError 14 | end 15 | 16 | def appointments 17 | raise NotImplementedError 18 | end 19 | 20 | def last_appointments 21 | @storage.get_appointments(self)&.to_i || 0 22 | end 23 | 24 | def new_appointments 25 | appointments - last_appointments 26 | end 27 | 28 | def link 29 | raise NotImplementedError 30 | end 31 | 32 | def sign_up_page 33 | link 34 | end 35 | 36 | def city 37 | nil 38 | end 39 | 40 | def render_slack_appointments 41 | appointment_txt = "#{appointments} (#{new_appointments} new)" 42 | if appointments >= 10 43 | ":siren: #{appointment_txt} :siren:" 44 | else 45 | appointment_txt 46 | end 47 | end 48 | 49 | def slack_blocks 50 | { 51 | type: 'section', 52 | text: { 53 | type: 'mrkdwn', 54 | text: "*#{title}*\n*Available appointments:* #{render_slack_appointments}\n*Link:* #{link}", 55 | }, 56 | } 57 | end 58 | 59 | def module_name 60 | 'DEFAULT' 61 | end 62 | 63 | def tweet_threshold 64 | ENV["#{module_name}_TWEET_THRESHOLD"]&.to_i || self.class::DEFAULT_TWEET_THRESHOLD 65 | end 66 | 67 | def tweet_cooldown 68 | ENV["#{module_name}_TWEET_COOLDOWN"]&.to_i || self.class::DEFAULT_TWEET_COOLDOWN 69 | end 70 | 71 | def tweet_increase_needed 72 | ENV["#{module_name}_TWEET_INCREASE_NEEDED"]&.to_i || self.class::DEFAULT_TWEET_INCREASE_NEEDED 73 | end 74 | 75 | def has_not_posted_recently? 76 | (Time.now - last_posted_time) > tweet_cooldown 77 | end 78 | 79 | def should_tweet? 80 | !link.nil? && 81 | appointments >= tweet_threshold && 82 | new_appointments >= tweet_increase_needed && 83 | has_not_posted_recently? 84 | end 85 | 86 | def twitter_text 87 | "#{appointments} appointments available at #{title}. Check eligibility and sign up at #{sign_up_page}" 88 | end 89 | 90 | def storage_key 91 | title 92 | end 93 | 94 | def save_appointments 95 | @storage.save_appointments(self) 96 | end 97 | 98 | def save_tweet_time 99 | @storage.save_post_time(self) 100 | end 101 | 102 | def last_posted_time 103 | DateTime.parse(@storage.get_post_time(self) || '2021-January-1').to_time 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/ma_immunizations_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/storage' 2 | require_relative '../lib/twitter' 3 | require_relative '../lib/sites/ma_immunizations' 4 | 5 | describe 'MaImmunizations' do 6 | let(:redis) { double('Redis') } 7 | let(:logger) { Logger.new('/dev/null') } 8 | let(:fixture) { File.read("#{__dir__}/fixtures/ma_immunizations.html") } 9 | let(:storage) { Storage.new(redis) } 10 | 11 | describe '.all_clinics' do 12 | it 'returns all clinics' do 13 | expect(redis).to receive(:get).with('vaccine-cookies:ma-immunization').and_return({ cookies: 'foo', expiration: Time.now + (60 * 60 * 24) }.to_json) 14 | response = double('RestClient::Response', body: fixture) 15 | expect(RestClient).to receive(:get).and_return(response) 16 | clinics = MaImmunizations.all_clinics(storage, logger) 17 | # NOTE(dan): there are 8 clinics in the example file but one is a 18 | # duplicate that we consolidate, so we only expect to have 7 19 | expect(clinics.length).to eq(3) 20 | first_clinic = clinics[0] 21 | expect(first_clinic.appointments).to eq(124) 22 | expect(first_clinic.title).to eq('Citizen Center on 05/13/2021') 23 | end 24 | 25 | it 'can work with twitter' do 26 | mock_twitter = double('Twitter') 27 | expect(FakeTwitter).to receive(:new).and_return(mock_twitter) 28 | twitter = TwitterClient.new(logger) 29 | response = double('RestClient::Response', body: fixture) 30 | expect(RestClient).to receive(:get).and_return(response) 31 | expect(redis).to receive(:get).with('vaccine-cookies:ma-immunization').and_return({ cookies: 'foo', expiration: Time.now + (60 * 60 * 24) }.to_json) 32 | clinics = MaImmunizations.all_clinics(storage, logger) 33 | allow(redis).to receive(:get).and_return(nil) 34 | allow(redis).to receive(:set) 35 | expect(mock_twitter).to receive(:update).with('124 appointments available at Citizen Center in Haverhill, MA on 05/13/2021 for Moderna COVID-19 Vaccine, Janssen COVID-19 Vaccine. Check eligibility and sign up at https://www.maimmunizations.org/appointment/en/clinic/search?q[venue_search_name_or_venue_name_i_cont]=Citizen%20Center&') 36 | expect(mock_twitter).to receive(:update).with('23 appointments available at Citizen Center in Haverhill, MA on 05/20/2021 for Moderna COVID-19 Vaccine. Check eligibility and sign up at https://www.maimmunizations.org/appointment/en/clinic/search?q[venue_search_name_or_venue_name_i_cont]=Citizen%20Center&') 37 | expect(mock_twitter).to receive(:update).with('79 appointments available at Citizen Center in Haverhill, MA on 05/27/2021 for Moderna COVID-19 Vaccine. Check eligibility and sign up at https://www.maimmunizations.org/appointment/en/clinic/search?q[venue_search_name_or_venue_name_i_cont]=Citizen%20Center&') 38 | twitter.post(clinics) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/sites/baystate_health.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'rest-client' 3 | 4 | require_relative '../browser' 5 | require_relative '../sentry_helper' 6 | require_relative './base_clinic' 7 | 8 | module BaystateHealth 9 | SIGN_UP_URL = 'https://workwell.apps.baystatehealth.org/guest/covid-vaccine/register?r=mafirstresp210121'.freeze 10 | API_URL = 'https://mobileprod.api.baystatehealth.org/workwell/schedules/campaigns?camId=mafirstresp210121&activeOnly=1&includeVac=1&includeSeat=1'.freeze 11 | 12 | # Baystate Health doesn't provide appointment info without registering, but 13 | # we can get the total number of vaccines available and put them all in one 14 | # clinic without a date 15 | def self.all_clinics(storage, logger) 16 | logger.info '[BaystateHealth] Checking site' 17 | 18 | clinic = Clinic.new(storage) 19 | 20 | SentryHelper.catch_errors(logger, 'BaystateHealth') do 21 | return [] unless registration_available?(logger) 22 | 23 | JSON.parse(RestClient.get(API_URL).body)['campaigns'].each do |campaign| 24 | next unless campaign['active'] == 1 25 | 26 | campaign['vaccines'].each do |vaccine| 27 | next unless vaccine['status'] == 'ACTIVE' && vaccine['cvaActive'] == 1 && vaccine['dose1Available'].positive? 28 | 29 | locations = vaccine['locations'].map { |l| l['city'] }.reject(&:empty?) 30 | next unless locations.any? 31 | 32 | logger.info("[BaystateHealth] Found #{vaccine['dose1Available']} appointments for #{vaccine['name']}") 33 | clinic.appointments += vaccine['dose1Available'] 34 | clinic.locations.merge(locations) 35 | clinic.vaccines.add(vaccine['name']) 36 | end 37 | end 38 | end 39 | 40 | [clinic] 41 | end 42 | 43 | def self.registration_available?(logger) 44 | Browser.run do |browser| 45 | browser.goto(SIGN_UP_URL) 46 | 47 | 5.times do 48 | browser.network.wait_for_idle 49 | html = Nokogiri.parse(browser.body) 50 | 51 | h3 = html.search('.content-card h3') 52 | if h3.any? 53 | if h3[0].text.include?('Registration Temporarily Unavailable') 54 | logger.info '[BaystateHealth] Registration unavailable' 55 | return false 56 | elsif html.search('ion-button').any? { |b| b.text.include?('Continue') } 57 | return true 58 | end 59 | else 60 | sleep 1 61 | end 62 | end 63 | 64 | false 65 | end 66 | end 67 | 68 | class Clinic < BaseClinic 69 | attr_accessor :appointments, :locations, :vaccines 70 | 71 | def initialize(storage) 72 | super(storage) 73 | @appointments = 0 74 | @locations = Set.new 75 | @vaccines = Set.new 76 | end 77 | 78 | def module_name 79 | 'BAYSTATE_HEALTH' 80 | end 81 | 82 | def title 83 | 'Baystate Health' 84 | end 85 | 86 | def link 87 | SIGN_UP_URL 88 | end 89 | 90 | def twitter_text 91 | txt = "#{appointments} appointments available at #{title} (in #{locations.join('/')})" 92 | txt += " for #{vaccines.join(', ')}" if vaccines.any? 93 | txt + ". Check eligibility and sign up at #{sign_up_page}" 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Anyone should feel free to make a pull request and contribute to vaccinetime. 4 | If you find a bug or have a feature request, please open an issue in this repo. 5 | 6 | ## Branch Organization 7 | 8 | Submit all changes directly to the `main branch`. We don't use separate 9 | branches for development. 10 | 11 | ## Architecture 12 | 13 | At a high level, the bot is a collection of site scrapers that run on an 14 | interval and check for appointments. When a site shows appointments, the bot 15 | looks at whether the number of appointments is greater than the previously seen 16 | amount and only then sends a tweet. It also only tweets after all three of the 17 | following criteria are met: 18 | 19 | 1. A minimum appointment threshold is reached - default 20 20 | 2. A minimum increase since the last check is reached - default 10 21 | 3. Hasn't tweeted about this clinic within a certain amount of time - default 22 | 30 minutes 23 | 24 | This keeps the number of Tweets down and ensures only significant appointment 25 | drops actually get sent, rather than tweeting every time there's a 26 | cancellation. All of these are also configurable on a per-clinic basis by using 27 | the name of the clinic in an environment variable, e.g. 28 | `MA_IMMUNIZATIONS_TWEET_THRESHOLD`. 29 | 30 | The Ruby code is predominantly programmed in an object oriented style, with the 31 | main loop contained in [run.rb](run.rb) and all other modules available under 32 | [lib/](lib/). The main loop is a simple infinite `loop do` with a `sleep` 33 | command to pause one minute between site scraping so that we don't DOS the 34 | sites or get rate limited. 35 | 36 | Before the main loop, the bot runs an initialization phase where it creates 37 | clients to interact with Redis, Slack, and Twitter. At this stage if the 38 | `SEED_REDIS` environment variable is set it will run through all the site 39 | scrapers once and then stores those results in Redis for future comparison. By 40 | seeding the data once in this way we can deploy to new environments with an 41 | empty Redis without sending erroneous tweets accidentally. 42 | 43 | Site scrapers are split into separate modules under [lib/sites/](lib/sites/). 44 | Each implements custom logic to fetch vaccine appointments for the given 45 | website with either a site scraper using [Nokogiri](https://nokogiri.org/) or 46 | using their JSON API if available. These modules should expose an `all_clinics` 47 | method which returns a list of `Clinic` objects and that will be called in 48 | [run.rb](run.rb). A `Clinic` represents a single location on a particular date, 49 | and encodes information about the site, the number of appointments found, and 50 | anything else needed to encode a message for it. `Clinic` objects receive 51 | dependencies via dependency injection so that they are loosely coupled, and 52 | typically subclass from either the `BaseClinic` or `PharmacyClinic` classes. 53 | 54 | `PharmacyClinic` classes are a bit different in that they group all locations 55 | under a single "clinic" instance and treat each location with availability as 56 | one appointment. This is usually done when a site doesn't provide easy access 57 | to seeing individual appointments, such as CVS which only shows a binary yes/no 58 | for each store's availability. 59 | -------------------------------------------------------------------------------- /lib/sites/southcoast.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'rest-client' 3 | 4 | require_relative '../sentry_helper' 5 | require_relative './base_clinic' 6 | 7 | module Southcoast 8 | SIGN_UP_URL = 'https://www.southcoast.org/covid-19-vaccine-scheduling/'.freeze 9 | API_URL = 'https://southcoastapps.southcoast.org/OnlineAppointmentSchedulingApi/api/resourceTypes/slots/search'.freeze 10 | TOKEN_URL = 'https://southcoastapps.southcoast.org/OnlineAppointmentSchedulingApi/api/sessions'.freeze 11 | 12 | SITES = [ 13 | 'Fall River', 14 | 'North Dartmouth', 15 | 'Wareham', 16 | ] 17 | 18 | def self.all_clinics(storage, logger) 19 | SentryHelper.catch_errors(logger, 'Southcoast') do 20 | main_page = RestClient.get(SIGN_UP_URL) 21 | if /At this time, there are no appointments available through online scheduling./ =~ main_page.body 22 | logger.info '[Southcoast] No sign ups available' 23 | return [] 24 | end 25 | end 26 | 27 | SITES.flat_map do |site| 28 | sleep(2) 29 | SentryHelper.catch_errors(logger, 'Southcoast') do 30 | logger.info "[Southcoast] Checking site #{site}" 31 | Page.new(storage, logger, site).clinics 32 | end 33 | end 34 | end 35 | 36 | class Page 37 | def initialize(storage, logger, site) 38 | @storage = storage 39 | @logger = logger 40 | @site = site 41 | end 42 | 43 | def clinics 44 | json_data['dateToSlots'].each_with_object(Hash.new(0)) do |(date, slots), h| 45 | slots.each do |_department, slot| 46 | h[date] += slot['slots'].length 47 | end 48 | end.map do |date, appointments| 49 | if appointments.positive? 50 | @logger.info "[Southcoast] Site #{@site} found #{appointments} appointments on #{date}" 51 | end 52 | Clinic.new(@storage, @site, date, appointments) 53 | end 54 | end 55 | 56 | def json_data 57 | payload = { 58 | ProviderCriteria: { 59 | SpecialtyID: nil, 60 | ConcentrationID: nil, 61 | }, 62 | ResourceTypeId: '98C5A8BE-25D1-4125-9AD5-1EE64AD164D2', 63 | StartDate: Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.000Z'), 64 | EndDate: (DateTime.now + 28).to_time.utc.strftime('%Y-%m-%dT%H:%M:%S.000Z'), 65 | Location: @site, 66 | } 67 | res = RestClient.post( 68 | API_URL, 69 | payload.to_json, 70 | content_type: :json, 71 | SessionToken: token, 72 | Origin: 'https://www.southcoast.org', 73 | Referer: 'https://www.southcoast.org/covid-19-vaccine-scheduling/' 74 | ) 75 | JSON.parse(res.body) 76 | end 77 | 78 | def token 79 | JSON.parse(RestClient.get(TOKEN_URL).body) 80 | end 81 | end 82 | 83 | class Clinic < BaseClinic 84 | attr_reader :date, :appointments 85 | 86 | def initialize(storage, site, date, appointments) 87 | super(storage) 88 | @site = site 89 | @date = date 90 | @appointments = appointments 91 | end 92 | 93 | def module_name 94 | 'SOUTHCOAST' 95 | end 96 | 97 | def title 98 | "Southcoast Health in #{@site}, MA on #{date}" 99 | end 100 | 101 | def link 102 | SIGN_UP_URL 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/twitter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require_relative '../lib/twitter' 3 | require_relative './mock_clinic' 4 | 5 | describe TwitterClient do 6 | let(:twitter) { TwitterClient.new(Logger.new('/dev/null')) } 7 | 8 | describe '#tweet' do 9 | it 'calls the twitter "update" method' do 10 | mock_twitter = double('Twitter') 11 | mock_clinic = double('Clinic', title: 'Test clinic', new_appointments: 1) 12 | expect(FakeTwitter).to receive(:new).and_return(mock_twitter) 13 | expect(mock_twitter).to receive(:update).with('test tweet') 14 | expect(mock_clinic).to receive(:twitter_text).and_return('test tweet') 15 | twitter.tweet(mock_clinic) 16 | end 17 | end 18 | 19 | describe '#should_tweet?' do 20 | it 'returns true if the clinic has more than 10 new appointments' do 21 | mock_clinic = MockClinic.new(appointments: 100, new_appointments: 100) 22 | expect(mock_clinic.should_tweet?).to be_truthy 23 | end 24 | 25 | it 'returns false if the clinic has no link' do 26 | mock_clinic = MockClinic.new(appointments: 100, new_appointments: 100, link: nil) 27 | expect(mock_clinic.should_tweet?).to be_falsy 28 | end 29 | 30 | it 'returns false if the clinic has fewer than 10 appointments' do 31 | mock_clinic = MockClinic.new(appointments: 9, new_appointments: 100) 32 | expect(mock_clinic.should_tweet?).to be_falsy 33 | end 34 | 35 | it 'returns false if the clinic has fewer than 5 new appointments' do 36 | mock_clinic = MockClinic.new(appointments: 100, new_appointments: 4) 37 | expect(mock_clinic.should_tweet?).to be_falsy 38 | end 39 | 40 | it 'returns false if the clinic has posted recently' do 41 | mock_clinic = MockClinic.new(appointments: 100, new_appointments: 100, last_posted_time: (Time.now - 60).to_s) 42 | expect(mock_clinic.should_tweet?).to be_falsy 43 | end 44 | end 45 | 46 | describe '#post' do 47 | it 'only tweets about clinics that should post' do 48 | valid_clinic = MockClinic.new(appointments: 100, new_appointments: 100) 49 | invalid_clinic = MockClinic.new(appointments: 0, new_appointments: 0) 50 | expect(twitter).to receive(:tweet).with(valid_clinic) 51 | expect(twitter).not_to receive(:tweet).with(invalid_clinic) 52 | expect(valid_clinic).to receive(:save_tweet_time) 53 | expect(invalid_clinic).not_to receive(:save_tweet_time) 54 | twitter.post([valid_clinic, invalid_clinic]) 55 | end 56 | 57 | it "doesn't care about the clinic order" do 58 | valid_clinic = MockClinic.new(appointments: 100, new_appointments: 100) 59 | invalid_clinic = MockClinic.new(appointments: 0, new_appointments: 0) 60 | expect(twitter).to receive(:tweet).with(valid_clinic) 61 | expect(twitter).not_to receive(:tweet).with(invalid_clinic) 62 | expect(valid_clinic).to receive(:save_tweet_time) 63 | expect(invalid_clinic).not_to receive(:save_tweet_time) 64 | twitter.post([invalid_clinic, valid_clinic]) 65 | end 66 | 67 | it 'works with no clinics' do 68 | expect { twitter.post([]) }.not_to raise_exception 69 | end 70 | end 71 | 72 | describe '#twitter_text' do 73 | it 'posts about appointments with a link' do 74 | mock_clinic = MockClinic.new(title: 'myclinic', appointments: 100, new_appointments: 20) 75 | expect(mock_clinic.twitter_text).to eq( 76 | '100 appointments available at myclinic. Check eligibility and sign up at clinicsite.com' 77 | ) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.7.0) 5 | public_suffix (>= 2.0.2, < 5.0) 6 | buftok (0.2.0) 7 | cliver (0.3.2) 8 | concurrent-ruby (1.1.8) 9 | diff-lcs (1.4.4) 10 | domain_name (0.5.20190701) 11 | unf (>= 0.0.5, < 1.0.0) 12 | equalizer (0.0.11) 13 | faraday (1.3.0) 14 | faraday-net_http (~> 1.0) 15 | multipart-post (>= 1.2, < 3) 16 | ruby2_keywords 17 | faraday-net_http (1.0.1) 18 | faraday_middleware (1.0.0) 19 | faraday (~> 1.0) 20 | ferrum (0.11) 21 | addressable (~> 2.5) 22 | cliver (~> 0.3) 23 | concurrent-ruby (~> 1.1) 24 | websocket-driver (>= 0.6, < 0.8) 25 | ffi (1.15.0) 26 | ffi-compiler (1.0.1) 27 | ffi (>= 1.0.0) 28 | rake 29 | gli (2.20.0) 30 | hashie (4.1.0) 31 | http (4.4.1) 32 | addressable (~> 2.3) 33 | http-cookie (~> 1.0) 34 | http-form_data (~> 2.2) 35 | http-parser (~> 1.2.0) 36 | http-accept (1.7.0) 37 | http-cookie (1.0.3) 38 | domain_name (~> 0.5) 39 | http-form_data (2.3.0) 40 | http-parser (1.2.3) 41 | ffi-compiler (>= 1.0, < 2.0) 42 | http_parser.rb (0.6.0) 43 | memoizable (0.4.2) 44 | thread_safe (~> 0.3, >= 0.3.1) 45 | mime-types (3.3.1) 46 | mime-types-data (~> 3.2015) 47 | mime-types-data (3.2021.0225) 48 | mini_portile2 (2.5.0) 49 | multipart-post (2.1.1) 50 | naught (1.1.0) 51 | netrc (0.11.0) 52 | nokogiri (1.11.2) 53 | mini_portile2 (~> 2.5.0) 54 | racc (~> 1.4) 55 | public_suffix (4.0.6) 56 | racc (1.5.2) 57 | rake (13.0.3) 58 | redis (4.2.5) 59 | rest-client (2.1.0) 60 | http-accept (>= 1.7.0, < 2.0) 61 | http-cookie (>= 1.0.2, < 2.0) 62 | mime-types (>= 1.16, < 4.0) 63 | netrc (~> 0.8) 64 | rspec (3.10.0) 65 | rspec-core (~> 3.10.0) 66 | rspec-expectations (~> 3.10.0) 67 | rspec-mocks (~> 3.10.0) 68 | rspec-core (3.10.1) 69 | rspec-support (~> 3.10.0) 70 | rspec-expectations (3.10.1) 71 | diff-lcs (>= 1.2.0, < 2.0) 72 | rspec-support (~> 3.10.0) 73 | rspec-mocks (3.10.2) 74 | diff-lcs (>= 1.2.0, < 2.0) 75 | rspec-support (~> 3.10.0) 76 | rspec-support (3.10.2) 77 | ruby2_keywords (0.0.4) 78 | sentry-ruby (4.3.1) 79 | concurrent-ruby (~> 1.0, >= 1.0.2) 80 | faraday (>= 1.0) 81 | sentry-ruby-core (= 4.3.1) 82 | sentry-ruby-core (4.3.1) 83 | concurrent-ruby 84 | faraday 85 | simple_oauth (0.3.1) 86 | slack-ruby-client (0.17.0) 87 | faraday (>= 1.0) 88 | faraday_middleware 89 | gli 90 | hashie 91 | websocket-driver 92 | thread_safe (0.3.6) 93 | twitter (7.0.0) 94 | addressable (~> 2.3) 95 | buftok (~> 0.2.0) 96 | equalizer (~> 0.0.11) 97 | http (~> 4.0) 98 | http-form_data (~> 2.0) 99 | http_parser.rb (~> 0.6.0) 100 | memoizable (~> 0.4.0) 101 | multipart-post (~> 2.0) 102 | naught (~> 1.0) 103 | simple_oauth (~> 0.3.0) 104 | unf (0.1.4) 105 | unf_ext 106 | unf_ext (0.0.7.7) 107 | websocket-driver (0.7.3) 108 | websocket-extensions (>= 0.1.0) 109 | websocket-extensions (0.1.5) 110 | 111 | PLATFORMS 112 | ruby 113 | 114 | DEPENDENCIES 115 | ferrum 116 | nokogiri 117 | redis 118 | rest-client 119 | rspec 120 | sentry-ruby 121 | slack-ruby-client 122 | twitter 123 | 124 | BUNDLED WITH 125 | 2.2.3 126 | -------------------------------------------------------------------------------- /lib/sites/ma_immunizations_registrations.rb: -------------------------------------------------------------------------------- 1 | require 'rest-client' 2 | require 'nokogiri' 3 | 4 | require_relative './base_clinic' 5 | 6 | module MaImmunizationsRegistrations 7 | def self.all_clinics(module_name, sign_up_page, pages, storage, logger, log_module, additional_info = nil) 8 | pages.each_with_object(Hash.new(0)) do |clinic_url, h| 9 | sleep(1) 10 | scrape_result = scrape_registration_site(logger, log_module, clinic_url) 11 | next unless scrape_result 12 | 13 | h[[scrape_result[0], scrape_result[1]]] += scrape_result[2] 14 | end.map do |(title, vaccine), appointments| 15 | Clinic.new(module_name, storage, title, sign_up_page, appointments, vaccine, additional_info) 16 | end 17 | end 18 | 19 | def self.scrape_registration_site(logger, log_module, url) 20 | res = RestClient.get(url).body 21 | 22 | if /Clinic does not have any appointment slots available/ =~ res 23 | logger.info "[#{log_module}] No appointment slots available" 24 | return nil 25 | end 26 | 27 | if /This clinic is closed/ =~ res 28 | logger.info "[#{log_module}] Clinic is closed" 29 | return nil 30 | end 31 | 32 | clinic = Nokogiri::HTML(res) 33 | title_search = clinic.search('h1') 34 | unless title_search.any? 35 | logger.warn "[#{log_module}] No title found" 36 | return nil 37 | end 38 | 39 | title = /Sign Up for Vaccinations - (.+)$/.match(title_search[0].text)[1].strip 40 | appointments = clinic.search('tbody tr').reduce(0) do |val, row| 41 | entry = row.search('td').last.text.split.join(' ') 42 | match = /(\d+) appointments available/.match(entry) 43 | if match 44 | val + match[1].to_i 45 | else 46 | val 47 | end 48 | end 49 | 50 | vaccine = nil 51 | button = clinic.search('#submitButton')[0] 52 | if button['data-pfizer-clinic'] == 'true' 53 | vaccine = 'Pfizer-BioNTech COVID-19 Vaccine' 54 | elsif button['data-moderna-clinic'] == 'true' 55 | vaccine = 'Moderna COVID-19 Vaccine' 56 | elsif button['data-janssen-clinic'] == 'true' 57 | vaccine = 'Janssen COVID-19 Vaccine' 58 | end 59 | 60 | logger.info "[#{log_module}] Found #{appointments} at #{title}" if appointments.positive? 61 | [title, vaccine, appointments] 62 | end 63 | 64 | class Clinic < BaseClinic 65 | TITLE_MATCHER = %r[^(.+) on (\d{2}/\d{2}/\d{4})$].freeze 66 | DEFAULT_TWEET_INCREASE_NEEDED = 50 67 | DEFAULT_TWEET_COOLDOWN = 3600 # 1 hour 68 | 69 | attr_reader :module_name, :title, :link, :appointments, :vaccine 70 | 71 | def initialize(mod_name, storage, title, link, appointments, vaccine, additional_info) 72 | super(storage) 73 | @module_name = mod_name 74 | @title = title 75 | @link = link 76 | @appointments = appointments 77 | @vaccine = vaccine 78 | @additional_info = additional_info 79 | end 80 | 81 | def name 82 | match = TITLE_MATCHER.match(title) 83 | match[1].strip 84 | end 85 | 86 | def date 87 | match = TITLE_MATCHER.match(title) 88 | match[2] 89 | end 90 | 91 | def twitter_text 92 | txt = "#{appointments} appointments available at #{title}" 93 | txt += " for #{vaccine}" if vaccine 94 | txt += " (#{@additional_info})" if @additional_info 95 | txt + ". Check eligibility and sign up at #{sign_up_page}" 96 | end 97 | 98 | def slack_blocks 99 | { 100 | type: 'section', 101 | text: { 102 | type: 'mrkdwn', 103 | text: "*#{title}*\n*Vaccine:* #{vaccine}\n*Available appointments:* #{render_slack_appointments}\n*Additional info:* #{@additional_info}\n*Link:* #{link}", 104 | }, 105 | } 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/sites/curative.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'json' 3 | require 'rest-client' 4 | 5 | require_relative '../sentry_helper' 6 | require_relative './base_clinic' 7 | 8 | module Curative 9 | BASE_URL = 'https://curative.com/sites/'.freeze 10 | API_URL = 'https://labtools.curativeinc.com/api/v1/testing_sites/'.freeze 11 | SITES = { 12 | 28418 => 'DoubleTree Hotel - Danvers', 13 | 28419 => 'Eastfield Mall - Springfield', 14 | 28417 => 'Circuit City - Dartmouth', 15 | }.freeze 16 | 17 | def self.all_clinics(storage, logger) 18 | SITES.flat_map do |site_num, site_name| 19 | sleep(1) 20 | SentryHelper.catch_errors(logger, 'Curative') do 21 | logger.info "[Curative] Checking site #{site_num}: #{site_name}" 22 | Page.new(site_num, storage, logger).clinics 23 | end 24 | end 25 | end 26 | 27 | class Page 28 | SIGN_UP_SEEN_KEY = 'vaccine-curative-sign-up-date'.freeze 29 | QUEUE_SITE = 'https://curative.queue-it.net'.freeze 30 | 31 | attr_reader :json 32 | 33 | def initialize(site_num, storage, logger) 34 | @site_num = site_num 35 | @json = JSON.parse(RestClient.get(API_URL + site_num.to_s).body) 36 | @storage = storage 37 | @logger = logger 38 | end 39 | 40 | def clinics 41 | return [] unless appointments_are_visible? 42 | 43 | appointments_by_date.map do |k, v| 44 | @logger.info "[Curative] Site #{@site_num} on #{k}: found #{v} appoinments" if v.positive? 45 | Clinic.new(@site_num, @json, @storage, k, v) 46 | end 47 | end 48 | 49 | def appointments_are_visible? 50 | now = Time.now 51 | if ENV['ENVIRONMENT'] == 'production' && !(Date.today.thursday? && now.hour >= 8 && now.min >= 30) 52 | @logger.info "[Curative] Site #{@site_num} is not Thursday after 8:30" 53 | return false 54 | end 55 | 56 | if @json['invitation_required_for_public_booking'] == true 57 | @logger.info "[Curative] Site #{@site_num} requires invitation" 58 | return false 59 | end 60 | 61 | base_site = RestClient.get(BASE_URL + @site_num.to_s) 62 | if base_site.request.url.start_with?("#{QUEUE_SITE}/afterevent") 63 | @logger.info "[Curative] Site #{@site_num} event has ended" 64 | return false 65 | end 66 | 67 | true 68 | end 69 | 70 | def appointments_by_date 71 | @json['appointment_windows'].each_with_object(Hash.new(0)) do |window, h| 72 | date = DateTime.parse(window['start_time']) 73 | h["#{date.month}/#{date.day}/#{date.year}"] += 74 | if window['status'] == 'Active' 75 | window['public_slots_available'] 76 | else 77 | 0 78 | end 79 | end 80 | end 81 | end 82 | 83 | class Clinic < BaseClinic 84 | attr_reader :appointments, :date 85 | 86 | def initialize(site_num, json, storage, date, appointments) 87 | super(storage) 88 | @site_num = site_num 89 | @json = json 90 | @date = date 91 | @appointments = appointments 92 | end 93 | 94 | def module_name 95 | 'CURATIVE' 96 | end 97 | 98 | def name 99 | @json['name'] 100 | end 101 | 102 | def title 103 | "#{name} on #{date}" 104 | end 105 | 106 | def address 107 | addr = @json['street_address_1'] 108 | addr += " #{@json['street_address_2']}" unless @json['street_address_2'].empty? 109 | addr + ", #{@json['city']} #{@json['state']} #{@json['postal_code']}" 110 | end 111 | 112 | def vaccine 113 | @json['services'].join(', ') 114 | end 115 | 116 | def link 117 | "https://curative.com/sites/#{@site_num}" 118 | end 119 | 120 | def slack_blocks 121 | { 122 | type: 'section', 123 | text: { 124 | type: 'mrkdwn', 125 | text: "*#{title}*\n*Address:* #{address}\n*Vaccine:* #{vaccine}\n*Available appointments:* #{render_slack_appointments}\n*Link:* #{link}", 126 | }, 127 | } 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /spec/storage_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/storage' 2 | 3 | describe Storage do 4 | describe '#with_prefix' do 5 | it 'uses a prefix plus storage key' do 6 | mock_clinic = double('Clinic', storage_key: 'example clinic') 7 | storage = Storage.new 8 | expect(storage.with_prefix('my-prefix', mock_clinic)).to eq('my-prefix:example clinic') 9 | end 10 | end 11 | 12 | describe '#set' do 13 | it 'saves directly to redis' do 14 | mock_redis = double('Redis') 15 | expect(Redis).to receive(:new).and_return(mock_redis) 16 | storage = Storage.new 17 | expect(mock_redis).to receive(:set).with('foo', 'bar') 18 | storage.set('foo', 'bar') 19 | end 20 | end 21 | 22 | describe '#get' do 23 | it 'fetches from redis' do 24 | mock_redis = double('Redis') 25 | expect(Redis).to receive(:new).and_return(mock_redis) 26 | storage = Storage.new 27 | expect(mock_redis).to receive(:get).with('foo').and_return('bar') 28 | expect(storage.get('foo')).to eq('bar') 29 | end 30 | end 31 | 32 | describe '#save_appointments' do 33 | it 'saves appointments using a storage key' do 34 | mock_clinic = double('Clinic', storage_key: 'example clinic', appointments: 7) 35 | mock_redis = double('Redis') 36 | expect(Redis).to receive(:new).and_return(mock_redis) 37 | storage = Storage.new 38 | expect(mock_redis).to receive(:set).with('slack-vaccine-appt:example clinic', 7) 39 | storage.save_appointments(mock_clinic) 40 | end 41 | end 42 | 43 | describe '#get_appointments' do 44 | it 'gets appointments using a storage key' do 45 | mock_clinic = double('Clinic', storage_key: 'example clinic', appointments: 7) 46 | mock_redis = double('Redis') 47 | expect(Redis).to receive(:new).and_return(mock_redis) 48 | storage = Storage.new 49 | expect(mock_redis).to receive(:get).with('slack-vaccine-appt:example clinic').and_return(5) 50 | expect(storage.get_appointments(mock_clinic)).to eq(5) 51 | end 52 | end 53 | 54 | describe '#save_post_time' do 55 | it 'saves post time using a storage key' do 56 | mock_clinic = double('Clinic', storage_key: 'example clinic', appointments: 7) 57 | mock_redis = double('Redis') 58 | now = Time.now 59 | expect(Redis).to receive(:new).and_return(mock_redis) 60 | storage = Storage.new 61 | expect(Time).to receive(:now).and_return(now) 62 | expect(mock_redis).to receive(:set).with('slack-vaccine-post:example clinic', now) 63 | storage.save_post_time(mock_clinic) 64 | end 65 | end 66 | 67 | describe '#get_post_time' do 68 | it 'gets the post time using a storage key' do 69 | mock_clinic = double('Clinic', storage_key: 'example clinic', appointments: 7) 70 | mock_redis = double('Redis') 71 | now = Time.now 72 | expect(Redis).to receive(:new).and_return(mock_redis) 73 | storage = Storage.new 74 | expect(mock_redis).to receive(:get).with('slack-vaccine-post:example clinic').and_return(now) 75 | expect(storage.get_post_time(mock_clinic)).to eq(now) 76 | end 77 | end 78 | 79 | describe '#save_cookies' do 80 | it 'saves cookies to redis' do 81 | mock_redis = double('Redis') 82 | expect(Redis).to receive(:new).and_return(mock_redis) 83 | storage = Storage.new 84 | now = DateTime.now 85 | expect(mock_redis).to receive(:set).with('vaccine-cookies:ma-immunization', { 'cookies' => 'foo', 'expiration' => now }.to_json) 86 | storage.save_cookies('ma-immunization', 'foo', now) 87 | end 88 | end 89 | 90 | describe '#get_cookies' do 91 | it 'gets cookies from storage' do 92 | mock_redis = double('Redis') 93 | expect(Redis).to receive(:new).and_return(mock_redis) 94 | storage = Storage.new 95 | now = DateTime.now 96 | expect(mock_redis).to receive(:get).with('vaccine-cookies:ma-immunization').and_return({ 'cookies' => 'foo', 'expiration' => now }.to_json) 97 | res = storage.get_cookies('ma-immunization') 98 | expect(res['cookies']).to eq('foo') 99 | expect(res['expiration']).to eq(now.to_s) 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/sites/lowell_general.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'json' 3 | require 'rest-client' 4 | require 'nokogiri' 5 | 6 | require_relative '../sentry_helper' 7 | require_relative './base_clinic' 8 | 9 | module LowellGeneral 10 | SIGN_UP_URL = 'https://www.lowellgeneralvaccine.com/'.freeze 11 | BASE_URL = 'https://lowellgeneralvaccine.myhealthdirect.com'.freeze 12 | API_URL = "#{BASE_URL}/DataAccess/PageData/ProviderInfo/32122".freeze 13 | NEXT_URL = "#{BASE_URL}/DecisionSupport/Next".freeze 14 | WORKFLOW_URL = "#{BASE_URL}/DecisionSupport/Workflow".freeze 15 | 16 | def self.all_clinics(storage, logger) 17 | logger.info '[LowellGeneral] Checking site' 18 | SentryHelper.catch_errors(logger, 'LowellGeneral') do 19 | fetch_appointments(logger)['appointments'].each_with_object(Hash.new(0)) do |appointment, h| 20 | date, _time = appointment['localSlotDateTimeString'].split(' ') 21 | h[date] += 1 22 | end.map do |date, appointments| 23 | logger.info "[LowellGeneral] Found #{appointments} appointments on #{date}" 24 | Clinic.new(storage, date, appointments) 25 | end 26 | end 27 | end 28 | 29 | def self.fetch_appointments(logger) 30 | base_page = RestClient::Request.execute(url: SIGN_UP_URL, method: :get, verify_ssl: false).body 31 | html = Nokogiri::HTML(base_page) 32 | 33 | unless html.search('button[data-submit-text="Schedule My Appointment"]').any? 34 | logger.info('[LowellGeneral] No vaccine appointments available') 35 | return { 'appointments' => [] } 36 | end 37 | 38 | JSON.parse( 39 | RestClient.get( 40 | API_URL, 41 | params: { 42 | Month: Date.today.month, 43 | Year: Date.today.year, 44 | AppointmentTypeId: 0, 45 | appointmentStartDateString: '', 46 | autoAdvance: true, 47 | days: 30, 48 | }, 49 | cookies: token_cookies 50 | ).body 51 | ) 52 | end 53 | 54 | def self.token_cookies 55 | page1 = RestClient.get(BASE_URL) 56 | submit_form1(page1) 57 | page2 = RestClient.get(WORKFLOW_URL, cookies: page1.cookies) 58 | submit_form2(page2) 59 | RestClient.get(WORKFLOW_URL, cookies: page1.cookies) 60 | page1.cookies 61 | end 62 | 63 | def self.submit_form1(page) 64 | html = Nokogiri::HTML(page.body) 65 | inputs = html.search('form#workboxForm').each_with_object({}) do |form, h| 66 | form.search('input[type=hidden]').each do |input| 67 | h[input['name']] = input['value'] 68 | end 69 | form.search('select').each do |select| 70 | h[select['name']] = select.search('option')[1]['value'] 71 | end 72 | form.search('fieldset').each do |fieldset| 73 | radio = fieldset.search('input')[0] 74 | h[radio['name']] = radio['value'] 75 | end 76 | end 77 | RestClient.post(NEXT_URL, inputs, cookies: page.cookies) 78 | end 79 | 80 | def self.submit_form2(page) 81 | html = Nokogiri::HTML(page.body) 82 | inputs = html.search('form#workboxForm').each_with_object({}) do |form, h| 83 | form.search('input[type=hidden]').each do |input| 84 | h[input['name']] = input['value'] 85 | end 86 | form.search('select').each do |select| 87 | h[select['name']] = select.search('option')[1]['value'] 88 | end 89 | form.search('input[type=text]').each do |input| 90 | h[input['name']] = 'N/A' 91 | end 92 | form.search('fieldset').each do |fieldset| 93 | radio = fieldset.search('input')[1] 94 | h[radio['name']] = radio['value'] 95 | end 96 | end 97 | RestClient.post(NEXT_URL, inputs, cookies: page.cookies) 98 | end 99 | 100 | class Clinic < BaseClinic 101 | attr_reader :date, :appointments 102 | 103 | def initialize(storage, date, appointments) 104 | super(storage) 105 | @date = date 106 | @appointments = appointments 107 | end 108 | 109 | def module_name 110 | 'LOWELL_GENERAL' 111 | end 112 | 113 | def title 114 | "#{name} on #{date}" 115 | end 116 | 117 | def name 118 | 'Lowell General Hospital' 119 | end 120 | 121 | def link 122 | SIGN_UP_URL 123 | end 124 | 125 | def address 126 | '1001 Pawtucket Boulevard East, Lowell MA 01854' 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /run.rb: -------------------------------------------------------------------------------- 1 | require 'optparse' 2 | require 'sentry-ruby' 3 | 4 | require_relative 'lib/multi_logger' 5 | require_relative 'lib/storage' 6 | require_relative 'lib/slack' 7 | require_relative 'lib/twitter' 8 | 9 | # Sites 10 | require_relative 'lib/sites/ma_immunizations' 11 | require_relative 'lib/sites/curative' 12 | require_relative 'lib/sites/color' 13 | require_relative 'lib/sites/cvs' 14 | require_relative 'lib/sites/lowell_general' 15 | require_relative 'lib/sites/my_chart' 16 | require_relative 'lib/sites/zocdoc' 17 | require_relative 'lib/sites/acuity' 18 | require_relative 'lib/sites/trinity_health' 19 | require_relative 'lib/sites/southcoast' 20 | #require_relative 'lib/sites/northampton' 21 | require_relative 'lib/sites/rutland' 22 | require_relative 'lib/sites/heywood_healthcare' 23 | require_relative 'lib/sites/vaccinespotter' 24 | require_relative 'lib/sites/baystate_health' 25 | require_relative 'lib/sites/athena' 26 | require_relative 'lib/sites/costco' 27 | require_relative 'lib/sites/rxtouch' 28 | #require_relative 'lib/sites/walgreens' 29 | require_relative 'lib/sites/amesbury' 30 | require_relative 'lib/sites/holyoke_health' 31 | 32 | UPDATE_FREQUENCY = ENV['UPDATE_FREQUENCY']&.to_i || 60 # seconds 33 | 34 | SCRAPERS = { 35 | 'curative' => Curative, 36 | 'color' => Color, 37 | 'cvs' => Cvs, 38 | 'lowell_general' => LowellGeneral, 39 | 'my_chart' => MyChart, 40 | 'ma_immunizations' => MaImmunizations, 41 | 'zocdoc' => Zocdoc, 42 | 'acuity' => Acuity, 43 | 'trinity_health' => TrinityHealth, 44 | 'southcoast' => Southcoast, 45 | #'northampton' => Northampton, 46 | 'rutland' => Rutland, 47 | 'heywood_healthcare' => HeywoodHealthcare, 48 | 'vaccinespotter' => Vaccinespotter, 49 | 'baystate_health' => BaystateHealth, 50 | 'athena' => Athena, 51 | 'costco' => Costco, 52 | 'rxtouch' => Rxtouch, 53 | #'walgreens' => Walgreens, NOTE not working yet 54 | 'amesbury' => Amesbury, 55 | 'holyoke_health' => HolyokeHealth, 56 | }.freeze 57 | 58 | def all_clinics(storage, logger, scrapers: 'all', except: []) 59 | if scrapers == 'all' 60 | SCRAPERS.each do |scraper_name, scraper_module| 61 | next if except.include?(scraper_name) 62 | 63 | yield scraper_module.all_clinics(storage, logger) 64 | end 65 | else 66 | scrapers.each do |scraper| 67 | next if except.include?(scraper) 68 | 69 | scraper_module = SCRAPERS[scraper] 70 | raise "Module #{scraper} not found" unless scraper_module 71 | 72 | yield scraper_module.all_clinics(storage, logger) 73 | end 74 | end 75 | end 76 | 77 | def sleep_for(frequency) 78 | start_time = Time.now 79 | yield 80 | running_time = Time.now - start_time 81 | sleep([frequency - running_time, 0].max) 82 | end 83 | 84 | def main(opts) 85 | environment = ENV['ENVIRONMENT'] || 'dev' 86 | 87 | if ENV['SENTRY_DSN'] 88 | Sentry.init do |config| 89 | config.dsn = ENV['SENTRY_DSN'] 90 | config.environment = environment 91 | end 92 | end 93 | 94 | logger = MultiLogger.new( 95 | Logger.new($stdout), 96 | Logger.new("log/#{environment}.txt", 'daily') 97 | ) 98 | storage = Storage.new 99 | slack = SlackClient.new(logger) 100 | twitter = TwitterClient.new(logger) 101 | 102 | logger.info "[Main] Update frequency is set to every #{UPDATE_FREQUENCY} seconds" 103 | 104 | if ENV['SEED_REDIS'] 105 | sleep_for(UPDATE_FREQUENCY) do 106 | logger.info '[Main] Seeding redis with current appointments' 107 | all_clinics(storage, logger, **opts) { |clinics| clinics.each(&:save_appointments) } 108 | logger.info '[Main] Done seeding redis' 109 | end 110 | end 111 | 112 | loop do 113 | sleep_for(UPDATE_FREQUENCY) do 114 | logger.info '[Main] Started checking' 115 | all_clinics(storage, logger, **opts) do |clinics| 116 | slack.post(clinics) 117 | twitter.post(clinics) 118 | 119 | clinics.each(&:save_appointments) 120 | end 121 | logger.info '[Main] Done checking' 122 | end 123 | end 124 | 125 | rescue => e 126 | Sentry.capture_exception(e) 127 | logger.error "[Main] Error: #{e}" 128 | raise 129 | end 130 | 131 | options = {} 132 | OptionParser.new do |opts| 133 | opts.banner = "Usage: bundle exec ruby run.rb [options]" 134 | 135 | opts.on('-s', '--scrapers SCRAPER1,SCRAPER2', Array, "Scraper to run, choose from: #{SCRAPERS.keys.join(', ')}") do |s| 136 | options[:scrapers] = s 137 | end 138 | 139 | opts.on('-e', '--except SCRAPER1,SCRAPER2', Array, "Scrapers not to run, choose from: #{SCRAPERS.keys.join(', ')}") do |e| 140 | options[:except] = e 141 | end 142 | end.parse! 143 | 144 | main(options) 145 | -------------------------------------------------------------------------------- /lib/sites/zocdoc.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'rest-client' 3 | 4 | require_relative '../sentry_helper' 5 | require_relative './base_clinic' 6 | 7 | module Zocdoc 8 | GQL_URL = 'https://api.zocdoc.com/directory/v2/gql'.freeze 9 | GQL_QUERY = %{ 10 | query VaccineTime($providers: [String]) { 11 | providers(ids: $providers) { 12 | id 13 | nameInSentence 14 | providerLocations { 15 | location { 16 | address1 17 | address2 18 | city 19 | state 20 | zipCode 21 | } 22 | availability(numDays: 28) { 23 | times { 24 | date 25 | timeslots { 26 | startTime 27 | } 28 | } 29 | } 30 | } 31 | } 32 | } 33 | }.freeze 34 | 35 | SITES = { 36 | 'pr_fSHH-Tyvm0SZvoK3pfH8tx' => { 37 | name: 'Tufts MC Vaccine Site - Boston Location', 38 | sign_up_link: 'https://www.zocdoc.com/wl/tuftscovid19vaccination/patientvaccine', 39 | }, 40 | 'pr_BDBebslqJU2vrCAvVMhYeh' => { 41 | name: 'Holtzman Medical Group - Mount Ida Campus', 42 | sign_up_link: 'https://www.zocdoc.com/vaccine/screener?state=MA', 43 | }, 44 | 'pr_MFpAkwJEAEaLWrzldda5NR' => { 45 | name: 'Holtzman Medical Group- COVID19 clinic at American Legion Post 440', 46 | sign_up_link: 'https://www.zocdoc.com/vaccine/screener?state=MA', 47 | }, 48 | 'pr_iXjD9x2P-0OrLNoIknFr8R' => { 49 | name: 'AFC Saugus', 50 | sign_up_link: 'https://www.zocdoc.com/vaccine/screener?state=MA', 51 | }, 52 | 'pr_TeD-JuoydUKqszEn2ATb8h' => { 53 | name: 'AFC New Bedford', 54 | sign_up_link: 'https://www.zocdoc.com/vaccine/screener?state=MA', 55 | }, 56 | 'pr_pEgrY3r5qEuYKsKvc4Kavx' => { 57 | name: 'AFC Worcester', 58 | sign_up_link: 'https://www.zocdoc.com/vaccine/screener?state=MA', 59 | }, 60 | 'pr_VUnpWUtg1k2WFBMK8IhZkx' => { 61 | name: 'AFC Dedham', 62 | sign_up_link: 'https://www.zocdoc.com/vaccine/screener?state=MA', 63 | }, 64 | 'pr_4Vg_3ZeLY0aHJJxsCU-WhB' => { 65 | name: 'AFC West Springfield', 66 | sign_up_link: 'https://www.zocdoc.com/vaccine/screener?state=MA', 67 | }, 68 | 'pr_CUmBnwtlz0C16bif5EU0IR' => { 69 | name: 'AFC Springfield', 70 | sign_up_link: 'https://www.zocdoc.com/vaccine/screener?state=MA', 71 | }, 72 | }.freeze 73 | 74 | def self.all_clinics(storage, logger) 75 | logger.info '[Zocdoc] Checking site' 76 | SentryHelper.catch_errors(logger, 'Zocdoc') do 77 | fetch_graphql['providers'].flat_map do |provider| 78 | Page.new(storage, logger, provider).clinics 79 | end 80 | end 81 | end 82 | 83 | def self.fetch_graphql 84 | res = RestClient.post( 85 | GQL_URL, 86 | { 87 | operationName: 'VaccineTime', 88 | query: GQL_QUERY, 89 | variables: { providers: SITES.keys }, 90 | }.to_json, 91 | content_type: :json 92 | ) 93 | JSON.parse(res)['data'] 94 | end 95 | 96 | class Page 97 | def initialize(storage, logger, provider) 98 | @storage = storage 99 | @logger = logger 100 | @provider = provider 101 | end 102 | 103 | def name 104 | @provider['nameInSentence'] 105 | end 106 | 107 | def sign_up_link 108 | SITES[@provider['id']][:sign_up_link] 109 | end 110 | 111 | def clinics 112 | @provider['providerLocations'].flat_map do |location| 113 | (location.dig('availability', 'times') || []).map do |time| 114 | date = time['date'] 115 | appointments = time['timeslots'].length 116 | if appointments.positive? 117 | @logger.info "[Zocdoc] Site #{name} on #{date}: found #{appointments} appointments" 118 | end 119 | Clinic.new( 120 | @storage, 121 | name, 122 | sign_up_link, 123 | date, 124 | appointments, 125 | location['location'] 126 | ) 127 | end 128 | end 129 | end 130 | end 131 | 132 | class Clinic < BaseClinic 133 | attr_reader :name, :link, :date, :appointments 134 | 135 | def initialize(storage, name, link, date, appointments, location) 136 | super(storage) 137 | @name = name 138 | @link = link 139 | @date = date 140 | @appointments = appointments 141 | @location = location 142 | end 143 | 144 | def module_name 145 | 'ZOCDOC' 146 | end 147 | 148 | def city 149 | @location['city'] 150 | end 151 | 152 | def address 153 | addr = @location['address1'] 154 | addr += " #{@location['address2']}" unless @location['address2'].empty? 155 | addr + ", #{@location['city']} #{@location['state']} #{@location['zipCode']}" 156 | end 157 | 158 | def title 159 | "#{name} in #{city}, MA on #{date}" 160 | end 161 | 162 | def slack_blocks 163 | { 164 | type: 'section', 165 | text: { 166 | type: 'mrkdwn', 167 | text: "*#{title}*\n*Address:* #{address}\n*Available appointments:* #{render_slack_appointments}\n*Link:* #{link}", 168 | }, 169 | } 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /lib/sites/athena.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'rest-client' 3 | 4 | require_relative '../sentry_helper' 5 | require_relative './base_clinic' 6 | 7 | module Athena 8 | GQL_URL = 'https://framework-backend.scheduling.athena.io/v1/graphql'.freeze 9 | GQL_QUERY = %{ 10 | query SearchSlots( 11 | $locationIds: [String!] 12 | $practitionerIds: [String!] 13 | $specialty: String 14 | $serviceTypeTokens: [String!]! 15 | $startAfter: String! 16 | $startBefore: String! 17 | $visitType: VisitType 18 | ) { 19 | searchSlots( 20 | locationIds: $locationIds 21 | practitionerIds: $practitionerIds 22 | specialty: $specialty 23 | serviceTypeTokens: $serviceTypeTokens 24 | startAfter: $startAfter 25 | startBefore: $startBefore 26 | visitType: $visitType 27 | ) { 28 | location { 29 | reference 30 | resource { 31 | ... on Location { 32 | id 33 | name 34 | address { 35 | line 36 | city 37 | state 38 | postalCode 39 | __typename 40 | } 41 | telecom { 42 | system 43 | value 44 | __typename 45 | } 46 | timezone 47 | managingOrganization { 48 | reference 49 | __typename 50 | } 51 | __typename 52 | } 53 | __typename 54 | } 55 | __typename 56 | } 57 | practitionerAvailability { 58 | isTelehealth 59 | practitioner { 60 | reference 61 | resource { 62 | ... on Practitioner { 63 | id 64 | name { 65 | text 66 | __typename 67 | } 68 | __typename 69 | } 70 | __typename 71 | } 72 | __typename 73 | } 74 | availability { 75 | id 76 | start 77 | end 78 | status 79 | serviceType { 80 | text 81 | coding { 82 | code 83 | system 84 | __typename 85 | } 86 | __typename 87 | } 88 | schedulingListToken 89 | __typename 90 | } 91 | __typename 92 | } 93 | __typename 94 | } 95 | } 96 | }.freeze 97 | 98 | def self.all_clinics(storage, logger) 99 | logger.info '[Athena] Checking site' 100 | 101 | SentryHelper.catch_errors(logger, 'Athena') do 102 | fetch_availability['data']['searchSlots'].each_with_object({}) do |slot, h| 103 | location = slot['location']['resource']['name'] 104 | h[location] ||= Hash.new(0) 105 | slot['practitionerAvailability'].each do |practitioner| 106 | practitioner['availability'].each do |availability| 107 | date = Date.parse(availability['start']).to_s 108 | h[location][date] += 1 109 | end 110 | end 111 | end.flat_map do |location, dates| 112 | dates.map do |date, appointments| 113 | logger.info "[Athena] Found #{appointments} appointments on #{date}" 114 | Clinic.new(storage, location, date, appointments) 115 | end 116 | end 117 | end 118 | end 119 | 120 | def self.token 121 | JSON.parse( 122 | RestClient.get('https://framework-backend.scheduling.athena.io/t').body 123 | )['token'] 124 | end 125 | 126 | def self.jwt 127 | JSON.parse( 128 | RestClient.get('https://framework-backend.scheduling.athena.io/u?locationId=2804-102&practitionerId=&contextId=2804').body 129 | )['token'] 130 | end 131 | 132 | def self.fetch_availability 133 | variables = { 134 | locationIds: ['2804-102'], 135 | practitionerIds: [], 136 | serviceTypeTokens: ['codesystem.scheduling.athena.io/servicetype.canonical|49b8e757-0345-4923-9889-a3b57f05aed2'], 137 | specialty: 'Unknown Provider', 138 | startAfter: Time.now.strftime('%Y-%m-%dT%H:%M:00-04:00'), 139 | startBefore: (Date.today + 28).strftime('%Y-%m-%dT23:59:59-04:00'), 140 | } 141 | 142 | JSON.parse( 143 | RestClient.post( 144 | GQL_URL, 145 | { 146 | operationName: 'SearchSlots', 147 | query: GQL_QUERY, 148 | variables: variables, 149 | }.to_json, 150 | content_type: :json, 151 | authorization: "Bearer #{token}", 152 | 'x-scheduling-jwt' => jwt 153 | ).body 154 | ) 155 | end 156 | 157 | class Clinic < BaseClinic 158 | attr_reader :date, :appointments 159 | 160 | def initialize(storage, location, date, appointments) 161 | super(storage) 162 | @location = location 163 | @date = date 164 | @appointments = appointments 165 | end 166 | 167 | def module_name 168 | 'ATHENA' 169 | end 170 | 171 | def title 172 | "#{@location} on #{date}" 173 | end 174 | 175 | def link 176 | 'https://consumer.scheduling.athena.io/?departmentId=2804-102' 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /lib/sites/holyoke_health.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'date' 3 | require 'rest-client' 4 | 5 | require_relative '../sentry_helper' 6 | require_relative './base_clinic' 7 | 8 | module HolyokeHealth 9 | GQL_URL = 'https://api.blockitnow.com/graphql'.freeze 10 | SPECIALTY_ID = 'bf21b91b-f6f9-4a78-aca4-dbdedbe23a75'.freeze 11 | PROCEDURE_ID = '468129ce-1d13-4114-92aa-78e2a3b04da5'.freeze 12 | 13 | def self.all_clinics(storage, logger) 14 | SentryHelper.catch_errors(logger, 'HolyokeHealth') do 15 | locations.flat_map do |location| 16 | fetch_appointments(location['id']).each_with_object(Hash.new(0)) do |appt, h| 17 | h[Date.parse(appt['start']).to_s] += 1 18 | end.map do |date, appts| 19 | Clinic.new(storage, location['location'], date, appts) 20 | end 21 | end 22 | end 23 | end 24 | 25 | def self.organization_id 26 | JSON.parse( 27 | RestClient.post( 28 | GQL_URL, 29 | { 30 | operationName: 'GetConsumerSchedulingOrganizationQuery', 31 | query: %{ 32 | query GetConsumerSchedulingOrganizationQuery($id: ID!) { 33 | getConsumerSchedulingOrganization(id: $id) { 34 | id 35 | name 36 | } 37 | } 38 | }, 39 | variables: { id: 'covid-holyoke' }, 40 | }.to_json, 41 | content_type: :json 42 | ).body 43 | )['data']['getConsumerSchedulingOrganization']['id'] 44 | end 45 | 46 | def self.locations 47 | JSON.parse( 48 | RestClient.post( 49 | GQL_URL, 50 | { 51 | operationName: 'SearchProfilesInOrganizationQuery', 52 | query: %{ 53 | query SearchProfilesInOrganizationQuery( 54 | $organizationId: ID! 55 | $page: Int 56 | $pageSize: Int 57 | $searchProfilesInput: SearchProfilesInput! 58 | ) { 59 | searchProfilesInOrganization( 60 | organizationId: $organizationId 61 | page: $page 62 | pageSize: $pageSize 63 | searchProfilesInput: $searchProfilesInput 64 | ) { 65 | id 66 | location { 67 | id 68 | name 69 | address1 70 | address2 71 | city 72 | state 73 | postalCode 74 | } 75 | } 76 | } 77 | }, 78 | variables: { 79 | organizationId: organization_id, 80 | page: 1, 81 | pageSize: 10, 82 | searchProfilesInput: { 83 | hasConsumerScheduling: true, 84 | isActive: true, 85 | organizationIsActive: true, 86 | procedureId: PROCEDURE_ID, 87 | sort: 'NEXT_AVAILABILITY', 88 | specialtyId: SPECIALTY_ID, 89 | }, 90 | }, 91 | }.to_json, 92 | content_type: :json 93 | ).body 94 | )['data']['searchProfilesInOrganization'] 95 | end 96 | 97 | def self.fetch_appointments(profile_id) 98 | res = JSON.parse( 99 | RestClient.post( 100 | GQL_URL, 101 | { 102 | operationName: 'GetConsumerSchedulingProfileSlotsQuery', 103 | query: %{ 104 | query GetConsumerSchedulingProfileSlotsQuery( 105 | $procedureId: ID! 106 | $profileId: ID! 107 | $start: String 108 | $end: String 109 | ) { 110 | getConsumerSchedulingProfileSlots( 111 | procedureId: $procedureId 112 | profileId: $profileId 113 | start: $start 114 | end: $end 115 | ) { 116 | id 117 | start 118 | end 119 | status 120 | slotIdsForAppointment 121 | __typename 122 | } 123 | } 124 | }, 125 | variables: { 126 | procedureId: PROCEDURE_ID, 127 | profileId: profile_id, 128 | start: Date.today.strftime('%Y-%m-%d'), 129 | end: (Date.today + 28).strftime('%Y-%m-%d'), 130 | }, 131 | }.to_json, 132 | content_type: :json 133 | ).body 134 | )['data']['getConsumerSchedulingProfileSlots'] 135 | end 136 | 137 | class Clinic < BaseClinic 138 | attr_reader :date, :appointments 139 | 140 | def initialize(storage, location_data, date, appointments) 141 | super(storage) 142 | @location_data = location_data 143 | @date = date 144 | @appointments = appointments 145 | end 146 | 147 | def module_name 148 | 'HOLYOKE_HEALTH' 149 | end 150 | 151 | def title 152 | "#{location} on #{date}" 153 | end 154 | 155 | def location 156 | @location_data['name'] 157 | end 158 | 159 | def city 160 | @location_data['city'] 161 | end 162 | 163 | def link 164 | 'https://app.blockitnow.com/consumer/covid-holyoke/search?specialtyId=bf21b91b-f6f9-4a78-aca4-dbdedbe23a75&procedureId=468129ce-1d13-4114-92aa-78e2a3b04da5' 165 | end 166 | 167 | def twitter_text 168 | "#{appointments} appointments available at #{title} in #{city}, MA. Check eligibility and sign up at #{sign_up_page}" 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /lib/sites/acuity.rb: -------------------------------------------------------------------------------- 1 | require 'rest-client' 2 | require 'nokogiri' 3 | 4 | require_relative '../sentry_helper' 5 | require_relative './base_clinic' 6 | 7 | module Acuity 8 | SITES = { 9 | 'Southbridge Community Center (non-local residents) in Southbridge, MA' => { 10 | sign_up_url: 'https://www.harringtonhospital.org/coronavirus/covid-19-vaccination/', 11 | api_url: 'https://app.acuityscheduling.com/schedule.php?action=showCalendar&fulldate=1&owner=22192301&template=weekly', 12 | api_params: { 13 | type: 20819310, 14 | calendar: 5202038, 15 | skip: true, 16 | 'options[qty]' => 1, 17 | 'options[numDays]' => 27, 18 | ignoreAppointment: '', 19 | appointmentType: '', 20 | calendarID: 5202038, 21 | }, 22 | }, 23 | 24 | 'Southbridge Community Center (local residents only) in Southbridge, MA' => { 25 | sign_up_url: 'https://www.harringtonhospital.org/coronavirus/covid-19-vaccination/', 26 | api_url: 'https://app.acuityscheduling.com/schedule.php?action=showCalendar&fulldate=1&owner=22192301&template=weekly', 27 | api_params: { 28 | type: 20926295, 29 | calendar: 5202050, 30 | skip: true, 31 | 'options[qty]' => 1, 32 | 'options[numDays]' => 27, 33 | ignoreAppointment: '', 34 | appointmentType: '', 35 | calendarID: 5202050, 36 | }, 37 | }, 38 | 39 | '700 Essex St. (GLFHC patients only) in Lawrence, MA' => { 40 | sign_up_url: 'https://glfhc.org/covid-19-vaccine-signup-form/', 41 | api_url: 'https://glfhccovid19iz.as.me/schedule.php?action=showCalendar&fulldate=1&owner=21956779&template=weekly&location=700+Essex+Street%2C+Lawrence+MA', 42 | api_params: { 43 | type: 20223228, 44 | calendar: 5075244, 45 | skip: true, 46 | 'options[qty]' => 1, 47 | 'options[numDays]' => 27, 48 | ignoreAppointment: '', 49 | appointmentType: '', 50 | calendarID: '', 51 | }, 52 | }, 53 | 54 | '147 Pelham St. in Methuen, MA' => { 55 | sign_up_url: 'https://glfhc.org/covid-19-vaccine-signup-form/', 56 | api_url: 'https://glfhccovid19iz.as.me/schedule.php?action=showCalendar&fulldate=1&owner=21956779&template=weekly', 57 | api_params: { 58 | type: 20223228, 59 | calendar: 5114836, 60 | skip: true, 61 | 'options[qty]' => 1, 62 | 'options[numDays]' => 27, 63 | ignoreAppointment: '', 64 | appointmentType: '', 65 | calendarID: 5114836, 66 | }, 67 | }, 68 | 69 | 'Central Plaza - 2 Water St. in Haverhill, MA' => { 70 | sign_up_url: 'https://glfhc.org/covid-19-vaccine-signup-form/', 71 | api_url: 'https://glfhccovid19iz.as.me/schedule.php?action=showCalendar&fulldate=1&owner=21956779&template=weekly&location=Central+Plaza%2C+2+Water+Street%2C+Haverhill+MA', 72 | api_params: { 73 | type: 20223228, 74 | calendar: 5236989, 75 | skip: true, 76 | 'options[qty]' => 1, 77 | 'options[numDays]' => 27, 78 | ignoreAppointment: '', 79 | appointmentType: '', 80 | calendarID: '', 81 | }, 82 | }, 83 | 84 | 'Northern Essex Community College 45 Franklin St. (Lawrence residents only) in Lawrence, MA' => { 85 | sign_up_url: 'https://glfhc.org/covid-19-vaccine-signup-form/', 86 | api_url: 'https://glfhccovid19iz.as.me/schedule.php?action=showCalendar&fulldate=1&owner=21956779&template=weekly&location=45+Franklin+Street%2C+Lawrence+MA', 87 | api_params: { 88 | type: 20223228, 89 | calendar: 5341082, 90 | skip: true, 91 | 'options[qty]' => 1, 92 | 'options[numDays]' => 27, 93 | ignoreAppointment: '', 94 | appointmentType: '', 95 | calendarID: '', 96 | }, 97 | }, 98 | 99 | # NOTE: Trinity is showing a message that all appointment types are private 100 | #'Trinity EMS in Haverhill, MA' => { 101 | #sign_up_url: 'https://trinityems.com/what-we-do/covid-19-vaccine-clinics/', 102 | #api_url: 'https://app.acuityscheduling.com/schedule.php?action=showCalendar&fulldate=1&owner=21713854&template=weekly', 103 | #api_params: { 104 | #type: 19620839, 105 | #calendar: 5109380, 106 | #skip: true, 107 | #'options[qty]' => 1, 108 | #'options[numDays]' => 5, 109 | #ignoreAppointment: '', 110 | #appointmentType: '', 111 | #calendarID: 5109380, 112 | #}, 113 | #} 114 | }.freeze 115 | 116 | def self.all_clinics(storage, logger) 117 | SITES.flat_map do |site_name, config| 118 | sleep(1) 119 | SentryHelper.catch_errors(logger, 'Acuity') do 120 | logger.info "[Acuity] Checking site #{site_name}" 121 | fetch_appointments(config).map do |date, appointments| 122 | logger.info "[Acuity] Site #{site_name} found #{appointments} appointments on #{date}" 123 | Clinic.new(storage, site_name, date, appointments, config[:sign_up_url]) 124 | end 125 | end 126 | end 127 | end 128 | 129 | def self.fetch_appointments(config) 130 | res = RestClient.post(config[:api_url], config[:api_params]) 131 | html = Nokogiri::HTML(res.body) 132 | html.search('input.time-selection').each_with_object(Hash.new(0)) do |appt, h| 133 | h[appt['data-readable-date']] += 1 134 | end 135 | end 136 | 137 | class Clinic < BaseClinic 138 | attr_reader :name, :date, :appointments, :link 139 | 140 | def initialize(storage, name, date, appointments, link) 141 | super(storage) 142 | @name = name 143 | @date = date 144 | @appointments = appointments 145 | @link = link 146 | end 147 | 148 | def module_name 149 | 'ACUITY' 150 | end 151 | 152 | def title 153 | "#{name} on #{date}" 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | dan@ginkgobioworks.com. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available at 120 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 127 | at [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | 135 | -------------------------------------------------------------------------------- /spec/base_clinic_spec.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | 3 | require_relative '../lib/sites/base_clinic' 4 | 5 | class TestClinic < BaseClinic 6 | end 7 | 8 | describe BaseClinic do 9 | let(:redis) { double('Redis') } 10 | let(:storage) { Storage.new(redis) } 11 | 12 | describe '#last_appointments' do 13 | it 'gets last appointments from storage' do 14 | clinic = BaseClinic.new(storage) 15 | expect(storage).to receive(:get_appointments).with(clinic).and_return(10) 16 | expect(clinic.last_appointments).to eq(10) 17 | end 18 | 19 | it 'returns 0 if nothing is in storage' do 20 | clinic = BaseClinic.new(storage) 21 | expect(storage).to receive(:get_appointments).with(clinic).and_return(nil) 22 | expect(clinic.last_appointments).to eq(0) 23 | end 24 | end 25 | 26 | describe '#new_appointments' do 27 | it 'returns the difference in appointments since last check' do 28 | clinic = BaseClinic.new(storage) 29 | expect(storage).to receive(:get_appointments).with(clinic).and_return(10) 30 | allow(clinic).to receive(:appointments).and_return(15) 31 | expect(clinic.new_appointments).to eq(5) 32 | end 33 | 34 | it 'can return negative numbers' do 35 | clinic = BaseClinic.new(storage) 36 | expect(storage).to receive(:get_appointments).with(clinic).and_return(10) 37 | allow(clinic).to receive(:appointments).and_return(5) 38 | expect(clinic.new_appointments).to eq(-5) 39 | end 40 | end 41 | 42 | describe '#render_slack_appointments' do 43 | it 'includes new appointments' do 44 | clinic = BaseClinic.new(storage) 45 | expect(storage).to receive(:get_appointments).with(clinic).and_return(5) 46 | allow(clinic).to receive(:appointments).and_return(8) 47 | expect(clinic.render_slack_appointments).to eq('8 (3 new)') 48 | end 49 | 50 | it 'renders sirens if over 10 appointments' do 51 | clinic = BaseClinic.new(storage) 52 | expect(storage).to receive(:get_appointments).with(clinic).and_return(5) 53 | allow(clinic).to receive(:appointments).and_return(11) 54 | expect(clinic.render_slack_appointments).to eq(':siren: 11 (6 new) :siren:') 55 | end 56 | end 57 | 58 | describe '#slack_blocks' do 59 | it 'formats a message for slack' do 60 | clinic = BaseClinic.new(storage) 61 | expect(storage).to receive(:get_appointments).with(clinic).and_return(5) 62 | allow(clinic).to receive(:title).and_return('Base clinic on 4/10/21') 63 | allow(clinic).to receive(:link).and_return('foo.com') 64 | allow(clinic).to receive(:appointments).and_return(12) 65 | expect(clinic.slack_blocks).to eq({ 66 | type: 'section', 67 | text: { 68 | type: 'mrkdwn', 69 | text: "*Base clinic on 4/10/21*\n*Available appointments:* :siren: 12 (7 new) :siren:\n*Link:* foo.com", 70 | }, 71 | }) 72 | end 73 | end 74 | 75 | describe '#has_not_posted_recently?' do 76 | it 'returns true if there are no previous posts' do 77 | clinic = BaseClinic.new(storage) 78 | expect(storage).to receive(:get_post_time).with(clinic).and_return(nil) 79 | expect(clinic.has_not_posted_recently?).to be true 80 | end 81 | 82 | it 'returns true if no posts in the last 30 minutes' do 83 | clinic = BaseClinic.new(storage) 84 | allow(storage).to receive(:get_post_time).with(clinic).and_return((Time.now - 40 * 60).to_s) 85 | expect(clinic.has_not_posted_recently?).to be true 86 | end 87 | 88 | it 'returns false if there is a post in the last 30 minutes' do 89 | clinic = BaseClinic.new(storage) 90 | expect(storage).to receive(:get_post_time).with(clinic).and_return((Time.now - 5 * 60).to_s) 91 | expect(clinic.has_not_posted_recently?).to be false 92 | end 93 | end 94 | 95 | describe '#should_tweet' do 96 | it "returns true if there's a link and enough appointments" do 97 | clinic = BaseClinic.new(storage) 98 | allow(clinic).to receive(:link).and_return('foo.com') 99 | allow(clinic).to receive(:appointments).and_return(50) 100 | allow(storage).to receive(:get_appointments).and_return(10) 101 | allow(storage).to receive(:get_post_time).and_return(nil) 102 | expect(clinic.should_tweet?).to be true 103 | end 104 | 105 | it "returns false if there's no link" do 106 | clinic = BaseClinic.new(storage) 107 | allow(clinic).to receive(:link).and_return(nil) 108 | allow(clinic).to receive(:appointments).and_return(50) 109 | allow(storage).to receive(:get_appointments).and_return(10) 110 | allow(storage).to receive(:get_post_time).and_return(nil) 111 | expect(clinic.should_tweet?).to be false 112 | end 113 | 114 | it "returns false if there's not enough appointments" do 115 | clinic = BaseClinic.new(storage) 116 | allow(clinic).to receive(:link).and_return('foo.com') 117 | allow(clinic).to receive(:appointments).and_return(5) 118 | allow(storage).to receive(:get_appointments).and_return(0) 119 | allow(storage).to receive(:get_post_time).and_return(nil) 120 | expect(clinic.should_tweet?).to be false 121 | end 122 | 123 | it "returns false if there's not enough new appointments" do 124 | clinic = BaseClinic.new(storage) 125 | allow(clinic).to receive(:link).and_return('foo.com') 126 | allow(clinic).to receive(:appointments).and_return(50) 127 | allow(storage).to receive(:get_appointments).and_return(49) 128 | allow(storage).to receive(:get_post_time).and_return(nil) 129 | expect(clinic.should_tweet?).to be false 130 | end 131 | 132 | it "returns false if there's been a tweet recently" do 133 | clinic = BaseClinic.new(storage) 134 | allow(clinic).to receive(:link).and_return('foo.com') 135 | allow(clinic).to receive(:appointments).and_return(50) 136 | allow(storage).to receive(:get_appointments).and_return(0) 137 | allow(storage).to receive(:get_post_time).and_return((Time.now - 60).to_s) 138 | expect(clinic.should_tweet?).to be false 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /lib/sites/cvs.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'json' 3 | require 'rest-client' 4 | 5 | require_relative './pharmacy_clinic' 6 | 7 | module Cvs 8 | STATE = 'MA'.freeze 9 | USER_AGENTS = [] 10 | 11 | class CvsCity 12 | attr_reader :city, :stores, :should_tweet 13 | 14 | def initialize(city, stores, should_tweet) 15 | @city = city 16 | @stores = stores 17 | @should_tweet = should_tweet 18 | end 19 | end 20 | 21 | File.open("#{__dir__}/config/user_agents.txt", 'r') do |f| 22 | f.each_line do |line| 23 | USER_AGENTS.append(line.strip) 24 | end 25 | end 26 | 27 | def self.all_clinics(storage, logger) 28 | clinics = [] 29 | SentryHelper.catch_errors(logger, 'CVS', on_error: clinics) do 30 | # For now, CVS is counted as one "clinic" for the whole state and every city offering 31 | # with stores offering the vaccine is counted as one "appointment". 32 | cvs_client = CvsClient.new(STATE, USER_AGENTS) 33 | cvs_client.init_session(logger) 34 | 35 | cities_with_appointments = cvs_client.cities_with_appointments(logger) 36 | if cities_with_appointments.any? 37 | logger.info "[CVS] There are #{cities_with_appointments.length} cities with appointments" 38 | logger.info "[CVS] Cities with appointments: #{cities_with_appointments.join(', ')}" 39 | else 40 | logger.info "[CVS] No availability for any city in #{STATE}" 41 | end 42 | 43 | clinics = [StateClinic.new(storage, cities_with_appointments, STATE)] 44 | end 45 | 46 | clinics 47 | end 48 | 49 | class StateClinic < PharmacyClinic 50 | LAST_SEEN_CITIES_KEY = 'cvs-last-cities'.freeze 51 | 52 | def initialize(storage, cities, state) 53 | super(storage) 54 | @cities = cities.sort 55 | @state = state 56 | end 57 | 58 | def title 59 | "CVS stores in #{@state}" 60 | end 61 | 62 | def appointments 63 | @cities.length 64 | end 65 | 66 | def last_appointments 67 | last_cities.length 68 | end 69 | 70 | def new_appointments 71 | new_cities.length 72 | end 73 | 74 | def link 75 | 'https://www.cvs.com/immunizations/covid-19-vaccine' 76 | end 77 | 78 | def twitter_text 79 | tweet_groups = [] 80 | 81 | tweet_cities = @cities 82 | cities_text = tweet_cities.shift 83 | while (city = tweet_cities.shift) 84 | pending_text = ", #{city}" 85 | if cities_text.length + pending_text.length > 192 86 | tweet_groups << cities_text 87 | cities_text = city 88 | else 89 | cities_text += pending_text 90 | end 91 | end 92 | tweet_groups << cities_text 93 | 94 | # 30 chars: "CVS appointments available in " 95 | # 192 chars: max of cities_text 96 | # 35 chars: ". Check eligibility and sign up at " 97 | # 23 chars: shortened link 98 | # --------- 99 | # 280 chars total, 280 is the maximum 100 | tweet_groups.map do |group| 101 | "CVS appointments available in #{group}. Check eligibility and sign up at #{sign_up_page}" 102 | end 103 | end 104 | 105 | def slack_blocks 106 | { 107 | type: 'section', 108 | text: { 109 | type: 'mrkdwn', 110 | text: "*#{title}*\n*Available appointments in:* #{@cities.join(', ')}\n*New cities:* #{new_cities.length} new - #{new_cities.join(', ')}\n*Link:* #{link}", 111 | }, 112 | } 113 | end 114 | 115 | def save_appointments 116 | @storage.set("#{LAST_SEEN_CITIES_KEY}:#{@state}", @cities.join(',')) 117 | end 118 | 119 | def last_cities 120 | stored_value = @storage.get("#{LAST_SEEN_CITIES_KEY}:#{@state}") 121 | stored_value.nil? ? [] : stored_value.split(',') 122 | end 123 | 124 | def new_cities 125 | @cities - last_cities 126 | end 127 | end 128 | 129 | class CvsClient 130 | 131 | def initialize(state, user_agents) 132 | @cookies = {} 133 | @user_agents = user_agents 134 | @user_agent = @user_agents.sample 135 | @state = state 136 | @state_status_url = "https://www.cvs.com/immunizations/covid-19-vaccine.vaccine-status.#{state}.json?vaccineinfo".freeze 137 | end 138 | 139 | def module_name 140 | 'CVS' 141 | end 142 | 143 | def init_session(logger) 144 | @user_agent = @user_agents.sample 145 | headers = { 146 | :Referer => 'https://www.cvs.com/', 147 | :accept => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', 148 | :user_agent => @user_agent, 149 | } 150 | begin 151 | response = RestClient.get('https://www.cvs.com', headers) 152 | @cookies = response.cookies 153 | rescue RestClient::Exception => e 154 | logger.warn "[CVS] Failed to get cookies: #{e} - #{e.response}" 155 | end 156 | end 157 | 158 | def cities_with_appointments(logger) 159 | logger.info "[CVS] Checking status for all cities in #{@state}" 160 | headers = { 161 | :Referer => "https://www.cvs.com/immunizations/covid-19-vaccine?icid=cvs-home-hero1-banner-1-link2-coronavirus-vaccine", 162 | :user_agent => @user_agent, 163 | :cookies => @cookies 164 | } 165 | begin 166 | response = JSON.parse(RestClient.get("#{@state_status_url}&nonce=#{Time.now.to_i}", headers)) 167 | rescue RestClient::Exception => e 168 | logger.error "[CVS] Failed to get state status for #{@state}: #{e}" 169 | return [] 170 | end 171 | if response['responsePayloadData'].nil? || response['responsePayloadData']['data'].nil? || 172 | response['responsePayloadData']['data'][@state].nil? 173 | logger.warn "[CVS] Response for state status missing 'responsePayloadData.data.#{@state}' field: #{response}" 174 | return [] 175 | end 176 | response['responsePayloadData']['data'][@state].filter do |location| 177 | location['status'] == 'Available' 178 | end.map do | location | 179 | location['city'] 180 | end 181 | end 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vaccinetime 2 | 3 | https://twitter.com/vaccinetime/ 4 | 5 | This bot checks Massachusetts vaccine appointment sites every minute and posts 6 | on Twitter when new appointments show up. The bot is written in 7 | [Ruby](https://www.ruby-lang.org/en/). 8 | 9 | ## Integrated appointment websites 10 | 11 | The following websites are currently checked: 12 | 13 | * [maimmunizations](https://www.maimmunizations.org) - every site 14 | * [CVS pharmacies in MA](https://www.cvs.com) 15 | * [Lowell General Hospital](https://www.lowellgeneralvaccine.com) 16 | * [BMC](https://www.bmc.org/covid-19-vaccine-locations) 17 | * [UMass Memorial](https://mychartonline.umassmemorial.org/mychart/openscheduling?specialty=15&hidespecialtysection=1) 18 | * [SBCHC](https://forms.office.com/Pages/ResponsePage.aspx?id=J8HP3h4Z8U-yP8ih3jOCukT-1W6NpnVIp4kp5MOEapVUOTNIUVZLODVSMlNSSVc2RlVMQ1o1RjNFUy4u) 19 | * [Lawrence General Hospital](https://lawrencegeneralcovidvaccine.as.me/schedule.php) 20 | * [Martha's Vineyard Hospital & Nantucket VFW](https://covidvaccine.massgeneralbrigham.org/) 21 | * [Tufts Medical Center Vaccine Site](https://www.tuftsmcvaccine.org) 22 | * [Zocdoc](https://www.zocdoc.com/vaccine/screener?state=MA) 23 | * [Southbridge Community Center](https://www.harringtonhospital.org) 24 | * [Trinity Health of New England](https://www.trinityhealthofne.org) 25 | * [Southcoast Health](https://www.southcoast.org) 26 | * [Northampton](https://www.northamptonma.gov/2219/Vaccine-Clinics) 27 | * [Rutland](https://www.rrecc.us/vaccine) 28 | * [Heywood Healthcare](https://gardnervaccinations.as.me/schedule.php) 29 | * [Vaccinespotter](https://www.vaccinespotter.org/MA/) - Pharmacies other than CVS 30 | * [Pediatric Associates of Greater Salem](https://consumer.scheduling.athena.io/?departmentId=2804-102) 31 | * [Costco](https://www.costco.com/covid-vaccine.html) 32 | * [Hannaford](https://hannafordsched.rxtouch.com/rbssched/program/covid19/Patient) 33 | * [Color](https://home.color.com) - Lawrence General Hospital 34 | * [Amesbury](https://www.amesburyma.gov/home/urgent-alerts/covid-19-vaccine-distribution) 35 | * [Holyoke Health](https://app.blockitnow.com/consumer/covid-holyoke/search?specialtyId=bf21b91b-f6f9-4a78-aca4-dbdedbe23a75&procedureId=468129ce-1d13-4114-92aa-78e2a3b04da5) 36 | 37 | ## Previously scraped websites 38 | 39 | The following websites have moved onto the 40 | [Massachusetts preregistration system](https://www.mass.gov/info-details/preregister-for-a-covid-19-vaccine-appointment), 41 | so their scrapers have been disabled: 42 | 43 | * https://curative.com - DoubleTree Hotel in Danvers, Eastfield Mall in Springfield, and Circuit City in Dartmouth 44 | * https://home.color.com - Natick Mall, Reggie Lewis Center, Gillette Stadium, Fenway Park, Hynes Convention Center 45 | 46 | ## Quick start 47 | 48 | A Dockerfile is provided for easy portability. Use docker-compose to run: 49 | 50 | ```bash 51 | docker-compose up 52 | ``` 53 | 54 | This will run the bot without tweeting, instead sending any activity to a 55 | "FakeTwitter" class that outputs to the terminal. To set up real tweets, see 56 | the configuration section below. 57 | 58 | Logs are stored in files in the `log/` directory, and any errors will terminate 59 | the program by default and print the error trace. Set the environment variable 60 | `ENVIRONMENT=production` to keep running even if an error occurs (errors will 61 | still get logged). 62 | 63 | ### Running locally 64 | 65 | The bot uses [Redis](https://redis.io/) for storage and Chrome or Chromium for 66 | browser automation. If you wish to run the bot locally, install Chrome/Chromium 67 | and Redis, and run redis locally with `redis-server`. Finally install the ruby 68 | dependencies with: 69 | 70 | ```bash 71 | bundle install 72 | bundle exec ruby run.rb 73 | ``` 74 | 75 | You can optionally run a subset of scrapers by providing an option `-s` or 76 | `--scrapers` to the `run.rb` script like this: 77 | 78 | ```bash 79 | bundle exec ruby run.rb -s ma_immunizations,cvs 80 | ``` 81 | 82 | Running locally works fine on MacOS, but hasn't been tested elsewhere. 83 | 84 | ## Configuration 85 | 86 | Configuration is done via environment variables that are passed into docker at 87 | runtime. To enable tweeting, provide the following: 88 | 89 | * TWITTER_ACCESS_TOKEN 90 | * TWITTER_ACCESS_TOKEN_SECRET 91 | * TWITTER_CONSUMER_KEY 92 | * TWITTER_CONSUMER_SECRET 93 | 94 | This bot can also be configured to send slack notifications to a channel by 95 | setting the following environment variables: 96 | 97 | * SLACK_API_TOKEN 98 | * SLACK_CHANNEL 99 | * SLACK_USERNAME 100 | * SLACK_ICON 101 | 102 | Additional configuration can be done with the following: 103 | 104 | * SENTRY_DSN - sets up error handling with [Sentry](https://sentry.io) 105 | * ENVIRONMENT - configures Sentry environment and error handling 106 | * UPDATE_FREQUENCY - number of seconds to wait between updates (default 60) 107 | * SEED_REDIS - set to true to seed redis with the first batch of found 108 | appointments (useful for deploying to fresh redis instances) 109 | 110 | ### Site specific configuration 111 | 112 | All modules allow for site specific configuration by setting environment 113 | variables based on the module name. For example, to set MaImmunizations 114 | configuration, use: 115 | 116 | * MA_IMMUNIZATIONS_TWEET_THRESHOLD 117 | * MA_IMMUNIZATIONS_TWEET_INCREASE_NEEDED 118 | * MA_IMMUNIZATIONS_TWEET_COOLDOWN 119 | 120 | Defaults can be set for non-pharmacy sites using: 121 | 122 | * DEFAULT_TWEET_THRESHOLD 123 | * DEFAULT_TWEET_INCREASE_NEEDED 124 | * DEFAULT_TWEET_COOLDOWN 125 | 126 | Defaults can be set for pharmacies using: 127 | 128 | * PHARMACY_DEFAULT_TWEET_THRESHOLD 129 | * PHARMACY_DEFAULT_TWEET_INCREASE_NEEDED 130 | * PHARMACY_DEFAULT_TWEET_COOLDOWN 131 | 132 | ## Contributing 133 | 134 | See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute. We 135 | expect external contributors to adhere to the 136 | [code of conduct](CODE_OF_CONDUCT.md). 137 | 138 | ## License 139 | 140 | Copyright 2021 Ginkgo Bioworks 141 | 142 | Licensed under the Apache License, Version 2.0 (the "License"); 143 | you may not use this file except in compliance with the License. 144 | You may obtain a copy of the License at 145 | 146 | http://www.apache.org/licenses/LICENSE-2.0 147 | 148 | Unless required by applicable law or agreed to in writing, software 149 | distributed under the License is distributed on an "AS IS" BASIS, 150 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 151 | See the License for the specific language governing permissions and 152 | limitations under the License. 153 | -------------------------------------------------------------------------------- /lib/sites/rxtouch.rb: -------------------------------------------------------------------------------- 1 | require 'rest-client' 2 | require 'nokogiri' 3 | 4 | require_relative '../user_agents' 5 | require_relative '../sentry_helper' 6 | require_relative './pharmacy_clinic' 7 | 8 | module Rxtouch 9 | class Page 10 | def initialize(storage, logger) 11 | @storage = storage 12 | @logger = logger 13 | end 14 | 15 | def clinics 16 | @logger.info "[Rxtouch] Checking #{name}" 17 | cities = Set.new 18 | success, cookies = fetch_cookies 19 | unless success 20 | @logger.info "[Rxtouch] #{cookies}" 21 | return [] 22 | end 23 | 24 | zip_codes.keys.each_with_index do |zip, idx| 25 | result = fetch_zip(zip, cookies) 26 | if result.include?('loggedout') 27 | success, cookies = fetch_cookies 28 | unless success 29 | @logger.info "[Rxtouch] #{cookies}" 30 | break 31 | end 32 | result = fetch_zip(zip, cookies) 33 | end 34 | 35 | next if result.include?('There are no locations with available appointments') 36 | next unless result.empty? 37 | 38 | cities.merge(fetch_facilities(zip, cookies)) 39 | end 40 | 41 | @logger.info "[Rxtouch] Found #{name} appointments in #{cities.join(', ')}" if cities.any? 42 | Clinic.new(@storage, name, cities.to_a, sign_up_url) 43 | end 44 | 45 | def fetch_cookies 46 | cookies = {} 47 | 12.times do 48 | res = RestClient.get(sign_up_url, cookies: cookies, user_agent: UserAgents.random) 49 | cookies = res.cookies 50 | return [true, cookies] unless res.request.url.include?('queue-it.net') 51 | 52 | sleep 5 53 | end 54 | 55 | [false, "Couldn't get through queue"] 56 | end 57 | 58 | def fetch_zip(zip, cookies) 59 | JSON.parse( 60 | RestClient.post( 61 | api_url, 62 | { 63 | zip: zip, 64 | appointmentType: appointment_type, 65 | PatientInterfaceMode: '0', 66 | }, 67 | cookies: cookies 68 | ).body 69 | ) 70 | end 71 | 72 | def fetch_facilities(zip, cookies) 73 | html = Nokogiri::HTML( 74 | RestClient.get( 75 | "#{base_url}/Schedule?zip=#{zip}&appointmentType=#{appointment_type}", 76 | cookies: cookies 77 | ).body 78 | ) 79 | html.search('select#facility option').map do |facility| 80 | city = /Pharmacy #\d+ - ([^-]+) -/.match(facility.text) 81 | city[1] if city 82 | end.compact 83 | end 84 | 85 | def sign_up_url 86 | "#{base_url}/Advisory" 87 | end 88 | end 89 | 90 | class StopAndShop < Page 91 | def name 92 | 'Stop & Shop' 93 | end 94 | 95 | def appointment_type 96 | '5957' 97 | end 98 | 99 | def base_url 100 | 'https://stopandshopsched.rxtouch.com/rbssched/program/covid19/Patient' 101 | end 102 | 103 | def api_url 104 | "#{base_url}/CheckZipCode" 105 | end 106 | 107 | def zip_codes 108 | { 109 | '01913' => 'Amesbury', 110 | '02721' => 'Fall River', 111 | '01030' => 'Feeding Hills', 112 | '02338' => 'Halifax', 113 | '02645' => 'Harwich', 114 | '02601' => 'Hyannis', 115 | '01904' => 'Lynn', 116 | '01247' => 'North Adams', 117 | '02653' => 'Orleans', 118 | '01201' => 'Pittsfield', 119 | '01907' => 'Swampscott', 120 | '01089' => 'West Springfield', 121 | '01801' => 'Woburn', 122 | } 123 | end 124 | end 125 | 126 | class Hannaford < Page 127 | def name 128 | 'Hannaford' 129 | end 130 | 131 | def appointment_type 132 | '5954' 133 | end 134 | 135 | def base_url 136 | 'https://hannafordsched.rxtouch.com/rbssched/program/covid19/Patient' 137 | end 138 | 139 | def api_url 140 | "#{base_url}/CheckZipCode" 141 | end 142 | 143 | def zip_codes 144 | { 145 | '01002' => 'Amherst', 146 | '02189' => 'Weymouth', 147 | } 148 | end 149 | end 150 | 151 | class Clinic < PharmacyClinic 152 | LAST_SEEN_STORAGE_PREFIX = 'rxtouch-last-cities'.freeze 153 | 154 | attr_reader :cities, :link 155 | 156 | def initialize(storage, brand, cities, link) 157 | super(storage) 158 | @brand = brand 159 | @cities = cities 160 | @link = link 161 | end 162 | 163 | def module_name 164 | 'RXTOUCH' 165 | end 166 | 167 | def title 168 | @brand 169 | end 170 | 171 | def appointments 172 | cities.length 173 | end 174 | 175 | def storage_key 176 | "#{LAST_SEEN_STORAGE_PREFIX}:#{@brand}" 177 | end 178 | 179 | def save_appointments 180 | @storage.set(storage_key, cities.to_json) 181 | end 182 | 183 | def last_cities 184 | stored_value = @storage.get(storage_key) 185 | stored_value.nil? ? [] : JSON.parse(stored_value) 186 | end 187 | 188 | def new_cities 189 | cities - last_cities 190 | end 191 | 192 | def new_appointments 193 | new_cities.length 194 | end 195 | 196 | def slack_blocks 197 | { 198 | type: 'section', 199 | text: { 200 | type: 'mrkdwn', 201 | text: "*#{title}*\n*Available appointments in:* #{cities.join(', ')}\n*Link:* #{link}", 202 | }, 203 | } 204 | end 205 | 206 | def twitter_text 207 | tweet_groups = [] 208 | 209 | # 27 chars: " appointments available in " 210 | # 35 chars: ". Check eligibility and sign up at " 211 | # 23 chars: shortened link 212 | # --------- 213 | # 280 chars total, 280 is the maximum 214 | text_limit = 280 - (title.length + 27 + 35 + 23) 215 | 216 | tweet_cities = cities 217 | cities_text = tweet_cities.shift 218 | while (city = tweet_cities.shift) 219 | pending_text = ", #{city}" 220 | if cities_text.length + pending_text.length > text_limit 221 | tweet_groups << cities_text 222 | cities_text = city 223 | else 224 | cities_text += pending_text 225 | end 226 | end 227 | tweet_groups << cities_text 228 | 229 | tweet_groups.map do |group| 230 | "#{title} appointments available in #{group}. Check eligibility and sign up at #{sign_up_page}" 231 | end 232 | end 233 | end 234 | 235 | ALL_SITES = [ 236 | StopAndShop, 237 | Hannaford, 238 | ].freeze 239 | 240 | def self.all_clinics(storage, logger) 241 | ALL_SITES.flat_map do |site_class| 242 | SentryHelper.catch_errors(logger, 'Rxtouch') do 243 | page = site_class.new(storage, logger) 244 | page.clinics 245 | end 246 | end 247 | end 248 | end 249 | -------------------------------------------------------------------------------- /lib/sites/vaccinespotter.rb: -------------------------------------------------------------------------------- 1 | require 'rest-client' 2 | require 'json' 3 | 4 | require_relative '../sentry_helper' 5 | require_relative './pharmacy_clinic' 6 | 7 | module Vaccinespotter 8 | API_URL = 'https://www.vaccinespotter.org/api/v0/states/MA.json'.freeze 9 | IGNORE_BRANDS = ['CVS', 'Costco', 'Hannaford', 'PrepMod'].freeze 10 | 11 | def self.all_clinics(storage, logger) 12 | SentryHelper.catch_errors(logger, 'Vaccinespotter') do 13 | logger.info '[Vaccinespotter] Checking site' 14 | ma_stores.flat_map do |brand, stores| 15 | if stores.all? { |store| store['appointments'].length.positive? } 16 | link = stores.detect { |s| s['url'] }['url'] 17 | group_stores_by_date(stores).map do |date, appts| 18 | logger.info "[Vaccinespotter] Found #{appts.keys.length} #{brand} stores with #{appts.values.sum} appointments on #{date}" 19 | ClinicWithAppointments.new(storage, brand, date, appts, link) 20 | end 21 | else 22 | logger.info "[Vaccinespotter] Found #{stores.length} #{brand} stores with appointments" 23 | Clinic.new(storage, brand, stores) 24 | end 25 | end 26 | end 27 | end 28 | 29 | def self.group_stores_by_date(stores) 30 | stores.each_with_object({}) do |store, h| 31 | store['appointments'].reject { |appt| appt['type']&.include?('2nd Dose Only') }.each do |appt| 32 | date = Date.parse(appt['time'] || appt['date']) 33 | h[date] ||= Hash.new(0) 34 | h[date][store['city']] += 1 35 | end 36 | end 37 | end 38 | 39 | def self.ma_stores 40 | ma_data['features'].each_with_object({}) do |feature, h| 41 | properties = feature['properties'] 42 | next unless properties 43 | 44 | brand = get_brand(properties['provider_brand_name']) 45 | appointments_available = properties['appointments_available_all_doses'] 46 | next unless brand && appointments_available && !IGNORE_BRANDS.include?(brand) 47 | 48 | h[brand] ||= [] 49 | h[brand] << properties 50 | end 51 | end 52 | 53 | def self.get_brand(brand_name) 54 | if ['Price Chopper', 'Market 32'].include?(brand_name) 55 | 'Price Chopper/Market 32' 56 | else 57 | brand_name 58 | end 59 | end 60 | 61 | def self.ma_data 62 | JSON.parse(RestClient.get(API_URL).body) 63 | end 64 | 65 | class Clinic < PharmacyClinic 66 | LAST_SEEN_STORAGE_PREFIX = 'vaccinespotter-last-cities'.freeze 67 | 68 | def initialize(storage, brand, stores) 69 | super(storage) 70 | @brand = brand 71 | @stores = stores 72 | end 73 | 74 | def module_name 75 | @brand.gsub(' ', '_').gsub('/', '_').upcase 76 | end 77 | 78 | def cities 79 | @stores.map { |store| store['city'] }.compact.uniq.sort 80 | end 81 | 82 | def title 83 | @brand 84 | end 85 | 86 | def appointments 87 | cities.length 88 | end 89 | 90 | def storage_key 91 | "#{LAST_SEEN_STORAGE_PREFIX}:#{@brand}" 92 | end 93 | 94 | def save_appointments 95 | @storage.set(storage_key, cities.to_json) 96 | end 97 | 98 | def last_cities 99 | stored_value = @storage.get(storage_key) 100 | stored_value.nil? ? [] : JSON.parse(stored_value) 101 | end 102 | 103 | def new_cities 104 | cities - last_cities 105 | end 106 | 107 | def new_appointments 108 | new_cities.length 109 | end 110 | 111 | def link 112 | @stores.detect { |s| s['url'] }['url'] 113 | end 114 | 115 | def slack_blocks 116 | { 117 | type: 'section', 118 | text: { 119 | type: 'mrkdwn', 120 | text: "*#{title}*\n*Available appointments in:* #{cities.join(', ')}\n*Link:* #{link}", 121 | }, 122 | } 123 | end 124 | 125 | def twitter_text 126 | tweet_groups = [] 127 | 128 | # 27 chars: " appointments available in " 129 | # 35 chars: ". Check eligibility and sign up at " 130 | # 23 chars: shortened link 131 | # --------- 132 | # 280 chars total, 280 is the maximum 133 | text_limit = 280 - (title.length + 27 + 35 + 23) 134 | 135 | tweet_cities = cities 136 | cities_text = tweet_cities.shift 137 | while (city = tweet_cities.shift) 138 | pending_text = ", #{city}" 139 | if cities_text.length + pending_text.length > text_limit 140 | tweet_groups << cities_text 141 | cities_text = city 142 | else 143 | cities_text += pending_text 144 | end 145 | end 146 | tweet_groups << cities_text 147 | 148 | tweet_groups.map do |group| 149 | "#{title} appointments available in #{group}. Check eligibility and sign up at #{sign_up_page}" 150 | end 151 | end 152 | end 153 | 154 | class ClinicWithAppointments < BaseClinic 155 | attr_reader :date, :link 156 | 157 | def initialize(storage, brand, date, appts, link) 158 | super(storage) 159 | @brand = brand 160 | @date = date 161 | @appts_by_store = appts 162 | @link = link 163 | end 164 | 165 | def module_name 166 | @brand.gsub(' ', '_').upcase 167 | end 168 | 169 | def title 170 | "#{@brand} on #{@date}" 171 | end 172 | 173 | def cities 174 | @appts_by_store.keys.sort 175 | end 176 | 177 | def appointments 178 | @appts_by_store.values.sum 179 | end 180 | 181 | def slack_blocks 182 | { 183 | type: 'section', 184 | text: { 185 | type: 'mrkdwn', 186 | text: "*#{title}*\n*Available appointments in:* #{cities.join(', ')}\n*Number of appointments:* #{render_slack_appointments}\n*Link:* #{link}", 187 | }, 188 | } 189 | end 190 | 191 | def twitter_text 192 | date_text = @date.strftime('%-m/%d') 193 | tweet_groups = [] 194 | 195 | # x chars: NUM_APPOINTMENTS 196 | # 27 chars: " appointments available at " 197 | # y chars: BRAND 198 | # 4 chars: " on " 199 | # z chars: DATE 200 | # 4 chars: " in " 201 | # w chars: STORES 202 | # 35 chars: ". Check eligibility and sign up at " 203 | # 23 chars: shortened link 204 | # --------- 205 | # 280 chars total, 280 is the maximum 206 | text_limit = 280 - (27 + @brand.length + 4 + date_text.length + 4 + 35 + 23) 207 | 208 | tweet_stores = cities.dup 209 | first_city = tweet_stores.shift 210 | cities_text = first_city 211 | group_appointments = @appts_by_store[first_city] 212 | 213 | while (city = tweet_stores.shift) 214 | pending_appts = group_appointments + @appts_by_store[city] 215 | pending_text = ", #{city}" 216 | if pending_appts.to_s.length + cities_text.length + pending_text.length > text_limit 217 | tweet_groups << { cities_text: cities_text, group_appointments: group_appointments } 218 | cities_text = city 219 | group_appointments = @appts_by_store[city] 220 | else 221 | cities_text += pending_text 222 | group_appointments = pending_appts 223 | end 224 | end 225 | tweet_groups << { cities_text: cities_text, group_appointments: group_appointments } 226 | 227 | tweet_groups.map do |group| 228 | "#{group[:group_appointments]} appointments available at #{@brand} on #{date_text} in #{group[:cities_text]}. Check eligibility and sign up at #{sign_up_page}" 229 | end 230 | end 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /lib/sites/costco.rb: -------------------------------------------------------------------------------- 1 | require 'rest-client' 2 | require 'json' 3 | 4 | require_relative '../sentry_helper' 5 | require_relative './base_clinic' 6 | 7 | module Costco 8 | BASE_API_URL = 'https://book-costcopharmacy.appointment-plus.com'.freeze 9 | BOOKING_ID = 'd133yng2'.freeze 10 | PREFERENCES_URL = "#{BASE_API_URL}/get-preferences".freeze 11 | LOCATION_URL = "#{BASE_API_URL}/book-appointment/get-clients".freeze 12 | EMPLOYEE_URL = "#{BASE_API_URL}/book-appointment/get-employees".freeze 13 | SERVICES_URL = "#{BASE_API_URL}/book-appointment/get-services".freeze 14 | APPOINTMENT_URL = "#{BASE_API_URL}/book-appointment/get-grid-hours".freeze 15 | 16 | def self.all_clinics(storage, logger) 17 | logger.info '[Costco] Checking site' 18 | 19 | SentryHelper.catch_errors(logger, 'Costco') do 20 | appointments = get_locations['clientObjects'].filter do |client| 21 | client['displayToCustomer'] == true 22 | end.map do |client| 23 | { 24 | location: client['locationName'].gsub('Costco', '').strip, 25 | appointments: get_city_appointments(client['id'], client['clientMasterId']), 26 | } 27 | end.filter do |client| 28 | client[:appointments].positive? 29 | end 30 | 31 | logger.info "[Costco] Found #{appointments.map { |a| a[:appointments] }.sum} appointments in #{appointments.map { |a| a[:location] }.join(', ')}" if appointments.any? 32 | [Clinic.new(storage, appointments)] 33 | end 34 | end 35 | 36 | def self.get_city_appointments(client_id, client_master_id) 37 | employees = get_employees(client_id, client_master_id) 38 | return 0 unless employees['employeeObjects'].any? 39 | 40 | employee_id = employees['employeeObjects'][0]['id'] 41 | services = get_services(client_id, client_master_id, employee_id) 42 | return 0 unless services.any? 43 | 44 | appointments = get_appointments(client_master_id, employee_id, services) 45 | appointments['data']['gridHours'].map do |_date, obj| 46 | obj['timeSlots']['numberOfSpots'].zip(obj['timeSlots']['numberOfSpotsTaken']).map { |a, b| a - b }.sum 47 | end.sum 48 | end 49 | 50 | def self.get_master_id 51 | JSON.parse( 52 | RestClient.get( 53 | PREFERENCES_URL, 54 | params: { 55 | clientMasterId: '', 56 | clientId: '', 57 | bookingId: BOOKING_ID, 58 | '_' => Time.now.to_i, 59 | } 60 | ).body 61 | )['data']['clientmasterId'] 62 | end 63 | 64 | def self.get_locations 65 | JSON.parse( 66 | RestClient.get( 67 | LOCATION_URL, 68 | params: { 69 | clientMasterId: get_master_id, 70 | pageNumber: 1, 71 | itemsPerPage: 10, 72 | keyword: '01545', 73 | clientId: '', 74 | employeeId: '', 75 | 'centerCoordinates[id]' => 528587, 76 | 'centerCoordinates[latitude]' => 42.283459, 77 | 'centerCoordinates[longitude]' => -71.726662, 78 | 'centerCoordinates[accuracy]' => '', 79 | 'centerCoordinates[whenAdded]' => '2021-04-10 11:09:11', 80 | 'centerCoordinates[searchQuery]' => '01545', 81 | radiusInKilometers: 100, 82 | '_' => Time.now.to_i 83 | } 84 | ).body 85 | ) 86 | end 87 | 88 | def self.get_employees(client_id, client_master_id) 89 | JSON.parse( 90 | RestClient.get( 91 | EMPLOYEE_URL, 92 | params: { 93 | clientMasterId: client_master_id, 94 | clientId: client_id, 95 | pageNumber: 1, 96 | itemsPerPage: 10, 97 | keyword: '', 98 | employeeObjects: '', 99 | '_' => Time.now.to_i 100 | } 101 | ).body 102 | ) 103 | end 104 | 105 | def self.get_services(client_id, client_master_id, employee_id) 106 | JSON.parse( 107 | RestClient.get( 108 | "#{SERVICES_URL}/#{employee_id}", 109 | params: { 110 | clientMasterId: client_master_id, 111 | clientId: client_id, 112 | pageNumber: 1, 113 | itemsPerPage: 10, 114 | keyword: '', 115 | serviceId: '', 116 | '_' => Time.now.to_i, 117 | } 118 | ).body 119 | )['serviceObjects'].map { |service| service['id'] } 120 | end 121 | 122 | def self.get_appointments(client_master_id, employee_id, services) 123 | JSON.parse( 124 | RestClient.get( 125 | APPOINTMENT_URL, 126 | params: { 127 | startTimestamp: Time.now.strftime('%Y-%m-%d %H:%M:%S'), 128 | endTimestamp: (Date.today + 28).strftime('%Y-%m-%d %H:%M:%S'), 129 | limitNumberOfDaysWithOpenSlots: 5, 130 | employeeId: employee_id, 131 | services: services, 132 | numberOfSpotsNeeded: 1, 133 | isStoreHours: true, 134 | clientMasterId: client_master_id, 135 | toTimeZone: false, 136 | fromTimeZone: 149, 137 | '_' => Time.now.to_i 138 | } 139 | ).body 140 | ) 141 | end 142 | 143 | class Clinic < BaseClinic 144 | def initialize(storage, appointment_data) 145 | super(storage) 146 | @appointment_data = appointment_data.sort_by { |client| client[:location] } 147 | end 148 | 149 | def module_name 150 | 'COSTCO' 151 | end 152 | 153 | def title 154 | 'Costco' 155 | end 156 | 157 | def link 158 | 'https://www.costco.com/covid-vaccine.html' 159 | end 160 | 161 | def appointments 162 | @appointment_data.map { |a| a[:appointments] }.sum 163 | end 164 | 165 | def cities 166 | @appointment_data.map { |a| a[:location] } 167 | end 168 | 169 | def slack_blocks 170 | { 171 | type: 'section', 172 | text: { 173 | type: 'mrkdwn', 174 | text: "*#{title}*\n*Available appointments in:* #{cities.join(', ')}\n*Number of appointments:* #{appointments}\n*Link:* #{link}", 175 | }, 176 | } 177 | end 178 | 179 | def twitter_text 180 | tweet_groups = [] 181 | 182 | # x chars: NUM_APPOINTMENTS 183 | # 1 chars: " " 184 | # y chars: BRAND 185 | # 27 chars: " appointments available in " 186 | # z chars: STORES 187 | # 35 chars: ". Check eligibility and sign up at " 188 | # 23 chars: shortened link 189 | # --------- 190 | # 280 chars total, 280 is the maximum 191 | text_limit = 280 - (1 + title.length + 27 + 35 + 23) 192 | 193 | tweet_stores = @appointment_data.dup 194 | first_store = tweet_stores.shift 195 | cities_text = first_store[:location] 196 | group_appointments = first_store[:appointments] 197 | 198 | while (store = tweet_stores.shift) 199 | pending_appts = group_appointments + store[:appointments] 200 | pending_text = ", #{store[:location]}" 201 | if pending_appts.to_s.length + cities_text.length + pending_text.length > text_limit 202 | tweet_groups << { cities_text: cities_text, group_appointments: group_appointments } 203 | cities_text = store[:location] 204 | group_appointments = store[:appointments] 205 | else 206 | cities_text += pending_text 207 | group_appointments = pending_appts 208 | end 209 | end 210 | tweet_groups << { cities_text: cities_text, group_appointments: group_appointments } 211 | 212 | tweet_groups.map do |group| 213 | "#{group[:group_appointments]} #{title} appointments available in #{group[:cities_text]}. Check eligibility and sign up at #{sign_up_page}" 214 | end 215 | end 216 | end 217 | end 218 | -------------------------------------------------------------------------------- /lib/sites/ma_immunizations.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'nokogiri' 3 | require 'open-uri' 4 | require 'rest-client' 5 | require 'sentry-ruby' 6 | 7 | require_relative '../sentry_helper' 8 | require_relative './base_clinic' 9 | 10 | module MaImmunizations 11 | BASE_URL = "https://clinics.maimmunizations.org/appointment/en/clinic/search?q[services_name_in][]=Vaccination".freeze 12 | 13 | def self.all_clinics(storage, logger) 14 | unconsolidated_clinics(storage, logger).each_with_object({}) do |clinic, h| 15 | if h[clinic.title] 16 | h[clinic.title].appointments += clinic.appointments 17 | h[clinic.title].vaccines.merge(clinic.vaccines) if clinic.appointments.positive? 18 | else 19 | h[clinic.title] = clinic 20 | end 21 | end.values 22 | end 23 | 24 | def self.unconsolidated_clinics(storage, logger) 25 | page_num = 1 26 | clinics = [] 27 | SentryHelper.catch_errors(logger, 'MaImmunizations', on_error: clinics) do 28 | loop do 29 | raise "Too many pages: #{page_num}" if page_num > 100 30 | 31 | logger.info "[MaImmunizations] Checking page #{page_num}" 32 | page = Page.new(page_num, storage, logger) 33 | page.fetch 34 | return clinics if page.waiting_page 35 | 36 | clinics += page.clinics 37 | return clinics if page.final_page? 38 | 39 | page_num += 1 40 | sleep(1) 41 | end 42 | end 43 | clinics 44 | end 45 | 46 | class Page 47 | CLINIC_PAGE_IDENTIFIER = /Find a Vaccination Clinic/.freeze 48 | COOKIE_SITE = 'ma-immunization'.freeze 49 | 50 | attr_reader :waiting_page 51 | 52 | def initialize(page, storage, logger) 53 | @page = page 54 | @storage = storage 55 | @logger = logger 56 | @waiting_page = true 57 | end 58 | 59 | def fetch 60 | cookies = get_cookies 61 | response = RestClient.get(BASE_URL + "&page=#{@page}", cookies: cookies).body 62 | 63 | if CLINIC_PAGE_IDENTIFIER !~ response 64 | @logger.info '[MaImmunizations] Got waiting page' 65 | 12.times do 66 | response = RestClient.get(BASE_URL + "&page=#{@page}", cookies: cookies).body 67 | break if CLINIC_PAGE_IDENTIFIER =~ response 68 | 69 | sleep(5) 70 | end 71 | end 72 | 73 | if CLINIC_PAGE_IDENTIFIER =~ response 74 | @logger.info '[MaImmunizations] Made it through waiting page' 75 | @waiting_page = false 76 | else 77 | minutes_left = /(\d+) minute/.match(response) 78 | if minutes_left 79 | @logger.info "[MaImmunizations] Waited too long, estimate left: #{minutes_left[1]}" 80 | else 81 | @logger.info '[MaImmunizations] Waited too long, no estimate found' 82 | end 83 | end 84 | 85 | @doc = Nokogiri::HTML(response) 86 | end 87 | 88 | def get_cookies 89 | existing_cookies = @storage.get_cookies(COOKIE_SITE) || {} 90 | cookies = existing_cookies['cookies'] 91 | if cookies 92 | cookie_expiration = Time.parse(existing_cookies['expiration']) 93 | # use existing cookies unless they're expired 94 | if cookie_expiration > Time.now 95 | @logger.info '[MaImmunizations] Using existing cookies' 96 | return cookies 97 | end 98 | end 99 | 100 | @logger.info '[MaImmunizations] Getting new cookies' 101 | response = RestClient.get(BASE_URL, cookies: cookies) 102 | new_cookies = response.cookies 103 | cookie_expiration = response.cookie_jar.map(&:expires_at).compact.min 104 | @storage.save_cookies(COOKIE_SITE, new_cookies, cookie_expiration) 105 | new_cookies 106 | end 107 | 108 | def final_page? 109 | @doc.search('.page.next').empty? || @doc.search('.page.next.disabled').any? 110 | end 111 | 112 | def clinics 113 | container = @doc.search('#maincontent > div')[0] 114 | 115 | unless container 116 | @logger.warn "[MaImmunizations] Couldn't find main page container!" 117 | return [] 118 | end 119 | 120 | results = container.search('> div.justify-between').map do |group| 121 | Clinic.new(group, @storage) 122 | end.filter do |clinic| 123 | clinic.valid? 124 | end 125 | 126 | unless results.any? 127 | Sentry.capture_message("[MaImmunizations] Couldn't find any clinics!") 128 | @logger.warn "[MaImmunizations] Couldn't find any clinics!" 129 | end 130 | 131 | results.filter do |clinic| 132 | clinic.appointments.positive? 133 | end.each do |clinic| 134 | @logger.info "[MaImmunizations] Site #{clinic.title}: found #{clinic.appointments} appointments (link: #{!clinic.link.nil?})" 135 | end 136 | 137 | results 138 | end 139 | end 140 | 141 | class Clinic < BaseClinic 142 | TITLE_MATCHER = %r[^(.+) on (\d{2}/\d{2}/\d{4})$].freeze 143 | 144 | attr_accessor :appointments, :vaccines 145 | 146 | def initialize(group, storage) 147 | super(storage) 148 | @group = group 149 | @paragraphs = group.search('p') 150 | @parsed_info = @paragraphs[2..].each_with_object({}) do |p, h| 151 | match = /^([\w\d\s]+):\s+(.+)$/.match(p.content) 152 | next unless match 153 | 154 | h[match[1].strip] = match[2].strip 155 | end 156 | @appointments = @parsed_info['Available Appointments'].to_i 157 | @vaccines = Set.new 158 | if @appointments.positive? && @parsed_info['Vaccinations offered'] 159 | @vaccines.add(@parsed_info['Vaccinations offered']) 160 | end 161 | end 162 | 163 | def module_name 164 | 'MA_IMMUNIZATIONS' 165 | end 166 | 167 | def valid? 168 | @parsed_info.key?('Available Appointments') 169 | end 170 | 171 | def to_s 172 | "Clinic: #{title}" 173 | end 174 | 175 | def title 176 | @paragraphs[0].content.strip 177 | end 178 | 179 | def address 180 | @paragraphs[1].content.strip 181 | end 182 | 183 | def city 184 | match = address.match(/^.*, ([\w\d\s]+) (MA|Massachusetts),/i) 185 | return nil unless match 186 | 187 | match[1] 188 | end 189 | 190 | def vaccine 191 | @parsed_info['Vaccinations offered'] 192 | end 193 | 194 | def age_groups 195 | @parsed_info['Age groups served'] 196 | end 197 | 198 | def additional_info 199 | @parsed_info['Additional Information'] 200 | end 201 | 202 | def link 203 | a_tag = @paragraphs.last.search('a') 204 | return nil unless a_tag.any? 205 | 206 | 'https://www.maimmunizations.org' + a_tag[0]['href'] 207 | end 208 | 209 | def name 210 | match = TITLE_MATCHER.match(title) 211 | match[1].strip 212 | end 213 | 214 | def date 215 | match = TITLE_MATCHER.match(title) 216 | match[2] 217 | end 218 | 219 | def slack_blocks 220 | { 221 | type: 'section', 222 | text: { 223 | type: 'mrkdwn', 224 | text: "*#{title}*\n*Address:* #{address}\n*Vaccine:* #{vaccines.join(', ')}\n*Age groups*: #{age_groups}\n*Available appointments:* #{render_slack_appointments}\n*Additional info:* #{additional_info}\n*Link:* #{link}", 225 | }, 226 | } 227 | end 228 | 229 | def twitter_text 230 | txt = "#{appointments} appointments available at #{name}" 231 | txt += " in #{city}, MA" if city 232 | txt += " on #{date}" 233 | txt += " for #{vaccines.join(', ')}" if vaccines.any? 234 | txt + ". Check eligibility and sign up at #{sign_up_page}" 235 | end 236 | 237 | def sign_up_page 238 | addr = 'https://www.maimmunizations.org/appointment/en/clinic/search?' 239 | addr += "q[venue_search_name_or_venue_name_i_cont]=#{name}&" if name 240 | URI.parse(addr) 241 | end 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /lib/sites/color.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'json' 3 | require 'rest-client' 4 | require 'nokogiri' 5 | 6 | require_relative '../sentry_helper' 7 | require_relative './base_clinic' 8 | 9 | module Color 10 | BASE_URL = 'https://home.color.com/api/v1'.freeze 11 | TOKEN_URL = "#{BASE_URL}/get_onsite_claim".freeze 12 | APPOINTMENT_URL = "#{BASE_URL}/vaccination_appointments/availability".freeze 13 | 14 | class Page 15 | def initialize(storage, logger) 16 | @storage = storage 17 | @logger = logger 18 | end 19 | 20 | def get_appointments(token, name) 21 | JSON.parse( 22 | RestClient.get( 23 | APPOINTMENT_URL, 24 | params: { 25 | claim_token: token, 26 | collection_site: name, 27 | } 28 | ) 29 | ) 30 | end 31 | 32 | def get_token_response 33 | JSON.parse( 34 | RestClient.get( 35 | TOKEN_URL, 36 | params: { 37 | partner: site_id, 38 | } 39 | ) 40 | ) 41 | end 42 | 43 | def appointments_by_date(token, name) 44 | json = get_appointments(token, name) 45 | json['results'].each_with_object(Hash.new(0)) do |window, h| 46 | date = DateTime.parse(window['start']) 47 | h["#{date.month}/#{date.day}/#{date.year}"] += window['remaining_spaces'] 48 | end 49 | end 50 | 51 | def clinics 52 | @logger.info "[Color] Checking site #{site_id}" 53 | token_response = get_token_response 54 | token = token_response['token'] 55 | 56 | token_response['population_settings']['collection_sites'].flat_map do |site_info| 57 | appointments_by_date(token, site_info['name']).map do |date, appointments| 58 | @logger.info "[Color] Site #{site_info['name']} on #{date}: found #{appointments} appointments" if appointments.positive? 59 | Clinic.new(@storage, site_info, date, appointments, link) 60 | end 61 | end 62 | end 63 | 64 | def link 65 | "https://home.color.com/vaccine/register/#{site_id}" 66 | end 67 | end 68 | 69 | class Gillette < Page 70 | def site_id 71 | 'gillettestadium' 72 | end 73 | end 74 | 75 | class Hynes < Page 76 | def site_id 77 | 'fenway-hynes' 78 | end 79 | end 80 | 81 | class ReggieLewis < Page 82 | def site_id 83 | 'reggielewis' 84 | end 85 | end 86 | 87 | class LawrenceGeneral < Page 88 | def site_id 89 | 'lawrencegeneral' 90 | end 91 | end 92 | 93 | class WestSpringfield < Page 94 | def site_id 95 | 'westspringfield' 96 | end 97 | end 98 | 99 | class MetroNorth < Page 100 | def site_id 101 | 'metronorth' 102 | end 103 | end 104 | 105 | class CicCommunity 106 | class CommunityPage < Page 107 | attr_reader :link, :site_id, :site_name, :calendar 108 | 109 | def initialize(storage, logger, link, site_name, site_id, calendar) 110 | super(storage, logger) 111 | @link = link 112 | @site_name = site_name 113 | @site_id = site_id 114 | @calendar = calendar 115 | end 116 | 117 | def get_appointments(token, name) 118 | JSON.parse( 119 | RestClient.get( 120 | APPOINTMENT_URL, 121 | params: { 122 | claim_token: token, 123 | collection_site: name, 124 | calendar: calendar, 125 | } 126 | ) 127 | ) 128 | end 129 | 130 | def clinics 131 | @logger.info "[Color] Checking site #{site_name}" 132 | token_response = get_token_response 133 | token = token_response['token'] 134 | 135 | token_response['population_settings']['collection_sites'].flat_map do |site_info| 136 | appointments_by_date(token, site_info['name']).map do |date, appointments| 137 | @logger.info "[Color] Site #{site_name} on #{date}: found #{appointments} appointments" if appointments.positive? 138 | Clinic.new(@storage, site_info, date, appointments, link, site_name: site_name, module_name: 'COLOR_COMMUNITY') 139 | end 140 | end 141 | end 142 | end 143 | 144 | def initialize(storage, logger) 145 | @storage = storage 146 | @logger = logger 147 | end 148 | 149 | def clinics 150 | html = Nokogiri::HTML(RestClient.get('https://www.cic-health.com/popups').body) 151 | html.search('select.location-select option').flat_map do |option| 152 | next if option['value'] == '-1' 153 | 154 | sleep 1 155 | begin 156 | site_name = option.text.strip 157 | page_url = option['value'] 158 | page = RestClient.get(page_url).body 159 | match = %r{"https://home\.color\.com/vaccine/register/([\w_-]+)\?calendar=([\d\w-]+)"}.match(page) 160 | next unless match 161 | 162 | CommunityPage.new(@storage, @logger, page_url, site_name, match[1], match[2]).clinics 163 | rescue RestClient::TooManyRequests 164 | @logger.warn("[Color] Too many requests for #{option['value']}") 165 | sleep 1 166 | nil 167 | end 168 | end.compact 169 | end 170 | end 171 | 172 | class Northampton < Page 173 | def site_id 174 | 'northampton' 175 | end 176 | 177 | def get_calendar 178 | res = RestClient.get('https://northamptonma.gov/2219/Vaccine-Clinics').body 179 | %r{"https://home\.color\.com/vaccine/register/northampton\?calendar=([\d\w-]+)"}.match(res)[1] 180 | end 181 | 182 | def get_appointments(token, name) 183 | calendar = get_calendar 184 | unless calendar 185 | @logger.warn '[Color] No Northampton calendar ID found' 186 | return { 'results' => [] } 187 | end 188 | 189 | JSON.parse( 190 | RestClient.get( 191 | APPOINTMENT_URL, 192 | params: { 193 | claim_token: token, 194 | collection_site: name, 195 | calendar: calendar, 196 | } 197 | ) 198 | ) 199 | end 200 | 201 | def link 202 | 'https://northamptonma.gov/2219/Vaccine-Clinics' 203 | end 204 | end 205 | 206 | SITES = [ 207 | Gillette, 208 | Hynes, 209 | ReggieLewis, 210 | CicCommunity, 211 | LawrenceGeneral, 212 | Northampton, 213 | WestSpringfield, 214 | MetroNorth, 215 | ].freeze 216 | 217 | def self.all_clinics(storage, logger) 218 | SITES.flat_map do |page_class| 219 | sleep(1) 220 | SentryHelper.catch_errors(logger, 'Color') do 221 | page = page_class.new(storage, logger) 222 | page.clinics 223 | end 224 | end 225 | end 226 | 227 | class Clinic < BaseClinic 228 | attr_reader :appointments, :date, :link, :module_name 229 | 230 | def initialize(storage, site_info, date, appointments, link, site_name: nil, module_name: 'COLOR') 231 | super(storage) 232 | @site_info = site_info 233 | @date = date 234 | @appointments = appointments 235 | @link = link 236 | @site_name = site_name 237 | @module_name = module_name 238 | end 239 | 240 | def name 241 | @site_name || @site_info['name'] 242 | end 243 | 244 | def title 245 | "#{name} on #{date}" 246 | end 247 | 248 | def city 249 | @site_info['address']['city'] 250 | end 251 | 252 | def address 253 | addr = @site_info['address']['line1'] 254 | addr += @site_info['address']['line2'] unless @site_info['address']['line2'].empty? 255 | addr + ", #{@site_info['address']['city']} #{@site_info['address']['state']} #{@site_info['address']['postal_code']}" 256 | end 257 | 258 | def slack_blocks 259 | { 260 | type: 'section', 261 | text: { 262 | type: 'mrkdwn', 263 | text: "*#{title}*\n*Address:* #{address}\n*Available appointments:* #{render_slack_appointments}\n*Link:* #{link}", 264 | }, 265 | } 266 | end 267 | 268 | def twitter_text 269 | "#{appointments} appointments available at #{name} in #{city}, MA on #{date}. Check eligibility and sign up at #{sign_up_page}" 270 | end 271 | end 272 | end 273 | -------------------------------------------------------------------------------- /lib/sites/walgreens.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'date' 3 | require 'rest-client' 4 | 5 | require_relative '../sentry_helper' 6 | require_relative '../browser' 7 | require_relative './base_clinic' 8 | 9 | module Walgreens 10 | COOKIE_STORAGE = 'walgreens-cookies' 11 | ACCOUNT_URL = 'https://www.walgreens.com/youraccount/default.jsp'.freeze 12 | SIGN_UP_URL = 'https://www.walgreens.com/findcare/vaccination/covid-19/'.freeze 13 | LOGIN_URL = 'https://www.walgreens.com/login.jsp'.freeze 14 | API_URL = 'https://www.walgreens.com/hcschedulersvc/svc/v2/immunizationLocations/timeslots'.freeze 15 | USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36'.freeze 16 | 17 | # https://www.mapdevelopers.com/draw-circle-tool.php?circles=%5B%5B40233.5%2C42.3520885%2C-71.4868472%2C%22%23AAAAAA%22%2C%22%23000000%22%2C0.4%5D%2C%5B40233.5%2C41.8628846%2C-70.9477294%2C%22%23AAAAAA%22%2C%22%23000000%22%2C0.4%5D%2C%5B40233.5%2C41.7814149%2C-70.3310229%2C%22%23AAAAAA%22%2C%22%23000000%22%2C0.4%5D%2C%5B40233.5%2C42.3744147%2C-72.5757593%2C%22%23AAAAAA%22%2C%22%23000000%22%2C0.4%5D%2C%5B40233.5%2C42.36567%2C-72.0101209%2C%22%23AAAAAA%22%2C%22%23000000%22%2C0.4%5D%2C%5B40233.5%2C42.3857057%2C-73.1035201%2C%22%23AAAAAA%22%2C%22%23000000%22%2C0.4%5D%2C%5B40233.5%2C42.5305705%2C-70.8835315%2C%22%23AAAAAA%22%2C%22%23000000%22%2C0.4%5D%5D 18 | LOCATIONS = [ 19 | { latitude: 42.385706, longitude: -73.103520 }, 20 | { latitude: 42.374415, longitude: -72.575759 }, 21 | { latitude: 42.365670, longitude: -72.010121 }, 22 | { latitude: 42.352089, longitude: -71.486847 }, 23 | { latitude: 42.530571, longitude: -70.883532 }, 24 | { latitude: 41.862885, longitude: -70.947729 }, 25 | { latitude: 41.781415, longitude: -70.331023 }, 26 | ].freeze 27 | 28 | def self.all_clinics(storage, logger) 29 | SentryHelper.catch_errors(logger, 'Walgreens') do 30 | logger.info '[Walgreens] Checking site' 31 | cookies = stored_cookies(storage).each_with_object({}) { |c, h| h[c[:name]] = c[:value] } 32 | cookies = refresh_cookies(storage, logger) if cookies.empty? 33 | cities = LOCATIONS.flat_map do |loc| 34 | retried = false 35 | random_wait 2 36 | begin 37 | fetch_stores(cookies, loc[:latitude], loc[:longitude]) 38 | rescue RestClient::Unauthorized => e 39 | cookies = refresh_cookies(storage, logger) 40 | raise e if retried 41 | 42 | retried = true 43 | retry 44 | end 45 | end.uniq 46 | logger.info "[Walgreens] Found appointments in #{cities.join(', ')}" if cities.any? 47 | [Clinic.new(storage, cities)] 48 | end 49 | end 50 | 51 | def self.fetch_stores(cookies, lat, lon) 52 | res = JSON.parse( 53 | RestClient.post( 54 | API_URL, 55 | { 56 | appointmentAvailability: { 57 | startDateTime: (Date.today + 1).strftime('%Y-%m-%d'), 58 | }, 59 | position: { 60 | latitude: lat, 61 | longitude: lon, 62 | }, 63 | radius: 25, 64 | serviceId: '99', 65 | size: 25, 66 | state: 'MA', 67 | vaccine: { 68 | productId: '', 69 | }, 70 | }.to_json, 71 | content_type: :json, 72 | accept: :json, 73 | user_agent: USER_AGENT, 74 | cookies: cookies, 75 | host: 'www.walgreens.com', 76 | referer: 'https://www.walgreens.com/findcare/vaccination/covid-19/appointment/next-available' 77 | ).body 78 | ) 79 | return [] if res['errors']&.any? 80 | 81 | res['locations'].filter do |location| 82 | location['address']['state'] == 'MA' 83 | end.map do |location| 84 | location['address']['city'] 85 | end 86 | rescue RestClient::NotFound 87 | [] 88 | end 89 | 90 | def self.stored_cookies(storage) 91 | JSON.parse(storage.get(COOKIE_STORAGE) || '[]', symbolize_names: true) 92 | end 93 | 94 | def self.refresh_cookies(storage, logger) 95 | logger.info '[Walgreens] Refreshing cookies' 96 | Browser.run do |browser| 97 | #stored_cookies(storage).each do |cookie| 98 | #browser.cookies.set(**cookie) 99 | #end 100 | 101 | browser.goto(ACCOUNT_URL) 102 | sleep 2 103 | browser.network.wait_for_idle 104 | 105 | if browser.current_url.start_with?(LOGIN_URL) 106 | username = Browser.wait_for(browser, 'input#user_name') 107 | username.focus.type(ENV['WALGREENS_USERNAME']) 108 | password = Browser.wait_for(browser, 'input#user_password') 109 | password.focus.type(ENV['WALGREENS_PASSWORD']) 110 | 111 | random_wait(2) 112 | browser.at_css('button#submit_btn').click 113 | 114 | sleep 2 115 | browser.network.wait_for_idle 116 | unless browser.current_url == 'https://www.walgreens.com/youraccount/default.jsp' 117 | security_q = Browser.wait_for(browser, 'input#radio-security') 118 | if security_q 119 | security_q.click 120 | random_wait(2) 121 | browser.at_css('button#optionContinue').click 122 | browser.network.wait_for_idle 123 | 124 | question = Browser.wait_for(browser, 'input#secQues') 125 | question.focus.type(ENV['WALGREENS_SECURITY_ANSWER']) 126 | random_wait(2) 127 | browser.at_css('button#validate_security_answer').click 128 | browser.network.wait_for_idle 129 | end 130 | end 131 | end 132 | 133 | storage.set( 134 | COOKIE_STORAGE, 135 | browser.cookies.all.values.map do |cookie| 136 | { 137 | name: cookie.name, 138 | value: cookie.value, 139 | domain: cookie.domain, 140 | } 141 | end.to_json 142 | ) 143 | 144 | browser.cookies.all.values.each_with_object({}) { |c, h| h[c.name] = c.value } 145 | end 146 | end 147 | 148 | def self.random_wait(base_wait) 149 | sleep(base_wait + ((-10..10).to_a.sample.to_f / 10)) 150 | end 151 | 152 | class Clinic < BaseClinic 153 | TWEET_THRESHOLD = ENV['PHARMACY_TWEET_THRESHOLD']&.to_i || BaseClinic::PHARMACY_TWEET_THRESHOLD 154 | TWEET_INCREASE_NEEDED = ENV['PHARMACY_TWEET_INCREASE_NEEDED']&.to_i || BaseClinic::PHARMACY_TWEET_INCREASE_NEEDED 155 | TWEET_COOLDOWN = ENV['PHARMACY_TWEET_COOLDOWN']&.to_i || BaseClinic::TWEET_COOLDOWN 156 | 157 | attr_reader :cities 158 | 159 | def initialize(storage, cities) 160 | super(storage) 161 | @cities = cities 162 | end 163 | 164 | def module_name 165 | 'WALGREENS' 166 | end 167 | 168 | def title 169 | 'Walgreens' 170 | end 171 | 172 | def link 173 | SIGN_UP_URL 174 | end 175 | 176 | def appointments 177 | cities.length 178 | end 179 | 180 | def storage_key 181 | 'walgreens-last-cities' 182 | end 183 | 184 | def save_appointments 185 | @storage.set(storage_key, cities.to_json) 186 | end 187 | 188 | def last_cities 189 | stored_value = @storage.get(storage_key) 190 | stored_value.nil? ? [] : JSON.parse(stored_value) 191 | end 192 | 193 | def new_cities 194 | cities - last_cities 195 | end 196 | 197 | def new_appointments 198 | new_cities.length 199 | end 200 | 201 | def slack_blocks 202 | { 203 | type: 'section', 204 | text: { 205 | type: 'mrkdwn', 206 | text: "*#{title}*\n*Available appointments in:* #{cities.join(', ')}\n*Link:* #{link}", 207 | }, 208 | } 209 | end 210 | 211 | def twitter_text 212 | tweet_groups = [] 213 | 214 | # 27 chars: " appointments available in " 215 | # 35 chars: ". Check eligibility and sign up at " 216 | # 23 chars: shortened link 217 | # --------- 218 | # 280 chars total, 280 is the maximum 219 | text_limit = 280 - (title.length + 27 + 35 + 23) 220 | 221 | tweet_cities = cities 222 | cities_text = tweet_cities.shift 223 | while (city = tweet_cities.shift) 224 | pending_text = ", #{city}" 225 | if cities_text.length + pending_text.length > text_limit 226 | tweet_groups << cities_text 227 | cities_text = city 228 | else 229 | cities_text += pending_text 230 | end 231 | end 232 | tweet_groups << cities_text 233 | 234 | tweet_groups.map do |group| 235 | "#{title} appointments available in #{group}. Check eligibility and sign up at #{sign_up_page}" 236 | end 237 | end 238 | end 239 | end 240 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /lib/sites/my_chart.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'nokogiri' 3 | 4 | require_relative '../sentry_helper' 5 | require_relative './base_clinic' 6 | 7 | module MyChart 8 | class Page 9 | def initialize(storage, logger) 10 | @storage = storage 11 | @logger = logger 12 | end 13 | 14 | def credentials 15 | response = RestClient.get(token_url) 16 | cookies = response.cookies 17 | doc = Nokogiri::HTML(response) 18 | token = doc.search('input[name=__RequestVerificationToken]')[0]['value'] 19 | { 20 | cookies: cookies, 21 | '__RequestVerificationToken' => token, 22 | } 23 | end 24 | 25 | def appointments_json(start) 26 | payload = api_payload 27 | payload['start'] = start.strftime('%Y-%m-%d') 28 | res = RestClient.post( 29 | "#{scheduling_api_url}?noCache=#{Time.now.to_i}", 30 | payload, 31 | credentials 32 | ) 33 | JSON.parse(res.body) 34 | end 35 | 36 | def clinics 37 | slots = {} 38 | 39 | date = Date.today 40 | 4.times do 41 | json = appointments_json(date) 42 | break if json['ErrorCode'] 43 | 44 | json['ByDateThenProviderCollated'].each do |date, date_info| 45 | date_info['ProvidersAndHours'].each do |_provider, provider_info| 46 | provider_info['DepartmentAndSlots'].each do |department, department_info| 47 | department_info['HoursAndSlots'].each do |_hour, hour_info| 48 | slots[date] ||= { department: json['AllDepartments'][department], slots: 0 } 49 | slots[date][:slots] += hour_info['Slots'].length 50 | end 51 | end 52 | end 53 | end 54 | date += 7 55 | end 56 | 57 | slots.map do |date, info| 58 | @logger.info "[MyChart] Site #{info[:department]['Name']} on #{date}: found #{info[:slots]} appointments" 59 | Clinic.new(info[:department], date, info[:slots], sign_up_page, @logger, @storage, storage_key_prefix) 60 | end 61 | end 62 | 63 | def storage_key_prefix 64 | nil 65 | end 66 | end 67 | 68 | class UMassMemorial < Page 69 | def name 70 | 'UMass Memorial' 71 | end 72 | 73 | def token_url 74 | 'https://mychartonline.umassmemorial.org/mychart/openscheduling?specialty=15&hidespecialtysection=1' 75 | end 76 | 77 | def scheduling_api_url 78 | 'https://mychartonline.umassmemorial.org/MyChart/OpenScheduling/OpenScheduling/GetScheduleDays' 79 | end 80 | 81 | def api_payload 82 | { 83 | 'view' => 'grouped', 84 | 'specList' => '15', 85 | 'vtList' => '5060', 86 | 'start' => '', 87 | 'filters' => { 88 | 'Providers' => {}, 89 | 'Departments' => {}, 90 | 'DaysOfWeek' => {}, 91 | 'TimesOfDay': 'both', 92 | }, 93 | } 94 | end 95 | 96 | def sign_up_page 97 | 'https://mychartonline.umassmemorial.org/mychart/openscheduling?specialty=15&hidespecialtysection=1' 98 | end 99 | end 100 | 101 | class SBCHC < Page 102 | def name 103 | 'SBCHC' 104 | end 105 | 106 | def token_url 107 | 'https://mychartos.ochin.org/mychart/SignupAndSchedule/EmbeddedSchedule?id=1900119&dept=150001007&vt=1089&payor=-1,-2,-3,4653,1624,4660,4655,1292,4881,5543,5979,2209,5257,1026,1001,2998,3360,3502,4896,2731' 108 | end 109 | 110 | def scheduling_api_url 111 | 'https://mychartos.ochin.org/mychart/OpenScheduling/OpenScheduling/GetOpeningsForProvider' 112 | end 113 | 114 | def api_payload 115 | { 116 | 'id' => '1900119', 117 | 'vt' => '1089', 118 | 'dept' => '150001007', 119 | 'view' => 'grouped', 120 | 'start' => '', 121 | 'filters' => { 122 | 'Providers' => {}, 123 | 'Departments' => {}, 124 | 'DaysOfWeek' => {}, 125 | 'TimesOfDay': 'both', 126 | }, 127 | } 128 | end 129 | 130 | def sign_up_page 131 | 'https://forms.office.com/Pages/ResponsePage.aspx?id=J8HP3h4Z8U-yP8ih3jOCukT-1W6NpnVIp4kp5MOEapVUOTNIUVZLODVSMlNSSVc2RlVMQ1o1RjNFUy4u' 132 | end 133 | end 134 | 135 | class BMC < Page 136 | def name 137 | 'BMC' 138 | end 139 | 140 | def token_url 141 | 'https://mychartscheduling.bmc.org/mychartscheduling/SignupAndSchedule/EmbeddedSchedule' 142 | end 143 | 144 | def scheduling_api_url 145 | 'https://mychartscheduling.bmc.org/MyChartscheduling/OpenScheduling/OpenScheduling/GetOpeningsForProvider' 146 | end 147 | 148 | def api_payload 149 | return @api_payload if @api_payload 150 | 151 | settings = RestClient.get("https://mychartscheduling.bmc.org/MyChartscheduling/scripts/guest/covid19-screening/custom/settings.js?updateDt=#{Time.now.to_i}").body 152 | iframe_src = %r{url: "(https://mychartscheduling\.bmc\.org/mychartscheduling/SignupAndSchedule/EmbeddedSchedule[^"]+)"}.match(settings)[1] 153 | 154 | id = iframe_src.match(/id=([\d,]+)&/)[1] 155 | dept = iframe_src.match(/dept=([\d,]+)&/)[1] 156 | vt = iframe_src.match(/vt=(\d+)/)[1] 157 | 158 | @api_payload = { 159 | 'id' => id, 160 | 'vt' => vt, 161 | 'dept' => dept, 162 | 'view' => 'grouped', 163 | 'start' => '', 164 | 'filters' => { 165 | 'Providers' => {}, 166 | 'Departments' => {}, 167 | 'DaysOfWeek' => {}, 168 | 'TimesOfDay': 'both', 169 | }, 170 | } 171 | end 172 | 173 | def sign_up_page 174 | 'https://mychartscheduling.bmc.org/MyChartscheduling/covid19#/' 175 | end 176 | 177 | def storage_key_prefix 178 | 'BMC' 179 | end 180 | end 181 | 182 | class BMCCommunity < Page 183 | def name 184 | 'BMC Community' end 185 | 186 | def token_url 187 | 'https://mychartscheduling.bmc.org/MyChartscheduling/SignupAndSchedule/EmbeddedSchedule' 188 | end 189 | 190 | def scheduling_api_url 191 | 'https://mychartscheduling.bmc.org/MyChartscheduling/OpenScheduling/OpenScheduling/GetOpeningsForProvider' 192 | end 193 | 194 | def api_payload 195 | html = Nokogiri::HTML(RestClient.get(sign_up_page).body) 196 | iframe = html.search('iframe#openSchedulingFrame') 197 | raise "Couldn't find scheduling iframe" unless iframe.any? 198 | 199 | iframe_src = iframe[0]['src'] 200 | id = iframe_src.match(/id=([\d,]+)&/)[1] 201 | dept = iframe_src.match(/dept=([\d,]+)&/)[1] 202 | vt = iframe_src.match(/vt=(\d+)/)[1] 203 | 204 | { 205 | 'id' => id, 206 | 'vt' => vt, 207 | 'dept' => dept, 208 | 'view' => 'grouped', 209 | 'start' => '', 210 | 'filters' => { 211 | 'Providers' => {}, 212 | 'Departments' => {}, 213 | 'DaysOfWeek' => {}, 214 | 'TimesOfDay': 'both', 215 | }, 216 | } 217 | end 218 | 219 | def sign_up_page 220 | 'https://www.bmc.org/community-covid-vaccine-scheduling' 221 | end 222 | 223 | def storage_key_prefix 224 | 'BMC Community' 225 | end 226 | end 227 | 228 | class HarvardVanguardNeedham < Page 229 | def name 230 | 'Harvard Vanguard Medical Associates - Needham' 231 | end 232 | 233 | def token_url 234 | 'https://myhealth.atriushealth.org/DPH?' 235 | end 236 | 237 | def scheduling_api_url 238 | 'https://myhealth.atriushealth.org/OpenScheduling/OpenScheduling/GetScheduleDays' 239 | end 240 | 241 | def api_payload 242 | { 243 | 'view' => 'grouped', 244 | 'specList' => 121, 245 | 'vtList' => 1424, 246 | 'start' => '', 247 | 'filters': { 248 | 'Providers' => {}, 249 | 'Departments' => {}, 250 | 'DaysOfWeek' => {}, 251 | 'TimesOfDay' => 'both', 252 | }, 253 | } 254 | end 255 | 256 | def sign_up_page 257 | 'https://myhealth.atriushealth.org/fr/' 258 | end 259 | end 260 | 261 | class MassGeneralBrigham < Page 262 | def clinic_identifier 263 | raise NotImplementedError 264 | end 265 | 266 | def credentials 267 | token_response = RestClient.get('https://covidvaccine.massgeneralbrigham.org/') 268 | token_doc = Nokogiri::HTML(token_response.body) 269 | token = token_doc.search('input[name=__RequestVerificationToken]')[0]['value'] 270 | 271 | session_response = RestClient.post( 272 | 'https://covidvaccine.massgeneralbrigham.org/Home/CreateSession', 273 | {}, 274 | cookies: token_response.cookies, 275 | RequestVerificationToken: token 276 | ) 277 | 278 | redirect_response = RestClient.post( 279 | 'https://covidvaccine.massgeneralbrigham.org/Home/RedirectUrl', 280 | { 281 | Institution: clinic_identifier, 282 | tokenStr: session_response.body, 283 | }, 284 | cookies: token_response.cookies, 285 | RequestVerificationToken: token 286 | ) 287 | 288 | scheduling_page = RestClient.get(redirect_response) 289 | scheduling_doc = Nokogiri(scheduling_page.body) 290 | scheduling_token = scheduling_doc.search('input[name=__RequestVerificationToken]')[0]['value'] 291 | { 292 | cookies: scheduling_page.cookies, 293 | '__RequestVerificationToken' => scheduling_token, 294 | } 295 | end 296 | end 297 | 298 | class MarthasVineyard < MassGeneralBrigham 299 | def name 300 | "Martha's Vineyard Hospital" 301 | end 302 | 303 | def clinic_identifier 304 | 'MVH' 305 | end 306 | 307 | def scheduling_api_url 308 | 'https://patientgateway.massgeneralbrigham.org/MyChart-PRD/OpenScheduling/OpenScheduling/GetOpeningsForProvider' 309 | end 310 | 311 | def api_payload 312 | { 313 | 'vt' => '555097', 314 | 'dept' => '10110010104', 315 | 'view' => 'grouped', 316 | 'start' => '', 317 | 'filters' => { 318 | 'Providers' => {}, 319 | 'Departments' => {}, 320 | 'DaysOfWeek' => {}, 321 | 'TimesOfDay' => 'both', 322 | }.to_json, 323 | } 324 | end 325 | 326 | def sign_up_page 327 | 'https://covidvaccine.massgeneralbrigham.org/' 328 | end 329 | end 330 | 331 | class Nantucket < MassGeneralBrigham 332 | def name 333 | 'Nantucket VFW' 334 | end 335 | 336 | def clinic_identifier 337 | 'NCH' 338 | end 339 | 340 | def scheduling_api_url 341 | 'https://patientgateway.massgeneralbrigham.org/MyChart-PRD/OpenScheduling/OpenScheduling/GetOpeningsForProvider' 342 | end 343 | 344 | def api_payload 345 | { 346 | 'vt' => '555097', 347 | 'dept' => '10120040001', 348 | 'view' => 'grouped', 349 | 'start' => '', 350 | 'filters' => { 351 | 'Providers' => {}, 352 | 'Departments' => {}, 353 | 'DaysOfWeek' => {}, 354 | 'TimesOfDay' => 'both', 355 | }, 356 | } 357 | end 358 | 359 | def sign_up_page 360 | 'https://covidvaccine.massgeneralbrigham.org/' 361 | end 362 | end 363 | 364 | ALL_PAGES = [ 365 | UMassMemorial, 366 | BMC, 367 | #BMCCommunity, 368 | SBCHC, 369 | MarthasVineyard, 370 | Nantucket, 371 | HarvardVanguardNeedham, 372 | ].freeze 373 | 374 | def self.all_clinics(storage, logger) 375 | ALL_PAGES.flat_map do |page_class| 376 | SentryHelper.catch_errors(logger, 'MyChart') do 377 | page = page_class.new(storage, logger) 378 | logger.info "[MyChart] Checking site #{page.name}" 379 | page.clinics 380 | end 381 | end 382 | end 383 | 384 | class Clinic < BaseClinic 385 | attr_reader :date, :appointments, :link 386 | 387 | def initialize(department, date, appointments, link, logger, storage, storage_key_prefix) 388 | super(storage) 389 | @department = department 390 | @date = date 391 | @appointments = appointments 392 | @link = link 393 | @logger = logger 394 | @storage_key_prefix = storage_key_prefix 395 | end 396 | 397 | def module_name 398 | 'MY_CHART' 399 | end 400 | 401 | def name 402 | @department['Name'] 403 | end 404 | 405 | def address 406 | "#{@department['Address']['Street'].join(' ')}, #{city}, MA" 407 | end 408 | 409 | def city 410 | @department['Address']['City'].split.map(&:capitalize).join(' ') 411 | end 412 | 413 | def title 414 | "#{name} on #{@date}" 415 | end 416 | 417 | def slack_blocks 418 | { 419 | type: 'section', 420 | text: { 421 | type: 'mrkdwn', 422 | text: "*#{title}*\n*Address:* #{address}\n*Available appointments:* #{render_slack_appointments}\n*Link:* #{link}", 423 | }, 424 | } 425 | end 426 | 427 | def twitter_text 428 | txt = "#{appointments} appointments available at #{name}" 429 | txt += " in #{city}, MA" if city 430 | txt + " on #{date}. Check eligibility and sign up at #{sign_up_page}" 431 | end 432 | 433 | def storage_key 434 | if @storage_key_prefix 435 | "#{@storage_key_prefix}:#{title}" 436 | else 437 | title 438 | end 439 | end 440 | end 441 | end 442 | -------------------------------------------------------------------------------- /lib/sites/config/user_agents.txt: -------------------------------------------------------------------------------- 1 | Mac OS X/10.6.8 (10K549); ExchangeWebServices/1.3 (61); Mail/4.6 (1085) 2 | MacOutlook/14.6.9.160926 (Intel Mac OS X 10.9.6) 3 | Mozilla/4.0 (compatible; MSIE 6.0; Windows 98) 4 | Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0) 5 | Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; .NET CLR 1.1.4322) 6 | Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1) 7 | Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; .NET CLR 1.1.4322) 8 | Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1) 9 | Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322) 10 | Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322; .NET CLR 2.0.50727) 11 | Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; .NET CLR 1.1.4322) 12 | Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0) 13 | Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1) 14 | Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1; 125LA; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022) 15 | Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Mobile Safari/537.36 16 | Mozilla/5.0 (Linux; Android 10; SM-N960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Mobile Safari/537.36 17 | Mozilla/5.0 (Linux; U; Android 2.2) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1 18 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:45.0) Gecko/20100101 Firefox/45.0 19 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:16.0) Gecko/20100101 Firefox/16.0 20 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:48.0) Gecko/20100101 Firefox/48.0 21 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10.7; rv:48.0) Gecko/20100101 Firefox/48.0 22 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:48.0) Gecko/20100101 Firefox/48.0 23 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:30.0) Gecko/20100101 Firefox/30.0 24 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10; rv:33.0) Gecko/20100101 Firefox/33.0 25 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/600.3.18 (KHTML, like Gecko) Version/8.0.3 Safari/600.3.18 26 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/600.4.10 (KHTML, like Gecko) Version/8.0.4 Safari/600.4.10 27 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.89 Safari/537.36 28 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/600.5.17 (KHTML, like Gecko) Version/8.0.5 Safari/600.5.17 29 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/600.6.3 (KHTML, like Gecko) Version/8.0.6 Safari/600.6.3 30 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_4) AppleWebKit/600.7.12 (KHTML, like Gecko) Version/8.0.7 Safari/600.7.12 31 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/600.8.9 (KHTML, like Gecko) 32 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/600.8.9 (KHTML, like Gecko) Version/8.0.8 Safari/600.8.9 33 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/601.2.7 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.7 34 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/601.4.4 (KHTML, like Gecko) Version/9.0.3 Safari/601.4.4 35 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/603.3.8 (KHTML, like Gecko) Version/10.1.2 Safari/603.3.8 36 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11) AppleWebKit/601.1.56 (KHTML, like Gecko) Version/9.0 Safari/601.1.56 37 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.7 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.7 38 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/601.3.9 (KHTML, like Gecko) Version/9.0.2 Safari/601.3.9 39 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_3) AppleWebKit/601.4.4 (KHTML, like Gecko) Version/9.0.3 Safari/601.4.4 40 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/601.5.17 (KHTML, like Gecko) Version/9.1 Safari/601.5.17 41 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_5) AppleWebKit/601.6.17 (KHTML, like Gecko) Version/9.1.1 Safari/601.6.17 42 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 43 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/601.7.7 (KHTML, like Gecko) Version/9.1.2 Safari/601.7.7 44 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/601.7.8 (KHTML, like Gecko) 45 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/601.7.8 (KHTML, like Gecko) Version/9.1.3 Safari/601.7.8 46 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/602.1.50 (KHTML, like Gecko) Version/10.0 Safari/602.1.50 47 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/602.3.12 (KHTML, like Gecko) Version/10.0.2 Safari/602.3.12 48 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/603.2.5 (KHTML, like Gecko) Version/10.1.1 Safari/603.2.5 49 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.2 Safari/605.1.15 50 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/603.3.8 (KHTML, like Gecko) 51 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) 52 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) 53 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) 54 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.1 Safari/605.1.15 55 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Safari/605.1.15 56 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_5_8) AppleWebKit/534.50.2 (KHTML, like Gecko) Version/5.0.6 Safari/533.22.3 57 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/534.59.10 (KHTML, like Gecko) Version/5.1.9 Safari/534.59.10 58 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.112 Safari/537.36 59 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.112 Safari/537.36 60 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_5) AppleWebKit/537.78.2 (KHTML, like Gecko) Version/6.1.6 Safari/537.78.2 61 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.112 Safari/537.36 62 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_5) AppleWebKit/600.8.9 (KHTML, like Gecko) Version/6.2.8 Safari/537.85.17 63 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.131 Safari/537.36 64 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.75.14 (KHTML, like Gecko) Version/7.0.3 Safari/7046A194A 65 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Safari/537.36 66 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/537.78.2 (KHTML, like Gecko) Version/7.0.6 Safari/537.78.2 67 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/600.1.17 (KHTML, like Gecko) Version/7.1 Safari/537.85.10 68 | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/601.7.8 (KHTML, like Gecko) Version/9.1.3 Safari/537.86.7 69 | Mozilla/5.0 (Macintosh; Intel Mac OS X 11.2; rv:78.0) Gecko/20100101 Firefox/78.0 70 | Mozilla/5.0 (Macintosh; Intel Mac OS X 11.2; rv:86.0) Gecko/20100101 Firefox/86.0 71 | Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36 72 | Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Safari/605.1.15 73 | Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_4; de-de) AppleWebKit/525.18 (KHTML, like Gecko) Version/3.1.2 Safari/525.20.1 74 | Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_6; en-en) AppleWebKit/533.19.4 (KHTML, like Gecko) Version/5.0.3 Safari/533.19.4 75 | Mozilla/5.0 (Macintosh; U; PPC Mac OS X; en) AppleWebKit/417.9 (KHTML, like Gecko) Safari/417.8 76 | Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36 77 | Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36 78 | Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko 79 | Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36 80 | Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko 81 | Mozilla/5.0 (Windows NT 10.0; WOW64; rv:50.0) Gecko/20100101 Firefox/50.0 82 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246 83 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.79 Safari/537.36 Edge/14.14393 84 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36 Edge/15.15063 85 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299 86 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36 87 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36 88 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/17.17134 89 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/18.17763 90 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Safari/537.36 91 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36 92 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36 93 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36 94 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.18362 95 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.18363 96 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36 97 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36 98 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.157 Safari/537.36 99 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36 100 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36 101 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36 102 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36 103 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36 104 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36 105 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36 106 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36 107 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36 108 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36 109 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36 110 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36 111 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36 112 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 113 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 Edg/87.0.664.75 114 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 115 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.104 Safari/537.36 116 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36 117 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36 Edg/89.0.774.45 118 | Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:66.0) Gecko/20100101 Firefox/66.0 119 | Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:78.0) Gecko/20100101 Firefox/78.0 120 | Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0 121 | Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.71 Safari/537.36 122 | Mozilla/5.0 (Windows NT 5.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36 123 | Mozilla/5.0 (Windows NT 5.1; rv:33.0) Gecko/20100101 Firefox/33.0 124 | Mozilla/5.0 (Windows NT 5.1; rv:36.0) Gecko/20100101 Firefox/36.0 125 | Mozilla/5.0 (Windows NT 5.1; rv:7.0.1) Gecko/20100101 Firefox/7.0.1 126 | Mozilla/5.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko 127 | Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.83 Safari/537.1 128 | Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36 129 | Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko 130 | Mozilla/5.0 (Windows NT 6.1; WOW64; rv:18.0) Gecko/20100101 Firefox/18.0 131 | Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1 132 | Mozilla/5.0 (Windows NT 6.1; WOW64; rv:54.0) Gecko/20100101 Firefox/54.0 133 | Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36 134 | Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36 135 | Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36 136 | Mozilla/5.0 (Windows NT 6.2; Trident/7.0; rv:11.0) like Gecko 137 | Mozilla/5.0 (Windows NT 6.2; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36 138 | Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko 139 | Mozilla/5.0 (Windows NT 6.3; WOW64; Trident/7.0; rv:11.0) like Gecko 140 | Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36 141 | Mozilla/5.0 (X11; CrOS aarch64 13597.94.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.186 Safari/537.36 142 | Mozilla/5.0 (X11; CrOS x86_64 13597.94.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.186 Safari/537.36 143 | Mozilla/5.0 (X11; CrOS x86_64 8172.45.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.64 Safari/537.36 144 | Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.157 Safari/537.36 145 | Mozilla/5.0 (X11; Ubuntu; Linux i686; rv:24.0) Gecko/20100101 Firefox/24.0 146 | Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2) 147 | Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0) 148 | Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0; KTXN) 149 | Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0) 150 | Mozilla/5.0 (iPad; CPU OS 11_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/32.0 Mobile/15E148 Safari/605.1.15 151 | Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 152 | Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1 Mobile/15E148 Safari/604.1 153 | Mozilla/5.0 (iPad; CPU OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/87.0.4280.77 Mobile/15E148 Safari/604.1 154 | Mozilla/5.0 (iPad; CPU OS 14_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1 155 | Mozilla/5.0 (iPad; CPU OS 9_3_5 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Mobile/13G36 156 | Mozilla/5.0 (iPad; CPU OS 9_3_5 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13G36 Safari/601.1 157 | Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_3 like Mac OS X) AppleWebKit/603.3.8 (KHTML, like Gecko) Version/10.0 Mobile/14G60 Safari/602.1 158 | Mozilla/5.0 (iPhone; CPU iPhone OS 11_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/32.0 Mobile/15E148 Safari/605.1.15 159 | Mozilla/5.0 (iPhone; CPU iPhone OS 11_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.0 Mobile/15E148 Safari/604.1 160 | Mozilla/5.0 (iPhone; CPU iPhone OS 11_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15G77 161 | Mozilla/5.0 (iPhone; CPU iPhone OS 11_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.0 Mobile/15E148 Safari/604.1 162 | Mozilla/5.0 (iPhone; CPU iPhone OS 12_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1 163 | Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/16D57 164 | Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) 165 | Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 166 | Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1 Mobile/15E148 Safari/604.1 167 | Mozilla/5.0 (iPhone; CPU iPhone OS 12_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 168 | Mozilla/5.0 (iPhone; CPU iPhone OS 12_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.1 Mobile/15E148 Safari/604.1 169 | Mozilla/5.0 (iPhone; CPU iPhone OS 12_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Mobile/15E148 Safari/604.1 170 | Mozilla/5.0 (iPhone; CPU iPhone OS 12_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Mobile/15E148 Safari/604.1 171 | Mozilla/5.0 (iPhone; CPU iPhone OS 13_1_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.1 Mobile/15E148 Safari/604.1 172 | Mozilla/5.0 (iPhone; CPU iPhone OS 13_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.4 Mobile/15E148 Safari/604.1 173 | Mozilla/5.0 (iPhone; CPU iPhone OS 13_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.5 Mobile/15E148 Safari/604.1 174 | Mozilla/5.0 (iPhone; CPU iPhone OS 13_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1 175 | Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1 176 | Mozilla/5.0 (iPhone; CPU iPhone OS 13_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Mobile/15E148 Safari/604.1 177 | Mozilla/5.0 (iPhone; CPU iPhone OS 13_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Mobile/15E148 Safari/604.1 178 | Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Mobile/15E148 Safari/604.1 179 | Mozilla/5.0 (iPhone; CPU iPhone OS 14_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1 180 | Mozilla/5.0 (iPhone; CPU iPhone OS 14_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1 181 | Mozilla/5.0 (iPhone; CPU iPhone OS 14_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.1 Mobile/15E148 Safari/604.1 182 | Mozilla/5.0 (iPhone; CPU iPhone OS 14_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.2 Mobile/15E148 Safari/604.1 183 | Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1 184 | Mozilla/5.0 (iPod; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/87.0.4280.77 Mobile/15E148 Safari/604.1 185 | Mozilla/5.0 CK={} (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko 186 | Outlook-iOS/709.2189947.prod.iphone (3.24.0) 187 | Outlook-iOS/709.2226530.prod.iphone (3.24.1) 188 | --------------------------------------------------------------------------------