From 2bcfafb4b6c3db26d409c21f653d8ddae8e0358e Mon Sep 17 00:00:00 2001 From: nicolaus-hee <48563755+nicolaus-hee@users.noreply.github.com> Date: Tue, 25 May 2021 20:19:16 +0200 Subject: [PATCH 01/58] notification sound when appointment found --- README.md | 1 + ding.mp3 | Bin 0 -> 15000 bytes doctoshotgun.py | 3 +++ requirements.txt | 3 ++- 4 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 ding.mp3 diff --git a/README.md b/README.md index 94fb807..8fe47cd 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ tomorrow, following rules from the French Government. - cloudscraper - dateutil - termcolor +- playsound ## How to use it diff --git a/ding.mp3 b/ding.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..d94c09fb9e386b9696b23e9d4d0c81930391cb88 GIT binary patch literal 15000 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 gy6+)6>ECHj;F`!D6?UYu_ Date: Thu, 27 May 2021 09:18:41 +0200 Subject: [PATCH 02/58] make playsound an optional dependence --- doctoshotgun.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index c8c60f3..da34e7a 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -23,7 +23,11 @@ from woob.browser.pages import JsonPage, HTMLPage from woob.tools.log import createColoredFormatter -from playsound import playsound +try: + from playsound import playsound +except ImportError: + def playsound(*args): + pass def log(text, *args, **kwargs): args = (colored(arg, 'yellow') for arg in args) From dde59561fdb227839d2b10fcf725a5ffe9448044 Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Thu, 27 May 2021 10:21:07 +0200 Subject: [PATCH 03/58] do not crash if playsound() can't play sound --- doctoshotgun.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index da34e7a..90b693f 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -24,7 +24,12 @@ from woob.tools.log import createColoredFormatter try: - from playsound import playsound + from playsound import playsound as _playsound, PlaysoundException + def playsound(*args): + try: + return _playsound(*args) + except PlaysoundException: + pass # do not crash if, for one reason or another, something wrong happens except ImportError: def playsound(*args): pass From bb4bb15d08c9a501f3c88ea3759b85bbfc8ed915 Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Thu, 27 May 2021 11:37:11 +0200 Subject: [PATCH 04/58] catch another kind of error --- doctoshotgun.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 90b693f..94c2770 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -28,7 +28,7 @@ def playsound(*args): try: return _playsound(*args) - except PlaysoundException: + except (PlaysoundException,ModuleNotFoundError): pass # do not crash if, for one reason or another, something wrong happens except ImportError: def playsound(*args): From a3b5946b5649a0cd82074159380af92a784e1f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=A9rence=20Chateign=C3=A9?= Date: Wed, 26 May 2021 23:42:18 +0200 Subject: [PATCH 05/58] add Dockerfile and docker usage instructions --- Dockerfile | 12 ++++++++++++ README.md | 14 ++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..90ba6d3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.9.5-slim + +WORKDIR /usr/src/app + +# Install dependencies +COPY ./requirements.txt . +RUN pip install -r requirements.txt + +COPY ./doctoshotgun.py . + +# Entrypoint - run the script +ENTRYPOINT ["./doctoshotgun.py"] diff --git a/README.md b/README.md index 8fe47cd..f2bb0dd 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,20 @@ Optional arguments: --debug : display debug information ``` +### With Docker + +Build the image: + +``` +docker build . -t doctoshotgun +``` + +Run the container: + +``` +docker run doctoshotgun [password] +``` + ### Multiple cities You can also look for slot in multiple cities at the same time. Cities must be separated by commas: From 476bf2faf16f610ff890eb3055eac5c0b0ef458f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=A9rence=20Chateign=C3=A9?= Date: Thu, 27 May 2021 21:04:53 +0200 Subject: [PATCH 06/58] lighter Docker image --- Dockerfile | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 90ba6d3..ec30735 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,26 @@ -FROM python:3.9.5-slim +# Base image +FROM python:3.9.5-slim as base -WORKDIR /usr/src/app +# Build stage +FROM base as builder + +# Dependency install directory +RUN mkdir /install +WORKDIR /install # Install dependencies COPY ./requirements.txt . -RUN pip install -r requirements.txt +RUN pip install --prefix /install -r requirements.txt + +# Run stage +FROM base + +WORKDIR /usr/src/app + +# Fetch dependencies from the build stage +COPY --from=builder /install /usr/local COPY ./doctoshotgun.py . -# Entrypoint - run the script +# Entrypoint - Run the main script ENTRYPOINT ["./doctoshotgun.py"] From c72fb0dac2fade7963dda3e9f5aed6a68ef80331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Vermot?= Date: Sat, 29 May 2021 13:16:01 +0200 Subject: [PATCH 07/58] Pfizer filter (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ajout d une option pour selectionner le vaccin Pfizer (moins de 18 ans) Co-authored-by: Yann Brelière --- README.md | 7 +++++++ doctoshotgun.py | 18 +++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f2bb0dd..2d992b4 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,13 @@ $ ./doctoshotgun.py paris roger.philibert@gmail.com PASSWORD -p 1 Starting to look for vaccine slots for Luce Philibert... ``` +### Filter by vaccine +The Pfizer vaccine is the only vaccine allowed in France for people between 16 and 18. For this case, you can use the -z option. + +``` +$ ./doctoshotgun.py paris roger.philibert@gmail.com PASSWORD -z +Starting to look for vaccine (Pfizer only) slots for Luce Philibert... +``` ## Development diff --git a/doctoshotgun.py b/doctoshotgun.py index 94c2770..ec9a81a 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -195,10 +195,13 @@ def do_login(self): return True - def find_centers(self, where): + def find_centers(self, where, limitToPfizer=False): + motives = ['6970', '7005'] + if limitToPfizer: + motives = ['6970'] for city in where: try: - self.centers.go(where=city, params={'ref_visit_motive_ids[]': ['6970', '7005']}) + self.centers.go(where=city, params={'ref_visit_motive_ids[]': motives}) except ServerError as e: if e.response.status_code in [503]: return None @@ -206,7 +209,7 @@ def find_centers(self, where): raise e for i in self.page.iter_centers_ids(): - page = self.center_result.open(id=i, params={'limit': '4', 'ref_visit_motive_ids[]': ['6970', '7005'], 'speciality_id': '5494', 'search_result_format': 'json'}) + page = self.center_result.open(id=i, params={'limit': '4', 'ref_visit_motive_ids[]': motives, 'speciality_id': '5494', 'search_result_format': 'json'}) # XXX return all pages even if there are no indicated availabilities. #for a in page.doc['availabilities']: # if len(a['slots']) > 0: @@ -390,6 +393,7 @@ def setup_loggers(self, level): def main(self): parser = argparse.ArgumentParser(description="Book a vaccine slot on Doctolib") parser.add_argument('--debug', '-d', action='store_true', help='show debug information') + parser.add_argument('--pfizer', '-z', action='store_true', help='select only pfizer vaccine') parser.add_argument('--patient', '-p', type=int, default=-1, help='give patient ID') parser.add_argument('--center', '-c', action='append', help='filter centers') parser.add_argument('city', help='city where to book') @@ -432,13 +436,17 @@ def main(self): break else: docto.patient = patients[0] + + vaccineList = "" + if (args.pfizer): + vaccineList = "(Pfizer only) " - log('Starting to look for vaccine slots for %s %s...', docto.patient['first_name'], docto.patient['last_name']) + log('Starting to look for vaccine %sslots for %s %s...', vaccineList, docto.patient['first_name'], docto.patient['last_name']) log('This may take a few minutes/hours, be patient!') cities = [docto.normalize(city) for city in args.city.split(',')] while True: - for center in docto.find_centers(cities): + for center in docto.find_centers(cities, args.pfizer): if args.center: if center['name_with_title'] not in args.center: logging.debug("Skipping center '%s'", center['name_with_title']) From 3f8bc966f6a7121e5e3a244bfaed8060f51d6e9b Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Sat, 29 May 2021 13:32:35 +0200 Subject: [PATCH 08/58] add -m option to filter also on Moderna --- README.md | 9 ++++++++- doctoshotgun.py | 31 ++++++++++++++++++++----------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2d992b4..7533afb 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ Optional arguments: ``` --center "" [--center "" …] : filter centers to only choose one from the provided list --patient : select patient for which book a slot +--pfizer : looking only for a Pfizer vaccine +--moderna : looking only for a Moderna vaccine --debug : display debug information ``` @@ -90,13 +92,18 @@ Starting to look for vaccine slots for Luce Philibert... ``` ### Filter by vaccine + The Pfizer vaccine is the only vaccine allowed in France for people between 16 and 18. For this case, you can use the -z option. ``` $ ./doctoshotgun.py paris roger.philibert@gmail.com PASSWORD -z -Starting to look for vaccine (Pfizer only) slots for Luce Philibert... +Starting to look for vaccine slots for Luce Philibert... +Vaccines: Pfizer +This may take a few minutes/hours, be patient! ``` +It is also possible to filter on Moderna vaccine with the -m option. + ## Development ### Running tests diff --git a/doctoshotgun.py b/doctoshotgun.py index ec9a81a..03b9a8b 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -195,10 +195,7 @@ def do_login(self): return True - def find_centers(self, where, limitToPfizer=False): - motives = ['6970', '7005'] - if limitToPfizer: - motives = ['6970'] + def find_centers(self, where, motives=('6970', '7005')): for city in where: try: self.centers.go(where=city, params={'ref_visit_motive_ids[]': motives}) @@ -375,6 +372,10 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids): return self.page.doc['confirmed'] class Application: + vaccine_motives = {'6970': 'Pfizer', + '7005': 'Moderna', + } + @classmethod def create_default_logger(cls): # stderr logger @@ -393,7 +394,8 @@ def setup_loggers(self, level): def main(self): parser = argparse.ArgumentParser(description="Book a vaccine slot on Doctolib") parser.add_argument('--debug', '-d', action='store_true', help='show debug information') - parser.add_argument('--pfizer', '-z', action='store_true', help='select only pfizer vaccine') + parser.add_argument('--pfizer', '-z', action='store_true', help='select only Pfizer vaccine') + parser.add_argument('--moderna', '-m', action='store_true', help='select only Moderna vaccine') parser.add_argument('--patient', '-p', type=int, default=-1, help='give patient ID') parser.add_argument('--center', '-c', action='append', help='filter centers') parser.add_argument('city', help='city where to book') @@ -436,17 +438,24 @@ def main(self): break else: docto.patient = patients[0] - - vaccineList = "" - if (args.pfizer): - vaccineList = "(Pfizer only) " - log('Starting to look for vaccine %sslots for %s %s...', vaccineList, docto.patient['first_name'], docto.patient['last_name']) + motives = [] + if not args.pfizer and not args.moderna: + motives = ['6970', '7005'] + if args.pfizer: + motives.append('6970') + if args.moderna: + motives.append('7005') + + vaccine_list = [self.vaccine_motives[motive] for motive in motives] + + log('Starting to look for vaccine slots for %s %s...', docto.patient['first_name'], docto.patient['last_name']) + log('Vaccines: %s' % ', '.join(vaccine_list)) log('This may take a few minutes/hours, be patient!') cities = [docto.normalize(city) for city in args.city.split(',')] while True: - for center in docto.find_centers(cities, args.pfizer): + for center in docto.find_centers(cities, motives): if args.center: if center['name_with_title'] not in args.center: logging.debug("Skipping center '%s'", center['name_with_title']) From e6e19bc1fe3ddda90b8a9ca31095cb8e6b15017c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Vermot?= Date: Sat, 29 May 2021 13:55:56 +0200 Subject: [PATCH 09/58] Looking for next step : ability for anybody to book anytime : Checking for more than 1 day (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * give ability to extend time window * time window wording in code * Update README.md Co-authored-by: Romain Bignon Co-authored-by: Yann Brelière --- README.md | 14 +++++++++++++- doctoshotgun.py | 17 +++++++++-------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7533afb..3b93e6c 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Optional arguments: --pfizer : looking only for a Pfizer vaccine --moderna : looking only for a Moderna vaccine --debug : display debug information +--time-window : set how many next days the script look for slots ``` ### With Docker @@ -88,7 +89,18 @@ You can also give the patient id as argument: ``` $ ./doctoshotgun.py paris roger.philibert@gmail.com PASSWORD -p 1 -Starting to look for vaccine slots for Luce Philibert... +Starting to look for vaccine slots for Luce Philibert in 1 next day(s)... +``` + +### Set time window + +By default, the script looks for slots between now and next day at 23:59:59. If you belong to a category of patients that is allowed to book a slot in a more distant future, you can expand the time window. For exemple, if you want to search in the next 5 days : + +``` +$ ./doctoshotgun.py paris roger.philibert@gmail.com -t 5 +Password: +Starting to look for vaccine slots for Clément VERMOT-DESROCHES in 5 next day(s)... +This may take a few minutes/hours, be patient! ``` ### Filter by vaccine diff --git a/doctoshotgun.py b/doctoshotgun.py index 03b9a8b..0b17700 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -106,9 +106,9 @@ def get_profile_id(self): class AvailabilitiesPage(JsonPage): - def find_best_slot(self, limit=True): + def find_best_slot(self, limit=True, time_window=1): for a in self.doc['availabilities']: - if limit and parse_date(a['date']).date() > datetime.date.today() + relativedelta(days=1): + if limit and parse_date(a['date']).date() > datetime.date.today() + relativedelta(days=time_window): continue if len(a['slots']) == 0: @@ -227,7 +227,7 @@ def normalize(self, string): normalized = re.sub(r'\W', '-', normalized) return normalized.lower() - def try_to_book(self, center): + def try_to_book(self, center, time_window=1): self.open(center['url']) p = urlparse(center['url']) center_id = p.path.split('/')[-1] @@ -249,12 +249,12 @@ def try_to_book(self, center): # do not filter to give a chance agenda_ids = center_page.get_agenda_ids(motive_id) - if self.try_to_book_place(profile_id, motive_id, practice_id, agenda_ids): + if self.try_to_book_place(profile_id, motive_id, practice_id, agenda_ids, time_window): return True return False - def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids): + def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, time_window=1): date = datetime.date.today().strftime('%Y-%m-%d') while date is not None: self.availabilities.go(params={'start_date': date, @@ -273,7 +273,7 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids): log('no availabilities', color='red') return False - slot = self.page.find_best_slot() + slot = self.page.find_best_slot(time_window=time_window) if not slot: log('first slot not found :(', color='red') return False @@ -397,6 +397,7 @@ def main(self): parser.add_argument('--pfizer', '-z', action='store_true', help='select only Pfizer vaccine') parser.add_argument('--moderna', '-m', action='store_true', help='select only Moderna vaccine') parser.add_argument('--patient', '-p', type=int, default=-1, help='give patient ID') + parser.add_argument('--time-window', '-t', type=int, default=1, help='set how many next days the script look for slots (default = 1)') parser.add_argument('--center', '-c', action='append', help='filter centers') parser.add_argument('city', help='city where to book') parser.add_argument('username', help='Doctolib username') @@ -449,7 +450,7 @@ def main(self): vaccine_list = [self.vaccine_motives[motive] for motive in motives] - log('Starting to look for vaccine slots for %s %s...', docto.patient['first_name'], docto.patient['last_name']) + log('Starting to look for vaccine slots for %s %s in %s next day(s)...', docto.patient['first_name'], docto.patient['last_name'], args.time_window) log('Vaccines: %s' % ', '.join(vaccine_list)) log('This may take a few minutes/hours, be patient!') cities = [docto.normalize(city) for city in args.city.split(',')] @@ -468,7 +469,7 @@ def main(self): log('') log('Center %s:', center['name_with_title']) - if docto.try_to_book(center): + if docto.try_to_book(center, args.time_window): log('') log('💉 %s Congratulations.' % colored('Booked!', 'green', attrs=('bold',))) return 0 From 90121a768763a5f383655f2eabeb0224a615bf3b Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Sat, 29 May 2021 14:00:48 +0200 Subject: [PATCH 10/58] set default --time-window value to 7 In France it is now possible to book for a vaccine without any limit of time. --- doctoshotgun.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 0b17700..902bd69 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -106,9 +106,9 @@ def get_profile_id(self): class AvailabilitiesPage(JsonPage): - def find_best_slot(self, limit=True, time_window=1): + def find_best_slot(self, time_window=1): for a in self.doc['availabilities']: - if limit and parse_date(a['date']).date() > datetime.date.today() + relativedelta(days=time_window): + if time_window and parse_date(a['date']).date() > datetime.date.today() + relativedelta(days=time_window): continue if len(a['slots']) == 0: @@ -313,7 +313,7 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, time 'practice_ids': practice_id, 'limit': 3}) - second_slot = self.page.find_best_slot(limit=False) + second_slot = self.page.find_best_slot(time_window=None) if not second_slot: log(' └╴ No second shot found') return False @@ -397,7 +397,7 @@ def main(self): parser.add_argument('--pfizer', '-z', action='store_true', help='select only Pfizer vaccine') parser.add_argument('--moderna', '-m', action='store_true', help='select only Moderna vaccine') parser.add_argument('--patient', '-p', type=int, default=-1, help='give patient ID') - parser.add_argument('--time-window', '-t', type=int, default=1, help='set how many next days the script look for slots (default = 1)') + parser.add_argument('--time-window', '-t', type=int, default=7, help='set how many next days the script look for slots (default = 7)') parser.add_argument('--center', '-c', action='append', help='filter centers') parser.add_argument('city', help='city where to book') parser.add_argument('username', help='Doctolib username') From 5175ae0425aba6ad06827f68a237e15acca3ca1a Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Sat, 29 May 2021 14:03:10 +0200 Subject: [PATCH 11/58] fix coding style --- doctoshotgun.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 902bd69..08cc15e 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -23,6 +23,7 @@ from woob.browser.pages import JsonPage, HTMLPage from woob.tools.log import createColoredFormatter + try: from playsound import playsound as _playsound, PlaysoundException def playsound(*args): @@ -34,6 +35,7 @@ def playsound(*args): def playsound(*args): pass + def log(text, *args, **kwargs): args = (colored(arg, 'yellow') for arg in args) if 'color' in kwargs: @@ -202,8 +204,7 @@ def find_centers(self, where, motives=('6970', '7005')): except ServerError as e: if e.response.status_code in [503]: return None - else: - raise e + raise e for i in self.page.iter_centers_ids(): page = self.center_result.open(id=i, params={'limit': '4', 'ref_visit_motive_ids[]': motives, 'speciality_id': '5494', 'search_result_format': 'json'}) @@ -221,7 +222,8 @@ def get_patients(self): return self.page.get_patients() - def normalize(self, string): + @classmethod + def normalize(cls, string): nfkd = unicodedata.normalize('NFKD', string) normalized = u"".join([c for c in nfkd if not unicodedata.combining(c)]) normalized = re.sub(r'\W', '-', normalized) @@ -277,7 +279,8 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, time if not slot: log('first slot not found :(', color='red') return False - if type(slot) != dict: + + if not isinstance(slot, dict): log('error while fetching first slot.', color='red') return False From 8c399c81deb28900875f8419c8b74529c60bef35 Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Sat, 29 May 2021 14:54:16 +0200 Subject: [PATCH 12/58] display an error message if the city is not found Closes #50 --- doctoshotgun.py | 52 +++++++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 08cc15e..5bce0d6 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -17,7 +17,7 @@ import cloudscraper from termcolor import colored -from woob.browser.exceptions import ClientError, ServerError +from woob.browser.exceptions import ClientError, ServerError, HTTPNotFound from woob.browser.browsers import LoginBrowser from woob.browser.url import URL from woob.browser.pages import JsonPage, HTMLPage @@ -145,6 +145,10 @@ def get_name(self): return '%s %s' % (self.doc[0]['first_name'], self.doc[0]['last_name']) +class CityNotFound(Exception): + pass + + class Doctolib(LoginBrowser): BASEURL = 'https://www.doctolib.fr' @@ -203,8 +207,10 @@ def find_centers(self, where, motives=('6970', '7005')): self.centers.go(where=city, params={'ref_visit_motive_ids[]': motives}) except ServerError as e: if e.response.status_code in [503]: - return None - raise e + return + raise + except HTTPNotFound as e: + raise CityNotFound(city) from e for i in self.page.iter_centers_ids(): page = self.center_result.open(id=i, params={'limit': '4', 'ref_visit_motive_ids[]': motives, 'speciality_id': '5494', 'search_result_format': 'json'}) @@ -458,28 +464,32 @@ def main(self): log('This may take a few minutes/hours, be patient!') cities = [docto.normalize(city) for city in args.city.split(',')] - while True: - for center in docto.find_centers(cities, motives): - if args.center: - if center['name_with_title'] not in args.center: - logging.debug("Skipping center '%s'", center['name_with_title']) - continue - else: - if docto.normalize(center['city']) not in cities: - logging.debug("Skipping city '%(city)s' %(name_with_title)s", center) - continue - - log('') - log('Center %s:', center['name_with_title']) + try: + while True: + for center in docto.find_centers(cities, motives): + if args.center: + if center['name_with_title'] not in args.center: + logging.debug("Skipping center '%s'", center['name_with_title']) + continue + else: + if docto.normalize(center['city']) not in cities: + logging.debug("Skipping city '%(city)s' %(name_with_title)s", center) + continue - if docto.try_to_book(center, args.time_window): log('') - log('💉 %s Congratulations.' % colored('Booked!', 'green', attrs=('bold',))) - return 0 + log('Center %s:', center['name_with_title']) - sleep(1) + if docto.try_to_book(center, args.time_window): + log('') + log('💉 %s Congratulations.' % colored('Booked!', 'green', attrs=('bold',))) + return 0 - sleep(5) + sleep(1) + + sleep(5) + except CityNotFound as e: + print('\n%s: City %s not found. For now Doctoshotgun works only in France.' % (colored('Error', 'red'), colored(e, 'yellow'))) + return 1 return 0 From 22e4f8b23f8957d95309001909a20b247393dba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Vermot?= Date: Sat, 29 May 2021 15:04:48 +0200 Subject: [PATCH 13/58] change name in example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3b93e6c..2231500 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ By default, the script looks for slots between now and next day at 23:59:59. If ``` $ ./doctoshotgun.py paris roger.philibert@gmail.com -t 5 Password: -Starting to look for vaccine slots for Clément VERMOT-DESROCHES in 5 next day(s)... +Starting to look for vaccine slots for Roger Philibert in 5 next day(s)... This may take a few minutes/hours, be patient! ``` From 901d9c45b30089863d942551a781960459a2e79c Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Sun, 30 May 2021 12:04:55 +0200 Subject: [PATCH 14/58] change description Now the script looks for a slot in the next seven days by default. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2231500..83f4deb 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # DOCTOSHOTGUN -This script lets you automatically book a vaccine slot on Doctolib for today or -tomorrow, following rules from the French Government. +This script lets you automatically book a vaccine slot on Doctolib in France in +the next seven days.

@@ -98,7 +98,7 @@ By default, the script looks for slots between now and next day at 23:59:59. If ``` $ ./doctoshotgun.py paris roger.philibert@gmail.com -t 5 -Password: +Password: Starting to look for vaccine slots for Roger Philibert in 5 next day(s)... This may take a few minutes/hours, be patient! ``` From e64a7bfef800efbe4dd7b6a718db659aceec4fdd Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Mon, 31 May 2021 23:34:26 +0200 Subject: [PATCH 15/58] README: add short option names --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 83f4deb..fcd9a51 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,12 @@ Run: Optional arguments: ``` ---center "" [--center "" …] : filter centers to only choose one from the provided list ---patient : select patient for which book a slot ---pfizer : looking only for a Pfizer vaccine ---moderna : looking only for a Moderna vaccine ---debug : display debug information ---time-window : set how many next days the script look for slots +--center "" [--center …] : filter centers to only choose one from the provided list +-p , --patient : select patient for which book a slot +-z, --pfizer : looking only for a Pfizer vaccine +-m, --moderna : looking only for a Moderna vaccine +-d, --debug : display debug information +-t , --time-window : set how many next days the script look for slots ``` ### With Docker From 9f292227cd98464526f5b2ffe1be4def35e548d5 Mon Sep 17 00:00:00 2001 From: Raphael Meudec Date: Tue, 1 Jun 2021 10:13:36 +0200 Subject: [PATCH 16/58] Enable to select a specific date for first shot --- doctoshotgun.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 5bce0d6..7977fc8 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -235,7 +235,7 @@ def normalize(cls, string): normalized = re.sub(r'\W', '-', normalized) return normalized.lower() - def try_to_book(self, center, time_window=1): + def try_to_book(self, center, time_window=1, date=None): self.open(center['url']) p = urlparse(center['url']) center_id = p.path.split('/')[-1] @@ -257,13 +257,13 @@ def try_to_book(self, center, time_window=1): # do not filter to give a chance agenda_ids = center_page.get_agenda_ids(motive_id) - if self.try_to_book_place(profile_id, motive_id, practice_id, agenda_ids, time_window): + if self.try_to_book_place(profile_id, motive_id, practice_id, agenda_ids, time_window, date): return True return False - def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, time_window=1): - date = datetime.date.today().strftime('%Y-%m-%d') + def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, time_window=1, date=None): + date = datetime.datetime.strptime(date, '%d/%m/%Y').strftime('%Y-%m-%d') if date else datetime.date.today().strftime('%Y-%m-%d') while date is not None: self.availabilities.go(params={'start_date': date, 'visit_motive_ids': motive_id, @@ -408,6 +408,7 @@ def main(self): parser.add_argument('--patient', '-p', type=int, default=-1, help='give patient ID') parser.add_argument('--time-window', '-t', type=int, default=7, help='set how many next days the script look for slots (default = 7)') parser.add_argument('--center', '-c', action='append', help='filter centers') + parser.add_argument('--date', type=str, default=None, help='date on which you want to book the first slot (format should be DD/MM/YYYY)') parser.add_argument('city', help='city where to book') parser.add_argument('username', help='Doctolib username') parser.add_argument('password', nargs='?', help='Doctolib password') @@ -479,7 +480,7 @@ def main(self): log('') log('Center %s:', center['name_with_title']) - if docto.try_to_book(center, args.time_window): + if docto.try_to_book(center, args.time_window, args.date): log('') log('💉 %s Congratulations.' % colored('Booked!', 'green', attrs=('bold',))) return 0 From cbb99c34272591e0060d5e849a5eca1d45d706f9 Mon Sep 17 00:00:00 2001 From: Raphael Meudec Date: Tue, 1 Jun 2021 10:17:20 +0200 Subject: [PATCH 17/58] Add in readme --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index fcd9a51..23d5785 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Optional arguments: -m, --moderna : looking only for a Moderna vaccine -d, --debug : display debug information -t , --time-window : set how many next days the script look for slots +--date

: set a specific start date on which to start looking ``` ### With Docker @@ -103,6 +104,17 @@ Starting to look for vaccine slots for Roger Philibert in 5 next day(s)... This may take a few minutes/hours, be patient! ``` +### Look on specific date + +By default, the script looks for slots between now and next day at 23:59:59. If you can't be vaccinated right now (e.g covid in the last 3 months or out of town) and you are looking for an appointment in a distant future, you can pass a starting date: + +``` +$ ./doctoshotgun.py paris roger.philibert@gmail.com --date 17/06/2021 +Password: +Starting to look for vaccine slots for Roger Philibert in 5 next day(s)... +This may take a few minutes/hours, be patient! +``` + ### Filter by vaccine The Pfizer vaccine is the only vaccine allowed in France for people between 16 and 18. For this case, you can use the -z option. From 4fe4be5ac692422bb21e4923cadd8da9ca182b1c Mon Sep 17 00:00:00 2001 From: Raphael Meudec Date: Tue, 1 Jun 2021 14:48:59 +0200 Subject: [PATCH 18/58] From --date to --start-date --- doctoshotgun.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 7977fc8..77653c1 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -408,7 +408,7 @@ def main(self): parser.add_argument('--patient', '-p', type=int, default=-1, help='give patient ID') parser.add_argument('--time-window', '-t', type=int, default=7, help='set how many next days the script look for slots (default = 7)') parser.add_argument('--center', '-c', action='append', help='filter centers') - parser.add_argument('--date', type=str, default=None, help='date on which you want to book the first slot (format should be DD/MM/YYYY)') + parser.add_argument('--start-date', type=str, default=None, help='date on which you want to book the first slot (format should be DD/MM/YYYY)') parser.add_argument('city', help='city where to book') parser.add_argument('username', help='Doctolib username') parser.add_argument('password', nargs='?', help='Doctolib password') @@ -480,7 +480,7 @@ def main(self): log('') log('Center %s:', center['name_with_title']) - if docto.try_to_book(center, args.time_window, args.date): + if docto.try_to_book(center, args.time_window, args.start_date): log('') log('💉 %s Congratulations.' % colored('Booked!', 'green', attrs=('bold',))) return 0 From bd9b4092783e97990060296c1ab8ca16c94a4902 Mon Sep 17 00:00:00 2001 From: Raphael Meudec Date: Tue, 1 Jun 2021 14:50:22 +0200 Subject: [PATCH 19/58] --start-date in readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 23d5785..69ebe72 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Optional arguments: -m, --moderna : looking only for a Moderna vaccine -d, --debug : display debug information -t , --time-window : set how many next days the script look for slots ---date
: set a specific start date on which to start looking +--start-date
: set a specific start date on which to start looking ``` ### With Docker @@ -109,7 +109,7 @@ This may take a few minutes/hours, be patient! By default, the script looks for slots between now and next day at 23:59:59. If you can't be vaccinated right now (e.g covid in the last 3 months or out of town) and you are looking for an appointment in a distant future, you can pass a starting date: ``` -$ ./doctoshotgun.py paris roger.philibert@gmail.com --date 17/06/2021 +$ ./doctoshotgun.py paris roger.philibert@gmail.com --start-date 17/06/2021 Password: Starting to look for vaccine slots for Roger Philibert in 5 next day(s)... This may take a few minutes/hours, be patient! From d7714836b96789b565c45e8a8a16b4ccca1fd046 Mon Sep 17 00:00:00 2001 From: Raphael Meudec Date: Tue, 1 Jun 2021 15:02:57 +0200 Subject: [PATCH 20/58] Add log on start date --- README.md | 6 +++--- doctoshotgun.py | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 69ebe72..86f545a 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ You can also give the patient id as argument: ``` $ ./doctoshotgun.py paris roger.philibert@gmail.com PASSWORD -p 1 -Starting to look for vaccine slots for Luce Philibert in 1 next day(s)... +Starting to look for vaccine slots for Luce Philibert in 1 next day(s) starting today... ``` ### Set time window @@ -100,7 +100,7 @@ By default, the script looks for slots between now and next day at 23:59:59. If ``` $ ./doctoshotgun.py paris roger.philibert@gmail.com -t 5 Password: -Starting to look for vaccine slots for Roger Philibert in 5 next day(s)... +Starting to look for vaccine slots for Roger Philibert in 5 next day(s) starting today... This may take a few minutes/hours, be patient! ``` @@ -111,7 +111,7 @@ By default, the script looks for slots between now and next day at 23:59:59. If ``` $ ./doctoshotgun.py paris roger.philibert@gmail.com --start-date 17/06/2021 Password: -Starting to look for vaccine slots for Roger Philibert in 5 next day(s)... +Starting to look for vaccine slots for Roger Philibert in 7 next day(s) starting 17/06/2021... This may take a few minutes/hours, be patient! ``` diff --git a/doctoshotgun.py b/doctoshotgun.py index 77653c1..53c2c99 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -460,7 +460,8 @@ def main(self): vaccine_list = [self.vaccine_motives[motive] for motive in motives] - log('Starting to look for vaccine slots for %s %s in %s next day(s)...', docto.patient['first_name'], docto.patient['last_name'], args.time_window) + start_date_log = args.start_date if args.start_date else 'today' + log('Starting to look for vaccine slots for %s %s in %s next day(s) starting %s...', docto.patient['first_name'], docto.patient['last_name'], args.time_window, start_date_log) log('Vaccines: %s' % ', '.join(vaccine_list)) log('This may take a few minutes/hours, be patient!') cities = [docto.normalize(city) for city in args.city.split(',')] From c79b7cde2f0bdf3c221c1f32d404fc939ea71d4e Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Wed, 2 Jun 2021 05:50:10 +0200 Subject: [PATCH 21/58] add --dry-run to not really book a slot --- README.md | 1 + doctoshotgun.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 86f545a..19abc66 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Optional arguments: -d, --debug : display debug information -t , --time-window : set how many next days the script look for slots --start-date
: set a specific start date on which to start looking +--dry-run : do not really book a slot ``` ### With Docker diff --git a/doctoshotgun.py b/doctoshotgun.py index 53c2c99..3b30d8c 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -235,7 +235,7 @@ def normalize(cls, string): normalized = re.sub(r'\W', '-', normalized) return normalized.lower() - def try_to_book(self, center, time_window=1, date=None): + def try_to_book(self, center, time_window=1, date=None, dry_run=False): self.open(center['url']) p = urlparse(center['url']) center_id = p.path.split('/')[-1] @@ -257,12 +257,12 @@ def try_to_book(self, center, time_window=1, date=None): # do not filter to give a chance agenda_ids = center_page.get_agenda_ids(motive_id) - if self.try_to_book_place(profile_id, motive_id, practice_id, agenda_ids, time_window, date): + if self.try_to_book_place(profile_id, motive_id, practice_id, agenda_ids, time_window, date, dry_run): return True return False - def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, time_window=1, date=None): + def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, time_window=1, date=None, dry_run=False): date = datetime.datetime.strptime(date, '%d/%m/%Y').strftime('%Y-%m-%d') if date else datetime.date.today().strftime('%Y-%m-%d') while date is not None: self.availabilities.go(params={'start_date': date, @@ -356,6 +356,10 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, time custom_fields[field['id']] = value + if dry_run: + log(' └╴ Booking status: %s', 'fake') + return True + data = {'appointment': {'custom_fields_values': custom_fields, 'new_patient': True, 'qualification_answers': {}, @@ -409,6 +413,7 @@ def main(self): parser.add_argument('--time-window', '-t', type=int, default=7, help='set how many next days the script look for slots (default = 7)') parser.add_argument('--center', '-c', action='append', help='filter centers') parser.add_argument('--start-date', type=str, default=None, help='date on which you want to book the first slot (format should be DD/MM/YYYY)') + parser.add_argument('--dry-run', action='store_true', help='do not really book the slot') parser.add_argument('city', help='city where to book') parser.add_argument('username', help='Doctolib username') parser.add_argument('password', nargs='?', help='Doctolib password') @@ -481,7 +486,7 @@ def main(self): log('') log('Center %s:', center['name_with_title']) - if docto.try_to_book(center, args.time_window, args.start_date): + if docto.try_to_book(center, args.time_window, args.start_date, args.dry_run): log('') log('💉 %s Congratulations.' % colored('Booked!', 'green', attrs=('bold',))) return 0 From 367ee9afb133c499153399e0d0a0413d7eff1527 Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Sun, 6 Jun 2021 15:09:15 +0200 Subject: [PATCH 22/58] remove dependence to playsound (as it may not work) --- README.md | 2 +- requirements.txt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 19abc66..49143c0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ the next seven days. - cloudscraper - dateutil - termcolor -- playsound +- playsound (optional) ## How to use it diff --git a/requirements.txt b/requirements.txt index 6cf9904..1278fa2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,3 @@ woob cloudscraper python-dateutil termcolor -playsound \ No newline at end of file From b15ad96122d36d56448b083ecc76a856799dac18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Duffeck?= Date: Wed, 9 Jun 2021 15:48:08 +0200 Subject: [PATCH 23/58] Implement 2-factor authentication during login (by email) --- doctoshotgun.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 3b30d8c..bacac9c 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -58,8 +58,16 @@ def send(self, *args, **kwargs): class LoginPage(JsonPage): - pass + def redirect(self): + return self.doc['redirection'] + +class SendAuthCodePage(JsonPage): + def build_doc(self, content): + return "" # Do not choke on empty response from server +class ChallengePage(JsonPage): + def build_doc(self, content): + return "" # Do not choke on empty response from server class CentersPage(HTMLPage): def iter_centers_ids(self): @@ -153,6 +161,8 @@ class Doctolib(LoginBrowser): BASEURL = 'https://www.doctolib.fr' login = URL('/login.json', LoginPage) + send_auth_code = URL('/api/accounts/send_auth_code', SendAuthCodePage) + challenge = URL('/login/challenge', ChallengePage) centers = URL(r'/vaccination-covid-19/(?P\w+)', CentersPage) center_result = URL(r'/search_results/(?P\d+).json', CenterResultPage) center = URL(r'/centre-de-sante/.*', CenterPage) @@ -197,8 +207,19 @@ def do_login(self): 'remember': True, 'remember_username': True}) except ClientError: + print('Wrong login/password') return False + if self.page.redirect() == "/sessions/two-factor": + print("Requesting 2fa code...") + self.send_auth_code.go(json={'two_factor_auth_method': 'email'}, method="POST") + code = input("Enter auth code: ") + try: + self.challenge.go(json={'auth_code': code, 'two_factor_auth_method': 'email'}, method="POST") + except HTTPNotFound: + print("Invalid auth code") + return False + return True def find_centers(self, where, motives=('6970', '7005')): @@ -431,7 +452,6 @@ def main(self): docto = Doctolib(args.username, args.password, responses_dirname=responses_dirname) if not docto.do_login(): - print('Wrong login/password') return 1 patients = docto.get_patients() From ce0be0333b7f15408d987b529aa4525640d19150 Mon Sep 17 00:00:00 2001 From: Edgar Lubicz Date: Sun, 6 Jun 2021 14:31:34 +0200 Subject: [PATCH 24/58] Adding country support. Now France (fr) and Germany are available (de) Adding Janssen vaccine option Adjusting test cases and readme Retry after 5s in case of failure in connection In case on many vaccine types, try all of the motives instead of only one. --- README.md | 29 ++++---- doctoshotgun.py | 180 ++++++++++++++++++++++++++++++++---------------- test_browser.py | 52 +++++++++++--- 3 files changed, 180 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 49143c0..ddf585a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # DOCTOSHOTGUN -This script lets you automatically book a vaccine slot on Doctolib in France in -the next seven days. +This script lets you automatically book a vaccine slot on Doctolib in France and in Germany in +the next seven days.

@@ -27,16 +27,17 @@ pip install -r requirements.txt Run: ``` -./doctoshotgun.py [password] +./doctoshotgun.py --city --country --username [--password ] ``` -Optional arguments: +Further optional arguments: ``` --center "" [--center …] : filter centers to only choose one from the provided list -p , --patient : select patient for which book a slot -z, --pfizer : looking only for a Pfizer vaccine -m, --moderna : looking only for a Moderna vaccine +-j, --janssen : looking only for a Janssen vaccine -d, --debug : display debug information -t , --time-window : set how many next days the script look for slots --start-date

: set a specific start date on which to start looking @@ -54,7 +55,7 @@ docker build . -t doctoshotgun Run the container: ``` -docker run doctoshotgun [password] +docker run doctoshotgun --city --country --username [--password ] ``` ### Multiple cities @@ -62,15 +63,15 @@ docker run doctoshotgun [password] You can also look for slot in multiple cities at the same time. Cities must be separated by commas: ``` -$ ./doctoshotgun.py ,, [password] +$ ./doctoshotgun.py --city ,, --country --username [--password ] ``` ### Filter on centers -You can give name of centers in which you want specifictly looking for: +You can give name of centers in which you want specifically looking for: ``` -$ ./doctoshotgun.py paris roger.philibert@gmail.com \ +$ ./doctoshotgun.py --city paris --country fr --username roger.philibert@gmail.com \ --center "Centre de Vaccination Covid 19 - Ville de Paris" \ --center "Centre de Vaccination du 7eme arrondissement de Paris - Gymnase Camou" ``` @@ -80,7 +81,7 @@ $ ./doctoshotgun.py paris roger.philibert@gmail.com \ For doctolib accounts with more thant one patient, you can select patient just after launching the script: ``` -$ ./doctoshotgun.py paris roger.philibert@gmail.com PASSWORD +$ ./doctoshotgun.py --city paris --country fr --username roger.philibert@gmail.com --password PASSWORD Available patients are: * [0] Roger Philibert * [1] Luce Philibert @@ -90,7 +91,7 @@ For which patient do you want to book a slot? You can also give the patient id as argument: ``` -$ ./doctoshotgun.py paris roger.philibert@gmail.com PASSWORD -p 1 +$ ./doctoshotgun.py --city paris --country fr --username roger.philibert@gmail.com --password PASSWORD -p 1 Starting to look for vaccine slots for Luce Philibert in 1 next day(s) starting today... ``` @@ -99,7 +100,7 @@ Starting to look for vaccine slots for Luce Philibert in 1 next day(s) starting By default, the script looks for slots between now and next day at 23:59:59. If you belong to a category of patients that is allowed to book a slot in a more distant future, you can expand the time window. For exemple, if you want to search in the next 5 days : ``` -$ ./doctoshotgun.py paris roger.philibert@gmail.com -t 5 +$ ./doctoshotgun.py --city paris --country fr --username roger.philibert@gmail.com -t 5 Password: Starting to look for vaccine slots for Roger Philibert in 5 next day(s) starting today... This may take a few minutes/hours, be patient! @@ -110,7 +111,7 @@ This may take a few minutes/hours, be patient! By default, the script looks for slots between now and next day at 23:59:59. If you can't be vaccinated right now (e.g covid in the last 3 months or out of town) and you are looking for an appointment in a distant future, you can pass a starting date: ``` -$ ./doctoshotgun.py paris roger.philibert@gmail.com --start-date 17/06/2021 +$ ./doctoshotgun.py --city paris --country fr --username roger.philibert@gmail.com --start-date 17/06/2021 Password: Starting to look for vaccine slots for Roger Philibert in 7 next day(s) starting 17/06/2021... This may take a few minutes/hours, be patient! @@ -121,13 +122,13 @@ This may take a few minutes/hours, be patient! The Pfizer vaccine is the only vaccine allowed in France for people between 16 and 18. For this case, you can use the -z option. ``` -$ ./doctoshotgun.py paris roger.philibert@gmail.com PASSWORD -z +$ ./doctoshotgun.py --city paris --country fr --username roger.philibert@gmail.com --password PASSWORD -z Starting to look for vaccine slots for Luce Philibert... Vaccines: Pfizer This may take a few minutes/hours, be patient! ``` -It is also possible to filter on Moderna vaccine with the -m option. +It is also possible to filter on Moderna vaccine with the -m option and Janssen with the -j option. ## Development diff --git a/doctoshotgun.py b/doctoshotgun.py index bacac9c..b6c3f80 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -15,7 +15,9 @@ from dateutil.relativedelta import relativedelta import cloudscraper +from requests.adapters import ReadTimeout, ConnectionError from termcolor import colored +from urllib3.exceptions import NewConnectionError from woob.browser.exceptions import ClientError, ServerError, HTTPNotFound from woob.browser.browsers import LoginBrowser @@ -120,7 +122,6 @@ def find_best_slot(self, time_window=1): for a in self.doc['availabilities']: if time_window and parse_date(a['date']).date() > datetime.date.today() + relativedelta(days=time_window): continue - if len(a['slots']) == 0: continue return a['slots'][-1] @@ -156,16 +157,17 @@ def get_name(self): class CityNotFound(Exception): pass - class Doctolib(LoginBrowser): - BASEURL = 'https://www.doctolib.fr' - + # individual properties for each country. To be defined in subclasses + BASEURL = "" + vaccine_motives = {} + centers = URL('') + center = URL('') + # common properties login = URL('/login.json', LoginPage) send_auth_code = URL('/api/accounts/send_auth_code', SendAuthCodePage) challenge = URL('/login/challenge', ChallengePage) - centers = URL(r'/vaccination-covid-19/(?P\w+)', CentersPage) center_result = URL(r'/search_results/(?P\d+).json', CenterResultPage) - center = URL(r'/centre-de-sante/.*', CenterPage) center_booking = URL(r'/booking/(?P.+).json', CenterBookingPage) availabilities = URL(r'/availabilities.json', AvailabilitiesPage) second_shot_availabilities = URL(r'/second_shot_availabilities.json', AvailabilitiesPage) @@ -222,7 +224,9 @@ def do_login(self): return True - def find_centers(self, where, motives=('6970', '7005')): + def find_centers(self, where, motives=None): + if motives is None: + motives = self.vaccine_motives.keys() for city in where: try: self.centers.go(where=city, params={'ref_visit_motive_ids[]': motives}) @@ -234,11 +238,15 @@ def find_centers(self, where, motives=('6970', '7005')): raise CityNotFound(city) from e for i in self.page.iter_centers_ids(): - page = self.center_result.open(id=i, params={'limit': '4', 'ref_visit_motive_ids[]': motives, 'speciality_id': '5494', 'search_result_format': 'json'}) - # XXX return all pages even if there are no indicated availabilities. - #for a in page.doc['availabilities']: - # if len(a['slots']) > 0: - # yield page.doc['search_result'] + page = self.center_result.open( + id=i, + params={ + 'limit': '4', + 'ref_visit_motive_ids[]': motives, + 'speciality_id': '5494', + 'search_result_format': 'json' + } + ) try: yield page.doc['search_result'] except KeyError: @@ -256,43 +264,50 @@ def normalize(cls, string): normalized = re.sub(r'\W', '-', normalized) return normalized.lower() - def try_to_book(self, center, time_window=1, date=None, dry_run=False): + def try_to_book(self, center, vaccine_list, time_window=1, date=None, dry_run=False): self.open(center['url']) p = urlparse(center['url']) center_id = p.path.split('/')[-1] center_page = self.center_booking.go(center_id=center_id) profile_id = self.page.get_profile_id() - motive_id = self.page.find_motive(r'1re.*(Pfizer|Moderna)') - - if not motive_id: - log('Unable to find mRNA motive') + # extract motive ids based on the vaccine names + motives_id = dict() + for vaccine in vaccine_list: + motives_id[vaccine] = self.page.find_motive(r'.*({})'.format(vaccine)) + + motives_id = dict((k, v) for k, v in motives_id.items() if v is not None) + if len(motives_id.values()) == 0: + log('Unable to find requested vaccines in motives') log('Motives: %s', ', '.join(self.page.get_motives())) return False for place in self.page.get_places(): - log('– %s...', place['name'], end=' ', flush=True) + log('– %s...', place['name'], flush=True) practice_id = place['practice_ids'][0] - agenda_ids = center_page.get_agenda_ids(motive_id, practice_id) - if len(agenda_ids) == 0: - # do not filter to give a chance - agenda_ids = center_page.get_agenda_ids(motive_id) + for vac_name, motive_id in motives_id.items(): + log('- Trying %s...', vac_name, end=' ', flush=True) + agenda_ids = center_page.get_agenda_ids(motive_id, practice_id) + if len(agenda_ids) == 0: + # do not filter to give a chance + agenda_ids = center_page.get_agenda_ids(motive_id) - if self.try_to_book_place(profile_id, motive_id, practice_id, agenda_ids, time_window, date, dry_run): - return True + if self.try_to_book_place(profile_id, motive_id, practice_id, agenda_ids, time_window, date, dry_run): + return True return False def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, time_window=1, date=None, dry_run=False): date = datetime.datetime.strptime(date, '%d/%m/%Y').strftime('%Y-%m-%d') if date else datetime.date.today().strftime('%Y-%m-%d') while date is not None: - self.availabilities.go(params={'start_date': date, - 'visit_motive_ids': motive_id, - 'agenda_ids': '-'.join(agenda_ids), - 'insurance_sector': 'public', - 'practice_ids': practice_id, - 'destroy_temporary': 'true', - 'limit': 3}) + self.availabilities.go( + params={'start_date': date, + 'visit_motive_ids': motive_id, + 'agenda_ids': '-'.join(agenda_ids), + 'insurance_sector': 'public', + 'practice_ids': practice_id, + 'destroy_temporary': 'true', + 'limit': 3}) if 'next_slot' in self.page.doc: date = self.page.doc['next_slot'] else: @@ -335,13 +350,14 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, time playsound('ding.mp3') - self.second_shot_availabilities.go(params={'start_date': slot['steps'][1]['start_date'].split('T')[0], - 'visit_motive_ids': motive_id, - 'agenda_ids': '-'.join(agenda_ids), - 'first_slot': slot['start_date'], - 'insurance_sector': 'public', - 'practice_ids': practice_id, - 'limit': 3}) + self.second_shot_availabilities.go( + params={'start_date': slot['steps'][1]['start_date'].split('T')[0], + 'visit_motive_ids': motive_id, + 'agenda_ids': '-'.join(agenda_ids), + 'first_slot': slot['start_date'], + 'insurance_sector': 'public', + 'practice_ids': practice_id, + 'limit': 3}) second_slot = self.page.find_best_slot(time_window=None) if not second_slot: @@ -397,7 +413,7 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, time self.appointment_post.go(id=a_id, data=json.dumps(data), headers=headers, method='PUT') if 'redirection' in self.page.doc and not 'confirmed-appointment' in self.page.doc['redirection']: - log(' ├╴ Open %s to complete', 'https://www.doctolib.fr' + self.page.doc['redirection']) + log(' ├╴ Open %s to complete', self.BASEURL + self.page.doc['redirection']) self.appointment_post.go(id=a_id) @@ -405,11 +421,34 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, time return self.page.doc['confirmed'] -class Application: - vaccine_motives = {'6970': 'Pfizer', - '7005': 'Moderna', - } +class DoctolibDE(Doctolib): + BASEURL = 'https://www.doctolib.de' + _KEY_PFIZER = '6768' + _KEY_MODERNA = '6936' + _KEY_JANSSEN = '7978' + vaccine_motives = { + _KEY_PFIZER: 'Pfizer', + _KEY_MODERNA: 'Moderna', + _KEY_JANSSEN: 'Janssen', + } + centers = URL(r'/impfung-covid-19-corona/(?P\w+)', CentersPage) + center = URL(r'/praxis/.*', CenterPage) + +class DoctolibFR(Doctolib): + BASEURL = 'https://www.doctolib.fr' + _KEY_PFIZER = '6970' + _KEY_MODERNA = '7005' + _KEY_JANSSEN = '7945' + vaccine_motives = { + _KEY_PFIZER: 'Pfizer', + _KEY_MODERNA: 'Moderna', + _KEY_JANSSEN: 'Janssen', + } + centers = URL(r'/vaccination-covid-19/(?P\w+)', CentersPage) + center = URL(r'/centre-de-sante/.*', CenterPage) + +class Application: @classmethod def create_default_logger(cls): # stderr logger @@ -426,18 +465,25 @@ def setup_loggers(self, level): logging.root.addHandler(self.create_default_logger()) def main(self): + doctolib_map = { + "fr": DoctolibFR, + "de": DoctolibDE + } + parser = argparse.ArgumentParser(description="Book a vaccine slot on Doctolib") parser.add_argument('--debug', '-d', action='store_true', help='show debug information') parser.add_argument('--pfizer', '-z', action='store_true', help='select only Pfizer vaccine') parser.add_argument('--moderna', '-m', action='store_true', help='select only Moderna vaccine') + parser.add_argument('--janssen', '-j', action='store_true', help='select only Janssen vaccine') parser.add_argument('--patient', '-p', type=int, default=-1, help='give patient ID') parser.add_argument('--time-window', '-t', type=int, default=7, help='set how many next days the script look for slots (default = 7)') parser.add_argument('--center', '-c', action='append', help='filter centers') parser.add_argument('--start-date', type=str, default=None, help='date on which you want to book the first slot (format should be DD/MM/YYYY)') parser.add_argument('--dry-run', action='store_true', help='do not really book the slot') - parser.add_argument('city', help='city where to book') - parser.add_argument('username', help='Doctolib username') - parser.add_argument('password', nargs='?', help='Doctolib password') + parser.add_argument('--city', help='city where to book', required=True) + parser.add_argument('--country', help='country where to book: ' + str(doctolib_map.keys()), required=True) + parser.add_argument('--username', help='Doctolib username', required=True) + parser.add_argument('--password', nargs='?', help='Doctolib password') args = parser.parse_args() if args.debug: @@ -450,7 +496,12 @@ def main(self): if not args.password: args.password = getpass.getpass() - docto = Doctolib(args.username, args.password, responses_dirname=responses_dirname) + country = args.country.lower() + if country not in doctolib_map: + print(colored('Choose one of the available countries: ' + str(doctolib_map.keys()), 'red')) + return 1 + + docto = doctolib_map[country](args.username, args.password, responses_dirname=responses_dirname) if not docto.do_login(): return 1 @@ -476,23 +527,26 @@ def main(self): docto.patient = patients[0] motives = [] - if not args.pfizer and not args.moderna: - motives = ['6970', '7005'] + if not args.pfizer and not args.moderna and not args.janssen: + motives = docto.vaccine_motives.keys() if args.pfizer: - motives.append('6970') + motives.append(docto._KEY_PFIZER) if args.moderna: - motives.append('7005') + motives.append(docto._KEY_MODERNA) + if args.janssen: + motives.append(docto._KEY_JANSSEN) - vaccine_list = [self.vaccine_motives[motive] for motive in motives] + vaccine_list = [docto.vaccine_motives[motive] for motive in motives] start_date_log = args.start_date if args.start_date else 'today' log('Starting to look for vaccine slots for %s %s in %s next day(s) starting %s...', docto.patient['first_name'], docto.patient['last_name'], args.time_window, start_date_log) log('Vaccines: %s' % ', '.join(vaccine_list)) + log('Country: %s ' % country) log('This may take a few minutes/hours, be patient!') cities = [docto.normalize(city) for city in args.city.split(',')] - try: - while True: + while True: + try: for center in docto.find_centers(cities, motives): if args.center: if center['name_with_title'] not in args.center: @@ -506,7 +560,7 @@ def main(self): log('') log('Center %s:', center['name_with_title']) - if docto.try_to_book(center, args.time_window, args.start_date, args.dry_run): + if docto.try_to_book(center, vaccine_list, args.time_window, args.start_date, args.dry_run): log('') log('💉 %s Congratulations.' % colored('Booked!', 'green', attrs=('bold',))) return 0 @@ -514,10 +568,18 @@ def main(self): sleep(1) sleep(5) - except CityNotFound as e: - print('\n%s: City %s not found. For now Doctoshotgun works only in France.' % (colored('Error', 'red'), colored(e, 'yellow'))) - return 1 - + except CityNotFound as e: + print('\n%s: City %s not found. Make sure you selected a city from the available countries.' % (colored('Error', 'red'), colored(e, 'yellow'))) + return 1 + except (ReadTimeout, ConnectionError, NewConnectionError) as e: + print('\n%s' % (colored('Connection error. Check your internet connection. Retrying ...', 'red'))) + print(str(e)) + sleep(5) + except Exception as e: + template = "An unexpected exception of type {0} occurred. Arguments:\n{1!r}" + message = template.format(type(e).__name__, e.args) + print(message) + return 1 return 0 diff --git a/test_browser.py b/test_browser.py index d2a29a8..df4014d 100644 --- a/test_browser.py +++ b/test_browser.py @@ -1,21 +1,20 @@ import pytest import responses from woob.browser.exceptions import ServerError - -from doctoshotgun import Doctolib +from doctoshotgun import DoctolibDE, DoctolibFR @responses.activate -def test_find_centers_returns_503_should_continue(tmp_path): +def test_find_centers_fr_returns_503_should_continue(tmp_path): """ Check that find_centers doesn't raise a ServerError in case of 503 HTTP response """ - docto = Doctolib("roger.phillibert@gmail.com", "1234", responses_dirname=tmp_path) + docto = DoctolibFR("roger.phillibert@gmail.com", "1234", responses_dirname=tmp_path) docto.BASEURL = "https://127.0.0.1" responses.add( responses.GET, - "https://127.0.0.1/vaccination-covid-19/Paris?ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=7005", + "https://127.0.0.1/vaccination-covid-19/Paris?ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=7005&ref_visit_motive_ids%5B%5D=7945", status=503 ) @@ -23,18 +22,36 @@ def test_find_centers_returns_503_should_continue(tmp_path): for _ in docto.find_centers(["Paris"]): pass +@responses.activate +def test_find_centers_de_returns_503_should_continue(tmp_path): + """ + Check that find_centers doesn't raise a ServerError in case of 503 HTTP response + """ + docto = DoctolibDE("roger.phillibert@gmail.com", "1234", responses_dirname=tmp_path) + docto.BASEURL = "https://127.0.0.1" + + responses.add( + responses.GET, + "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=7978", + status=503 + ) + + # this should not raise an exception + for _ in docto.find_centers(["München"]): + pass + @responses.activate -def test_find_centers_returns_502_should_fail(tmp_path): +def test_find_centers_fr_returns_502_should_fail(tmp_path): """ Check that find_centers raises an error in case of non-whitelisted status code """ - docto = Doctolib("roger.phillibert@gmail.com", "1234", responses_dirname=tmp_path) + docto = DoctolibFR("roger.phillibert@gmail.com", "1234", responses_dirname=tmp_path) docto.BASEURL = "https://127.0.0.1" responses.add( responses.GET, - "https://127.0.0.1/vaccination-covid-19/Paris?ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=7005", + "https://127.0.0.1/vaccination-covid-19/Paris?ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=7005&ref_visit_motive_ids%5B%5D=7945", status=502 ) @@ -42,3 +59,22 @@ def test_find_centers_returns_502_should_fail(tmp_path): with pytest.raises(ServerError): for _ in docto.find_centers(["Paris"]): pass + +@responses.activate +def test_find_centers_de_returns_502_should_fail(tmp_path): + """ + Check that find_centers raises an error in case of non-whitelisted status code + """ + docto = DoctolibDE("roger.phillibert@gmail.com", "1234", responses_dirname=tmp_path) + docto.BASEURL = "https://127.0.0.1" + + responses.add( + responses.GET, + "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=7978", + status=502 + ) + + # this should raise an exception + with pytest.raises(ServerError): + for _ in docto.find_centers(["München"]): + pass From e478c423d8e3174434894a22a501d6ba85a96450 Mon Sep 17 00:00:00 2001 From: Edgar Lubicz Date: Sun, 6 Jun 2021 14:38:20 +0200 Subject: [PATCH 25/58] Better handling of country argument Leaving out deprecated example image on Readme --- README.md | 10 +++------- doctoshotgun.py | 11 +++-------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index ddf585a..7086b62 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,6 @@ This script lets you automatically book a vaccine slot on Doctolib in France and the next seven days. -

- -

- ## Python dependencies - [woob](https://woob.tech) @@ -27,7 +23,7 @@ pip install -r requirements.txt Run: ``` -./doctoshotgun.py --city --country --username [--password ] +./doctoshotgun.py --city --country <{fr,de}> --username [--password ] ``` Further optional arguments: @@ -55,7 +51,7 @@ docker build . -t doctoshotgun Run the container: ``` -docker run doctoshotgun --city --country --username [--password ] +docker run doctoshotgun --city --country <{fr,de}}> --username [--password ] ``` ### Multiple cities @@ -63,7 +59,7 @@ docker run doctoshotgun --city --country --username [-- You can also look for slot in multiple cities at the same time. Cities must be separated by commas: ``` -$ ./doctoshotgun.py --city ,, --country --username [--password ] +$ ./doctoshotgun.py --city ,, --country <{fr,de}> --username [--password ] ``` ### Filter on centers diff --git a/doctoshotgun.py b/doctoshotgun.py index b6c3f80..fcc2258 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -481,7 +481,7 @@ def main(self): parser.add_argument('--start-date', type=str, default=None, help='date on which you want to book the first slot (format should be DD/MM/YYYY)') parser.add_argument('--dry-run', action='store_true', help='do not really book the slot') parser.add_argument('--city', help='city where to book', required=True) - parser.add_argument('--country', help='country where to book: ' + str(doctolib_map.keys()), required=True) + parser.add_argument('--country', help='country where to book', choices=list(doctolib_map.keys()), required=True) parser.add_argument('--username', help='Doctolib username', required=True) parser.add_argument('--password', nargs='?', help='Doctolib password') args = parser.parse_args() @@ -496,12 +496,7 @@ def main(self): if not args.password: args.password = getpass.getpass() - country = args.country.lower() - if country not in doctolib_map: - print(colored('Choose one of the available countries: ' + str(doctolib_map.keys()), 'red')) - return 1 - - docto = doctolib_map[country](args.username, args.password, responses_dirname=responses_dirname) + docto = doctolib_map[args.country](args.username, args.password, responses_dirname=responses_dirname) if not docto.do_login(): return 1 @@ -541,7 +536,7 @@ def main(self): start_date_log = args.start_date if args.start_date else 'today' log('Starting to look for vaccine slots for %s %s in %s next day(s) starting %s...', docto.patient['first_name'], docto.patient['last_name'], args.time_window, start_date_log) log('Vaccines: %s' % ', '.join(vaccine_list)) - log('Country: %s ' % country) + log('Country: %s ' % args.country) log('This may take a few minutes/hours, be patient!') cities = [docto.normalize(city) for city in args.city.split(',')] From efc99cae5b610266862494b95fd6c3d5396fcdee Mon Sep 17 00:00:00 2001 From: Edgar Lubicz Date: Mon, 7 Jun 2021 09:27:31 +0200 Subject: [PATCH 26/58] city, username and password are now positional arguments again Adding country as first positional argument Aligning readme --- README.md | 18 +++++++++--------- doctoshotgun.py | 8 ++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7086b62..fa15c38 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ pip install -r requirements.txt Run: ``` -./doctoshotgun.py --city --country <{fr,de}> --username [--password ] +./doctoshotgun.py [] ``` Further optional arguments: @@ -51,7 +51,7 @@ docker build . -t doctoshotgun Run the container: ``` -docker run doctoshotgun --city --country <{fr,de}}> --username [--password ] +docker run doctoshotgun [] ``` ### Multiple cities @@ -59,7 +59,7 @@ docker run doctoshotgun --city --country <{fr,de}}> --username [- You can also look for slot in multiple cities at the same time. Cities must be separated by commas: ``` -$ ./doctoshotgun.py --city ,, --country <{fr,de}> --username [--password ] +$ ./doctoshotgun.py ,, [] ``` ### Filter on centers @@ -67,7 +67,7 @@ $ ./doctoshotgun.py --city ,, --country <{fr,de}> --usernam You can give name of centers in which you want specifically looking for: ``` -$ ./doctoshotgun.py --city paris --country fr --username roger.philibert@gmail.com \ +$ ./doctoshotgun.py fr paris roger.philibert@gmail.com \ --center "Centre de Vaccination Covid 19 - Ville de Paris" \ --center "Centre de Vaccination du 7eme arrondissement de Paris - Gymnase Camou" ``` @@ -77,7 +77,7 @@ $ ./doctoshotgun.py --city paris --country fr --username roger.philibert@gmail.c For doctolib accounts with more thant one patient, you can select patient just after launching the script: ``` -$ ./doctoshotgun.py --city paris --country fr --username roger.philibert@gmail.com --password PASSWORD +$ ./doctoshotgun.py fr paris roger.philibert@gmail.com PASSWORD Available patients are: * [0] Roger Philibert * [1] Luce Philibert @@ -87,7 +87,7 @@ For which patient do you want to book a slot? You can also give the patient id as argument: ``` -$ ./doctoshotgun.py --city paris --country fr --username roger.philibert@gmail.com --password PASSWORD -p 1 +$ ./doctoshotgun.py fr paris roger.philibert@gmail.com PASSWORD -p 1 Starting to look for vaccine slots for Luce Philibert in 1 next day(s) starting today... ``` @@ -96,7 +96,7 @@ Starting to look for vaccine slots for Luce Philibert in 1 next day(s) starting By default, the script looks for slots between now and next day at 23:59:59. If you belong to a category of patients that is allowed to book a slot in a more distant future, you can expand the time window. For exemple, if you want to search in the next 5 days : ``` -$ ./doctoshotgun.py --city paris --country fr --username roger.philibert@gmail.com -t 5 +$ ./doctoshotgun.py fr paris roger.philibert@gmail.com -t 5 Password: Starting to look for vaccine slots for Roger Philibert in 5 next day(s) starting today... This may take a few minutes/hours, be patient! @@ -107,7 +107,7 @@ This may take a few minutes/hours, be patient! By default, the script looks for slots between now and next day at 23:59:59. If you can't be vaccinated right now (e.g covid in the last 3 months or out of town) and you are looking for an appointment in a distant future, you can pass a starting date: ``` -$ ./doctoshotgun.py --city paris --country fr --username roger.philibert@gmail.com --start-date 17/06/2021 +$ ./doctoshotgun.py fr paris roger.philibert@gmail.com --start-date 17/06/2021 Password: Starting to look for vaccine slots for Roger Philibert in 7 next day(s) starting 17/06/2021... This may take a few minutes/hours, be patient! @@ -118,7 +118,7 @@ This may take a few minutes/hours, be patient! The Pfizer vaccine is the only vaccine allowed in France for people between 16 and 18. For this case, you can use the -z option. ``` -$ ./doctoshotgun.py --city paris --country fr --username roger.philibert@gmail.com --password PASSWORD -z +$ ./doctoshotgun.py fr paris roger.philibert@gmail.com PASSWORD -z Starting to look for vaccine slots for Luce Philibert... Vaccines: Pfizer This may take a few minutes/hours, be patient! diff --git a/doctoshotgun.py b/doctoshotgun.py index fcc2258..d7d325f 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -480,10 +480,10 @@ def main(self): parser.add_argument('--center', '-c', action='append', help='filter centers') parser.add_argument('--start-date', type=str, default=None, help='date on which you want to book the first slot (format should be DD/MM/YYYY)') parser.add_argument('--dry-run', action='store_true', help='do not really book the slot') - parser.add_argument('--city', help='city where to book', required=True) - parser.add_argument('--country', help='country where to book', choices=list(doctolib_map.keys()), required=True) - parser.add_argument('--username', help='Doctolib username', required=True) - parser.add_argument('--password', nargs='?', help='Doctolib password') + parser.add_argument('country', help='country where to book', choices=list(doctolib_map.keys())) + parser.add_argument('city', help='city where to book') + parser.add_argument('username', help='Doctolib username') + parser.add_argument('password', nargs='?', help='Doctolib password') args = parser.parse_args() if args.debug: From 13426cc3dad72493defea61369a5c70710523622 Mon Sep 17 00:00:00 2001 From: Edgar Lubicz Date: Mon, 7 Jun 2021 21:13:42 +0200 Subject: [PATCH 27/58] Not looking anymore for Janssen's 2nd shot, as there isn't any Dealing with different slot response formats Missing dependencies to make playsound play sound --- doctoshotgun.py | 73 ++++++++++++++++++++++++++++++++---------------- requirements.txt | 2 ++ 2 files changed, 51 insertions(+), 24 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index d7d325f..9b583f6 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -292,12 +292,12 @@ def try_to_book(self, center, vaccine_list, time_window=1, date=None, dry_run=Fa # do not filter to give a chance agenda_ids = center_page.get_agenda_ids(motive_id) - if self.try_to_book_place(profile_id, motive_id, practice_id, agenda_ids, time_window, date, dry_run): + if self.try_to_book_place(profile_id, motive_id, practice_id, agenda_ids, vac_name.lower(), time_window, date, dry_run): return True return False - def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, time_window=1, date=None, dry_run=False): + def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_name, time_window=1, date=None, dry_run=False): date = datetime.datetime.strptime(date, '%d/%m/%Y').strftime('%Y-%m-%d') if date else datetime.date.today().strftime('%Y-%m-%d') while date is not None: self.availabilities.go( @@ -322,16 +322,27 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, time log('first slot not found :(', color='red') return False - if not isinstance(slot, dict): + # depending on the country, the slot is returned in a different format. Go figure... + if isinstance(slot, dict) and 'start_date' in slot: + slot_date_first = slot['start_date'] + if vac_name != "janssen": + slot_date_second = slot['steps'][1]['start_date'] + elif isinstance(slot, str): + slot_date_first = slot # should be for Janssen only, otherwise it is a list + elif isinstance(slot, list): + slot_date_first = slot[0] + if vac_name != "janssen": # maybe redundant? + slot_date_second = slot[1] + else: log('error while fetching first slot.', color='red') return False log('found!', color='green') - log(' ├╴ Best slot found: %s', parse_date(slot['start_date']).strftime('%c')) + log(' ├╴ Best slot found: %s', parse_date(slot_date_first).strftime('%c')) appointment = {'profile_id': profile_id, 'source_action': 'profile', - 'start_date': slot['start_date'], + 'start_date': slot_date_first, 'visit_motive_ids': str(motive_id), } @@ -350,28 +361,42 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, time playsound('ding.mp3') - self.second_shot_availabilities.go( - params={'start_date': slot['steps'][1]['start_date'].split('T')[0], - 'visit_motive_ids': motive_id, - 'agenda_ids': '-'.join(agenda_ids), - 'first_slot': slot['start_date'], - 'insurance_sector': 'public', - 'practice_ids': practice_id, - 'limit': 3}) - - second_slot = self.page.find_best_slot(time_window=None) - if not second_slot: - log(' └╴ No second shot found') - return False + if vac_name != "janssen": # janssen has only one shot + self.second_shot_availabilities.go( + params={'start_date': slot_date_second.split('T')[0], + 'visit_motive_ids': motive_id, + 'agenda_ids': '-'.join(agenda_ids), + 'first_slot': slot_date_first, + 'insurance_sector': 'public', + 'practice_ids': practice_id, + 'limit': 3}) - log(' ├╴ Second shot: %s', parse_date(second_slot['start_date']).strftime('%c')) + second_slot = self.page.find_best_slot(time_window=None) + if not second_slot: + log(' └╴ No second shot found') + return False - data['second_slot'] = second_slot['start_date'] - self.appointment.go(data=json.dumps(data), headers=headers) + # in theory we could use the stored slot_date_second result from above, + # but we refresh with the new results to play safe + if isinstance(second_slot, dict) and 'start_date' in second_slot: + slot_date_second = second_slot['start_date'] + elif isinstance(slot, str): + slot_date_second = second_slot + # TODO: is this else needed? + #elif isinstance(slot, list): + # slot_date_second = second_slot[1] + else: + log('error while fetching second slot.', color='red') + return False - if self.page.is_error(): - log(' └╴ Appointment not available anymore :( %s', self.page.get_error()) - return False + log(' ├╴ Second shot: %s', parse_date(slot_date_second).strftime('%c')) + + data['second_slot'] = slot_date_second + self.appointment.go(data=json.dumps(data), headers=headers) + + if self.page.is_error(): + log(' └╴ Appointment not available anymore :( %s', self.page.get_error()) + return False a_id = self.page.doc['id'] diff --git a/requirements.txt b/requirements.txt index 1278fa2..25cc35d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ woob cloudscraper python-dateutil termcolor +vext +vext.gi From b7be121743a18f0e5b19228d9d435c971bab0240 Mon Sep 17 00:00:00 2001 From: Edgar Lubicz Date: Mon, 7 Jun 2021 21:21:48 +0200 Subject: [PATCH 28/58] Removing playsound dependencies as they are now optional on the main repo --- requirements.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 25cc35d..1278fa2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,3 @@ woob cloudscraper python-dateutil termcolor -vext -vext.gi From 6a9b5223925093a6105aab5b314a4dc73136dca6 Mon Sep 17 00:00:00 2001 From: Edgar Lubicz Date: Wed, 9 Jun 2021 08:51:06 +0200 Subject: [PATCH 29/58] Making terminal colors work for windows Updating readme with python3 mentioned explicitely. --- README.md | 3 +++ doctoshotgun.py | 3 +++ requirements.txt | 1 + 3 files changed, 7 insertions(+) diff --git a/README.md b/README.md index fa15c38..9c6858c 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,13 @@ the next seven days. - cloudscraper - dateutil - termcolor +- colorama - playsound (optional) ## How to use it +You need python3 for this script. If you don't have it, please [install it first](https://www.python.org/). + Install dependencies: ``` diff --git a/doctoshotgun.py b/doctoshotgun.py index 9b583f6..8e5fc21 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -15,6 +15,7 @@ from dateutil.relativedelta import relativedelta import cloudscraper +import colorama from requests.adapters import ReadTimeout, ConnectionError from termcolor import colored from urllib3.exceptions import NewConnectionError @@ -490,6 +491,8 @@ def setup_loggers(self, level): logging.root.addHandler(self.create_default_logger()) def main(self): + colorama.init() # needed for windows + doctolib_map = { "fr": DoctolibFR, "de": DoctolibDE diff --git a/requirements.txt b/requirements.txt index 1278fa2..2e3b0cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ woob cloudscraper python-dateutil termcolor +colorama From ac0cccae715b697f2086297dd53d1c0f8d9d07b9 Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Thu, 10 Jun 2021 09:20:32 +0200 Subject: [PATCH 30/58] rename _KEY_* attributes to KEY_* --- doctoshotgun.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 8e5fc21..98cbe7c 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -449,26 +449,26 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_ class DoctolibDE(Doctolib): BASEURL = 'https://www.doctolib.de' - _KEY_PFIZER = '6768' - _KEY_MODERNA = '6936' - _KEY_JANSSEN = '7978' + KEY_PFIZER = '6768' + KEY_MODERNA = '6936' + KEY_JANSSEN = '7978' vaccine_motives = { - _KEY_PFIZER: 'Pfizer', - _KEY_MODERNA: 'Moderna', - _KEY_JANSSEN: 'Janssen', + KEY_PFIZER: 'Pfizer', + KEY_MODERNA: 'Moderna', + KEY_JANSSEN: 'Janssen', } centers = URL(r'/impfung-covid-19-corona/(?P\w+)', CentersPage) center = URL(r'/praxis/.*', CenterPage) class DoctolibFR(Doctolib): BASEURL = 'https://www.doctolib.fr' - _KEY_PFIZER = '6970' - _KEY_MODERNA = '7005' - _KEY_JANSSEN = '7945' + KEY_PFIZER = '6970' + KEY_MODERNA = '7005' + KEY_JANSSEN = '7945' vaccine_motives = { - _KEY_PFIZER: 'Pfizer', - _KEY_MODERNA: 'Moderna', - _KEY_JANSSEN: 'Janssen', + KEY_PFIZER: 'Pfizer', + KEY_MODERNA: 'Moderna', + KEY_JANSSEN: 'Janssen', } centers = URL(r'/vaccination-covid-19/(?P\w+)', CentersPage) @@ -553,11 +553,11 @@ def main(self): if not args.pfizer and not args.moderna and not args.janssen: motives = docto.vaccine_motives.keys() if args.pfizer: - motives.append(docto._KEY_PFIZER) + motives.append(docto.KEY_PFIZER) if args.moderna: - motives.append(docto._KEY_MODERNA) + motives.append(docto.KEY_MODERNA) if args.janssen: - motives.append(docto._KEY_JANSSEN) + motives.append(docto.KEY_JANSSEN) vaccine_list = [docto.vaccine_motives[motive] for motive in motives] From af67011f171895a9397faa44d18daf5aabb19019 Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Fri, 11 Jun 2021 08:57:08 +0200 Subject: [PATCH 31/58] fix: use BASEURL during login to work with doctolib.de --- doctoshotgun.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 98cbe7c..6fe1733 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -202,7 +202,7 @@ def logged(self): return self._logged def do_login(self): - self.open('https://www.doctolib.fr/sessions/new') + self.open(self.BASEURL + '/sessions/new') try: self.login.go(json={'kind': 'patient', 'username': self.username, From 59363111592e05b0eb9f801a052b958b7cd38847 Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Fri, 11 Jun 2021 08:57:40 +0200 Subject: [PATCH 32/58] remove useless 'logged' property --- doctoshotgun.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 6fe1733..becca27 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -194,13 +194,8 @@ def __init__(self, *args, **kwargs): self.session.headers['sec-fetch-site'] = 'same-origin' self.session.headers['User-Agent'] = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36' - self._logged = False self.patient = None - @property - def logged(self): - return self._logged - def do_login(self): self.open(self.BASEURL + '/sessions/new') try: From 580b8056e4ba0e004077f2b775a9570c676ee30f Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Fri, 11 Jun 2021 09:14:25 +0200 Subject: [PATCH 33/58] fix usage of --start-date, and add --end-date option --- doctoshotgun.py | 49 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index becca27..8c5540e 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -119,9 +119,10 @@ def get_profile_id(self): class AvailabilitiesPage(JsonPage): - def find_best_slot(self, time_window=1): + def find_best_slot(self, start_date=None, end_date=None): for a in self.doc['availabilities']: - if time_window and parse_date(a['date']).date() > datetime.date.today() + relativedelta(days=time_window): + date = parse_date(a['date']).date() + if start_date and date < start_date or end_date and date > end_date: continue if len(a['slots']) == 0: continue @@ -260,7 +261,7 @@ def normalize(cls, string): normalized = re.sub(r'\W', '-', normalized) return normalized.lower() - def try_to_book(self, center, vaccine_list, time_window=1, date=None, dry_run=False): + def try_to_book(self, center, vaccine_list, start_date, end_date, dry_run=False): self.open(center['url']) p = urlparse(center['url']) center_id = p.path.split('/')[-1] @@ -279,22 +280,22 @@ def try_to_book(self, center, vaccine_list, time_window=1, date=None, dry_run=Fa return False for place in self.page.get_places(): - log('– %s...', place['name'], flush=True) + log('– %s...', place['name']) practice_id = place['practice_ids'][0] for vac_name, motive_id in motives_id.items(): - log('- Trying %s...', vac_name, end=' ', flush=True) + log(' Vaccine %s...', vac_name, end=' ', flush=True) agenda_ids = center_page.get_agenda_ids(motive_id, practice_id) if len(agenda_ids) == 0: # do not filter to give a chance agenda_ids = center_page.get_agenda_ids(motive_id) - if self.try_to_book_place(profile_id, motive_id, practice_id, agenda_ids, vac_name.lower(), time_window, date, dry_run): + if self.try_to_book_place(profile_id, motive_id, practice_id, agenda_ids, vac_name.lower(), start_date, end_date, dry_run): return True return False - def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_name, time_window=1, date=None, dry_run=False): - date = datetime.datetime.strptime(date, '%d/%m/%Y').strftime('%Y-%m-%d') if date else datetime.date.today().strftime('%Y-%m-%d') + def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_name, start_date, end_date, dry_run=False): + date = start_date.strftime('%Y-%m-%d') while date is not None: self.availabilities.go( params={'start_date': date, @@ -313,7 +314,7 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_ log('no availabilities', color='red') return False - slot = self.page.find_best_slot(time_window=time_window) + slot = self.page.find_best_slot(start_date, end_date) if not slot: log('first slot not found :(', color='red') return False @@ -367,7 +368,7 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_ 'practice_ids': practice_id, 'limit': 3}) - second_slot = self.page.find_best_slot(time_window=None) + second_slot = self.page.find_best_slot() if not second_slot: log(' └╴ No second shot found') return False @@ -501,7 +502,8 @@ def main(self): parser.add_argument('--patient', '-p', type=int, default=-1, help='give patient ID') parser.add_argument('--time-window', '-t', type=int, default=7, help='set how many next days the script look for slots (default = 7)') parser.add_argument('--center', '-c', action='append', help='filter centers') - parser.add_argument('--start-date', type=str, default=None, help='date on which you want to book the first slot (format should be DD/MM/YYYY)') + parser.add_argument('--start-date', type=str, default=None, help='first date on which you want to book the first slot (format should be DD/MM/YYYY)') + parser.add_argument('--end-date', type=str, default=None, help='last date on which you want to book the first slot (format should be DD/MM/YYYY)') parser.add_argument('--dry-run', action='store_true', help='do not really book the slot') parser.add_argument('country', help='country where to book', choices=list(doctolib_map.keys())) parser.add_argument('city', help='city where to book') @@ -556,10 +558,25 @@ def main(self): vaccine_list = [docto.vaccine_motives[motive] for motive in motives] - start_date_log = args.start_date if args.start_date else 'today' - log('Starting to look for vaccine slots for %s %s in %s next day(s) starting %s...', docto.patient['first_name'], docto.patient['last_name'], args.time_window, start_date_log) - log('Vaccines: %s' % ', '.join(vaccine_list)) - log('Country: %s ' % args.country) + if args.start_date: + try: + start_date = datetime.datetime.strptime(args.start_date, '%d/%m/%Y').date() + except ValueError as e: + print('Invalid value for --start-date: %s' % e) + return 1 + else: + start_date = datetime.date.today() + if args.end_date: + try: + end_date = datetime.datetime.strptime(args.end_date, '%d/%m/%Y').date() + except ValueError as e: + print('Invalid value for --end-date: %s' % e) + return 1 + else: + end_date = start_date + relativedelta(days=args.time_window) + log('Starting to look for vaccine slots for %s %s between %s and %s...', docto.patient['first_name'], docto.patient['last_name'], start_date, end_date) + log('Vaccines: %s', ', '.join(vaccine_list)) + log('Country: %s ', args.country) log('This may take a few minutes/hours, be patient!') cities = [docto.normalize(city) for city in args.city.split(',')] @@ -578,7 +595,7 @@ def main(self): log('') log('Center %s:', center['name_with_title']) - if docto.try_to_book(center, vaccine_list, args.time_window, args.start_date, args.dry_run): + if docto.try_to_book(center, vaccine_list, start_date, end_date, args.dry_run): log('') log('💉 %s Congratulations.' % colored('Booked!', 'green', attrs=('bold',))) return 0 From 9a447753079b311a83f95162b59c2eda6306b62c Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Fri, 11 Jun 2021 09:22:50 +0200 Subject: [PATCH 34/58] do not display place name if empty --- doctoshotgun.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 8c5540e..47a050a 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -280,7 +280,8 @@ def try_to_book(self, center, vaccine_list, start_date, end_date, dry_run=False) return False for place in self.page.get_places(): - log('– %s...', place['name']) + if place['name']: + log('– %s...', place['name']) practice_id = place['practice_ids'][0] for vac_name, motive_id in motives_id.items(): log(' Vaccine %s...', vac_name, end=' ', flush=True) From 74518719a59184ca3ec4df57e7519cab40bc184a Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Fri, 11 Jun 2021 09:22:57 +0200 Subject: [PATCH 35/58] introduce --end-date in README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9c6858c..cbdad69 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # DOCTOSHOTGUN This script lets you automatically book a vaccine slot on Doctolib in France and in Germany in -the next seven days. +the next seven days. ## Python dependencies @@ -39,7 +39,8 @@ Further optional arguments: -j, --janssen : looking only for a Janssen vaccine -d, --debug : display debug information -t , --time-window : set how many next days the script look for slots ---start-date
: set a specific start date on which to start looking +--start-date
: first date on which you want to book the first slot +--end-date
: last date on which you want to book the first slot --dry-run : do not really book a slot ``` From 1349979132f5edf557f0a9d4db669eddc314bcd9 Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Fri, 11 Jun 2021 09:40:18 +0200 Subject: [PATCH 36/58] update example with new country parameter --- example.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example.svg b/example.svg index 7ce8047..1459a7e 100644 --- a/example.svg +++ b/example.svg @@ -1 +1 @@ -rom1@money(master)~/src/doctoshotgun$rom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromain@bignon.merom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromain@bignon.me-prom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromain@bignon.me-p0Password:StartingtolookforvaccineslotsforRomainBignon...Thismaytakeafewminutes/hours,bepatient!CenterCentredevaccinationCovid-19-CPAMdeParis75:CentredevaccinationAmelot-CPAMdeParis75...noavailabilitiesCentredevaccinationMaroc-CPAMdeParis75...noavailabilitiesCenterCentredevaccinationCovid-19-Paris17ᵉ:CentredeVaccinationCovid-19-Paris17e...noavailabilitiesCenterCentredeVaccinationCovid-19-VilledeParis:CentredeVaccination-Mairiedu10e...found!├╴Bestslotfound:MonMay1716:30:002021├╴Secondshot:SatJun2617:00:002021├╴BookingforRomainBignon...└╴Bookingstatus:True💉Booked!Congratulations.rom1@money(master)~/src/doctoshotgun$.rom1@money(master)~/src/doctoshotgun$./rom1@money(master)~/src/doctoshotgun$./drom1@money(master)~/src/doctoshotgun$./dorom1@money(master)~/src/doctoshotgun$./docrom1@money(master)~/src/doctoshotgun$./doctrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyprom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparirom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisrrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisrorom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromarom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromairom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromainrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromain@rom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromain@brom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromain@birom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromain@bigrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromain@bignrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromain@bignorom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromain@bignonrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromain@bignon.rom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromain@bignon.mrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyparisromain@bignon.me-CentredevaccinationAmelot-CPAMdeParis75...CentredevaccinationMaroc-CPAMdeParis75...CentredeVaccinationCovid-19-Paris17e...CentredeVaccination-Mairiedu10e... \ No newline at end of file +rom1@money(master)~/src/doctoshotgun$rom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromain@bignon.merom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromain@bignon.me-prom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromain@bignon.me-p0Password:StartingtolookforvaccineslotsforRomainBignon...Thismaytakeafewminutes/hours,bepatient!CenterCentredevaccinationCovid-19-CPAMdeParis75:CentredevaccinationAmelot-CPAMdeParis75...noavailabilitiesCentredevaccinationMaroc-CPAMdeParis75...noavailabilitiesCenterCentredevaccinationCovid-19-Paris17ᵉ:CentredeVaccinationCovid-19-Paris17e...noavailabilitiesCenterCentredeVaccinationCovid-19-VilledeParis:CentredeVaccination-Mairiedu10e...found!├╴Bestslotfound:MonMay1716:30:002021├╴Secondshot:SatJun2617:00:002021├╴BookingforRomainBignon...└╴Bookingstatus:True💉Booked!Congratulations.rom1@money(master)~/src/doctoshotgun$.rom1@money(master)~/src/doctoshotgun$./rom1@money(master)~/src/doctoshotgun$./drom1@money(master)~/src/doctoshotgun$./dorom1@money(master)~/src/doctoshotgun$./docrom1@money(master)~/src/doctoshotgun$./doctrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrprom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparirom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisrrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisrorom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromarom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromairom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromainrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromain@rom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromain@brom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromain@birom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromain@bigrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromain@bignrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromain@bignorom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromain@bignonrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromain@bignon.rom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromain@bignon.mrom1@money(master)~/src/doctoshotgun$./doctoshotgun.pyfrparisromain@bignon.me-CentredevaccinationAmelot-CPAMdeParis75...CentredevaccinationMaroc-CPAMdeParis75...CentredeVaccinationCovid-19-Paris17e...CentredeVaccination-Mairiedu10e... \ No newline at end of file From cb6b9411c2601a0be3285a7bae2161a5f7d9db1e Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Fri, 11 Jun 2021 09:41:52 +0200 Subject: [PATCH 37/58] README.md: reintroduce example.svg --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index cbdad69..bde00b5 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ This script lets you automatically book a vaccine slot on Doctolib in France and in Germany in the next seven days. +

+ +

## Python dependencies From a518ee5621a7b83845f89cf31a23484097d6cbf4 Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Sat, 12 Jun 2021 13:29:34 +0200 Subject: [PATCH 38/58] do not crash when receiving only one date for other vaccines than janssen --- doctoshotgun.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 47a050a..86ccb8d 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -325,7 +325,7 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_ slot_date_first = slot['start_date'] if vac_name != "janssen": slot_date_second = slot['steps'][1]['start_date'] - elif isinstance(slot, str): + elif isinstance(slot, str) and vac_name == 'janssen': slot_date_first = slot # should be for Janssen only, otherwise it is a list elif isinstance(slot, list): slot_date_first = slot[0] From 60b023e8728b00f7e95cbac40706b1e4ede505af Mon Sep 17 00:00:00 2001 From: gitolicious <26963495+gitolicious@users.noreply.github.com> Date: Fri, 11 Jun 2021 21:52:06 +0200 Subject: [PATCH 39/58] Do not book slots if new patients are not allowed --- doctoshotgun.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doctoshotgun.py b/doctoshotgun.py index 86ccb8d..15a4d7d 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -91,6 +91,9 @@ class CenterBookingPage(JsonPage): def find_motive(self, regex): for s in self.doc['data']['visit_motives']: if re.search(regex, s['name']): + if s['allow_new_patients'] == False: + log('Motive %s not allowed for new patients at this center. Skipping vaccine...', s['name'], flush=True) + return None return s['id'] return None From 10cfc0ea70c694eeaaa964eda4ab62107f497c95 Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Sat, 12 Jun 2021 13:37:20 +0200 Subject: [PATCH 40/58] change error message when no vaccine motives are found --- doctoshotgun.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 15a4d7d..5dea369 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -90,10 +90,7 @@ class CenterPage(HTMLPage): class CenterBookingPage(JsonPage): def find_motive(self, regex): for s in self.doc['data']['visit_motives']: - if re.search(regex, s['name']): - if s['allow_new_patients'] == False: - log('Motive %s not allowed for new patients at this center. Skipping vaccine...', s['name'], flush=True) - return None + if re.search(regex, s['name']) and s['allow_new_patients']: return s['id'] return None @@ -271,20 +268,21 @@ def try_to_book(self, center, vaccine_list, start_date, end_date, dry_run=False) center_page = self.center_booking.go(center_id=center_id) profile_id = self.page.get_profile_id() + # extract motive ids based on the vaccine names - motives_id = dict() + motives_id = {} for vaccine in vaccine_list: - motives_id[vaccine] = self.page.find_motive(r'.*({})'.format(vaccine)) + motive_id = self.page.find_motive(r'.*({})'.format(vaccine)) + if motive_id: + motives_id[vaccine] = motive_id - motives_id = dict((k, v) for k, v in motives_id.items() if v is not None) if len(motives_id.values()) == 0: - log('Unable to find requested vaccines in motives') - log('Motives: %s', ', '.join(self.page.get_motives())) + log(colored('unable to find requested vaccines', 'red')) return False for place in self.page.get_places(): if place['name']: - log('– %s...', place['name']) + log('– %s', place['name']) practice_id = place['practice_ids'][0] for vac_name, motive_id in motives_id.items(): log(' Vaccine %s...', vac_name, end=' ', flush=True) From 605af57f4e1ed4787e18dfa22ab4cb246fed7807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Duffeck?= Date: Fri, 18 Jun 2021 08:37:27 +0200 Subject: [PATCH 41/58] Adjust readme to run docker interactively That is needed e.g. for asking for the password or the 2fa code. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bde00b5..120ec94 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ docker build . -t doctoshotgun Run the container: ``` -docker run doctoshotgun [] +docker run -it doctoshotgun [] ``` ### Multiple cities From 11a965493c8727a0afcf62b8a6bfad1390442944 Mon Sep 17 00:00:00 2001 From: gitolicious <26963495+gitolicious@users.noreply.github.com> Date: Thu, 17 Jun 2021 23:25:06 +0200 Subject: [PATCH 42/58] add Cloudflare-specific error message on login request --- doctoshotgun.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 5dea369..08e4baf 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -198,7 +198,14 @@ def __init__(self, *args, **kwargs): self.patient = None def do_login(self): - self.open(self.BASEURL + '/sessions/new') + try: + self.open(self.BASEURL + '/sessions/new') + except ServerError as e: + if e.response.status_code in [503] \ + and 'text/html' in e.response.headers['Content-Type'] \ + and 'cloudflare' in e.response.text: + log('Request blocked by CloudFlare', color='red') + raise try: self.login.go(json={'kind': 'patient', 'username': self.username, From 44af15db885d2ffbccbf14201628b856a0a39429 Mon Sep 17 00:00:00 2001 From: gitolicious <26963495+gitolicious@users.noreply.github.com> Date: Wed, 23 Jun 2021 17:50:48 +0200 Subject: [PATCH 43/58] Enhanced Cloudflare log message --- doctoshotgun.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 08e4baf..39491fe 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -203,7 +203,7 @@ def do_login(self): except ServerError as e: if e.response.status_code in [503] \ and 'text/html' in e.response.headers['Content-Type'] \ - and 'cloudflare' in e.response.text: + and ('cloudflare' in e.response.text or 'Checking your browser before accessing' in e .response.text): log('Request blocked by CloudFlare', color='red') raise try: From 7813cc1e271a7799bb14c4db31467d180e378ffa Mon Sep 17 00:00:00 2001 From: gitolicious <26963495+gitolicious@users.noreply.github.com> Date: Thu, 1 Jul 2021 19:13:03 +0000 Subject: [PATCH 44/58] merged many open PRs into one credits go to the respective contributors @RomainL972 - Allow search second/third dose only #60 @edwillys - Log with timestamp before looping over centers #67 @strombringer - add an option to exclude a list of centers from the search results #72 @gitolicious - add try_to_book happy path test with mock responses #73 @gitolicious - skip second shot motives #78 @namnhatpham1995 - Add AstraZeneca choice #79 @Dreiundzwanzig - Add options for AstraZeneca plus second and third shot #81 @gitolicious - Add option to include neighbor cities #85 @gitolicious - Add 2FA code argument for non-interactive environments #86 @gitolicious - Add option to include and exclude centers by regular expression #91 @laucia - Relax city name filtering for centers #94 --- .gitignore | 3 + doctoshotgun.py | 372 ++++++++++++++++++++++------- test_browser.py | 234 +++++++++++++++++- test_cli_args.py | 91 +++++++ test_fixtures/availabilities.json | 20 ++ test_fixtures/doctor_response.json | 51 ++++ test_fixtures/search_result.json | 7 + 7 files changed, 681 insertions(+), 97 deletions(-) create mode 100644 test_cli_args.py create mode 100644 test_fixtures/availabilities.json create mode 100644 test_fixtures/doctor_response.json create mode 100644 test_fixtures/search_result.json diff --git a/.gitignore b/.gitignore index 8d35cb3..d118619 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ __pycache__ *.pyc + +.vscode +.devcontainer diff --git a/doctoshotgun.py b/doctoshotgun.py index 39491fe..488a462 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -26,14 +26,19 @@ from woob.browser.pages import JsonPage, HTMLPage from woob.tools.log import createColoredFormatter +SLEEP_INTERVAL_AFTER_CONNECTION_ERROR = 5 +SLEEP_INTERVAL_AFTER_LOGIN_ERROR = 10 +SLEEP_INTERVAL_AFTER_CENTER = 1 +SLEEP_INTERVAL_AFTER_RUN = 5 try: from playsound import playsound as _playsound, PlaysoundException + def playsound(*args): try: return _playsound(*args) - except (PlaysoundException,ModuleNotFoundError): - pass # do not crash if, for one reason or another, something wrong happens + except (PlaysoundException, ModuleNotFoundError): + pass # do not crash if, for one reason or another, something wrong happens except ImportError: def playsound(*args): pass @@ -47,6 +52,14 @@ def log(text, *args, **kwargs): print(text, **kwargs) +def log_ts(text=None, *args, **kwargs): + ''' Log with timestamp''' + now = datetime.datetime.now() + print("[%s]" % now.isoformat(" ", "seconds")) + if text: + log(text, *args, **kwargs) + + class Session(cloudscraper.CloudScraper): def send(self, *args, **kwargs): callback = kwargs.pop('callback', lambda future, response: response) @@ -64,13 +77,16 @@ class LoginPage(JsonPage): def redirect(self): return self.doc['redirection'] + class SendAuthCodePage(JsonPage): def build_doc(self, content): - return "" # Do not choke on empty response from server + return "" # Do not choke on empty response from server + class ChallengePage(JsonPage): def build_doc(self, content): - return "" # Do not choke on empty response from server + return "" # Do not choke on empty response from server + class CentersPage(HTMLPage): def iter_centers_ids(self): @@ -88,9 +104,18 @@ class CenterPage(HTMLPage): class CenterBookingPage(JsonPage): - def find_motive(self, regex): + def find_motive(self, regex, singleShot=False): for s in self.doc['data']['visit_motives']: - if re.search(regex, s['name']) and s['allow_new_patients']: + # ignore case as some doctors use their own spelling + if re.search(regex, s['name'], re.IGNORECASE): + if s['allow_new_patients'] == False: + log('Motive %s not allowed for new patients at this center. Skipping vaccine...', + s['name'], flush=True) + continue + if not singleShot and not s['first_shot_motive']: + log('Skipping second shot motive %s...', + s['name'], flush=True) + continue return s['id'] return None @@ -159,6 +184,7 @@ def get_name(self): class CityNotFound(Exception): pass + class Doctolib(LoginBrowser): # individual properties for each country. To be defined in subclasses BASEURL = "" @@ -172,10 +198,13 @@ class Doctolib(LoginBrowser): center_result = URL(r'/search_results/(?P\d+).json', CenterResultPage) center_booking = URL(r'/booking/(?P.+).json', CenterBookingPage) availabilities = URL(r'/availabilities.json', AvailabilitiesPage) - second_shot_availabilities = URL(r'/second_shot_availabilities.json', AvailabilitiesPage) + second_shot_availabilities = URL( + r'/second_shot_availabilities.json', AvailabilitiesPage) appointment = URL(r'/appointments.json', AppointmentPage) - appointment_edit = URL(r'/appointments/(?P.+)/edit.json', AppointmentEditPage) - appointment_post = URL(r'/appointments/(?P.+).json', AppointmentPostPage) + appointment_edit = URL( + r'/appointments/(?P.+)/edit.json', AppointmentEditPage) + appointment_post = URL( + r'/appointments/(?P.+).json', AppointmentPostPage) master_patient = URL(r'/account/master_patients.json', MasterPatientPage) def _setup_session(self, profile): @@ -187,7 +216,6 @@ def _setup_session(self, profile): self.session = session - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.session.headers['sec-fetch-dest'] = 'document' @@ -197,7 +225,7 @@ def __init__(self, *args, **kwargs): self.patient = None - def do_login(self): + def do_login(self, code): try: self.open(self.BASEURL + '/sessions/new') except ServerError as e: @@ -205,6 +233,8 @@ def do_login(self): and 'text/html' in e.response.headers['Content-Type'] \ and ('cloudflare' in e.response.text or 'Checking your browser before accessing' in e .response.text): log('Request blocked by CloudFlare', color='red') + if e.response.status_code in [520]: + log('Cloudflare is unable to connect to Doctolib server. Please retry later.', color='red') raise try: self.login.go(json={'kind': 'patient', @@ -218,10 +248,16 @@ def do_login(self): if self.page.redirect() == "/sessions/two-factor": print("Requesting 2fa code...") - self.send_auth_code.go(json={'two_factor_auth_method': 'email'}, method="POST") - code = input("Enter auth code: ") + if not code: + if not sys.__stdin__.isatty(): + log("Auth Code input required, but no interactive terminal available. Please provide it via command line argument '--code'.", color='red') + return False + self.send_auth_code.go( + json={'two_factor_auth_method': 'email'}, method="POST") + code = input("Enter auth code: ") try: - self.challenge.go(json={'auth_code': code, 'two_factor_auth_method': 'email'}, method="POST") + self.challenge.go( + json={'auth_code': code, 'two_factor_auth_method': 'email'}, method="POST") except HTTPNotFound: print("Invalid auth code") return False @@ -233,9 +269,17 @@ def find_centers(self, where, motives=None): motives = self.vaccine_motives.keys() for city in where: try: - self.centers.go(where=city, params={'ref_visit_motive_ids[]': motives}) + self.centers.go(where=city, params={ + 'ref_visit_motive_ids[]': motives}) except ServerError as e: if e.response.status_code in [503]: + if 'text/html' in e.response.headers['Content-Type'] \ + and ('cloudflare' in e.response.text or + 'Checking your browser before accessing' in e .response.text): + log('Request blocked by CloudFlare', color='red') + return + if e.response.status_code in [520]: + log('Cloudflare is unable to connect to Doctolib server. Please retry later.', color='red') return raise except HTTPNotFound as e: @@ -249,7 +293,7 @@ def find_centers(self, where, motives=None): 'ref_visit_motive_ids[]': motives, 'speciality_id': '5494', 'search_result_format': 'json' - } + } ) try: yield page.doc['search_result'] @@ -264,32 +308,34 @@ def get_patients(self): @classmethod def normalize(cls, string): nfkd = unicodedata.normalize('NFKD', string) - normalized = u"".join([c for c in nfkd if not unicodedata.combining(c)]) + normalized = u"".join( + [c for c in nfkd if not unicodedata.combining(c)]) normalized = re.sub(r'\W', '-', normalized) return normalized.lower() - def try_to_book(self, center, vaccine_list, start_date, end_date, dry_run=False): + def try_to_book(self, center, vaccine_list, start_date, end_date, only_second, only_third, dry_run=False): self.open(center['url']) p = urlparse(center['url']) center_id = p.path.split('/')[-1] center_page = self.center_booking.go(center_id=center_id) profile_id = self.page.get_profile_id() - # extract motive ids based on the vaccine names - motives_id = {} + motives_id = dict() for vaccine in vaccine_list: - motive_id = self.page.find_motive(r'.*({})'.format(vaccine)) - if motive_id: - motives_id[vaccine] = motive_id + motives_id[vaccine] = self.page.find_motive( + r'.*({})'.format(vaccine), singleShot=(vaccine == self.vaccine_motives[self.KEY_JANSSEN] or only_second or only_third)) + motives_id = dict((k, v) + for k, v in motives_id.items() if v is not None) if len(motives_id.values()) == 0: - log(colored('unable to find requested vaccines', 'red')) + log('Unable to find requested vaccines in motives') + log('Motives: %s', ', '.join(self.page.get_motives())) return False for place in self.page.get_places(): if place['name']: - log('– %s', place['name']) + log('– %s...', place['name']) practice_id = place['practice_ids'][0] for vac_name, motive_id in motives_id.items(): log(' Vaccine %s...', vac_name, end=' ', flush=True) @@ -298,12 +344,12 @@ def try_to_book(self, center, vaccine_list, start_date, end_date, dry_run=False) # do not filter to give a chance agenda_ids = center_page.get_agenda_ids(motive_id) - if self.try_to_book_place(profile_id, motive_id, practice_id, agenda_ids, vac_name.lower(), start_date, end_date, dry_run): + if self.try_to_book_place(profile_id, motive_id, practice_id, agenda_ids, vac_name.lower(), start_date, end_date, only_second, only_third, dry_run): return True return False - def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_name, start_date, end_date, dry_run=False): + def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_name, start_date, end_date, only_second, only_third, dry_run=False): date = start_date.strftime('%Y-%m-%d') while date is not None: self.availabilities.go( @@ -325,7 +371,10 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_ slot = self.page.find_best_slot(start_date, end_date) if not slot: - log('first slot not found :(', color='red') + if only_second == False and only_third == False: + log('First slot not found :(', color='red') + else: + log('Slot not found :(', color='red') return False # depending on the country, the slot is returned in a different format. Go figure... @@ -333,32 +382,37 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_ slot_date_first = slot['start_date'] if vac_name != "janssen": slot_date_second = slot['steps'][1]['start_date'] - elif isinstance(slot, str) and vac_name == 'janssen': - slot_date_first = slot # should be for Janssen only, otherwise it is a list + elif isinstance(slot, str): + if vac_name != "janssen" and not only_second and not only_third: + log('Only one slot for multi-shot vaccination found') + # should be for Janssen, second or third shots only, otherwise it is a list + slot_date_first = slot elif isinstance(slot, list): slot_date_first = slot[0] - if vac_name != "janssen": # maybe redundant? + if vac_name != "janssen": # maybe redundant? slot_date_second = slot[1] else: - log('error while fetching first slot.', color='red') + log('Error while fetching first slot.', color='red') return False - + if vac_name != "janssen" and not only_second and not only_third: + assert slot_date_second log('found!', color='green') - log(' ├╴ Best slot found: %s', parse_date(slot_date_first).strftime('%c')) + log(' ├╴ Best slot found: %s', parse_date( + slot_date_first).strftime('%c')) appointment = {'profile_id': profile_id, 'source_action': 'profile', 'start_date': slot_date_first, 'visit_motive_ids': str(motive_id), - } + } data = {'agenda_ids': '-'.join(agenda_ids), 'appointment': appointment, 'practice_ids': [practice_id]} headers = { - 'content-type': 'application/json', - } + 'content-type': 'application/json', + } self.appointment.go(data=json.dumps(data), headers=headers) if self.page.is_error(): @@ -367,7 +421,7 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_ playsound('ding.mp3') - if vac_name != "janssen": # janssen has only one shot + if vac_name != "janssen" and not only_second and not only_third: # janssen has only one shot self.second_shot_availabilities.go( params={'start_date': slot_date_second.split('T')[0], 'visit_motive_ids': motive_id, @@ -389,28 +443,31 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_ elif isinstance(slot, str): slot_date_second = second_slot # TODO: is this else needed? - #elif isinstance(slot, list): + # elif isinstance(slot, list): # slot_date_second = second_slot[1] else: - log('error while fetching second slot.', color='red') + log('Error while fetching second slot.', color='red') return False - log(' ├╴ Second shot: %s', parse_date(slot_date_second).strftime('%c')) + log(' ├╴ Second shot: %s', parse_date( + slot_date_second).strftime('%c')) data['second_slot'] = slot_date_second self.appointment.go(data=json.dumps(data), headers=headers) if self.page.is_error(): - log(' └╴ Appointment not available anymore :( %s', self.page.get_error()) + log(' └╴ Appointment not available anymore :( %s', + self.page.get_error()) return False a_id = self.page.doc['id'] self.appointment_edit.go(id=a_id) - log(' ├╴ Booking for %s %s...', self.patient['first_name'], self.patient['last_name']) + log(' ├╴ Booking for %(first_name)s %(last_name)s...' % self.patient) - self.appointment_edit.go(id=a_id, params={'master_patient_id': self.patient['id']}) + self.appointment_edit.go( + id=a_id, params={'master_patient_id': self.patient['id']}) custom_fields = {} for field in self.page.get_custom_fields(): @@ -419,7 +476,8 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_ elif field['placeholder']: value = field['placeholder'] else: - print('%s (%s):' % (field['label'], field['placeholder']), end=' ', flush=True) + print('%s (%s):' % + (field['label'], field['placeholder']), end=' ', flush=True) value = sys.stdin.readline().strip() custom_fields[field['id']] = value @@ -432,19 +490,21 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_ 'new_patient': True, 'qualification_answers': {}, 'referrer_id': None, - }, + }, 'bypass_mandatory_relative_contact_info': False, 'email': None, 'master_patient': self.patient, 'new_patient': True, 'patient': None, 'phone_number': None, - } + } - self.appointment_post.go(id=a_id, data=json.dumps(data), headers=headers, method='PUT') + self.appointment_post.go(id=a_id, data=json.dumps( + data), headers=headers, method='PUT') if 'redirection' in self.page.doc and not 'confirmed-appointment' in self.page.doc['redirection']: - log(' ├╴ Open %s to complete', self.BASEURL + self.page.doc['redirection']) + log(' ├╴ Open %s to complete', self.BASEURL + + self.page.doc['redirection']) self.appointment_post.go(id=a_id) @@ -452,33 +512,60 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_ return self.page.doc['confirmed'] + class DoctolibDE(Doctolib): BASEURL = 'https://www.doctolib.de' KEY_PFIZER = '6768' + KEY_PFIZER_SECOND = '6769' + KEY_PFIZER_THIRD = None KEY_MODERNA = '6936' + KEY_MODERNA_SECOND = '6937' + KEY_MODERNA_THIRD = None KEY_JANSSEN = '7978' + KEY_ASTRAZENECA = '7109' + KEY_ASTRAZENECA_SECOND = '7110' vaccine_motives = { KEY_PFIZER: 'Pfizer', + KEY_PFIZER_SECOND: 'Zweit.*Pfizer|Pfizer.*Zweit', + KEY_PFIZER_THIRD: 'Dritt.*Pfizer|Pfizer.*Dritt', KEY_MODERNA: 'Moderna', + KEY_MODERNA_SECOND: 'Zweit.*Moderna|Moderna.*Zweit', + KEY_MODERNA_THIRD: 'Dritt.*Moderna|Moderna.*Dritt', KEY_JANSSEN: 'Janssen', + KEY_ASTRAZENECA: 'AstraZeneca', + KEY_ASTRAZENECA_SECOND: 'Zweit.*AstraZeneca|AstraZeneca.*Zweit', } centers = URL(r'/impfung-covid-19-corona/(?P\w+)', CentersPage) center = URL(r'/praxis/.*', CenterPage) + class DoctolibFR(Doctolib): BASEURL = 'https://www.doctolib.fr' KEY_PFIZER = '6970' + KEY_PFIZER_SECOND = '6971' + KEY_PFIZER_THIRD = '8192' KEY_MODERNA = '7005' + KEY_MODERNA_SECOND = '7004' + KEY_MODERNA_THIRD = '8193' KEY_JANSSEN = '7945' + KEY_ASTRAZENECA = '7107' + KEY_ASTRAZENECA_SECOND = '7108' vaccine_motives = { KEY_PFIZER: 'Pfizer', + KEY_PFIZER_SECOND: '2de.*Pfizer', + KEY_PFIZER_THIRD: '3e.*Pfizer', KEY_MODERNA: 'Moderna', + KEY_MODERNA_SECOND: '2de.*Moderna', + KEY_MODERNA_THIRD: '3e.*Moderna', KEY_JANSSEN: 'Janssen', + KEY_ASTRAZENECA: 'AstraZeneca', + KEY_ASTRAZENECA_SECOND: '2de.*AstraZeneca', } centers = URL(r'/vaccination-covid-19/(?P\w+)', CentersPage) center = URL(r'/centre-de-sante/.*', CenterPage) + class Application: @classmethod def create_default_logger(cls): @@ -495,30 +582,59 @@ def setup_loggers(self, level): logging.root.setLevel(level) logging.root.addHandler(self.create_default_logger()) - def main(self): - colorama.init() # needed for windows + def main(self, cli_args=None): + colorama.init() # needed for windows doctolib_map = { "fr": DoctolibFR, "de": DoctolibDE } - parser = argparse.ArgumentParser(description="Book a vaccine slot on Doctolib") - parser.add_argument('--debug', '-d', action='store_true', help='show debug information') - parser.add_argument('--pfizer', '-z', action='store_true', help='select only Pfizer vaccine') - parser.add_argument('--moderna', '-m', action='store_true', help='select only Moderna vaccine') - parser.add_argument('--janssen', '-j', action='store_true', help='select only Janssen vaccine') - parser.add_argument('--patient', '-p', type=int, default=-1, help='give patient ID') - parser.add_argument('--time-window', '-t', type=int, default=7, help='set how many next days the script look for slots (default = 7)') - parser.add_argument('--center', '-c', action='append', help='filter centers') - parser.add_argument('--start-date', type=str, default=None, help='first date on which you want to book the first slot (format should be DD/MM/YYYY)') - parser.add_argument('--end-date', type=str, default=None, help='last date on which you want to book the first slot (format should be DD/MM/YYYY)') - parser.add_argument('--dry-run', action='store_true', help='do not really book the slot') - parser.add_argument('country', help='country where to book', choices=list(doctolib_map.keys())) + parser = argparse.ArgumentParser( + description="Book a vaccine slot on Doctolib") + parser.add_argument('--debug', '-d', action='store_true', + help='show debug information') + parser.add_argument('--pfizer', '-z', action='store_true', + help='select only Pfizer vaccine') + parser.add_argument('--moderna', '-m', action='store_true', + help='select only Moderna vaccine') + parser.add_argument('--janssen', '-j', action='store_true', + help='select only Janssen vaccine') + parser.add_argument('--astrazeneca', '-a', action='store_true', + help='select only AstraZeneca vaccine') + parser.add_argument('--only-second', '-2', + action='store_true', help='select only second dose') + parser.add_argument('--only-third', '-3', + action='store_true', help='select only third dose') + parser.add_argument('--patient', '-p', type=int, + default=-1, help='give patient ID') + parser.add_argument('--time-window', '-t', type=int, default=7, + help='set how many next days the script look for slots (default = 7)') + parser.add_argument( + '--center', '-c', action='append', help='filter centers') + parser.add_argument('--center-regex', + action='append', help='filter centers by regex') + parser.add_argument('--center-exclude', '-x', + action='append', help='exclude centers') + parser.add_argument('--center-exclude-regex', + action='append', help='exclude centers by regex') + parser.add_argument( + '--include-neighbor-city', '-n', action='store_true', help='include neighboring cities') + parser.add_argument('--start-date', type=str, default=None, + help='first date on which you want to book the first slot (format should be DD/MM/YYYY)') + parser.add_argument('--end-date', type=str, default=None, + help='last date on which you want to book the first slot (format should be DD/MM/YYYY)') + parser.add_argument('--dry-run', action='store_true', + help='do not really book the slot') + parser.add_argument( + 'country', help='country where to book', choices=list(doctolib_map.keys())) parser.add_argument('city', help='city where to book') parser.add_argument('username', help='Doctolib username') parser.add_argument('password', nargs='?', help='Doctolib password') - args = parser.parse_args() + parser.add_argument('--code', type=str, default=None, help='2FA code') + args = parser.parse_args(cli_args if cli_args else sys.argv[1:]) + + from types import SimpleNamespace if args.debug: responses_dirname = tempfile.mkdtemp(prefix='woob_session_') @@ -530,8 +646,9 @@ def main(self): if not args.password: args.password = getpass.getpass() - docto = doctolib_map[args.country](args.username, args.password, responses_dirname=responses_dirname) - if not docto.do_login(): + docto = doctolib_map[args.country]( + args.username, args.password, responses_dirname=responses_dirname) + if not docto.do_login(args.code): return 1 patients = docto.get_patients() @@ -543,9 +660,11 @@ def main(self): elif len(patients) > 1: print('Available patients are:') for i, patient in enumerate(patients): - print('* [%s] %s %s' % (i, patient['first_name'], patient['last_name'])) + print('* [%s] %s %s' % + (i, patient['first_name'], patient['last_name'])) while True: - print('For which patient do you want to book a slot?', end=' ', flush=True) + print('For which patient do you want to book a slot?', + end=' ', flush=True) try: docto.patient = patients[int(sys.stdin.readline().strip())] except (ValueError, IndexError): @@ -556,20 +675,63 @@ def main(self): docto.patient = patients[0] motives = [] - if not args.pfizer and not args.moderna and not args.janssen: - motives = docto.vaccine_motives.keys() + if not args.pfizer and not args.moderna and not args.janssen and not args.astrazeneca: + if args.only_second: + motives.append(docto.KEY_PFIZER_SECOND) + motives.append(docto.KEY_MODERNA_SECOND) + # motives.append(docto.KEY_ASTRAZENECA_SECOND) #do not add AstraZeneca by default + elif args.only_third: + if not docto.KEY_PFIZER_THIRD and not docto.KEY_MODERNA_THIRD: + print('Invalid args: No third shot vaccinations in this country') + return 1 + motives.append(docto.KEY_PFIZER_THIRD) + motives.append(docto.KEY_MODERNA_THIRD) + else: + motives.append(docto.KEY_PFIZER) + motives.append(docto.KEY_MODERNA) + motives.append(docto.KEY_JANSSEN) + # motives.append(docto.KEY_ASTRAZENECA) #do not add AstraZeneca by default if args.pfizer: - motives.append(docto.KEY_PFIZER) + if args.only_second: + motives.append(docto.KEY_PFIZER_SECOND) + elif args.only_third: + if not docto.KEY_PFIZER_THIRD: # not available in all countries + print('Invalid args: Pfizer has no third shot in this country') + return 1 + motives.append(docto.KEY_PFIZER_THIRD) + else: + motives.append(docto.KEY_PFIZER) if args.moderna: - motives.append(docto.KEY_MODERNA) + if args.only_second: + motives.append(docto.KEY_MODERNA_SECOND) + elif args.only_third: + if not docto.KEY_MODERNA_THIRD: # not available in all countries + print('Invalid args: Moderna has no third shot in this country') + return 1 + motives.append(docto.KEY_MODERNA_THIRD) + else: + motives.append(docto.KEY_MODERNA) if args.janssen: - motives.append(docto.KEY_JANSSEN) + if args.only_second or args.only_third: + print('Invalid args: Janssen has no second or third shot') + return 1 + else: + motives.append(docto.KEY_JANSSEN) + if args.astrazeneca: + if args.only_second: + motives.append(docto.KEY_ASTRAZENECA_SECOND) + elif args.only_third: + print('Invalid args: AstraZeneca has no third shot') + return 1 + else: + motives.append(docto.KEY_ASTRAZENECA) vaccine_list = [docto.vaccine_motives[motive] for motive in motives] if args.start_date: try: - start_date = datetime.datetime.strptime(args.start_date, '%d/%m/%Y').date() + start_date = datetime.datetime.strptime( + args.start_date, '%d/%m/%Y').date() except ValueError as e: print('Invalid value for --start-date: %s' % e) return 1 @@ -577,48 +739,82 @@ def main(self): start_date = datetime.date.today() if args.end_date: try: - end_date = datetime.datetime.strptime(args.end_date, '%d/%m/%Y').date() + end_date = datetime.datetime.strptime( + args.end_date, '%d/%m/%Y').date() except ValueError as e: print('Invalid value for --end-date: %s' % e) return 1 else: end_date = start_date + relativedelta(days=args.time_window) - log('Starting to look for vaccine slots for %s %s between %s and %s...', docto.patient['first_name'], docto.patient['last_name'], start_date, end_date) + log('Starting to look for vaccine slots for %s %s between %s and %s...', + docto.patient['first_name'], docto.patient['last_name'], start_date, end_date) log('Vaccines: %s', ', '.join(vaccine_list)) log('Country: %s ', args.country) log('This may take a few minutes/hours, be patient!') cities = [docto.normalize(city) for city in args.city.split(',')] while True: + log_ts() try: for center in docto.find_centers(cities, motives): if args.center: if center['name_with_title'] not in args.center: - logging.debug("Skipping center '%s'", center['name_with_title']) + logging.debug("Skipping center '%s'" % + center['name_with_title']) + continue + if args.center_regex: + center_matched = False + for center_regex in args.center_regex: + if re.match(center_regex, center['name_with_title']): + center_matched = True + else: + logging.debug( + "Skipping center '%(name_with_title)s'" % center) + if not center_matched: continue - else: - if docto.normalize(center['city']) not in cities: - logging.debug("Skipping city '%(city)s' %(name_with_title)s", center) + if args.center_exclude: + if center['name_with_title'] in args.center_exclude: + logging.debug( + "Skipping center '%(name_with_title)s' because it's excluded" % center) continue + if args.center_exclude_regex: + center_excluded = False + for center_exclude_regex in args.center_exclude_regex: + if re.match(center_exclude_regex, center['name_with_title']): + logging.debug( + "Skipping center '%(name_with_title)s' because it's excluded" % center) + center_excluded = True + if center_excluded: + continue + if not args.include_neighbor_city and not docto.normalize(center['city']).startswith(tuple(cities)): + logging.debug( + "Skipping city '%(city)s' %(name_with_title)s" % center) + continue log('') - log('Center %s:', center['name_with_title']) - if docto.try_to_book(center, vaccine_list, start_date, end_date, args.dry_run): + log('Center %(name_with_title)s (%(city)s):' % center) + + if docto.try_to_book(center, vaccine_list, start_date, end_date, args.only_second, args.only_third, args.dry_run): log('') - log('💉 %s Congratulations.' % colored('Booked!', 'green', attrs=('bold',))) + log('💉 %s Congratulations.' % + colored('Booked!', 'green', attrs=('bold',))) return 0 - sleep(1) + sleep(SLEEP_INTERVAL_AFTER_CENTER) - sleep(5) + log('') + log('No free slots found at selected centers. Trying another round in %s sec...', SLEEP_INTERVAL_AFTER_RUN) + sleep(SLEEP_INTERVAL_AFTER_RUN) except CityNotFound as e: - print('\n%s: City %s not found. Make sure you selected a city from the available countries.' % (colored('Error', 'red'), colored(e, 'yellow'))) + print('\n%s: City %s not found. Make sure you selected a city from the available countries.' % ( + colored('Error', 'red'), colored(e, 'yellow'))) return 1 except (ReadTimeout, ConnectionError, NewConnectionError) as e: - print('\n%s' % (colored('Connection error. Check your internet connection. Retrying ...', 'red'))) + print('\n%s' % (colored( + 'Connection error. Check your internet connection. Retrying ...', 'red'))) print(str(e)) - sleep(5) + sleep(SLEEP_INTERVAL_AFTER_CONNECTION_ERROR) except Exception as e: template = "An unexpected exception of type {0} occurred. Arguments:\n{1!r}" message = template.format(type(e).__name__, e.args) diff --git a/test_browser.py b/test_browser.py index df4014d..63e7aa2 100644 --- a/test_browser.py +++ b/test_browser.py @@ -1,7 +1,15 @@ import pytest +from requests.adapters import Response import responses +from html import escape +import json +import datetime +from woob.browser.browsers import Browser from woob.browser.exceptions import ServerError -from doctoshotgun import DoctolibDE, DoctolibFR +from doctoshotgun import DoctolibDE, DoctolibFR, CenterBookingPage + +# globals +FIXTURES_FOLDER = "test_fixtures" @responses.activate @@ -9,12 +17,13 @@ def test_find_centers_fr_returns_503_should_continue(tmp_path): """ Check that find_centers doesn't raise a ServerError in case of 503 HTTP response """ - docto = DoctolibFR("roger.phillibert@gmail.com", "1234", responses_dirname=tmp_path) + docto = DoctolibFR("roger.phillibert@gmail.com", + "1234", responses_dirname=tmp_path) docto.BASEURL = "https://127.0.0.1" responses.add( responses.GET, - "https://127.0.0.1/vaccination-covid-19/Paris?ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=7005&ref_visit_motive_ids%5B%5D=7945", + "https://127.0.0.1/vaccination-covid-19/Paris?ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=6971&ref_visit_motive_ids%5B%5D=8192&ref_visit_motive_ids%5B%5D=7005&ref_visit_motive_ids%5B%5D=7004&ref_visit_motive_ids%5B%5D=8193&ref_visit_motive_ids%5B%5D=7945&ref_visit_motive_ids%5B%5D=7107&ref_visit_motive_ids%5B%5D=7108", status=503 ) @@ -22,17 +31,19 @@ def test_find_centers_fr_returns_503_should_continue(tmp_path): for _ in docto.find_centers(["Paris"]): pass + @responses.activate def test_find_centers_de_returns_503_should_continue(tmp_path): """ Check that find_centers doesn't raise a ServerError in case of 503 HTTP response """ - docto = DoctolibDE("roger.phillibert@gmail.com", "1234", responses_dirname=tmp_path) + docto = DoctolibDE("roger.phillibert@gmail.com", + "1234", responses_dirname=tmp_path) docto.BASEURL = "https://127.0.0.1" responses.add( responses.GET, - "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=7978", + "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110", status=503 ) @@ -41,17 +52,38 @@ def test_find_centers_de_returns_503_should_continue(tmp_path): pass +@responses.activate +def test_find_centers_de_returns_520_should_continue(tmp_path): + """ + Check that find_centers doesn't raise a ServerError in case of 503 HTTP response + """ + docto = DoctolibDE("roger.phillibert@gmail.com", + "1234", responses_dirname=tmp_path) + docto.BASEURL = "https://127.0.0.1" + + responses.add( + responses.GET, + "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110", + status=520 + ) + + # this should not raise an exception + for _ in docto.find_centers(["München"]): + pass + + @responses.activate def test_find_centers_fr_returns_502_should_fail(tmp_path): """ Check that find_centers raises an error in case of non-whitelisted status code """ - docto = DoctolibFR("roger.phillibert@gmail.com", "1234", responses_dirname=tmp_path) + docto = DoctolibFR("roger.phillibert@gmail.com", + "1234", responses_dirname=tmp_path) docto.BASEURL = "https://127.0.0.1" responses.add( responses.GET, - "https://127.0.0.1/vaccination-covid-19/Paris?ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=7005&ref_visit_motive_ids%5B%5D=7945", + "https://127.0.0.1/vaccination-covid-19/Paris?ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=6971&ref_visit_motive_ids%5B%5D=8192&ref_visit_motive_ids%5B%5D=7005&ref_visit_motive_ids%5B%5D=7004&ref_visit_motive_ids%5B%5D=8193&ref_visit_motive_ids%5B%5D=7945&ref_visit_motive_ids%5B%5D=7107&ref_visit_motive_ids%5B%5D=7108", status=502 ) @@ -60,17 +92,19 @@ def test_find_centers_fr_returns_502_should_fail(tmp_path): for _ in docto.find_centers(["Paris"]): pass + @responses.activate def test_find_centers_de_returns_502_should_fail(tmp_path): """ Check that find_centers raises an error in case of non-whitelisted status code """ - docto = DoctolibDE("roger.phillibert@gmail.com", "1234", responses_dirname=tmp_path) + docto = DoctolibDE("roger.phillibert@gmail.com", + "1234", responses_dirname=tmp_path) docto.BASEURL = "https://127.0.0.1" responses.add( responses.GET, - "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=7978", + "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110", status=502 ) @@ -78,3 +112,185 @@ def test_find_centers_de_returns_502_should_fail(tmp_path): with pytest.raises(ServerError): for _ in docto.find_centers(["München"]): pass + + +@responses.activate +def test_book_slots_should_succeed(tmp_path): + """ + Check that try_to_book calls all services successfully + """ + docto = DoctolibDE("roger.phillibert@gmail.com", + "1234", responses_dirname=tmp_path) + docto.BASEURL = "https://127.0.0.1" + docto.patient = { + "id": "patient-id", + "first_name": "Roger", + "last_name": "Phillibert" + } + + mock_search_result_id = { + "searchResultId": 1234567 + } + + mock_search_result_id_escaped_json = escape( + json.dumps(mock_search_result_id, separators=(',', ':'))) + + responses.add( + responses.GET, + "https://127.0.0.1/impfung-covid-19-corona/K%C3%B6ln?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110", + status=200, + body="
".format( + dataProps=mock_search_result_id_escaped_json) + ) + + with open(FIXTURES_FOLDER + '/search_result.json') as json_file: + mock_search_result = json.load(json_file) + + responses.add( + responses.GET, + "https://127.0.0.1/search_results/1234567.json?limit=4&ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110&speciality_id=5494&search_result_format=json", + status=200, + body=json.dumps(mock_search_result) + ) + + with open(FIXTURES_FOLDER + '/doctor_response.json') as json_file: + mock_doctor_response = json.load(json_file) + + responses.add( + responses.GET, + "https://127.0.0.1/allgemeinmedizin/koeln/dr-dre?insurance_sector=public", + status=200, + body=json.dumps(mock_doctor_response) + ) + + responses.add( + responses.GET, + "https://127.0.0.1/booking/dr-dre.json", + status=200, + body=json.dumps(mock_doctor_response) + ) + + with open(FIXTURES_FOLDER + '/availabilities.json') as json_file: + mock_availabilities = json.load(json_file) + + responses.add( + responses.GET, + "https://127.0.0.1/availabilities.json?start_date=2021-06-01&visit_motive_ids=2920448&agenda_ids=&insurance_sector=public&practice_ids=234567&destroy_temporary=true&limit=3", + status=200, + body=json.dumps(mock_availabilities) + ) + responses.add( + responses.GET, + "https://127.0.0.1/availabilities.json?start_date=2021-06-01&visit_motive_ids=2746983&agenda_ids=&insurance_sector=public&practice_ids=234567&destroy_temporary=true&limit=3", + status=200, + body=json.dumps(mock_availabilities) + ) + + mock_appointments = { + "id": "appointment-id" + } + + responses.add( + responses.POST, + "https://127.0.0.1/appointments.json", + status=200, + body=json.dumps(mock_appointments) + ) + + mock_appointments_edit = { + "id": "appointment-edit-id", + "appointment": { + "custom_fields": {} + } + } + + responses.add( + responses.GET, + "https://127.0.0.1/appointments/appointment-id/edit.json", + status=200, + body=json.dumps(mock_appointments_edit) + ) + + responses.add( + responses.GET, + "https://127.0.0.1/second_shot_availabilities.json?start_date=2021-07-20&visit_motive_ids=2746983&agenda_ids=&first_slot=2021-06-10T08%3A40%3A00.000%2B02%3A00&insurance_sector=public&practice_ids=234567&limit=3", + status=200, + body=json.dumps(mock_availabilities) + ) + + mock_appointment_id_put = { + } + + responses.add( + responses.PUT, + "https://127.0.0.1/appointments/appointment-id.json", + status=200, + body=json.dumps(mock_appointment_id_put) + ) + + mock_appointment_id = { + "confirmed": True + } + + responses.add( + responses.GET, + "https://127.0.0.1/appointments/appointment-id.json", + status=200, + body=json.dumps(mock_appointment_id) + ) + + result_handled = False + for result in docto.find_centers(["Köln"]): + result_handled = True + + center = result['search_result'] + + # single shot vaccination + assert docto.try_to_book(center=center, + vaccine_list=["Janssen"], + start_date=datetime.date( + year=2021, month=6, day=1), + end_date=datetime.date( + year=2021, month=6, day=14), + only_second=False, + only_third=False, + dry_run=False) + assert len(responses.calls) == 10 + + # two shot vaccination + assert docto.try_to_book(center=center, + vaccine_list=["Pfizer"], + start_date=datetime.date( + year=2021, month=6, day=1), + end_date=datetime.date( + year=2021, month=6, day=14), + only_second=False, + only_third=False, + dry_run=False) + assert len(responses.calls) == 20 + pass + + assert result_handled + + +@responses.activate +def test_find_motive_should_ignore_second_shot(tmp_path): + """ + Check that find_motive ignores second shot motives + """ + + with open(FIXTURES_FOLDER + '/doctor_response.json') as json_file: + mock_doctor_response = json.load(json_file) + + response = Response() + response._content = b'{}' + + booking_page = CenterBookingPage(browser=Browser(), response=response) + booking_page.doc = mock_doctor_response + visit_motive_id = CenterBookingPage.find_motive( + booking_page, '.*(Pfizer)', False) + assert visit_motive_id == mock_doctor_response['data']['visit_motives'][1]['id'] + + visit_motive_id = CenterBookingPage.find_motive( + booking_page, '.*(Janssen)', True) + assert visit_motive_id == mock_doctor_response['data']['visit_motives'][3]['id'] diff --git a/test_cli_args.py b/test_cli_args.py new file mode 100644 index 0000000..3fcb562 --- /dev/null +++ b/test_cli_args.py @@ -0,0 +1,91 @@ +import responses +from unittest.mock import patch, MagicMock + +from doctoshotgun import Application, DoctolibDE, DoctolibFR, MasterPatientPage + +CENTERS = [ + { + "name_with_title": "Doktor", + "city": "koln", + }, + { + "name_with_title": "Doktor2", + "city": "koln", + }, + { + "name_with_title": "Doktor", + "city": "neuss", + }, +] + + +@responses.activate +@patch('doctoshotgun.DoctolibDE') +def test_center_arg_should_filter_centers(MockDoctolibDE, tmp_path): + """ + Check that booking is performed in correct city + """ + # prepare + mock_doctolib_de = get_mocked_doctolib(MockDoctolibDE) + + # call + center = 'Doktor' + city = 'koln' + call_application(city, cli_args=['--center', center]) + + # assert + assert mock_doctolib_de.get_patients.called + assert mock_doctolib_de.try_to_book.called + for call_args_list in mock_doctolib_de.try_to_book.call_args_list: + assert call_args_list.args[0]['name_with_title'] == center + assert call_args_list.args[0]['city'] == city + + +@responses.activate +@patch('doctoshotgun.DoctolibDE') +def test_center_exclude_arg_should_filter_excluded_centers(MockDoctolibDE, tmp_path): + """ + Check that booking is performed in correct city + """ + # prepare + mock_doctolib_de = get_mocked_doctolib(MockDoctolibDE) + + # call + excluded_center = 'Doktor' + city = 'koln' + call_application(city, cli_args=['--center-exclude', excluded_center]) + + # assert + assert mock_doctolib_de.get_patients.called + assert mock_doctolib_de.try_to_book.called + for call_args_list in mock_doctolib_de.try_to_book.call_args_list: + assert call_args_list.args[0]['name_with_title'] != excluded_center + assert call_args_list.args[0]['city'] == city + + +def get_mocked_doctolib(MockDoctolibDE): + mock_doctolib_de = MagicMock(wraps=DoctolibDE) + MockDoctolibDE.return_value = mock_doctolib_de + + mock_doctolib_de.vaccine_motives = DoctolibDE.vaccine_motives + mock_doctolib_de.KEY_PFIZER = DoctolibDE.KEY_PFIZER + mock_doctolib_de.KEY_MODERNA = DoctolibDE.KEY_MODERNA + mock_doctolib_de.KEY_JANSSEN = DoctolibDE.KEY_JANSSEN + + mock_doctolib_de.get_patients.return_value = [ + {"first_name": 'First', "last_name": 'Name'} + ] + mock_doctolib_de.do_login.return_value = True + + mock_doctolib_de.find_centers.return_value = CENTERS + + mock_doctolib_de.try_to_book.return_value = True + + return mock_doctolib_de + + +def call_application(city, cli_args=[]): + assert 0 == Application.main( + Application(), + cli_args=["de", city, "roger.phillibert@gmail.com", "1234"] + cli_args + ) diff --git a/test_fixtures/availabilities.json b/test_fixtures/availabilities.json new file mode 100644 index 0000000..51bd17e --- /dev/null +++ b/test_fixtures/availabilities.json @@ -0,0 +1,20 @@ +{ + "availabilities": [ + { + "date": "2021-06-10", + "slots": [ + { + "start_date": "2021-06-10T08:30:00.000+02:00", + "steps": [{}, { + "start_date": "2021-07-20T08:30:00.000+02:00" + }] + },{ + "start_date": "2021-06-10T08:40:00.000+02:00", + "steps": [{}, { + "start_date": "2021-07-20T08:40:00.000+02:00" + }] + } + ] + } + ] +} \ No newline at end of file diff --git a/test_fixtures/doctor_response.json b/test_fixtures/doctor_response.json new file mode 100644 index 0000000..f5beec3 --- /dev/null +++ b/test_fixtures/doctor_response.json @@ -0,0 +1,51 @@ +{ + "data": { + "profile": { + "id": 9876543 + }, + "visit_motives": [ + { + "id": 2741702, + "name": "Erstimpfung Covid-19 (AstraZeneca)", + "vaccination_days_range": 42, + "first_shot_motive": true, + "allow_new_patients": true + }, + { + "id": 2746983, + "name": "Erstimpfung Covid-19 (BioNTech-Pfizer)", + "vaccination_days_range": 21, + "first_shot_motive": true, + "allow_new_patients": true + }, + { + "id": 2746984, + "name": "Zweitimpfung Covid-19 (BioNTech-Pfizer)", + "vaccination_days_range": 0, + "first_shot_motive": false, + "allow_new_patients": true + }, + { + "id": 2920448, + "name": "Einzelimpfung Covid-19 (Janssen)", + "vaccination_days_range": 0, + "first_shot_motive": false, + "allow_new_patients": true + } + ], + "agendas": [ + { + "booking_disabled": false, + "visit_motive_ids": [] + } + ], + "places": [ + { + "name": "Praxis Prof. Dr. med. Dre", + "practice_ids": [ + 234567 + ] + } + ] + } +} \ No newline at end of file diff --git a/test_fixtures/search_result.json b/test_fixtures/search_result.json new file mode 100644 index 0000000..53c02ee --- /dev/null +++ b/test_fixtures/search_result.json @@ -0,0 +1,7 @@ +{ + "search_result": { + "search_result": { + "url": "/allgemeinmedizin/koeln/dr-dre?insurance_sector=public" + } + } +} From 477c90bf0d8a93456aed3609593223f356d810f6 Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Mon, 5 Jul 2021 08:31:58 +0200 Subject: [PATCH 45/58] update README with new arguments --- README.md | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 120ec94..f72631d 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,32 @@ Run: Further optional arguments: ``` ---center "" [--center …] : filter centers to only choose one from the provided list --p , --patient : select patient for which book a slot --z, --pfizer : looking only for a Pfizer vaccine --m, --moderna : looking only for a Moderna vaccine --j, --janssen : looking only for a Janssen vaccine --d, --debug : display debug information --t , --time-window : set how many next days the script look for slots ---start-date
: first date on which you want to book the first slot ---end-date
: last date on which you want to book the first slot ---dry-run : do not really book a slot +--debug, -d show debug information +--pfizer, -z select only Pfizer vaccine +--moderna, -m select only Moderna vaccine +--janssen, -j select only Janssen vaccine +--astrazeneca, -a select only AstraZeneca vaccine +--only-second, -2 select only second dose +--only-third, -3 select only third dose +--patient PATIENT, -p PATIENT + give patient ID +--time-window TIME_WINDOW, -t TIME_WINDOW + set how many next days the script look for slots (default = 7) +--center CENTER, -c CENTER + filter centers +--center-regex CENTER_REGEX + filter centers by regex +--center-exclude CENTER_EXCLUDE, -x CENTER_EXCLUDE + exclude centers +--center-exclude-regex CENTER_EXCLUDE_REGEX + exclude centers by regex +--include-neighbor-city, -n + include neighboring cities +--start-date START_DATE + first date on which you want to book the first slot (format should be DD/MM/YYYY) +--end-date END_DATE last date on which you want to book the first slot (format should be DD/MM/YYYY) +--dry-run do not really book the slot +--code CODE 2FA code ``` ### With Docker From 099f5d29b633353c17839c252c7d59fc875597bc Mon Sep 17 00:00:00 2001 From: Dreiundzwanzig Date: Tue, 6 Jul 2021 22:37:56 +0200 Subject: [PATCH 46/58] Add support for paged results on centers page --- doctoshotgun.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 488a462..6bbfddb 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -18,6 +18,7 @@ import colorama from requests.adapters import ReadTimeout, ConnectionError from termcolor import colored +from urllib import parse from urllib3.exceptions import NewConnectionError from woob.browser.exceptions import ClientError, ServerError, HTTPNotFound @@ -94,6 +95,15 @@ def iter_centers_ids(self): data = json.loads(div.attrib['data-props']) yield data['searchResultId'] + def get_next_page(self): + for a in self.doc.xpath('//div[contains(@class, "next")]/a'): + href = a.attrib['href'] + query = dict(parse.parse_qsl(parse.urlsplit(href).query)) + + if 'page' in query: + return int(query['page']) + + return None class CenterResultPage(JsonPage): pass @@ -264,13 +274,13 @@ def do_login(self, code): return True - def find_centers(self, where, motives=None): + def find_centers(self, where, motives=None, page=1): if motives is None: motives = self.vaccine_motives.keys() for city in where: try: self.centers.go(where=city, params={ - 'ref_visit_motive_ids[]': motives}) + 'ref_visit_motive_ids[]': motives, 'page': page}) except ServerError as e: if e.response.status_code in [503]: if 'text/html' in e.response.headers['Content-Type'] \ @@ -285,6 +295,8 @@ def find_centers(self, where, motives=None): except HTTPNotFound as e: raise CityNotFound(city) from e + next_page = self.page.get_next_page() + for i in self.page.iter_centers_ids(): page = self.center_result.open( id=i, @@ -300,6 +312,10 @@ def find_centers(self, where, motives=None): except KeyError: pass + if next_page: + for center in self.find_centers(where, motives, next_page): + yield center + def get_patients(self): self.master_patient.go() From ded2df315630c660bb45d3ae5ab4588fc6478901 Mon Sep 17 00:00:00 2001 From: Dreiundzwanzig Date: Tue, 6 Jul 2021 23:47:08 +0200 Subject: [PATCH 47/58] Fix tests --- test_browser.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test_browser.py b/test_browser.py index 63e7aa2..a4d19ca 100644 --- a/test_browser.py +++ b/test_browser.py @@ -23,7 +23,7 @@ def test_find_centers_fr_returns_503_should_continue(tmp_path): responses.add( responses.GET, - "https://127.0.0.1/vaccination-covid-19/Paris?ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=6971&ref_visit_motive_ids%5B%5D=8192&ref_visit_motive_ids%5B%5D=7005&ref_visit_motive_ids%5B%5D=7004&ref_visit_motive_ids%5B%5D=8193&ref_visit_motive_ids%5B%5D=7945&ref_visit_motive_ids%5B%5D=7107&ref_visit_motive_ids%5B%5D=7108", + "https://127.0.0.1/vaccination-covid-19/Paris?ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=6971&ref_visit_motive_ids%5B%5D=8192&ref_visit_motive_ids%5B%5D=7005&ref_visit_motive_ids%5B%5D=7004&ref_visit_motive_ids%5B%5D=8193&ref_visit_motive_ids%5B%5D=7945&ref_visit_motive_ids%5B%5D=7107&ref_visit_motive_ids%5B%5D=7108&page=1", status=503 ) @@ -43,7 +43,7 @@ def test_find_centers_de_returns_503_should_continue(tmp_path): responses.add( responses.GET, - "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110", + "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110&page=1", status=503 ) @@ -63,7 +63,7 @@ def test_find_centers_de_returns_520_should_continue(tmp_path): responses.add( responses.GET, - "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110", + "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110&page=1", status=520 ) @@ -83,7 +83,7 @@ def test_find_centers_fr_returns_502_should_fail(tmp_path): responses.add( responses.GET, - "https://127.0.0.1/vaccination-covid-19/Paris?ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=6971&ref_visit_motive_ids%5B%5D=8192&ref_visit_motive_ids%5B%5D=7005&ref_visit_motive_ids%5B%5D=7004&ref_visit_motive_ids%5B%5D=8193&ref_visit_motive_ids%5B%5D=7945&ref_visit_motive_ids%5B%5D=7107&ref_visit_motive_ids%5B%5D=7108", + "https://127.0.0.1/vaccination-covid-19/Paris?ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=6971&ref_visit_motive_ids%5B%5D=8192&ref_visit_motive_ids%5B%5D=7005&ref_visit_motive_ids%5B%5D=7004&ref_visit_motive_ids%5B%5D=8193&ref_visit_motive_ids%5B%5D=7945&ref_visit_motive_ids%5B%5D=7107&ref_visit_motive_ids%5B%5D=7108&page=1", status=502 ) @@ -104,7 +104,7 @@ def test_find_centers_de_returns_502_should_fail(tmp_path): responses.add( responses.GET, - "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110", + "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110&page=1", status=502 ) @@ -137,7 +137,7 @@ def test_book_slots_should_succeed(tmp_path): responses.add( responses.GET, - "https://127.0.0.1/impfung-covid-19-corona/K%C3%B6ln?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110", + "https://127.0.0.1/impfung-covid-19-corona/K%C3%B6ln?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110&page=1", status=200, body="
".format( dataProps=mock_search_result_id_escaped_json) From 7bd2004421e69cfcaf85b1a77833e7f4fbaefd04 Mon Sep 17 00:00:00 2001 From: Dreiundzwanzig Date: Wed, 7 Jul 2021 11:53:25 +0200 Subject: [PATCH 48/58] Add paging support for french doctolib + add tests --- doctoshotgun.py | 19 ++++ test_browser.py | 294 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 312 insertions(+), 1 deletion(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 6bbfddb..8e9096e 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -96,6 +96,25 @@ def iter_centers_ids(self): yield data['searchResultId'] def get_next_page(self): + # French doctolib uses data-u attribute of span-element to create the link when user hovers span + for span in self.doc.xpath('//div[contains(@class, "next")]/span'): + if not span.attrib.has_key('data-u'): + continue + + # How to find the corresponding javascript-code: + # Press F12 to open dev-tools, select elements-tab, find div.next, right click on element and enable break on substructure change + # Hover "Next" element and follow callstack upwards + # JavaScript: + # var t = (e = r()(e)).data("u") + # , n = atob(t.replace(/\s/g, '').split('').reverse().join('')); + + import base64 + href = base64.urlsafe_b64decode(''.join(span.attrib['data-u'].split())[::-1]).decode() + query = dict(parse.parse_qsl(parse.urlsplit(href).query)) + + if 'page' in query: + return int(query['page']) + for a in self.doc.xpath('//div[contains(@class, "next")]/a'): href = a.attrib['href'] query = dict(parse.parse_qsl(parse.urlsplit(href).query)) diff --git a/test_browser.py b/test_browser.py index a4d19ca..d493fbc 100644 --- a/test_browser.py +++ b/test_browser.py @@ -2,11 +2,12 @@ from requests.adapters import Response import responses from html import escape +import lxml.html as html import json import datetime from woob.browser.browsers import Browser from woob.browser.exceptions import ServerError -from doctoshotgun import DoctolibDE, DoctolibFR, CenterBookingPage +from doctoshotgun import CentersPage, DoctolibDE, DoctolibFR, CenterBookingPage # globals FIXTURES_FOLDER = "test_fixtures" @@ -114,6 +115,297 @@ def test_find_centers_de_returns_502_should_fail(tmp_path): pass +@responses.activate +def test_get_next_page_fr_should_return_2_on_page_1(tmp_path): + """ + Check that get_next_page returns 2 when we are on page 1 and there is a next page available + """ + + """ + Next (data-u decoded): /vaccination-covid-19-autres-professions-prioritaires/france?page=2&ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=7005 + """ + + htmlString = """ + + """ + doc = html.document_fromstring(htmlString) + + response = Response() + response._content = b'{}' + + centers_page = CentersPage(browser=Browser(), response=response) + centers_page.doc = doc + next_page = centers_page.get_next_page() + assert next_page == 2 + + +@responses.activate +def test_get_next_page_fr_should_return_3_on_page_2(tmp_path): + """ + Check that get_next_page returns 3 when we are on page 2 and next page is available + """ + + """ + Previous (data-u decoded): /vaccination-covid-19-autres-professions-prioritaires/france?ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=7005 + Next (data-u decoded): /vaccination-covid-19-autres-professions-prioritaires/france?page=3&ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=7005 + """ + + htmlString = """ + + """ + doc = html.document_fromstring(htmlString) + + response = Response() + response._content = b'{}' + + centers_page = CentersPage(browser=Browser(), response=response) + centers_page.doc = doc + next_page = centers_page.get_next_page() + assert next_page == 3 + + +@responses.activate +def test_get_next_page_fr_should_return_4_on_page_3(tmp_path): + """ + Check that get_next_page returns 4 when we are on page 3 and next page is available + """ + + """ + Previous (data-u decoded): /vaccination-covid-19-autres-professions-prioritaires/france?page=2&ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=7005 + Next (data-u decoded): /vaccination-covid-19-autres-professions-prioritaires/france?page=4&ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=7005 + """ + + htmlString = """ + + """ + doc = html.document_fromstring(htmlString) + + response = Response() + response._content = b'{}' + + centers_page = CentersPage(browser=Browser(), response=response) + centers_page.doc = doc + next_page = centers_page.get_next_page() + assert next_page == 4 + + +def test_get_next_page_fr_should_return_None_on_last_page(tmp_path): + """ + Check that get_next_page returns None when we are on the last page + """ + """ + Previous (data-u decoded): /vaccination-covid-19-autres-professions-prioritaires/france?page=7&ref_visit_motive_ids%5B%5D=6970&ref_visit_motive_ids%5B%5D=7005 + """ + + htmlString = """ + + """ + doc = html.document_fromstring(htmlString) + + response = Response() + response._content = b'{}' + + centers_page = CentersPage(browser=Browser(), response=response) + centers_page.doc = doc + next_page = centers_page.get_next_page() + assert next_page == None + + +@responses.activate +def test_get_next_page_de_should_return_2_on_page_1(tmp_path): + """ + Check that get_next_page returns 2 when we are on page 1 and next page is available + """ + + htmlString = """ + + """ + doc = html.document_fromstring(htmlString) + + response = Response() + response._content = b'{}' + + centers_page = CentersPage(browser=Browser(), response=response) + centers_page.doc = doc + next_page = centers_page.get_next_page() + assert next_page == 2 + + +@responses.activate +def test_get_next_page_de_should_return_3_on_page_2(tmp_path): + """ + Check that get_next_page returns 3 when we are on page 2 and next page is available + """ + + htmlString = """ + + """ + doc = html.document_fromstring(htmlString) + + response = Response() + response._content = b'{}' + + centers_page = CentersPage(browser=Browser(), response=response) + centers_page.doc = doc + next_page = centers_page.get_next_page() + assert next_page == 3 + + +@responses.activate +def test_get_next_page_de_should_return_4_on_page_3(tmp_path): + """ + Check that get_next_page returns 4 when we are on page 3 and next page is available + """ + + htmlString = """ + + """ + doc = html.document_fromstring(htmlString) + + response = Response() + response._content = b'{}' + + centers_page = CentersPage(browser=Browser(), response=response) + centers_page.doc = doc + next_page = centers_page.get_next_page() + assert next_page == 4 + + +def test_get_next_page_de_should_return_None_on_last_page(tmp_path): + """ + Check that get_next_page returns None when we are on the last page + """ + + htmlString = """ + + """ + doc = html.document_fromstring(htmlString) + + response = Response() + response._content = b'{}' + + centers_page = CentersPage(browser=Browser(), response=response) + centers_page.doc = doc + next_page = centers_page.get_next_page() + assert next_page == None + + @responses.activate def test_book_slots_should_succeed(tmp_path): """ From 2d95176cfc26132bd44cf2983551dfe5b9d10db5 Mon Sep 17 00:00:00 2001 From: Michael Hirsch Date: Fri, 19 Nov 2021 23:26:34 +0100 Subject: [PATCH 49/58] third shot for germany --- doctoshotgun.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 8e9096e..02e58a5 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -552,20 +552,20 @@ class DoctolibDE(Doctolib): BASEURL = 'https://www.doctolib.de' KEY_PFIZER = '6768' KEY_PFIZER_SECOND = '6769' - KEY_PFIZER_THIRD = None + KEY_PFIZER_THIRD = '9093' KEY_MODERNA = '6936' KEY_MODERNA_SECOND = '6937' - KEY_MODERNA_THIRD = None + KEY_MODERNA_THIRD = '9040' KEY_JANSSEN = '7978' KEY_ASTRAZENECA = '7109' KEY_ASTRAZENECA_SECOND = '7110' vaccine_motives = { KEY_PFIZER: 'Pfizer', KEY_PFIZER_SECOND: 'Zweit.*Pfizer|Pfizer.*Zweit', - KEY_PFIZER_THIRD: 'Dritt.*Pfizer|Pfizer.*Dritt', + KEY_PFIZER_THIRD: 'Auffrischung.*Pfizer|Pfizer.*Auffrischung|Dritt.*Pfizer|Booster.*Pfizer', KEY_MODERNA: 'Moderna', KEY_MODERNA_SECOND: 'Zweit.*Moderna|Moderna.*Zweit', - KEY_MODERNA_THIRD: 'Dritt.*Moderna|Moderna.*Dritt', + KEY_MODERNA_THIRD: 'Auffrischung.*Moderna|Moderna.*Auffrischung|Dritt.*Moderna|Booster.*Moderna', KEY_JANSSEN: 'Janssen', KEY_ASTRAZENECA: 'AstraZeneca', KEY_ASTRAZENECA_SECOND: 'Zweit.*AstraZeneca|AstraZeneca.*Zweit', From 208de4bcc7e739f50c63b580f5c20f2edb2fbb67 Mon Sep 17 00:00:00 2001 From: Michael Hirsch Date: Sat, 20 Nov 2021 11:20:58 +0100 Subject: [PATCH 50/58] added german third shot keys --- doctoshotgun.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 02e58a5..f18e313 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -552,7 +552,7 @@ class DoctolibDE(Doctolib): BASEURL = 'https://www.doctolib.de' KEY_PFIZER = '6768' KEY_PFIZER_SECOND = '6769' - KEY_PFIZER_THIRD = '9093' + KEY_PFIZER_THIRD = '9039' KEY_MODERNA = '6936' KEY_MODERNA_SECOND = '6937' KEY_MODERNA_THIRD = '9040' From ab31a64c51c0e35348d24da9094d08ef47fabaaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Schreiner?= Date: Mon, 22 Nov 2021 00:37:27 +0100 Subject: [PATCH 51/58] tests: fix search URLs responses for DE third shots --- test_browser.py | 52 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/test_browser.py b/test_browser.py index d493fbc..eda1472 100644 --- a/test_browser.py +++ b/test_browser.py @@ -12,6 +12,36 @@ # globals FIXTURES_FOLDER = "test_fixtures" +# URL to be mocked using responses +SEARCH_URL_FOR_KOLN = ( + 'https://127.0.0.1/search_results/1234567.json?limit=4' + '&ref_visit_motive_ids%5B%5D=6768' + '&ref_visit_motive_ids%5B%5D=6769' + '&ref_visit_motive_ids%5B%5D=9039' + '&ref_visit_motive_ids%5B%5D=6936' + '&ref_visit_motive_ids%5B%5D=6937' + '&ref_visit_motive_ids%5B%5D=9040' + '&ref_visit_motive_ids%5B%5D=7978' + '&ref_visit_motive_ids%5B%5D=7109' + '&ref_visit_motive_ids%5B%5D=7110' + '&speciality_id=5494' + '&search_result_format=json' +) + +SEARCH_URL_FOR_MUNCHEN=( + 'https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen' + '?ref_visit_motive_ids%5B%5D=6768' + '&ref_visit_motive_ids%5B%5D=6769' + '&ref_visit_motive_ids%5B%5D=9039' + '&ref_visit_motive_ids%5B%5D=6936' + '&ref_visit_motive_ids%5B%5D=6937' + '&ref_visit_motive_ids%5B%5D=9040' + '&ref_visit_motive_ids%5B%5D=7978' + '&ref_visit_motive_ids%5B%5D=7109' + '&ref_visit_motive_ids%5B%5D=7110' + '&page=1' +) + @responses.activate def test_find_centers_fr_returns_503_should_continue(tmp_path): @@ -44,7 +74,7 @@ def test_find_centers_de_returns_503_should_continue(tmp_path): responses.add( responses.GET, - "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110&page=1", + SEARCH_URL_FOR_MUNCHEN, status=503 ) @@ -64,7 +94,7 @@ def test_find_centers_de_returns_520_should_continue(tmp_path): responses.add( responses.GET, - "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110&page=1", + SEARCH_URL_FOR_MUNCHEN, status=520 ) @@ -105,7 +135,7 @@ def test_find_centers_de_returns_502_should_fail(tmp_path): responses.add( responses.GET, - "https://127.0.0.1/impfung-covid-19-corona/M%C3%BCnchen?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110&page=1", + SEARCH_URL_FOR_MUNCHEN, status=502 ) @@ -429,8 +459,18 @@ def test_book_slots_should_succeed(tmp_path): responses.add( responses.GET, - "https://127.0.0.1/impfung-covid-19-corona/K%C3%B6ln?ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110&page=1", - status=200, + ("https://127.0.0.1/impfung-covid-19-corona/K%C3%B6ln" + "?ref_visit_motive_ids%5B%5D=6768" + "&ref_visit_motive_ids%5B%5D=6769" + "&ref_visit_motive_ids%5B%5D=9039" + "&ref_visit_motive_ids%5B%5D=6936" + "&ref_visit_motive_ids%5B%5D=6937" + "&ref_visit_motive_ids%5B%5D=9040" + "&ref_visit_motive_ids%5B%5D=7978" + "&ref_visit_motive_ids%5B%5D=7109" + "&ref_visit_motive_ids%5B%5D=7110" + "&page=1"), + status=200, body="
".format( dataProps=mock_search_result_id_escaped_json) ) @@ -440,7 +480,7 @@ def test_book_slots_should_succeed(tmp_path): responses.add( responses.GET, - "https://127.0.0.1/search_results/1234567.json?limit=4&ref_visit_motive_ids%5B%5D=6768&ref_visit_motive_ids%5B%5D=6769&ref_visit_motive_ids%5B%5D=6936&ref_visit_motive_ids%5B%5D=6937&ref_visit_motive_ids%5B%5D=7978&ref_visit_motive_ids%5B%5D=7109&ref_visit_motive_ids%5B%5D=7110&speciality_id=5494&search_result_format=json", + SEARCH_URL_FOR_KOLN, status=200, body=json.dumps(mock_search_result) ) From 0eeb97216b884ef1b398df8763104b048e69bfa0 Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Mon, 29 Nov 2021 09:29:18 +0100 Subject: [PATCH 52/58] handle queuing system from Doctolib Closes: #292 --- doctoshotgun.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/doctoshotgun.py b/doctoshotgun.py index f18e313..62a2656 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -90,6 +90,13 @@ def build_doc(self, content): class CentersPage(HTMLPage): + def on_load(self): + try: + v = self.doc.xpath('//input[@id="wait-time-value"]')[0] + except IndexError: + return + raise WaitingInQueue(int(v.attrib['value'])) + def iter_centers_ids(self): for div in self.doc.xpath('//div[@class="js-dl-search-results-calendar"]'): data = json.loads(div.attrib['data-props']) @@ -210,6 +217,10 @@ def get_name(self): return '%s %s' % (self.doc[0]['first_name'], self.doc[0]['last_name']) +class WaitingInQueue(Exception): + pass + + class CityNotFound(Exception): pass @@ -845,6 +856,9 @@ def main(self, cli_args=None): print('\n%s: City %s not found. Make sure you selected a city from the available countries.' % ( colored('Error', 'red'), colored(e, 'yellow'))) return 1 + except WaitingInQueue as waiting_time: + log('Within the queue, estimated waiting time %s minutes', waiting_time) + sleep(30) except (ReadTimeout, ConnectionError, NewConnectionError) as e: print('\n%s' % (colored( 'Connection error. Check your internet connection. Retrying ...', 'red'))) From 6ccb9b1cde859244efe212c983eac003f6a6f46c Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Mon, 29 Nov 2021 09:30:39 +0100 Subject: [PATCH 53/58] store browser's state (prevent 2FA when the script relaunch) --- doctoshotgun.py | 363 ++++++++++++++++++++++++++---------------------- 1 file changed, 195 insertions(+), 168 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 62a2656..5ad0033 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import sys +import os import re import logging import tempfile @@ -8,6 +9,7 @@ from urllib.parse import urlparse import datetime import argparse +from pathlib import Path import getpass import unicodedata @@ -22,7 +24,7 @@ from urllib3.exceptions import NewConnectionError from woob.browser.exceptions import ClientError, ServerError, HTTPNotFound -from woob.browser.browsers import LoginBrowser +from woob.browser.browsers import LoginBrowser, StatesMixin from woob.browser.url import URL from woob.browser.pages import JsonPage, HTMLPage from woob.tools.log import createColoredFormatter @@ -114,7 +116,7 @@ def get_next_page(self): # JavaScript: # var t = (e = r()(e)).data("u") # , n = atob(t.replace(/\s/g, '').split('').reverse().join('')); - + import base64 href = base64.urlsafe_b64decode(''.join(span.attrib['data-u'].split())[::-1]).decode() query = dict(parse.parse_qsl(parse.urlsplit(href).query)) @@ -128,7 +130,7 @@ def get_next_page(self): if 'page' in query: return int(query['page']) - + return None class CenterResultPage(JsonPage): @@ -225,7 +227,7 @@ class CityNotFound(Exception): pass -class Doctolib(LoginBrowser): +class Doctolib(LoginBrowser, StatesMixin): # individual properties for each country. To be defined in subclasses BASEURL = "" vaccine_motives = {} @@ -265,6 +267,10 @@ def __init__(self, *args, **kwargs): self.patient = None + def locate_browser(self, state): + # When loading state, do not locate browser on the last url. + pass + def do_login(self, code): try: self.open(self.BASEURL + '/sessions/new') @@ -613,6 +619,9 @@ class DoctolibFR(Doctolib): class Application: + DATA_DIRNAME = (Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")) / 'doctoshotgun' + STATE_FILENAME = DATA_DIRNAME / 'state.json' + @classmethod def create_default_logger(cls): # stderr logger @@ -628,6 +637,21 @@ def setup_loggers(self, level): logging.root.setLevel(level) logging.root.addHandler(self.create_default_logger()) + def load_state(self): + try: + with open(self.STATE_FILENAME, 'r') as fp: + state = json.load(fp) + except IOError: + return {} + else: + return state + + def save_state(self, state): + if not os.path.exists(self.DATA_DIRNAME): + os.makedirs(self.DATA_DIRNAME) + with open(self.STATE_FILENAME, 'w') as fp: + json.dump(state, fp) + def main(self, cli_args=None): colorama.init() # needed for windows @@ -680,8 +704,6 @@ def main(self, cli_args=None): parser.add_argument('--code', type=str, default=None, help='2FA code') args = parser.parse_args(cli_args if cli_args else sys.argv[1:]) - from types import SimpleNamespace - if args.debug: responses_dirname = tempfile.mkdtemp(prefix='woob_session_') self.setup_loggers(logging.DEBUG) @@ -694,182 +716,187 @@ def main(self, cli_args=None): docto = doctolib_map[args.country]( args.username, args.password, responses_dirname=responses_dirname) - if not docto.do_login(args.code): - return 1 - - patients = docto.get_patients() - if len(patients) == 0: - print("It seems that you don't have any Patient registered in your Doctolib account. Please fill your Patient data on Doctolib Website.") - return 1 - if args.patient >= 0 and args.patient < len(patients): - docto.patient = patients[args.patient] - elif len(patients) > 1: - print('Available patients are:') - for i, patient in enumerate(patients): - print('* [%s] %s %s' % - (i, patient['first_name'], patient['last_name'])) - while True: - print('For which patient do you want to book a slot?', - end=' ', flush=True) - try: - docto.patient = patients[int(sys.stdin.readline().strip())] - except (ValueError, IndexError): - continue + docto.load_state(self.load_state()) + + try: + if not docto.do_login(args.code): + return 1 + + patients = docto.get_patients() + if len(patients) == 0: + print("It seems that you don't have any Patient registered in your Doctolib account. Please fill your Patient data on Doctolib Website.") + return 1 + if args.patient >= 0 and args.patient < len(patients): + docto.patient = patients[args.patient] + elif len(patients) > 1: + print('Available patients are:') + for i, patient in enumerate(patients): + print('* [%s] %s %s' % + (i, patient['first_name'], patient['last_name'])) + while True: + print('For which patient do you want to book a slot?', + end=' ', flush=True) + try: + docto.patient = patients[int(sys.stdin.readline().strip())] + except (ValueError, IndexError): + continue + else: + break + else: + docto.patient = patients[0] + + motives = [] + if not args.pfizer and not args.moderna and not args.janssen and not args.astrazeneca: + if args.only_second: + motives.append(docto.KEY_PFIZER_SECOND) + motives.append(docto.KEY_MODERNA_SECOND) + # motives.append(docto.KEY_ASTRAZENECA_SECOND) #do not add AstraZeneca by default + elif args.only_third: + if not docto.KEY_PFIZER_THIRD and not docto.KEY_MODERNA_THIRD: + print('Invalid args: No third shot vaccinations in this country') + return 1 + motives.append(docto.KEY_PFIZER_THIRD) + motives.append(docto.KEY_MODERNA_THIRD) else: - break - else: - docto.patient = patients[0] - - motives = [] - if not args.pfizer and not args.moderna and not args.janssen and not args.astrazeneca: - if args.only_second: - motives.append(docto.KEY_PFIZER_SECOND) - motives.append(docto.KEY_MODERNA_SECOND) - # motives.append(docto.KEY_ASTRAZENECA_SECOND) #do not add AstraZeneca by default - elif args.only_third: - if not docto.KEY_PFIZER_THIRD and not docto.KEY_MODERNA_THIRD: - print('Invalid args: No third shot vaccinations in this country') + motives.append(docto.KEY_PFIZER) + motives.append(docto.KEY_MODERNA) + motives.append(docto.KEY_JANSSEN) + # motives.append(docto.KEY_ASTRAZENECA) #do not add AstraZeneca by default + if args.pfizer: + if args.only_second: + motives.append(docto.KEY_PFIZER_SECOND) + elif args.only_third: + if not docto.KEY_PFIZER_THIRD: # not available in all countries + print('Invalid args: Pfizer has no third shot in this country') + return 1 + motives.append(docto.KEY_PFIZER_THIRD) + else: + motives.append(docto.KEY_PFIZER) + if args.moderna: + if args.only_second: + motives.append(docto.KEY_MODERNA_SECOND) + elif args.only_third: + if not docto.KEY_MODERNA_THIRD: # not available in all countries + print('Invalid args: Moderna has no third shot in this country') + return 1 + motives.append(docto.KEY_MODERNA_THIRD) + else: + motives.append(docto.KEY_MODERNA) + if args.janssen: + if args.only_second or args.only_third: + print('Invalid args: Janssen has no second or third shot') return 1 - motives.append(docto.KEY_PFIZER_THIRD) - motives.append(docto.KEY_MODERNA_THIRD) - else: - motives.append(docto.KEY_PFIZER) - motives.append(docto.KEY_MODERNA) - motives.append(docto.KEY_JANSSEN) - # motives.append(docto.KEY_ASTRAZENECA) #do not add AstraZeneca by default - if args.pfizer: - if args.only_second: - motives.append(docto.KEY_PFIZER_SECOND) - elif args.only_third: - if not docto.KEY_PFIZER_THIRD: # not available in all countries - print('Invalid args: Pfizer has no third shot in this country') + else: + motives.append(docto.KEY_JANSSEN) + if args.astrazeneca: + if args.only_second: + motives.append(docto.KEY_ASTRAZENECA_SECOND) + elif args.only_third: + print('Invalid args: AstraZeneca has no third shot') return 1 - motives.append(docto.KEY_PFIZER_THIRD) - else: - motives.append(docto.KEY_PFIZER) - if args.moderna: - if args.only_second: - motives.append(docto.KEY_MODERNA_SECOND) - elif args.only_third: - if not docto.KEY_MODERNA_THIRD: # not available in all countries - print('Invalid args: Moderna has no third shot in this country') + else: + motives.append(docto.KEY_ASTRAZENECA) + + vaccine_list = [docto.vaccine_motives[motive] for motive in motives] + + if args.start_date: + try: + start_date = datetime.datetime.strptime( + args.start_date, '%d/%m/%Y').date() + except ValueError as e: + print('Invalid value for --start-date: %s' % e) return 1 - motives.append(docto.KEY_MODERNA_THIRD) else: - motives.append(docto.KEY_MODERNA) - if args.janssen: - if args.only_second or args.only_third: - print('Invalid args: Janssen has no second or third shot') - return 1 - else: - motives.append(docto.KEY_JANSSEN) - if args.astrazeneca: - if args.only_second: - motives.append(docto.KEY_ASTRAZENECA_SECOND) - elif args.only_third: - print('Invalid args: AstraZeneca has no third shot') - return 1 + start_date = datetime.date.today() + if args.end_date: + try: + end_date = datetime.datetime.strptime( + args.end_date, '%d/%m/%Y').date() + except ValueError as e: + print('Invalid value for --end-date: %s' % e) + return 1 else: - motives.append(docto.KEY_ASTRAZENECA) - - vaccine_list = [docto.vaccine_motives[motive] for motive in motives] + end_date = start_date + relativedelta(days=args.time_window) + log('Starting to look for vaccine slots for %s %s between %s and %s...', + docto.patient['first_name'], docto.patient['last_name'], start_date, end_date) + log('Vaccines: %s', ', '.join(vaccine_list)) + log('Country: %s ', args.country) + log('This may take a few minutes/hours, be patient!') + cities = [docto.normalize(city) for city in args.city.split(',')] - if args.start_date: - try: - start_date = datetime.datetime.strptime( - args.start_date, '%d/%m/%Y').date() - except ValueError as e: - print('Invalid value for --start-date: %s' % e) - return 1 - else: - start_date = datetime.date.today() - if args.end_date: - try: - end_date = datetime.datetime.strptime( - args.end_date, '%d/%m/%Y').date() - except ValueError as e: - print('Invalid value for --end-date: %s' % e) - return 1 - else: - end_date = start_date + relativedelta(days=args.time_window) - log('Starting to look for vaccine slots for %s %s between %s and %s...', - docto.patient['first_name'], docto.patient['last_name'], start_date, end_date) - log('Vaccines: %s', ', '.join(vaccine_list)) - log('Country: %s ', args.country) - log('This may take a few minutes/hours, be patient!') - cities = [docto.normalize(city) for city in args.city.split(',')] - - while True: - log_ts() - try: - for center in docto.find_centers(cities, motives): - if args.center: - if center['name_with_title'] not in args.center: - logging.debug("Skipping center '%s'" % - center['name_with_title']) - continue - if args.center_regex: - center_matched = False - for center_regex in args.center_regex: - if re.match(center_regex, center['name_with_title']): - center_matched = True - else: - logging.debug( - "Skipping center '%(name_with_title)s'" % center) - if not center_matched: - continue - if args.center_exclude: - if center['name_with_title'] in args.center_exclude: - logging.debug( - "Skipping center '%(name_with_title)s' because it's excluded" % center) - continue - if args.center_exclude_regex: - center_excluded = False - for center_exclude_regex in args.center_exclude_regex: - if re.match(center_exclude_regex, center['name_with_title']): + while True: + log_ts() + try: + for center in docto.find_centers(cities, motives): + if args.center: + if center['name_with_title'] not in args.center: + logging.debug("Skipping center '%s'" % + center['name_with_title']) + continue + if args.center_regex: + center_matched = False + for center_regex in args.center_regex: + if re.match(center_regex, center['name_with_title']): + center_matched = True + else: + logging.debug( + "Skipping center '%(name_with_title)s'" % center) + if not center_matched: + continue + if args.center_exclude: + if center['name_with_title'] in args.center_exclude: logging.debug( "Skipping center '%(name_with_title)s' because it's excluded" % center) - center_excluded = True - if center_excluded: + continue + if args.center_exclude_regex: + center_excluded = False + for center_exclude_regex in args.center_exclude_regex: + if re.match(center_exclude_regex, center['name_with_title']): + logging.debug( + "Skipping center '%(name_with_title)s' because it's excluded" % center) + center_excluded = True + if center_excluded: + continue + if not args.include_neighbor_city and not docto.normalize(center['city']).startswith(tuple(cities)): + logging.debug( + "Skipping city '%(city)s' %(name_with_title)s" % center) continue - if not args.include_neighbor_city and not docto.normalize(center['city']).startswith(tuple(cities)): - logging.debug( - "Skipping city '%(city)s' %(name_with_title)s" % center) - continue - log('') + log('') + + log('Center %(name_with_title)s (%(city)s):' % center) - log('Center %(name_with_title)s (%(city)s):' % center) + if docto.try_to_book(center, vaccine_list, start_date, end_date, args.only_second, args.only_third, args.dry_run): + log('') + log('💉 %s Congratulations.' % + colored('Booked!', 'green', attrs=('bold',))) + return 0 + + sleep(SLEEP_INTERVAL_AFTER_CENTER) - if docto.try_to_book(center, vaccine_list, start_date, end_date, args.only_second, args.only_third, args.dry_run): log('') - log('💉 %s Congratulations.' % - colored('Booked!', 'green', attrs=('bold',))) - return 0 - - sleep(SLEEP_INTERVAL_AFTER_CENTER) - - log('') - log('No free slots found at selected centers. Trying another round in %s sec...', SLEEP_INTERVAL_AFTER_RUN) - sleep(SLEEP_INTERVAL_AFTER_RUN) - except CityNotFound as e: - print('\n%s: City %s not found. Make sure you selected a city from the available countries.' % ( - colored('Error', 'red'), colored(e, 'yellow'))) - return 1 - except WaitingInQueue as waiting_time: - log('Within the queue, estimated waiting time %s minutes', waiting_time) - sleep(30) - except (ReadTimeout, ConnectionError, NewConnectionError) as e: - print('\n%s' % (colored( - 'Connection error. Check your internet connection. Retrying ...', 'red'))) - print(str(e)) - sleep(SLEEP_INTERVAL_AFTER_CONNECTION_ERROR) - except Exception as e: - template = "An unexpected exception of type {0} occurred. Arguments:\n{1!r}" - message = template.format(type(e).__name__, e.args) - print(message) - return 1 - return 0 + log('No free slots found at selected centers. Trying another round in %s sec...', SLEEP_INTERVAL_AFTER_RUN) + sleep(SLEEP_INTERVAL_AFTER_RUN) + except CityNotFound as e: + print('\n%s: City %s not found. Make sure you selected a city from the available countries.' % ( + colored('Error', 'red'), colored(e, 'yellow'))) + return 1 + except WaitingInQueue as waiting_time: + log('Within the queue, estimated waiting time %s minutes', waiting_time) + sleep(30) + except (ReadTimeout, ConnectionError, NewConnectionError) as e: + print('\n%s' % (colored( + 'Connection error. Check your internet connection. Retrying ...', 'red'))) + print(str(e)) + sleep(SLEEP_INTERVAL_AFTER_CONNECTION_ERROR) + except Exception as e: + template = "An unexpected exception of type {0} occurred. Arguments:\n{1!r}" + message = template.format(type(e).__name__, e.args) + print(message) + return 1 + return 0 + finally: + self.save_state(docto.dump_state()) if __name__ == '__main__': From 18f2327c0e83b980cf134b8b6698e13490529624 Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Mon, 29 Nov 2021 22:37:34 +0100 Subject: [PATCH 54/58] display choices when user is prompted --- doctoshotgun.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 5ad0033..4e8bb17 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -528,8 +528,10 @@ def try_to_book_place(self, profile_id, motive_id, practice_id, agenda_ids, vac_ elif field['placeholder']: value = field['placeholder'] else: - print('%s (%s):' % - (field['label'], field['placeholder']), end=' ', flush=True) + for key, value in field.get('options', []): + print(' │ %s %s' % (colored(key, 'green'), colored(value, 'yellow'))) + print(' ├╴ %s%s:' % (field['label'], (' (%s)' % field['placeholder']) if field['placeholder'] else ''), + end=' ', flush=True) value = sys.stdin.readline().strip() custom_fields[field['id']] = value From 41504029a3a471744db5a0b5a27c84ef1a94cb1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Schreiner?= Date: Mon, 29 Nov 2021 22:20:51 +0100 Subject: [PATCH 55/58] tests: mock out state mixin methods Note: this does not necessarily mean that the load_state and dump_state methods will work on DoctolibDE. The dump_state method in MockDoctolibDE does nothing. --- test_cli_args.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test_cli_args.py b/test_cli_args.py index 3fcb562..d94f932 100644 --- a/test_cli_args.py +++ b/test_cli_args.py @@ -81,6 +81,9 @@ def get_mocked_doctolib(MockDoctolibDE): mock_doctolib_de.try_to_book.return_value = True + mock_doctolib_de.load_state.return_value = {} + mock_doctolib_de.dump_state.return_value = None + return mock_doctolib_de From 73264685affed0dbd16f593885f7c5045380e7de Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Tue, 30 Nov 2021 11:36:40 +0100 Subject: [PATCH 56/58] catch 410 error which occurs sometimes on centers Closes: #293 --- doctoshotgun.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/doctoshotgun.py b/doctoshotgun.py index 4e8bb17..4bf2871 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -366,7 +366,13 @@ def normalize(cls, string): return normalized.lower() def try_to_book(self, center, vaccine_list, start_date, end_date, only_second, only_third, dry_run=False): - self.open(center['url']) + try: + self.open(center['url']) + except ClientError as e: + # Sometimes there are referenced centers which are not available anymore (410 Gone) + log('Error: %s', e, color='red') + return False + p = urlparse(center['url']) center_id = p.path.split('/')[-1] @@ -381,8 +387,8 @@ def try_to_book(self, center, vaccine_list, start_date, end_date, only_second, o motives_id = dict((k, v) for k, v in motives_id.items() if v is not None) if len(motives_id.values()) == 0: - log('Unable to find requested vaccines in motives') - log('Motives: %s', ', '.join(self.page.get_motives())) + log('Unable to find requested vaccines in motives', color='red') + log('Motives: %s', ', '.join(self.page.get_motives()), color='red') return False for place in self.page.get_places(): From 3c1b700275ab13881d0099964b04cad9dc05d828 Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Tue, 30 Nov 2021 12:21:43 +0100 Subject: [PATCH 57/58] fix returned values in the mocked browser --- test_cli_args.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test_cli_args.py b/test_cli_args.py index d94f932..72a21af 100644 --- a/test_cli_args.py +++ b/test_cli_args.py @@ -81,8 +81,8 @@ def get_mocked_doctolib(MockDoctolibDE): mock_doctolib_de.try_to_book.return_value = True - mock_doctolib_de.load_state.return_value = {} - mock_doctolib_de.dump_state.return_value = None + mock_doctolib_de.load_state.return_value = None + mock_doctolib_de.dump_state.return_value = {} return mock_doctolib_de From b22c6cfcede5a45bd3e149bb8b6d2d612b57ae98 Mon Sep 17 00:00:00 2001 From: seranpion Date: Wed, 1 Dec 2021 12:04:21 +0100 Subject: [PATCH 58/58] Adding support for proxy configuration --- README.md | 2 ++ doctoshotgun.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/README.md b/README.md index f72631d..4a4c86e 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ Further optional arguments: --end-date END_DATE last date on which you want to book the first slot (format should be DD/MM/YYYY) --dry-run do not really book the slot --code CODE 2FA code +--proxy PROXY, -P PROXY + configure a network proxy to use ``` ### With Docker diff --git a/doctoshotgun.py b/doctoshotgun.py index 4bf2871..b7c9acd 100755 --- a/doctoshotgun.py +++ b/doctoshotgun.py @@ -710,6 +710,8 @@ def main(self, cli_args=None): parser.add_argument('username', help='Doctolib username') parser.add_argument('password', nargs='?', help='Doctolib password') parser.add_argument('--code', type=str, default=None, help='2FA code') + parser.add_argument('--proxy', '-P', type=str, default=None, + help='define a proxy to use') args = parser.parse_args(cli_args if cli_args else sys.argv[1:]) if args.debug: @@ -724,6 +726,10 @@ def main(self, cli_args=None): docto = doctolib_map[args.country]( args.username, args.password, responses_dirname=responses_dirname) + + if args.proxy: + docto.PROXIES = {'https': args.proxy} + docto.load_state(self.load_state()) try: