Comment scraper cDiscount avec Python et requests en 2023?

Sasha Bouloudnine
June 29, 2023
20 min read

cDiscount, c’est vraiment un superbe site de e-commerce Français. Selon la page de Wikipedia du groupe, on y trouve (tenez-vous bien) pas moins de 100 millions de références, et ce réparti dans plusieurs centaines de catégories, de l’univers de la maison, au high-tech, en passant par la mode et l’alimentation.

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image1.png

Avec 32 millions de visites mensuelles en Mai 2023, selon le site de classement de trafic SimilarWeb, le site se classe en 3e position dans la catégorie eCommerce & Shopping, juste derrière l’indétronable amazon.fr qui totalise jusqu’à 5 fois plus de trafic, avec 160+ millions de visite mensuelle. Chapeau Jeff, joli coup de filet.

Toutefois, si l’on tape scraping python cdiscount sur Google, on ne trouve aucune ressource sérieuse, si ce n’est ce thread du fameux GrowthHackingForum, le site de référence du scraping en France. On nous parle d’une mystérieuse URL POST, sans que personne ne soit en mesure de donner une réponse explicite et fonctionnelle.

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image2.png

Est-ce qu’il est si compliqué de scraper de la donnée sur cDiscount?

Dans ce tutoriel, on va voir comment scraper les données des produits sur cDiscount avec Python3 et requests, et obtenir de façon programmatique, des données fraîches, structurées et massivement exploitables.

Disclaimer: on va également percer le secret de cette mystérieuse requête POST…

Développeurs, data analysts, dirigeants soucieux de veiller sur les prix des barbecues de ses concurrents: cet article est fait pour vous!

Prérequis

Avant de rentrer dans le dur, on va commencer par installer les outils nécessaires à notre collecte.

D’abord assurez-vous bien entendu d’avoir installé l’outil de programmation Python, qu’il est possible de télécharger et d’installer depuis le site web, accessible ici. C’est avec ce langage que l’on va écrire le script de scraping cDiscount.

Puis on va télécharger 3 librairies additionnelles, avec pip, l’outil d’installation de Python: requests, la librairie la plus téléchargée pour Python, qui va nous permettre de naviguer programmatiquement sur Internet, retry, une librairie qui va nous permettre de recommencer certaine tâche si une erreur est rencontrée sur notre chemin, et lxml, une librairie qui va nous permettre d’aller chercher les éléments dans le code source d’une page HTML.

Avec une installation comme suit:

$ pip3 install requests lxml retry

Nous sommes maintenant prêts pour la suite des opérations.

Code Complet

Voilà le code complet, et directement accessible sur Github ici:

import requests import re import json from lxml import html import time from retry import retry import csv URL = 'https://www.cdiscount.com/search/10/barbecue.html' HEADERS = { 'authority': 'www.cdiscount.com', '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 = [ "sku", "url", "image_url", "title", "score", "reviews_count", "price", "is_cdav" ] DATA = [] class JavascriptChallengeError(Exception): pass class cDiscountScraper: def __init__(self): self.s = requests.Session() self.s.headers = HEADERS def solve_javascript_challenge(self, response): print('solving javascript challenge') raw_data = re.findall(r'(?<=__blnChallengeStore=)\{[^\;]+', response.text) assert raw_data and len(raw_data) == 1 raw_data = ''.join(raw_data) raw_data = json.loads(raw_data) challenge_cookie = raw_data['cookie'] challenge_cookie.pop('maxAge') challenge_cookie_value = challenge_cookie['value'] challenge_cookie_name = challenge_cookie['name'] self.s.cookies.set_cookie(requests.cookies.create_cookie(**challenge_cookie)) check_params = raw_data['checkChallengeParams'] url = 'https://www.cdiscount.com/.well-known/baleen/challengejs/check?%s=%s' % ( challenge_cookie_name, challenge_cookie_value ) data = '&'.join(['%s=%s' % (k,v) for k, v in check_params.items()]) response = self.s.post(url, data) assert response.status_code == 200 @retry(JavascriptChallengeError, tries=5, delay=5, backoff=1) def get_cdiscount_data(self): print('start') response = self.s.get(URL, headers=HEADERS) with open('first_req.html', 'w') as f: f.write(response.text) if 'Le JavaScript n\'est pas' in response.text: self.solve_javascript_challenge(response) raise JavascriptChallengeError assert response.status_code == 200 assert 'GEORGES' in response.text doc = html.fromstring(response.text) products = doc.xpath("//ul/[@id='lpBloc']/li[@data-sku]") assert products for product in products: sku = product.get('data-sku') url = "".join(product.xpath('./a/@href')) assert url.startswith('https://www.cdiscount.com') image_url = "".join(product.xpath('.//li/img[@class="prdtBImg"]/@data-src')) or "".join(product.xpath(".//li/img[@class='prdtBImg']/@src")) title = "".join(product.xpath('.//h2[@class="prdtTit"]/text()')) score = "".join(product.xpath('.//span[@class="c-stars-result c-stars-result--small"]/@data-score')) if score: score = float(score) / 20 reviews_count = "".join(product.xpath('.//span[@class="c-stars-result c-stars-result--small"]/following-sibling::span/text()')) if reviews_count: reviews_count = int(reviews_count.strip('()')) price = "".join(product.xpath('.//span[contains(@class, "price priceColor hideFromPro") and not(contains(@class, "price--xs"))]/text()')).strip('€') is_cdav = len(product.xpath('.//div[@class="cdavZone"]')) > 0 values = [sku, url, image_url, title, score, reviews_count, price, is_cdav] d = dict(zip(FIELDNAMES, values)) print(*d.values()) DATA.append(d) return DATA def write_csv(self, DATA): print('starting writing csv') with open('cdiscount_results.csv', 'w') as f: writer = csv.DictWriter(f, fieldnames=FIELDNAMES) writer.writeheader() for d in DATA: writer.writerow(d) if __name__ == '__main__': cdiscount_scraper = cDiscountScraper() DATA = cdiscount_scraper.get_cdiscount_data() cdiscount_scraper.write_csv(DATA) print('''~~ success _ _ _ | | | | | | | | ___ | |__ ___| |_ __ __ | |/ _ \| '_ \/ __| __/| '__| | | (_) | |_) \__ \ |_ | | |_|\___/|_.__/|___/\__||_| ''')

Pour utiliser ce script, c’est très simple: télécharger le fichier .py, et lancer le script comme suit:

$ python3 cdiscount_scraper.py start solving javascript challenge start starting writing csv ~~ success _ _ _ | | | | | | | | ___ | |__ ___| |_ __ __ | |/ _ \| '_ \/ __| __/| '__| | | (_) | |_) \__ \ |_ | | |_|\___/|_.__/|___/\__||_|

Et voilà!

Tutoriel étape par étape

A l’heure où cet article est écrit, l’été arrive. Et avec lui, son lot de délicieuses soirées barbecue entre amis, où presque tout est permis.

Dans ce tutoriel, on va voir comment scraper les données des 50 premiers barbecues en vente sur cDiscount, et directement accessibles via cet URL.

https://www.cdiscount.com/search/10/barbecue.html#his

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image3.png

Et on va récupérer 8 attributs distincts, comme suit:

  1. sku
  2. url
  3. image_url
  4. title
  5. score
  6. reviews_count
  7. price
  8. is_cdav

Et ce comme illustré ci-dessous:

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image4.png

Ce tutoriel sera divisé en 4 étapes:

  1. Résoudre l’erreur 403
  2. Résoudre le challenge Javascript
  3. Récupérer les données dans la page HTML
  4. Enregistrer les données dans un fichier .csv

À nous les soirées protéinées au coin du feu. \ Allons-y!

1. Résoudre l’erreur 403

Intuitivement, on a envie d’écrire un script très simple: une requête simple vers l’URL de la page mentionnée plus haut, afin de récupérer les données présentes dans la page.

Un premier jet immédiat peut être construit comme suit:

import requests s = requests.Session() response = s.get("https://www.cdiscount.com/search/10/barbecue.html") with open("premier_jet.html", "w") as f: f.write(response.text)

Sauf que voilà, lorsque l’on ouvre la dite page premier_jet.html, voilà ce qu’on obtient:

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image5.png

Une erreur 403. Coup dur.

Il est possible que cette première requête ait été un peu brute: pas de headers ni de cookies, directement identifiable comme étant une requête programmatique.

On va donc ajouter des headers, pour que cette requête se rapproche de celle d’un utilisateur traditionnel.

On se rend sur Chrome, et on va commencer par ouvrir notre console de développeur: Clic droit > Inspecter > Network.

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image6.gif

On va ensuite accéder à notre URL riche de données à scraper, c'est-à-dire la page cDiscount avec les barbecues.

Enfin, on va chercher, en utilisant la fonction recherche de la partie Network de la console, un mot présent sur la page. Ici le mot singulier: GEORGES.

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image7.png

On ne considère pas la première requête, car il s’agit d’une requête qui ramène un fichier .jpg, c'est-à-dire l’image du produit en question. Pas très intéressant ici.

L’outil de recherche va nous permettre de trouver la requête initiale, ou requête originelle, à partir de laquelle la donnée a été amenée du serveur à notre navigateur.

Une fois identifiée la requête pertinente, on va la copy as cURL, ou copier au format cURL en bon français, ce qui va nous permettre de sauvegarder, sous un format donnée, l’ensemble des paramètres de la requête effectuée:

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image8.png

cURL est un outil de ligne de commande qui permet de transférer de la donnée en utilisant divers protocoles. Dit plus simplement, c’est un outil qui permet de naviguer de façon programmatique sur Internet, et qu’il est possible d’utiliser directement depuis son terminal. Si vous souhaitez aller plus loin, cet article d’IBM est bien construit.

Enfin, on va convertir cette requête qui est au format cURL en requête Python/requests, avec ce superbe site: https://curlconverter.com/. On va venir tout simplement copier cette requête dans notre code Python.

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image9.gif

Et notre notre code à cette étape:

import requests headers = { 'authority': 'www.cdiscount.com', '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', 'cookie': 'CookieId=CookieId=230502110614CEICTORB&IsA=0; s_ecid=MCMID%7C16797685366166076358869452603464651012; TCPID=123521161511551056407; UniqueVisitContext=UniqueVisitId__230502110615FTHBMDWJ__; tcId=5ee0070d-c515-4236-bb68-f3d43c97d3df; _cs_c=3; TC_AB=A; app_vi=34214400%7C; prio30j=prio4; _$culture=CultureName__fr-FR__; visid_incap_2465919=dUWI1haIR9GTqqcgIrNCOwqgfGQAAAAAQUIPAAAAAACHWaWcYx2zEp5qWrQtGrgx; _cs_id=80e36f8d-3dcf-ab92-c779-066825140290.1683018376.3.1685889050.1685889037.1590586488.1717182376625; TC_PRIVACY=1@078%7C2%7C2%7C199@@@1685889051491%2C1685889051491%2C1701441051491@; TC_PRIVACY_CENTER=; _$cst=0; prio7j=prio4; chcook7=direct; SitePersoCookie=PersoCountryKey__FR__PersoLatitudeKey__45.1833__PersoLongitudeKey__0.7167__PersoTownKey__Perigueux__GeolocPriorityKey__10__PersoPostalCodeKey__24000__PersoUrlGeoSCKey__/n-190904/marketplace/retrait-immediat-paris.html__ExpressSellerId____ExpressShopName____ExpressGlobalSellerId____ShowroomVendorId____RetailStoreName____VehicleId__0__AddressId____; _$dtype=t:d; cache_cdn=; mssctse=W2dNXeEyrPIlc-Vb0OXhfpSvUDZBb4zp_O66j14y6UwCtJ9SHjEV2xrq24P65vvL6F_Rm25RpjG0CEkiod405g; AMCV_6A63EE6A54FA13E60A4C98A7%40AdobeOrg=1585540135%7CMCIDTS%7C19538%7CMCMID%7C16797685366166076358869452603464651012%7CMCAID%7CNONE%7CMCOPTOUT-1683025575s%7CNONE%7CvVersion%7C4.4.0; svisit=1; _$3custinf=AUT=0&XV1=0; s_cc=true; msswt=; s_pv=Recherche; msstvt=sN_Ypy2CO9_uH8xyMeB6Fv2fX_kGMtDY2oND-8_-lWTWuedd3ScG-4nLN4_lQVFy5wViq0BE3ivAYsnKanzoMQHgwnnSckuvFPg8yUubjzcr3HqX6UVH-sRcT8sSs10am8fzDvaNdYlrSCq2yhry8g; visit_baleen_ACM-655d43=cgDlAhbnqqYt9gTtRmBvPwVdT4hUToPAJr4Fu40-2VXUXZaHLgC04uiwcEhMl6TT7nIJYN1wYJoNIJxtPFz6FuPjeNF5ICa8GtFxrrdBTJjTA8HYk_k4wF7SqhK3ZWKXRW6DAYSNNFFW8UYUvJqV1p9DNjR01DBAPxauyeUtuKA-cTKEGkg0xCCbLqQiAuSAXLA8_GC0aEwlDs6qeWfDh126UIg6JBVPz8xWct_1oWJGgDk6qh3T1uj1RyFVQiWY4GFpSzTKA3EFrzx7rPpi_A; s_nr=1688044980321-Repeat; el2=0; tp=13575; VisitContextCookie=HGCJjPLdLJynSn3G8iVsFKxxgj6bTclHIMhEATBOtwStuI82FaEUuw; s_ppv=Recherche%2C11%2C4%2C1528', 'if-none-match': 'W/"fh_340c19a1c0ba672b7cdc06d54faa410dc8f8e7ff"', '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.cdiscount.com/search/10/barbecue.html', headers=headers) with open('second_essai.html', 'w') as g: g.write(response.text)

Pour des raisons de lisibilité, on a décommenter les cookies présents au niveau du headers, et supprimer les cookies qui étaient au format dictionnaire. Il s’agit seulement d’un ajustement esthétique, ça n’a aucune incidence sur la nature de la requête.

On lance notre code Python. Un fichier est enregistré, le bien nommé second_essai.html. Et en l’ouvrant avec Chrome… malheur, un écran complètement blanc:

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image10.png

Pas une trace de barbecue qui soit. Inexploitable.

Est-ce qu’il est l’heure de faire appel à la mystérieuse requête POST?

On va voir ça dans la seconde partie.

2. Résoudre le challenge Javascript

On a installé les librairies de développement, on est allé chercher la requête pertinente… et c’est la douche froide. Une page blanche, avec rien de lisible, et rien d’exploitable.

On va commencer par aller voir ce qui se cache au niveau du code source de la page. On fait donc Clic droit > Afficher le code source:

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image11.png

Et au niveau du code source, on va observer 2 éléments intéressants.

D’abord, on nous indique qu’il faut activer le Javascript afin d’accéder à la page. Il s’agit donc bien d’un challenge Javascript, c’est-à-dire que que le site exécute normalement un script Javascript présent sur la page.

L’exécution de ce script permet d’habitude à un navigateur usuel où le Javascript est activé par défault - Chrome, Firefox, tous les navigateurs en fait - d’être perçu comme licite par le site cible. Le plus souvent, par l’intermédiaire d’un cookie, qui agit comme un permis d’entrée.

👮

Comme nous utilisons un navigateur Python, le code Javascript n’est pas exécuté du tout, et la page est inaccessible.

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image12.png

Si on regarde plus haut, bingo! Il y a bien du code Javascript, et notamment un cookie stocké dans la variable blnChallengeStore.

Ce code Javascript, qui est exécuté passivement par un navigateur usuel, il va donc falloir qu’on l’exécute nous-même, activement, avec du code en Python. Mais qu’est-ce qu’il fait au juste ce bout de script?

On va commencer par le déminifier,Not%20minified.), c'est-à-dire y ajouter des espaces et des couleurs, afin que le code soit facilement lisible. On utilise pour ça l’outil en ligne Unminify.com, et voilà ce qui en sort:

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image13.png

Soit vous êtes un dingue de Javascript, soit, on a pas bien avancé.

Afin de gagner du temps, on va demander ça directement à ChatGPT. L’intelligence artificielle, développée par la société OpenAI, a été entraînée sur la base de données de StackOverFlow: elle maîtrise l’intégralité des langages de programmation usuels.

Voilà un imprimé écran du prompt, et la réponse complète accessible ici:

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image14.png

Pour faire simple, le script Javascript exécute les actions suivantes:

  1. il récupère le cookie présent dans la page
  2. il assigne le cookie au navigateur
  3. il réalise une requête POST vers un URL construit à partir de la valeur du cookie de la page

Oui, vous avez bien lu, c’est ici qu’intervient la mystérieuse requête POST.

C’est elle qui va nous permettre de contourner ce premier challenge Javascript. On va donc exactement reproduire ces étapes là, à l’aide du code Python!

On va donc d’abord commencer par récupérer la valeur du cookie présent dans le texte:

# on récupère la valeur du cookie dans le code source avec une regex raw_data = re.findall(r'(?<=__blnChallengeStore=)\{[^\;]+', response.text) assert raw_data and len(raw_data) == 1 raw_data = ''.join(raw_data) raw_data = json.loads(raw_data)

On utilise ici une regex, qui permet d’aller chercher rapidement dans le code source une chaîne de caractères avec un certain format. On ne peut que vous recommander l’excellent outil de création et de test regex101.

On va ensuite assigner le cookie à notre session de navigation:

# on assigne le cookie à notre navigateur challenge_cookie = raw_data['cookie'] challenge_cookie.pop('maxAge') challenge_cookie_value = challenge_cookie['value'] challenge_cookie_name = challenge_cookie['name'] self.s.cookies.set_cookie(requests.cookies.create_cookie(**challenge_cookie))

Enfin, on réalise cette fameuse requête POST:

# on réalise la mystérieuse requête POST check_params = raw_data['checkChallengeParams'] url = 'https://www.cdiscount.com/.well-known/baleen/challengejs/check?%s=%s' % ( challenge_cookie_name, challenge_cookie_value ) data = '&'.join(['%s=%s' % (k,v) for k, v in check_params.items()]) response = self.s.post(url, data) assert response.status_code == 200

Et à ce stade, voilà notre code Python entier:

import requests import re import json from retry import retry URL = 'https://www.cdiscount.com/search/10/barbecue.html' HEADERS = { 'authority': 'www.cdiscount.com', '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', } class JavascriptChallengeError(Exception): pass class cDiscountScraper: def __init__(self): self.s = requests.Session() self.s.headers = HEADERS def solve_javascript_challenge(self, response): print('solving javascript challenge') # on récupère la valeur du cookie dans le code source avec une regex raw_data = re.findall(r'(?<=__blnChallengeStore=)\{[^\;]+', response.text) assert raw_data and len(raw_data) == 1 raw_data = ''.join(raw_data) raw_data = json.loads(raw_data) # on assigne le cookie à notre navigateur challenge_cookie = raw_data['cookie'] challenge_cookie.pop('maxAge') challenge_cookie_value = challenge_cookie['value'] challenge_cookie_name = challenge_cookie['name'] self.s.cookies.set_cookie(requests.cookies.create_cookie(**challenge_cookie)) # on réalise la mystérieuse requête POST check_params = raw_data['checkChallengeParams'] url = 'https://www.cdiscount.com/.well-known/baleen/challengejs/check?%s=%s' % ( challenge_cookie_name, challenge_cookie_value ) data = '&'.join(['%s=%s' % (k,v) for k, v in check_params.items()]) response = self.s.post(url, data) assert response.status_code == 200 @retry(JavascriptChallengeError, tries=5, delay=5, backoff=1) def get_cdiscount_data(self): print('start') response = self.s.get(URL, headers=HEADERS) with open('main_response.html', 'w') as f: f.write(response.text) if 'Le JavaScript n\'est pas' in response.text: self.solve_javascript_challenge(response) raise JavascriptChallengeError if __name__ == '__main__': cdiscount_scraper = cDiscountScraper() cdiscount_scraper.get_cdiscount_data()

Comme vous le voyez, la librairie retry nous permet, si un élément suspect est détecté sur la page, de lever une certaine erreur, de la résoudre, puis de recommencer la première étape. Astucieux, et robuste.

Et alors… est-ce que ça a marché?

On va ouvrir le fichier main_response.html, qui est enregistré chaque fois que la requête vers l’URL principal est réalisée.

Et là, fantastique, de superbes outils de métal apparaissent, prêts à nous offrir des soirées d’été grasses et mémorables:

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image15.png

Magnifique! Le challenge Javascript a été résolu.

Et voilà, il ne nous reste plus qu’à récupérer les données présentes sur la page HTML, et à les enregistrer dans un fichier .csv.

Le plus dur a été fait.

3. Récupérer les données dans la page HTML

La route a été longue, et plutôt sinueuse, avec une première erreur 403, puis un écran blanc totalement déstabilisant.

Mais on s’en est sortis avec brio.

Dans cette partie, on va récupérer les données des 50 barbecues présents sur la page cDiscount, et pour chaque barbecue, 8 attributs distincts.

D’abord, on va stocker en haut de page la liste des attributs, et la liste qui contiendra à la fin de l’exécution du script, tous nos dictionnaires, avec 1 dictionnaire par produit.

... FIELDNAMES = [ "sku", "url", "image_url", "title", "score", "reviews_count", "price", "is_cdav" ] DATA = []

Ensuite, avec la librairie lxml, on va convertir le texte en document HTML. Ça va nous permettre, ensuite, d’aller chercher les éléments en fonction de leur position et de leur nature au sein du document HTML.

import lxml ... class cDiscountScraper: def __init__(self): self.s = requests.Session() self.s.headers = HEADERS ... @retry(JavascriptChallengeError, tries=5, delay=5, backoff=1) def get_cdiscount_data(self): print('start') ... doc = html.fromstring(response.text)

Et maintenant, comment récupérer ces données?

En observant le code HTML, dans la partie Elements de l’outil d’inspection, on voit que les produits se trouvent chacun dans une balise li, elle même présente sous une balise ul qui regroupe tous les produits présents sur la page.

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image16.png

On va donc récupérer la liste des produits, en allant chercher les balises li présentes au sein de la balise ul qui a pour id iPbloc.

Voilà notre liste de produits!

products = doc.xpath("//ul/[@id='lpBloc']/li[@data-sku]") assert products for product in products: # récupérer les attributs de chaque produit

Et ensuite, on va aller chercher les 8 attributs de chaque produit en utilisant la même logique, en fonction de la position de chaque attribut au sein du code HTML.

Avec un code comme suit:

sku = product.get('data-sku') url = "".join(product.xpath('./a/@href')) assert url.startswith('https://www.cdiscount.com') image_url = "".join(product.xpath('.//li/img[@class="prdtBImg"]/@data-src')) or "".join(product.xpath(".//li/img[@class='prdtBImg']/@src")) title = "".join(product.xpath('.//h2[@class="prdtTit"]/text()')) score = "".join(product.xpath('.//span[@class="c-stars-result c-stars-result--small"]/@data-score')) if score: score = float(score) / 20 reviews_count = "".join(product.xpath('.//span[@class="c-stars-result c-stars-result--small"]/following-sibling::span/text()')) if reviews_count: reviews_count = int(reviews_count.strip('()')) price = "".join(product.xpath('.//span[contains(@class, "price priceColor hideFromPro") and not(contains(@class, "price--xs"))]/text()')).strip('€') is_cdav = len(product.xpath('.//div[@class="cdavZone"]')) > 0

Le parsing de code HTML, plus traditionnel et moins sujet à la spéculation, ne sera pas traité ici, sinon de façon superficielle. On ne peut que vous conseiller l’excellent tutoriel de w3schools ici, et des tests divers et variés en grandeur nature.

Enfin, on va stocker tout ça dans un dictionnaire, afin de s’appuyer sur une donnée lisible, facilement manipulable, et proprement structuré:

values = [sku, url, image_url, title, score, reviews_count, price, is_cdav] d = dict(zip(FIELDNAMES, values)) print(d['sku'], d['title'], d['price']) DATA.append(d)

Et voilà magnifique!

A ce stade, voilà notre code complet:

import requests import re import json from lxml import html from retry import retry import csv URL = 'https://www.cdiscount.com/search/10/barbecue.html' HEADERS = { 'authority': 'www.cdiscount.com', '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 = [ "sku", "url", "image_url", "title", "score", "reviews_count", "price", "is_cdav" ] DATA = [] class JavascriptChallengeError(Exception): pass class cDiscountScraper: def __init__(self): self.s = requests.Session() self.s.headers = HEADERS def solve_javascript_challenge(self, response): print('solving javascript challenge') # on récupère la valeur du cookie dans le code source avec une regex raw_data = re.findall(r'(?<=__blnChallengeStore=)\{[^\;]+', response.text) assert raw_data and len(raw_data) == 1 raw_data = ''.join(raw_data) raw_data = json.loads(raw_data) # on assigne le cookie à notre navigateur challenge_cookie = raw_data['cookie'] challenge_cookie.pop('maxAge') challenge_cookie_value = challenge_cookie['value'] challenge_cookie_name = challenge_cookie['name'] self.s.cookies.set_cookie(requests.cookies.create_cookie(**challenge_cookie)) # on réalise la mystérieuse requête POST check_params = raw_data['checkChallengeParams'] url = 'https://www.cdiscount.com/.well-known/baleen/challengejs/check?%s=%s' % ( challenge_cookie_name, challenge_cookie_value ) data = '&'.join(['%s=%s' % (k,v) for k, v in check_params.items()]) response = self.s.post(url, data) assert response.status_code == 200 @retry(JavascriptChallengeError, tries=5, delay=5, backoff=1) def get_cdiscount_data(self): print('start') # on fait une première requête response = self.s.get(URL, headers=HEADERS) with open('first_req.html', 'w') as f: f.write(response.text) # en cas de challenge javascript, on résoud ce challenge et on recommence if 'Le JavaScript n\'est pas' in response.text: self.solve_javascript_challenge(response) raise JavascriptChallengeError # on s'assure que la réponse obtenue est exploitable # merci georgio assert response.status_code == 200 assert 'GEORGES' in response.text # on parse le document avec lxml doc = html.fromstring(response.text) products = doc.xpath("//ul/[@id='lpBloc']/li[@data-sku]") assert products for product in products: # on récupère les attributs de chaque produit sku = product.get('data-sku') url = "".join(product.xpath('./a/@href')) assert url.startswith('https://www.cdiscount.com') image_url = "".join(product.xpath('.//li/img[@class="prdtBImg"]/@data-src')) or "".join(product.xpath(".//li/img[@class='prdtBImg']/@src")) title = "".join(product.xpath('.//h2[@class="prdtTit"]/text()')) score = "".join(product.xpath('.//span[@class="c-stars-result c-stars-result--small"]/@data-score')) if score: score = float(score) / 20 reviews_count = "".join(product.xpath('.//span[@class="c-stars-result c-stars-result--small"]/following-sibling::span/text()')) if reviews_count: reviews_count = int(reviews_count.strip('()')) price = "".join(product.xpath('.//span[contains(@class, "price priceColor hideFromPro") and not(contains(@class, "price--xs"))]/text()')).strip('€') is_cdav = len(product.xpath('.//div[@class="cdavZone"]')) > 0 values = [sku, url, image_url, title, score, reviews_count, price, is_cdav] d = dict(zip(FIELDNAMES, values)) print(d['title'], d['score'], d['price']) DATA.append(d) return DATA if __name__ == '__main__': cdiscount_scraper = cDiscountScraper() DATA = cdiscount_scraper.get_cdiscount_data()

Et à l’exécution du script, de superbes données, parfaitement récupérées depuis le code source:

$ python3 cdiscount_scraper_202306.py start solving javascript challenge start TECTAKE Barbecue, Grill, Fumoir, Smoker Américain XXL avec thermomètre de température - Charbon de Bois 3.0 94,99 Barbecue charbon Premium Ø57cm - Charles - noir émaillé - aérateurs, acier émaillé, fumoir, récupérateur de cendres 4.0 90,99 Barbecue au gaz 3 brûleurs, tablettes rabattables - Bonacieux - noir, avec rangement 4.5 139,99 COSTWAY Barbecue à Charbon BBQ avec Tablette Latérale Pliable,2 Roues 2 Grilles Double Thermomètre 120,6x68,2x107,3 cm Noir 5.0 194,99 Barbecue à charbon GEORGES - Grille en acier chromé - Surface de cuisson : 54 cm - Noir 4.0 49,99 Barbecue à charbon WEBER Classic Kettle 47 cm - Noir 4.5 99,00 Barbecue à charbon WEBER Compact Kettle 47 cm - Noir 4.5 81,99 ...

Et après?

4. Enregistrer les données dans un fichier .csv

On a contourné les obstacles divers et variés présents sur notre route, et récupéré dans une page HTML plutôt chargée, tous les attributs de nos grosses machines à charbon.

Bravo!

Dans cette dernière partie, on va simplement enregistrer ces données dans un fichier .csv.

On dispose d’une liste de dictionnaires, pour cette dernière partie, on va donc utiliser l’outil csvDictwriter, un objet directement accessible depuis la librairie csv native de Python.

Le lien de la documentation est accessible juste ici.

On va donc fonctionner comme suit:

  1. Créer un fichier .csv
  2. Créer l’outil d’écriture càd le writer
  3. Écrire les headers, càd la première ligne du fichier avec les labels
  4. Écrire chaque dictionnaire dans une ligne à part entière

Et le code commenté ici:

import csv ... class cDiscountScraper: ... def write_csv(self, DATA): print('starting writing csv') # on crée un fichier csv qui a pour nom 'cdiscount_results.csv' with open('cdiscount_results.csv', 'w') as f: # on crée notre objet writer, en lui fournissant la liste des labels et le fichier créé f writer = csv.DictWriter(f, fieldnames=FIELDNAMES) # on crée la première ligne avec les labels writer.writeheader() # on enregistre chaque dictionnaire dans une ligne dédiée for d in DATA: writer.writerow(d) ... if __name__ == '__main__': cdiscount_scraper = cDiscountScraper() DATA = cdiscount_scraper.get_cdiscount_data() cdiscount_scraper.write_csv(DATA) print('success > done')

On lance le script et.. Ta-da!

Toutes les données ont été proprement collectées, et sont directement exploitables au format csv. C’est propre, structuré, et ultra-rapide.

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image17.png

Bénéfices

On vient de le voir dans la partie précédente, la collecte de données produits sur cDiscount, à partir d’une URL fonctionne à merveille.

Quelles sont les bénéfices de cette solution?

La méthode est rapide. Ainsi, on a pu scraper les données d’une page produit cDiscount, soit 50 produits distincts, en un peu moins de 2 secondes. Étendue à plus large échelle, ça représente les données de 1800 produits récupérés chaque heure.

A titre de comparaison, on estime empiriquement qu’un être humain, pour réaliser la même tâche, va mettre 10 secondes par article, soit 1800 produits récupérés toutes les 5 heures. Il s’agit donc d’un rapport de 1 à 5, par rapport à une méthode traditionnelle.

Par ailleurs, cette méthode est précise. Sauf cas non anticipé, ce qui peut arriver, les données vont être collectées systématiquement de la même manière. Pas d’erreur, pas d’imprécision. Des données toujours précises et exploitables.

Enfin, le scraping avec python et requests sur cdiscount est une méthode ultra compétitive. Un serveur sur scaleway coûte approx. 50 EUR par mois, c’est à peine le prix d’une demi-journée d’effort manuel en France, payé au salaire minimum.

Limitations

Si les avantages sont certains, quelles sont les désavantages?

D’abord, dans le cadre de ce tutoriel rapide, l’outil n’a pas de pagination. On récupère les données de la première page de produits, mais pas les données des pages suivantes. L’exhaustivité est donc limitée.

Par ailleurs, toujours dans le cadre de ce tutoriel de démonstration, on ne visite pas la page de chaque produit. Aussi, on passe à côté d’une large gamme d’attributs. Prix de la livraison, nom du vendeur, points fort, date de livraison, caractéristiques avancées du produit ou avis clients: tous ces éléments manquent.

Enfin, plus grave, il arrive (assez rapidement) que cDiscount vous demande de résoudre un captcha, ici un hCaptcha. Ce tutoriel étant ce qu’il est, la résolution de ce nouveau challenge n’est pas prise en charge.

comment-scraper-cdiscount-avec-python-et-requests-en-2023-image18.png

🤖

FAQ

Est-ce que le scraping sur cDiscount est légal en France?

Oui, c’est entièrement légal!

Comme stipulé dans l’article L342-3 du code de la propriété intellectuelle, lorsqu’une base de données est mise à disposition du public, celui-ci peut l’exploiter, tant qu’il s’agit d’une exploitation non exhaustive.

Vous trouverez un article détaillé ici.

Est-ce qu’il existe une solution no-code de scraping de produits cDiscount?

Non, pas pour le moment. On y réfléchit!

🤓

Pourquoi ne pas utiliser l’API officielle cDiscount?

C’est vrai, une API existe bien. Et sa documentation est accessible ici: https://marketplace.cdiscount.com/api/

Toutefois, l’API ne permet pas de collecter les données présentes sur le site: catégories, produits, vendeurs, reviews.

Elle permet simplement d’effectuer programmatiquement des tâches du côté vendeur: modification de prix ou de stock, mise en ligne ou retrait de produit, logistique.

Pour tout ce qui est récupération de données publiquement accessibles, le scraping est indispensable.

Est-ce que le script de scraping cDiscount va toujours fonctionner?

A priori, oui! Le scraper de produits à partir d’une URL de catégorie cDiscount a été construit pour être résilient dans le temps.

Toutefois, il peut arriver que la structure du site change. Dans ce cas là, n’hésitez pas à nous contacter via le chat, ou directement depuis notre page dédiée.

Conclusion

Et voilà, c’est la fin de ce tutoriel!

Dans ce tutoriel, on a vu comment scraper les données des produits à partir d’une catégorie cDiscount avec Python, et requests. Et récupérer les attributs principaux: le titre, le prix, le nombre d’avis, ou le score.

Surtout, nous avons vu comment résoudre deux challenges successifs: la page 403, premier obstacle pour scraper brutal et pressé, et le challenge javascript, script présent sur la page qu’il a fallu nous même exécuter avec un code Python maison.

Attention, ce tutoriel, parce que c’est un tutoriel, est limité: pas de pagination, pas d’attributs supplémentaires, et surtout pas de gestion du hCaptcha, qui finit par limiter la navigation sur cDiscount.

Et dans ce cas là, on vous invite à nous contacter directement ici.

Happy scraping!

🦀

Share this article

Related Articles