-
Notifications
You must be signed in to change notification settings - Fork 45
/
index.qmd
313 lines (231 loc) · 11.5 KB
/
index.qmd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
---
title: "Exercices supplémentaires de webscraping"
date: 2021-07-09T13:00:00Z
draft: false
weight: 80
slug: webscraping-exercices
tags:
- webscraping
- regex
- BeautifulSoup
- Selenium
- Exercice
categories:
- Exercice
- Manipulation
type: book
summary: |
Un exercice supplémentaire de _webscraping_,
où l'on construit de manière automatique sa liste de courses à partir des données
de [`Marmiton`](https://www.marmiton.org/).
eval: false
---
::: {.cell .markdown}
```{python}
#| echo: false
#| output: 'asis'
#| include: true
#| eval: true
import sys
sys.path.insert(1, '../../../../') #insert the utils module
from utils import print_badges
#print_badges(__file__)
print_badges("content/course/manipulation/06a_exo_supp_webscraping.qmd")
```
:::
Cette page présente une série d'exercices de webscraping. Ils permettent
d'aller plus loin que le [chapitre dédié](#webscraping)
## Construction automatisée d'une liste de courses via webscraping :spaghetti: :pizza: :strawberry:
Les comptes sont dans le rouge, le banquier appelle tous les jours.
Plus le choix : fini les commandes de plats tout faits via des plateformes bien connues,
il va falloir se faire des bons petits plats soi-même.
Mais la cuisine à l'ancienne, c'est long : il faut trouver le bon livre de cuisine,
la bonne recette, faire des règles de trois pour calculer les bonnes proportions, etc.
Et après ça, faire une liste de courses...
Heureusement, [Marmiton](https://www.marmiton.org/) est là pour nous.
Dans ce TP, on va construire un outil Python qui permet d'exporter directement une liste de courses,
en fonctions des plats que l'on a envie de manger cette semaine. Et tout ça en *webscrapant* les données de Marmiton. Plus d'excuse !
Pour cet exercice, on va utiliser principalement trois librairies très utilisées en webscraping :
* `requests` & `BeautifulSoup` pour scraper des pages statiques ;
* `selenium` lorsque l'on aura besoin d'interagir avec les éléments scriptés des pages web.
Pour pouvoir utiliser `selenium`, il est nécessaire d'avoir installé le *chromedriver* ([instructions](LIEN A AJOUTER)),
ou bien le driver adapté si vous utilisez un autre navigateur que Google Chrome.
1. Analyser comment fonctionne la recherche d'une recette sur Marmiton (structure de l'URL) et coder un outil
permettant de récupérer (à l'aide de `requests`) le code html des résultats de la recherche pour une recette donnée.
Formatter ce code en un arbre lxml à l'aide de `BeautifulSoup`.
```{python}
import requests
from bs4 import BeautifulSoup
```
```{python}
PLAT = "pates carbonara"
BASE_URL = "https://www.marmiton.org"
URL_SEARCH = BASE_URL + "/recettes/recherche.aspx?aqt=" + PLAT
```
Vous pouvez vérifier que `python` récupère bien un résultat à l'URL voulu en tapant
```{python}
requests.get(URL_SEARCH).status_code
```
Si le code retour est 200, il y a bien du contenu accessible sur la page.
{{% box status="note" title="Note" icon="fa fa-comment" %}}
L'utilisation de l'option *"lxml"* avec `BeautifulSoup` nécessite d'avoir
installé avant cela la librairie `lxml`.
{{% /box %}}
```{python}
response = requests.get(URL_SEARCH).text
soup = BeautifulSoup(response, "lxml")
```
2. Afficher le code source html de la page des résultats de la recherche à l'aide de votre navigateur (click droit sur la page => Inspecter),
analyser la structure de l'arbre,
et récupérer le code html de chacune des recettes.
A l'aide d'une boucle, récupérer pour chaque recette sa note moyenne et le nombre de fois où elle a été notée.
```{python}
import re
import numpy as np
```
```{python}
found_recipes = soup.find_all(name="a", class_=re.compile("^SearchResultsstyle__SearchCardResult"))
all_ratings = []
all_nb_ratings = []
for recipe in found_recipes:
try:
ratings_info = recipe.find(name="div", class_=re.compile("^RecipeCardResultstyle__RatingLayout")).text
except AttributeError:
continue
matches = re.search(r"([\d\.]+)/5\(([\d]+) avis\)", ratings_info, re.IGNORECASE)
rating = matches.group(1)
nb_ratings = matches.group(2)
all_ratings.append(float(rating))
all_nb_ratings.append(int(nb_ratings))
```
3. Sur Marmiton, on peut tomber sur de mauvaises surprises.
Pour éviter ça, restreindre les recettes à celles qui ont une note moyenne >= 4 et un nombre de notes >= 50.
Choisir la recette la mieux notée au sein de cette liste de candidates, et récupérer son URL.
```{python}
MIN_RATING = 4
MIN_NB_RATINGS = 50
idxs_eligible = [i for i, x in enumerate(found_recipes)
if all_ratings[i] > MIN_RATING and all_nb_ratings[i] >= MIN_NB_RATINGS]
idx_chosen = np.argmax(np.array(all_ratings)[idxs_eligible])
href_chosen = found_recipes[idx_chosen].get("href")
if href_chosen is not None:
url_chosen_recipe = BASE_URL + href_chosen
else:
raise ValueError("Aucune recette n'a été trouvée pour les critères demandés.")
```
4. Récupérer une photo de la recette et l'afficher dans le Notebook.
```{python}
list_imgs = found_recipes[idx_chosen].find(name="source", type="image/jpeg").get("srcset")
url_img_big = re.split("\s\d+w,?\s?", list_imgs)[-2]
```
```{python}
#| eval: false
# Dans un notebook
from IPython.display import Image
Image(url_img_big, width=400, height=400)
```
Nous avons choisi cette recette, un classique ! :spaghetti:
```{python}
#| echo: false
url_img_big
```
Convaincu ?
Sinon, ne pas hésiter à changer de recette au début,
on ne va quand même pas faire tout ça pour rien.
La recette est choisie, pour nous c'est *pates carbo*.
Nouvel objectif : faire la liste de courses ! :purse:
Mais les choses se compliquent : pour quantifier les ingrédients selon le nombre de convives
et afficher la liste au format courses sur Marmiton, on va devoir cliquer sur des boutons qui exécutent du `JavaScript`.
Les librairies `requests` et `BeautifulSoup` atteignent là leurs limites, mais pas de panique : `Selenium` est fait pour ça.
Il va nous permettre d'ouvrir un navigateur "fantôme", contrôlé via Python, avec lequel on va pouvoir effectuer des actions sur la page
(comme le ferait une personne naviguant sur la page web).
Autre subtilité : jusqu'à maintenant, on a repéré les éléments html par type et nom de classe.
Cette méthode fonctionne, mais elle pose également des problèmes :
parfois les noms de classes changent sans raison (c'est d'ailleurs pour ça qu'on a utilisé des regex précédemment, pour faire du matching partiel),
et il est moins pratique d'interagir avec les éléments d'une page de cette manière. Parfois, il est
pertinent d'utiliser les [sélécteurs XPath](https://fr.wikipedia.org/wiki/XPath),
qui permettent de sélectionner les éléments selon leur position dans l'arborescence html de la page.
On utilisera une combinaison des deux méthodes dans cette partie selon les cas.
On le voit, le webscraping reste une pratique assez instable,
dans la mesure où les sites web évoluent en permanence.
Il y a ainsi toutes les chances qu'au moment où vous effectuerez ce TP, le code proposé en solution ne fonctionne plus, car les balises auront changé.
Il vous faudra alors revenir à l'exploration du code source html de la page, repérer les balises permanentes, et les substituer dans le code de solution.
5. Ouvrir la page de la recette choisie à l'aide d'un navigateur fantôme.
Problème : la classique fenêtre de politique des cookies :cookie: s'ouvre, nous empêchant de naviguer sur la page.
Utiliser Selenium pour cliquer sur le bouton permettant d'accepter tous les cookies.
```{python}
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import NoSuchElementException
```
```{python, eval = FALSE}
driver = webdriver.Chrome()
driver.get(url_chosen_recipe)
```
```{python, eval = FALSE}
WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.ID, "didomi-notice-agree-button"))).click()
```
6. Choisir pour combien de personnes on va cuisiner.
Comparer ce nombre au nombre utilisé par défaut sur Marmiton,
et construire une boucle qui va clicker automatiquement le bon nombre de fois,
sur + ou - selon que le nombre de convives choisi est supérieur ou inférieur au nombre par défaut.
```{python, eval = FALSE}
try:
counter = driver.find_element_by_class_name("quantity-counter")
except NoSuchElementException:
raise Exception("La structure de cette page est particulière, il va falloir trouver les bonnes balises à la main.")
else:
xpath_current_count = './/input'
current_count = int(counter.find_element_by_xpath('.//div[2]/input').get_attribute("value"))
xpath_minus = './/div[@class="quantity-counter__action minus"]'
xpath_plus = './/div[@class="quantity-counter__action plus"]'
```
```{python, eval = FALSE}
NB_PERSONNES = 8
nb_clicks = NB_PERSONNES - current_count
if nb_clicks > 0:
xpath_button_nb_persons = xpath_plus
elif nb_clicks < 0:
xpath_button_nb_persons = xpath_minus
for i in range(abs(nb_clicks)):
driver.find_element_by_xpath(xpath_button_nb_persons).click()
```
7. Marmiton a un mode liste de courses qui va nous être bien pratique pour récupérer les ingrédients au bon format.
A l'aide de Selenium, cliquer sur le bouton "liste" (à droite de l'outil pour ajuster le nombre de personnes).
```{python, eval = FALSE}
display_options = driver.find_element_by_class_name("ingredient-list__display-options")
display_options.find_element_by_xpath(".//i[2]").click()
```
8. Selon les cas, il peut être nécessaire de cliquer ensuite sur un autre bouton permettant de développer la liste.
Effectuer cette action (si nécessaire !).
Cela permettra d'être sûr que l'on récupère bien tous les ingrédients pour construire notre liste de courses.
```{python, eval = FALSE}
# xpath_expand_list = "/html/body/div[2]/div[3]/main/div/div/div[1]/div[1]/div[7]/div[2]/div[3]"
# try:
# driver.find_element_by_xpath(xpath_expand_list).click()
# except ElementClickInterceptedException:
# pass
# driver.implicitly_wait(2) # Make sure that the elements are displayed after pressing button
```
9. Récupérer la liste des ingrédients ainsi que des quantités nécessaires. Stocker les éléments dans une liste.
```{python, eval = FALSE}
list_ings_div = driver.find_element_by_class_name("ingredient-list__ingredient-group")
list_ings = [x.text for x in list_ings_div.find_elements_by_tag_name("li")]
print(list_ings)
```
['500 g de lardons', 'poivre', '2 pincées de sel', '1 kg de pâtes', '1 l de crème fraîche', "6 jaunes d'oeuf", '2 oignons']
10. Exporter la liste dans un fichier texte sur votre ordinateur.
```{python, eval = FALSE}
with open("shopping_list.txt", "w") as f:
for ing in list_ings:
f.write(ing + "\n")
```
11. La liste est prête, mais il va aussi nous falloir la recette. Récupérer la recette, et l'exporter dans un fichier texte séparé, qui porte le nom du plat choisi.
```{python, eval = FALSE}
recipe = driver.find_element_by_class_name("recipe-step-list").text
with open(f"recipe_{PLAT}.txt", "w") as f:
f.write(recipe)
```
12. L'outil fonctionne... pour un plat donné. Adapter le code précédent pour prendre en entrée une liste de plats, et retourner en sortie la liste de courses complète (en un seul fichier) pour pouvoir réaliser ces différents plats. Hint: il sera sûrement utile de faire une fonction qui prend en input un plat et exporte la liste de courses pour ce plat, et ensuite d'appeler cette fonction pour chaque plat dans le cadre d'une boucle. Attention de ne pas écraser la liste de courses précédentes à chaque fois !