Comment scraper Doctolib avec Python et requests en 2023?

Sasha Bouloudnine
July 7, 2023
20 min read

Doctolib c’est vraiment un site merveilleux: on peut accéder au calendrier de plus de 215 000 médecins de toutes les spécialités, partout en France, et réserver, en quelques clics, son créneau du bout des doigts.

Valorisée à plus de 5,8 milliards d’euros, la plateforme est ainsi devenue depuis Mars 2022 selon cet article de l’Usine Digitale, la plus grosse licorne française. +3000 collaborateurs enregistrés sur Linkedin, 44 millions de visiteurs uniques chaque mois, et 1er en France dans la catégorie Santé selon le site d’audience en ligne SimilarWeb: il s’agit d’un géant de la startup à la française. Une licorne, une vraie.

Pourtant, si l’on se rend sur GrowthHacking, le forum de référence du scraping en France, un certain GEDEA, qui prétend avoir travaillé pour l’entreprise un peu plus tôt, nous déconseille vivement de scraper le site: il a “pas mal taffé dessus”, et “Ameli sera plus simple”.

On est prévenus. Et il récupère 8 likes.

Alors, qu’est-ce qui rend le scraping de doctolib si compliqué — est-ce même possible? Spoiler alert: oui.

🦀

Dans ce tutoriel, on va donc voir comment scraper toutes les données de médecins sur doctolib, quelle que soit la profession, avec python3 et requests.

Nerds, développeurs de santé, passionnés de médecine, ou chasseurs de docteur de France et de navarre, cet article est fait pour vous.

Pré-requis

Avant de se lancer dans le grand bain, voilà les 5 éléments que l’on va installer:

  1. Python
  2. Sublime Text
  3. curl-impersonate
  4. curl_cffi
  5. lxml

Python, d’abord, qui est le langage informatique avec lequel nous allons construire l’outil de scraping. Si vous avez un Mac, c’est installé par défaut, chouette. Sinon, vous pouvez le télécharger directement ici: https://www.python.org/.

Ensuite Sublime Text, qui est tout simplement l’éditeur de texte, comme Word. Simple, proprement illustré, élégant: l’outil se prête bien à l’écriture de logiciels stockés dans une page simple.

curl-impersonate est ce que l’on va installer après: un outil qui permet de réaliser des requêtes vers le site client, en utilisant exactement la même version du HTTP handshake que votre navigateur Chrome.

Si le HTTP handshake est un peu flou pour vous pour le moment: pas de frayeur, on va aborder ça en détails au cours du tutoriel.

Si vous utilisez Mac OS, il faut réaliser les étapes comme suit.

Télécharger les dépendances présentes ici: https://github.com/lwthiker/curl-impersonate/releases

Installer ces même dépendances:

$ brew install pkg-config make cmake ninja autoconf automake libtool # For the Firefox version only $ brew install sqlite nss $ pip3 install gyp-next # For the Chrome version only $ brew install go

Cloner le repo Git:

$ git clone https://github.com/lwthiker/curl-impersonate.git $ cd curl-impersonate

Et enfin compiler l’ensemble:

$ mkdir build && cd build ../configure # Build and install the Firefox version $ gmake firefox-build $ sudo gmake firefox-install # Build and install the Chrome version $ gmake chrome-build $ sudo gmake chrome-install # Optionally remove all the build files $ cd ../ && rm -Rf build

Si vous utilisez un autre système d’exploitation, vous pouvez directement vous rendre sur la page dédiée: https://github.com/lwthiker/curl-impersonate/blob/main/INSTALL.md#static-compilation

Au niveau des librairies Python, on va installer 3 librairies supplémentaires.

D’abord, requests, la librairie externe Python la plus téléchargée au monde, qu’on ne présente plus, qui permet tout simplement de naviguer sur internet avec un script python.

On va ensuite utiliser installer le module, qui va nous permettre d’utiliser ce fameux cURL impersonate, directement avec Python: il s’agit de curl_cffi.

Enfin, on va installer lxml, une librairie allemande (uh) qui permet de convertir du texte en un document HTML structuré, et de faciliter ainsi la récupération d’éléments depuis la page HTML.

$ pip3 install --upgrade requests curl_cffi lxml

Et voilà, on est fin prêts. Let’s go!

Code complet

Voilà le code complet, et accessible depuis ce Git.

from curl_cffi import requests from lxml import html import json import csv import time import argparse HEADERS = { 'authority': 'www.doctolib.fr', 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', 'accept-language': 'fr-FR,fr;q=0.9', 'sec-ch-ua': '"Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"macOS"', 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'none', 'sec-fetch-user': '?1', 'upgrade-insecure-requests': '1', 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', } FIELDNAMES = [ 'type', 'name', 'specialty', 'url', 'address_name', 'address_street', 'address_postal_code', 'address_locality', 'payment_accepted' ] DATA = [] class DoctolibScraper: def __init__(self): self.s = requests.Session(impersonate="chrome101") self.s.headers = HEADERS def iter_doctors(self, url, max_page): assert all([url, max_page]) for i in range(1, max_page+1): u = url + '?page=%s' % i print('going page %s' % i) time.sleep(1) response = self.s.get(u) assert response.status_code == 200 doc = html.fromstring(response.text) raw_json_data = doc.xpath("//script[@type='application/ld+json' and contains(text(), 'medicalSpecialty')]") if not raw_json_data: print('no more data') break assert raw_json_data and len(raw_json_data) == 1 raw_json_data = json.loads(raw_json_data[0].text) assert isinstance(raw_json_data, list) doctors = raw_json_data for doctor in doctors: d = {} d['type'] = doctor['@type'] d['name'] = doctor['name'] d['specialty'] = doctor['medicalSpecialty'] d['url'] = 'https://www.doctolib.fr/'+doctor['url'] _address = doctor['address'] d['address_name'] = _address['name'] d['address_street'] = _address['streetAddress'] d['address_postal_code'] = _address['postalCode'] d['address_locality'] = _address['addressLocality'] d['payment_accepted'] = doctor['paymentAccepted'] print('scraped: %s' % d['name']) DATA.append(d) return DATA def write_csv(self, data): filename = 'data_scraping_doctolib_lobstr_io.csv' assert data and isinstance(data, list) assert filename with open(filename, 'w') as f: writer = csv.DictWriter(f, fieldnames=FIELDNAMES) writer.writeheader() for d in DATA: writer.writerow(d) print('write csv: complete') if __name__ == '__main__': s = time.perf_counter() d = DoctolibScraper() argparser = argparse.ArgumentParser() argparser.add_argument('--search-url', '-u', type=str, required=False, help='doctolib search url', default='https://www.doctolib.fr/radiologue/lyon') argparser.add_argument('--max-page', '-p', type=int, required=False, help='max page to visit', default=2) args = argparser.parse_args() search_url = args.search_url max_page = args.max_page assert all([search_url, max_page]) data = d.iter_doctors(search_url, max_page) d.write_csv(data) elapsed = time.perf_counter() - s elapsed_formatted = "{:.2f}".format(elapsed) print("elapsed:", elapsed_formatted, "s") print('''~~ success _ _ _ | | | | | | | | ___ | |__ ___| |_ __ __ | |/ _ \| '_ \/ __| __/| '__| | | (_) | |_) \__ \ |_ | | |_|\___/|_.__/|___/\__||_| ''')

Vous allez pouvoir télécharger le fichier, et le lancer depuis la ligne de commande, en précisant 2 paramètres:

  1. u — l’URL de recherche Doctolib, par defaut https://www.doctolib.fr/radiologue/lyon
  2. p — la page maximale à laquelle vous souhaitez aller, par defaut 3

Et ce qui nous donne :

$ python3 doctolib_scraper_072023 -u https://www.doctolib.fr/pneumologue/reims -p 2 going page 1 scraped: Juliette VELLA-BOUCAUD scraped: Bruno Picavet scraped: Dragisa MILOSEVIC scraped: Julie Nardi scraped: Véronique Garcia scraped: Pierre BERTRAND scraped: Dragisa MILOSEVIC scraped: Bruno Picavet scraped: Juliette VELLA-BOUCAUD scraped: Centre Hospitalier de Laon scraped: Véronique Garcia scraped: Bruno Picavet scraped: Dragisa MILOSEVIC scraped: Francois Lebargy scraped: Anne Sophie Angelier scraped: Bertrand Guy going page 2 no more data write csv: complete elapsed: 2.92 s ~~ success _ _ _ | | | | | | | | ___ | |__ ___| |_ __ __ | |/ _ \| '_ \/ __| __/| '__| | | (_) | |_) \__ \ |_ | | |_|\___/|_.__/|___/\__||_|

Chirurgical.

👨‍⚕️

Tutoriel étape par étape

Dans ce tutoriel, on va voir comment scraper tous les médecins depuis n’importe quelle URL de recherche Doctolib, et jusqu’à la page qui nous chante.

Et parce qu’on est des nerds, on va commencer par scraper les plus nerds des médecins: les radiologues, à Lyon. Après tout pourquoi pas.

https://www.doctolib.fr/radiologue/lyon

Et on va récupérer 9 attributs distincts:

  1. type
  2. name
  3. specialty
  4. url
  5. address_name
  6. address_street
  7. address_postal_code
  8. address_locality
  9. payment_accepted

Et comme illustré ci-dessous:

L’URL du docteur, et les paiements acceptés ne sont pas présents sur cet imprimé écran, mais on va les retrouver lors de la récupération d’informations depuis le code source de la page.

Et ce tutoriel va se passer en 6 étapes distincts:

  1. Identification de la requête cURL principale
  2. Résolution du Retry Later challenge
  3. Parsing des données
  4. Navigation d’une page à l’autre
  5. Ajout des variables dynamiques
  6. Sauvegarde des données au format .csv

En route.

1. Identification de la requête cURL principale

Comme vu dans de précédents tutoriels à ce sujet, pour scraper de la donnée avec Python et requests, il faut d’abord identifier la requête qui est échangée entre le navigateur et le site cible, celle qui contient la donnée que l’on veut récupérer. Ensuite, on va reproduire cette requête avec notre code en Python.

On se rend donc sur notre URL de départ doctolib, et on fait: Clique droit > Inspecter.

Ensuite, on ouvre l’onglet Network, qui va simplement nous permettre d’observer les requêtes, toutes les requêtes, qui sont échangées entre le site cible et notre navigateur.

Enfin, on rafraîchit la page, pour que des requêtes soient à nouveau échangées entre le site cible et notre navigateur, et que toutes ces requêtes soient enregistrées.

OKK toutes les requêtes ont été proprement enregistrées!

Mais comment trouver la requête qui contient les données que l’on souhaite récupérer?

Et bien c’est très simple, on va utiliser l’outil de recherche de l’onglet Network, qui permet de chercher un élément de texte parmi toutes les requêtes enregistrées. Ici, on va chercher le texte Rockfeller.

C’est à dire qu’on va essayer d’identifier la requête échangée entre le navigateur et le site-cible qui a permis de récupérer la chaîne de caractère Rockfeller.

On procède donc comme suit:

  1. on clique sur l’icône de recherche pour ouvrir l’outil de recherche de l’onglet Network
  2. on copie la chaîne de caractères Rockfeller
  3. on la colle au niveau du champ de recherche
  4. on appuie sur entrée

Et top, la requête apparaît!

Et maintenant, comment réaliser cette requête avec un script Python?

Et bien c’est très simple, on va récupérer la valeur de la requête au format cURL, et ensuite la convertir au format Python.

cURL est un outil qui permet de transférer de la donnée, et directement utilisable depuis la ligne de commande.

Pour récupérer la requête cURL, on double clique sur notre requête au niveau de l’espace de recherche, puis une fois identifiée la requête dans la fenêtre de droite, on fait: Clique droit > Copy as cURL.

Voilà le cURL tant espéré!

curl 'https://www.doctolib.fr/radiologue/lyon' \ -H 'authority: www.doctolib.fr' \ -H 'accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' \ -H 'accept-language: fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7' \ -H 'cache-control: max-age=0' \ -H 'if-none-match: W/"363291d1faae7973b0c8b7aa7aa0856a"' \ -H 'sec-ch-ua: "Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"' \ -H 'sec-ch-ua-mobile: ?0' \ -H 'sec-ch-ua-platform: "macOS"' \ -H 'sec-fetch-dest: document' \ -H 'sec-fetch-mode: navigate' \ -H 'sec-fetch-site: same-origin' \ -H 'sec-fetch-user: ?1' \ -H 'upgrade-insecure-requests: 1' \ -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36' \ --compressed

Pour faciliter la lisibilité, on a simplement enlever les cookies, qui n’ont pas grand intérêt ici.

Et une fois cette requête cURL copiée, on la convertit avec cet outil en ligne gratuit: https://curlconverter.com/

Il suffit de:

  1. coller le curl dans le champ du dessus
  2. cliquer sur copy to clipboard

Et voilà, on a un code Python propre et directement exploitable.

On va simplement ajouter imprimer à la fin le contenu de la réponse:

import requests headers = { 'authority': 'www.doctolib.fr', 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', 'accept-language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7', 'cache-control': 'max-age=0', 'if-none-match': 'W/"363291d1faae7973b0c8b7aa7aa0856a"', 'sec-ch-ua': '"Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"macOS"', 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'same-origin', 'sec-fetch-user': '?1', 'upgrade-insecure-requests': '1', 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', } response = requests.get('https://www.doctolib.fr/radiologue/lyon', headers=headers) print(response.text)

Et là… Terrible, on se prend un villain stop. Retry Later. Essayez plus tard.

Et puis c’est tout.

% python3 doctolib_scraper_072023.py Retry later

GEDEA avait raison de nous alerter en introduction. C’est plus compliqué que prévu.

Comment contourner cette limite?

2. Résolution du Retry Later challenge

a. Identification du HTTP fingerprinting

Deux mots, secs, et intransigeants. Repassez plus tard, il n’y a rien à voir. Comment contourner ce challenge?

Comme nous utilisons plusieurs outils pour faire la requête, le problème peut venir de différents niveaux:

  1. cURL
  2. requests

On l’a vu, le script avec requests ne fonctionne pas. Quid de la requête cURL?

On va donc essayer directement avec cURL. On copy as cURL comme vu précédemment, et on colle ça dans notre ligne de commande:

$ curl 'https://www.doctolib.fr/radiologue/lyon' \ -H 'authority: www.doctolib.fr' \ -H 'accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' \ -H 'accept-language: fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7' \ -H 'cache-control: max-age=0' \ -H 'if-none-match: W/"363291d1faae7973b0c8b7aa7aa0856a"' \ -H 'sec-ch-ua: "Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"' \ -H 'sec-ch-ua-mobile: ?0' \ -H 'sec-ch-ua-platform: "macOS"' \ -H 'sec-fetch-dest: document' \ -H 'sec-fetch-mode: navigate' \ -H 'sec-fetch-site: same-origin' \ -H 'sec-fetch-user: ?1' \ -H 'upgrade-insecure-requests: 1' \ -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36' \ --compressed Retry later

Et là: même résultat, Retry Later.

En d’autres termes: - la requête passe depuis le navigateur - la requête ne passe pas avec cURL

Il doit y avoir une différence de paramétrage entre la requête cURL et la requête effectuée par le navigateur.

Essayons de regarder les paramètres de la requête du navigateur plus en détail. En regardant la requête avec Firefox cette fois-ci, on remarque un élément qu’on avait négligé jusqu’ici:

La version de la requête est HTTP/2. Selon Wikipedia, il s’agit d’une nouvelle version du protocole HTTP, utilisé pour naviguer sur Internet, et implémenté principalement depuis 2015. Toujours depuis la même page, 40% des 10 millions de pages les plus visitées d’internet supportent ce protocole.

OK mais quelle est la version de notre cURL?

Après une recherche Google rapide, sauf mise à jour, par défaut la version de la requête du cURL est HTTP/1:

On y est!

Le site utilise en réalité ce que l’on appelle le le HTTP handshakes fingerprinting: le site observe la version HTTP utilisée pour faire la requête, et utilise cette observation pour déterminer si oui ou non la requête vient d’un navigateur traditionnel ou d’un robot.

Il s’agit de stratégie de fingerprinting traditionnelle, mais qui s’appuie sur la version du HTTP handshake pour déterminer si le trafic doit être considéré comme licite.

Mais dès lors, comment réaliser une requête, idéalement avec Python, qui utilise cette version du HTTP handshake? C’est ce qu’on va voir dans la partie suivante.

b. Installation de curl-impersonate

Et bien c’est très simple, on va installer une nouvelle version de cURL, qui a exactement les mêmes paramètres de HTTP qu’une requête venant du navigateur chrome: il s’agit de curl-impersonate.

Comme mentionné dans la partie pré-requis, si vous utilisez Mac OS, il faut réaliser les étapes comme suit.

D’abord télécharger la dernière version de curl-impersonate: https://github.com/lwthiker/curl-impersonate/releases

Ensuite, il faut installer ces dépendances depuis la ligne de commande:

$ brew install pkg-config make cmake ninja autoconf automake libtool # For the Firefox version only $ brew install sqlite nss $ pip3 install gyp-next # For the Chrome version only $ brew install go

Ensuite, cloner le repo Git publique:

$ git clone https://github.com/lwthiker/curl-impersonate.git $ cd curl-impersonate

Et enfin, compiler l’ensemble:

$ mkdir build && cd build ../configure # Build and install the Firefox version $ gmake firefox-build $ sudo gmake firefox-install # Build and install the Chrome version $ gmake chrome-build $ sudo gmake chrome-install # Optionally remove all the build files $ cd ../ && rm -Rf build

La compilation est un mécanisme qui permet de transformer un code intelligible par l’humain, de “haut-niveau”, en un code intelligible par la machine, de “bas-niveau”.

Et maintenant comment réaliser un cURL avec cette version de cURL?

Et bien c’est très simple, on va réaliser la même requête, mais au lieu d’utiliser la commande curl, on va utiliser la commande curl_chrome110. Il s’agit, comme son nom l’indique d’un cURL, qui a exactement les même paramètres qu’une requête effectuée par le navigateur Chrome.

$ curl_chrome110 'https://www.doctolib.fr/radiologue/lyon' \ -H 'authority: www.doctolib.fr' \ -H 'accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' \ -H 'accept-language: fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7' \ -H 'cache-control: max-age=0' \ -H 'if-none-match: W/"363291d1faae7973b0c8b7aa7aa0856a"' \ -H 'sec-ch-ua: "Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"' \ -H 'sec-ch-ua-mobile: ?0' \ -H 'sec-ch-ua-platform: "macOS"' \ -H 'sec-fetch-dest: document' \ -H 'sec-fetch-mode: navigate' \ -H 'sec-fetch-site: same-origin' \ -H 'sec-fetch-user: ?1' \ -H 'upgrade-insecure-requests: 1' \ -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36' \ --compressed

Et là… ça fonctionne avec succès!

On reçoit dans la console le plein de données, et parmi elle, on retrouve bien la chaîne de caractères Rockfeller:

C’est réussi.

Et maintenant, comment utiliser cette requête cURL avec Python et requests? C’est ce qu’on va voir dans la partie suivante.

c. Utilisation de curl-impersonate avec Python et requests via curl_cffi

Utiliser curl-impersonate depuis la ligne de commande, c’est bien. Mais pour rappel, on souhaite utiliser cette méthode avec Python et requests.

Pour ce faire, on va télécharger curl-cffi: il s’agit, pour faire simple, d’une librairie Python qui permet d’utiliser curl-impersonate.. depuis un script Python.

Pour l’installer c’est très simple:

$ pip3 install --upgrade curl_cffi

Et ensuite, on l’importe au niveau du script Python, avant de réaliser la même requête que précédemment. Enfin, on vérifie que l’on a bien Rockfeller dans notre chaîne de caractères.

from curl_cffi import requests headers = { 'authority': 'www.doctolib.fr', 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', 'accept-language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7', 'cache-control': 'max-age=0', 'if-none-match': 'W/"363291d1faae7973b0c8b7aa7aa0856a"', 'sec-ch-ua': '"Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"macOS"', 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'same-origin', 'sec-fetch-user': '?1', 'upgrade-insecure-requests': '1', 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', } response = requests.get('https://www.doctolib.fr/radiologue/lyon', headers=headers, impersonate="chrome101") if 'Rockefeller' in response.text: print('crab crab')

On lance notre script et… crab crab. C’est un succès!

$ python3 doctolib_scraper_072023.py crab crab

🦀

Le challenge a été contourné avec succès!

Il ne nous reste plus maintenant qu’à récupérer les données, et à les stocker dans un fichier .csv. Allons-y.

3. Parsing des données

Si l’on observe la donnée que l’on récupère, on se rend compte d’abord que l’ensemble des données sont stockées au format JSON, au sein d’un dictionnaire présent dans une balise script.

Avec la librairie lxml, on va donc d’abord aller dénicher ce script. On installe d’abord la librairie allemande (uh) en question:

$ pip3 install lxml

Avec le code comme suit:

from curl_cffi import requests from lxml import html HEADERS = { 'authority': 'www.doctolib.fr', 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', 'accept-language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7', 'cache-control': 'max-age=0', 'if-none-match': 'W/"363291d1faae7973b0c8b7aa7aa0856a"', 'sec-ch-ua': '"Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"macOS"', 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'same-origin', 'sec-fetch-user': '?1', 'upgrade-insecure-requests': '1', 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', } DATA = [] class DoctolibScraper: def __init__(self): self.s = requests.Session(impersonate="chrome101") self.s.headers = HEADERS def iter_doctors(self): response = self.s.get('https://www.doctolib.fr/radiologue/lyon') assert response.status_code == 200 doc = html.fromstring(response.text) raw_json_data = doc.xpath("//script[@type='application/ld+json' and contains(text(), 'medicalSpecialty')]")

On vérifie qu’il y ait bien le terme medicalSpecialty dans notre script, histoire d’éviter de récupérer le contenu d’un script qui ne nous intéresse pas.

Et ensuite à l’aide la librairie json, on va convertir ce texte en dictionnaire, et stocker chacune de nos données dans le dictionnaire qui nous est propre:

import json ... raw_json_data = json.loads(raw_json_data[0].text) assert isinstance(raw_json_data, list) doctors = raw_json_data for doctor in doctors: d = {} d['type'] = doctor['@type'] d['name'] = doctor['name'] d['specialty'] = doctor['medicalSpecialty'] d['url'] = 'https://www.doctolib.fr/'+doctor['url'] _address = doctor['address'] d['address_name'] = _address['name'] d['address_street'] = _address['streetAddress'] d['address_postal_code'] = _address['postalCode'] d['address_locality'] = _address['addressLocality'] d['payment_accepted'] = doctor['paymentAccepted'] print('scraped: %s' % d['name']) DATA.append(d) return DATA

Et si l’on imprime le contenu d’un de nos dictionnaires?

$ python3 doctolib_parser_072023.py {'address_locality': 'Marseille', 'address_name': 'SCM Imagerie médicale du midi', 'address_postal_code': '13007', 'address_street': '7 Rampe Saint-Maurice', 'name': "Centre d'Imagerie Médicale - Dr Tenoudji & Dr Bueno", 'payment_accepted': 'Cash, Check, Credit card', 'specialty': "Centre d'imagerie médicale", 'type': 'Hospital', 'url': 'https://www.doctolib.fr//centre-d-imagerie-medicale/marseille/centre-d-imagerie-medicale-dr-tenoudji-dr-bueno'}

Nickel chrome. Bon, on a la première page, mais comment obtenir la seconde?

4. Navigation d’une page à l’autre

Si l’on réalise la même opération d’identification de requête réalisée lors de la première partie, mais sur la seconde page on se rend compte que la requête de la seconde page est structurée comme suit:

https://www.doctolib.fr/radiologue/marseille?page=2

En d’autres termes, il faut ajouter à la fin de l’URL, le paramètre ?page=$PAGE. Où $PAGE représente le numéro de la page à atteindre.

Et comme on le voit sur ce screenshot:

On va donc écrire une simple boucle for, pour naviguer progressivement d’une page à l’autre:

def iter_doctors(self): for i in range(1, 3): print('go to page %s' % i) u = 'https://www.doctolib.fr/radiologue/lyon?page=%s' % i response = self.s.get(u) assert response.status_code == 200

Et si on lance depuis la ligne de commande:

$ python3 doctolib_scraper_072023.py go to page 1 go to page 2

Enorme!

Bon, les radiologues, c’est bien. Mais que faire si on veut une autre profession? Et si on ne veut pas s’arrêter à la page 2?

5. Ajout des variables dynamiques

Dans cette partie, on va s’offrir un peu de flexibilité, et ajouter des variables dynamiques, afin de pouvoir préciser notre périmètre de recherche, directement depuis la ligne de commande.

Et on va ajouter 2 paramètres dynamiques

  1. search-url, si par exemple on souhaite scraper les pneumologues à Reims
  2. max-page, le nombre total de résultats que l’on souhaite récupérer

On utilise donc la librairie native de Python, argparse.

Et on ajoute ça au niveau de notre méthode principale de navigation:

import argparse if __name__ == '__main__': d = DoctolibScraper() argparser = argparse.ArgumentParser() argparser.add_argument('--search-url', '-u', type=str, required=False, help='doctolib search url', default='https://www.doctolib.fr/radiologue/marseille') argparser.add_argument('--max-page', '-p', type=int, required=False, help='max page to visit', default=2) args = argparser.parse_args() search_url = args.search_url max_page = args.max_page assert all([search_url, max_page]) data = d.iter_doctors(search_url, max_page)

Par ailleurs, on modifie notre méthode principale pour qu’elle prenne ces deux arguments en entrée, et on forme l’URL de recherche final avec ces deux arguments:

import time ... def iter_doctors(self, url, max_page): assert all([url, max_page]) for i in range(1, max_page+1): u = url + '?page=%s' % i print('going page %s' % u) time.sleep(1) response = self.s.get(u) assert response.status_code == 200

A noter qu’on utilise la librairie time, pour faire une petite pause d’1 seconde entre chaque requête. Histoire de ne pas saturer la bande passante du site cible, et de se comporter décemment.

Et si on lance depuis la ligne de commande:

$ python3 mmm.py -u https://www.doctolib.fr/pneumologue/reims -p 3 going page https://www.doctolib.fr/pneumologue/reims?page=1 going page https://www.doctolib.fr/pneumologue/reims?page=2

Ça fonctionne à merveille!

6. Sauvegarde des données au format .csv

Les données dans la console, c’est bien. Mais comment sauvegarder dans un format qui soit lisible et facilement exploitable?

Pour ça, c’est très simple, on va utiliser la librairie native Python csv, et notamment la méthode DictWriter, qui permet très rapidement de convertir une liste de dictionnaires en un fichier proprement structuré.

La méthode se construit comme suit:

import csv ... FIELDNAMES = [ 'type', 'name', 'specialty', 'url', 'address_name', 'address_street', 'address_postal_code', 'address_locality', 'payment_accepted' ] ... class DoctolibScraper: ... def write_csv(self, data): filename = 'data_scraping_doctolib_lobstr_io.csv' assert data and isinstance(data, list) assert filename with open(filename, 'w') as f: writer = csv.DictWriter(f, fieldnames=FIELDNAMES) writer.writeheader() for d in DATA: writer.writerow(d) print('write csv: complete')

On lance la machine et… magnifique! Un set de données exhaustif, proprement structuré et directement exploitable.

Lourd.

Limitations

Le script permet de scraper, sans aucune limitation, les données des médecins sur Doctolib, quel que soit l’URL de recherche ou la page à laquelle vous souhaitez vous rendre. Solide.

Toutefois, le script ne récupère que 9 attributs par médecin. Aucune visite sur la page du médecin n’est effectuée. Aussi, vous pouvez dire adieu au prix, aux créneaux horaires disponibles, ou au numéro de téléphone du praticien.

Par ailleurs, le script, si vous le téléchargez sur votre ordinateur et le faites tourner sur votre ordinateur, va utiliser les ressources de votre ordinateur. Autrement, cette solution n’est pas cloud-based. Si vous éteignez votre ordinateur, la collecte va s’éteindre avec lui.

FAQ

Contrairement à ce qu’on peut imaginer, oui c’est entièrement légal!

D’après l’article L342-3 du code de la propriété intellectuelle, si une base de données est mise à disposition du publique, celui-ci peut extraire et réutiliser les données, à condition de n’extraire qu’une partie non substantielle.

C’est donc légal, à condition de ne pas aspirer le site dans son intégralité.

Pourquoi utiliser le scraping plutôt que l’API officielle Doctolib?

L’API officielle Doctolib n’existe pas.

Demandé depuis 2020 par de nombreux professionnels, avec plus de 17 posts sur un post de la communauté, la société n’a pourtant pas donné suite.

On a bien une adresse https://api.doctolib.fr/, mais la page ne renvoie aucune information pertinente:

Est-ce que le script Python de scraping va fonctionner dans 1 mois?

Oui, on l’espère!

Sauf modification du côté du site, tout devrait parfaitement fonctionner. Si le script ne fonctionne pas, n’hésitez pas à nous contacter ici.

Pourquoi scraper de la donnée sur Doctolib?

Les cas d’usage sont multiples et diverses.

En voici quelques-uns:

  1. Études de marché: le scraping peut vous permettre de connaître, pour une typologie de médecins donnés, et sur une zone géographique donnée, le nombre de praticiens présents et les potentialités de “marché” possible. Jusqu’ici les médecins sont libres de s’installer où bon leur semble, ces informations peuvent avoir leur importance.

  2. Lead Generation: si vous avez une entreprise qui offre des services commerciaux à une population de médecins, le scraping peut vous permettre de très rapidement collecter les coordonnées des praticiens avant de les contacter. Attention dans le cadre de la RGPD, à les notifier une fois leurs coordonnées récupérées, afin de vous assurer qu’ils souhaitent bien être contactés par vos équipes.

  3. Recherche académique: en tant que chercher vous pouvez être intéressé par le scraping sur doctolib, afin d’interroger des thématiques comme l'accessibilité des soins de santé, l'efficacité de la prise de rendez-vous en ligne, etc.

Il ne s’agit que d’une liste non exhaustive, tirée de nos expériences personnelles.

Est-ce qu’il existe un scraper Doctolib no-code?

Non, pas encore! Mais on y réfléchit.

Si l’idée vous intéresse, contactez-nous ici.

Conclusion

Et voilà c’est terminé!

Dans ce tutoriel, on a vu comment, avec Python et requests, scraper les données de médecins sur Doctolib, depuis n’importe quel URL de recherche et jusqu’à n’importe quelle page.

A noter notamment le contournement astucieux du HTTP handshake fingerprinting, avec l’utilisation de l’outil de ligne de commande curl-impersonate, et de son adaptation pour Python, curl-cffi.

C’est tout pour cette fois.

Joyeux scraping!

🦀

1516989175726.jpeg

Sasha Bouloudnine

Co-founder @ lobstr.io since 2019. Genuine data avid and lowercase aesthetic observer. Ensure you get the hot data you need.

Related Articles