Cours Python avec exercices

écrit par Christophe Darmangeat dans le cadre du M2 PiSE

Initiation au webscraping

Une des très nombreuses applications de python consiste à récupérer des informations « sales » sur le net pour les traiter et entirer la substantifique moelle. Lorsque ce processus est effectué automatiquement, on le désigne sous le néologisme anglais de webscraping, qu'on pourrait traduire littéralement par le « grattage d'internet ». On peut ainsi imaginer surveiller en direct les prix pratiqués par la concurrence, recueillir et analyser les avis des consommateurs afin de procéder à une étude de marché ou de suivre l'accueil reçu par ses propres produits, établir une veille sur les thèmes abordés par des medias d'information ou des réseaux sociaux, etc.

Comme à chaque fois, l'avantage de python tient à sa modularité. De nombreuses bibliothèques ont ainsi été développées, qui mâchent le travail à accomplir et permettent d'arriver au but en quelques lignes (et le progrès humain consiste précisément à ne pas réinventer l'eau tiède et le fil à couper le beurre à chaque fois qu'on en a besoin !)

Le processus de webscraping recouvre trois grandes étapes.

  1. il faut récupérer les informations depuis le site internet concerné. Sous la forme la plus simple, on se contente de « gratter » une page précise. Dans une version plus élaborée (berrichonne), on explorera systématiquement l'ensemble des pages (et donc, des « sous-pages ») d'un site ou d'un sous-domaine.
  2. une fois les informations récupérées sous la forme d'un code-source html, il faut les « parser » (encore un abominable anglicisme francisé), c'est-à-dire les découper / formater de manière à réorganiser les informations afin de les rendre utilisables.
  3. les informations désormais réorganisées, on peut les extraire et les traiter.

Récupérer une page depuis le web

Première étape donc, récupérer une page unique depuis internet (chaque chose en son temps : commençons par le cas simple, et on reviendra plus loin sur la tâche plus corsée consistant à récupérer l'ensemble d'une arborescence). Pour cela, une bibliothèque (un package) est indispensable. Choisissons requests, dont on trouve ici la documentation en ligne, qu'il faut installer dans notre environnement de développement afin de pouvoir l'utiliser.

Comment installer un package ? Étant donné que nous utilisons Visual Studio, la procédure est la suivante : en haut à droite de notre écran, on repère la fenêtre intitulée Explorateur de solutions. On effectue un clic droit sur Environnements python, et on opte pour Gérer les packages. Dès lors, il n'y a plus qu'à taper le nom du package désiré, et le tour est joué.

Le package requests nous permet d'entrer le code suivant :

truc = requests.get('https://quotes.toscrape.com')

Ici, truc est un nom d'objet que l'on choisit évidemment à sa guise. Si tout se passe bien, le contenu de la page ainsi récupérée se trouve dans la propriété .text de l'objet truc, et on peut alors (déjà !) passer à l'étape suivante. Mais les choses peuvent coincer dès cette étape, soit que le site web ne soit en lui-même pas accessible (une adresse mal tapée ou un serveur kaputt), soit qu'il nous identifie comme un importun et qu'il barre la route de notre requête.

Le résultat de la requête est stocké dans la propriété .status_code de la variable affectée par la méthode. Ici, si la requête s'est correctement déroulée, cette propriété vaut [200]. Dans le cas contraire, sa valeur serait par exemple de 404 (le fameux code d'erreur de la page inexistante). Cela permet d'envisager un test, afin de vérifier que le processus que le processus peut être poursuivi :

truc = requests.get('http://pise.info')
if truc.status_code == 200:
   print("Connexion réussie, on continue !")
   etc.
else:
   print("Erreur, ça coince")

Dans le cas où tout s'est passé correctement, le contenu de la page web – équivalent à son « code source » – est à présent tout entier contenu dans la propriété text (NB : une autre propriété, content, possède à peu près le même résultat, mais elle conserve les sauts de ligne sous la forme \n au lieu de les purger).

Analyser (« parser ») la page html

Arrivés là, nous pourrions tout à fait lancer notre analyse automatique : la méthode, universelle, consiste à repérer dans quelles balises se trouvent les contenus qui nous intéressent (en leur attribuant le cas échéant des poids différents selon le niveau de titre auquel ils se trouvent), puis à extraire et traiter ces contenus. Avec ce que nous avons appris des expressions régulières, nous pourrions parfaitement parvenir au but.

Il existe toutefois un package écrit pour nous prémâcher le travail : Beautiful Soup (dont on trouvera la documentation complète an ligne). Une fois installé, celui-ci permet, avec une simple instruction (en fait, pour plus de sûreté, deux), de nettoyer et réorganiser le code html récupéré :

htmlpropre = BeautifulSoup(truc.text, "html.parser" )
htmlpropre.prettify()

Le second argument de la première instruction indique à BeautifulSoup quel schéma il doit utiliser pour traiter le texte. En l'occurrence, il s'agit de produire une structure dite arborescente – à l'image des répertoires et sous-répertoires d'un disque – qui pourra être aisément interrogée et parcourue par la machine. Beautifulsoup va par ailleurs en profiter pour nettoyer la page, en éliminant les informations non significatives telles que les espaces, les sauts de lignes vides, les tabulations, etc. La seconde méthode, .prettify(), introduit les sauts de lignes et les indentations nécesaires pour permettre d'améliorer la lisibilité du résultat.

Au sein de l'arborescence générée par BeautifulSoup, on se contentera ici d'effectuer des recherches, mais il serait également possible d'effectuer des modifications. La méthode la plus utile est sans doute find_all (qui existe aussi sous la forme findAll) qui renvoie sous forme de liste l'ensemble des balises correspondant à l'argument. Ainsi, l'instruction :

htmlpropre.find_all('b')

...renvoie l'ensemble des balises <b> et de leur contenu. Il est à noter qu'on peut tout à fait fournir en argument une expression régulière, à condition d'employer la méthode . Ainsi, pour obtenir toutes les balises commençant par li (telles que li ou link), on écrira :

htmlpropre.find_all(re.compile('^li'))

Il est également possible de rechercher des éléments en fonction de leur li ou de leur li. On aura par exemple :

htmlpropre.find_all(id="en-avant")

Attention, dans la recherche sur les classes, la syntaxe exige la présence d'un underscore un peu inattendu :

htmlpropre.find_all(class_="en-arrière")

Il est tout à fait possible de combiner une recherche par balise et par attribut. Ainsi, si on cherche tous les liens qui relève de la classe "prix", on écrira :

htmlpropre.find_all("a", class_="prix")

Autre possibilité, faire porter les critères de recherche non plus sur les balises, mais sur les éléments eux-mêmes, ce qui s'effectue via l'argument string. Ainsi, pour chercher tous les items de liste dans lesquels apparaît le symbole €, opn écrira :

htmlpropre.find_all("li", string="€")

L'affaire peut évidemment être raffinée à loisir : on peut ainsi rechercher les éléments qui appartiennent à la fois à plusieurs classes, utiliser des expressions régulières pour davantage de souplesse, etc. Mais vous aurez compris le principe.

Nettoyer / classer les résultats

Une fois la série des balises récupérées sous forme de liste, on peut en faire bien des choses, selon le but poursuivi. Je ne donnerai qu'un exemple de traitement : celui où on s'intéresse exclusivement au contenu des balises. L'objectif est donc soit en nettoyant notre liste, soit en en constituant une nouvelle, d'éliminer tout ce qui relève des balises proprement dites, pour ne conserver que leur contenu. Ici, il y a bien sûr une histoire d'expressions régulières : il faut exclure de la chaîne de caractères tout ce qui est encadré par les symboles < et >.

Si l'on veut purger la liste existante de ses balises, on va donc écrire :

  1. parcourir tous les éléments de htmlpropre par une boucle
  2. à chaque élément, utiliser la méthode re.sub pour effectuer la modification (en fait, le remplacement des balises et de leur contenu par une chaîne vide.
    NB1 : il est impératif de convertir l'élément de liste en string pour que la méthode re.sub puisse le traiter.
    NB2 : l'expression régulière doit se méfier de la fameuse « gourmandise ». On ne peut pas simplement écrire : '<.*>', qui récupèrerait à tort l'ensemble de la ligne.

recolte = htmlpropre.find_all("a", class_="prix")
for ligne in recolte:
   ligne = re.sub(r'<[^>]+>', '', str(ligne))

Et sur le même principe, rien n'est plus facile que constituer une nouvelle liste :

recolte = htmlpropre.find_all("a", class_="prix")
newRecolte = []
for ligne in recolte:
   newRecolte.append(re.sub(r'<[^>]+>', '', str(ligne)))

Ex 1 : Citations

Il s'agit d'explorer automatiquement le site https://quotes.toscrape.com/. Ecrivez le code permettant d'afficher à l'écran la série des citations présentes, suivies, entre parenthèses, du nom de leur auteur

Ex 2 : Tags

Toujours avec le même site, écrivez le code permettant d'afficher à l'écran la série des tags utilisés, de demander à l'utilisteur d'en choisir un, et de lui fournir l'ensemble des citations (avec auteur) correspondantes.

Parcourir l'ensemble d'un site web

Méthodes semi-automatiques

Pour terminer, nous n'avons jusque-là scrapé que des pages individuelles. Mais si nous voulons donner à ce traitement un caractère plus systématique, pour ne pas dire industriel, il faut passer à la vitesse supérieure.

Une première possibilité, intermédiaire, consiste à explorer une série de pages, en spécifiant leurs adresses. On constitue donc une liste d'URLs, puis on effectue une boucle sur cette liste :

links=['http://www.lemonde.fr','http://www.liberation.fr', 'http://lequipe.fr']
for url in links:
   page = requests.get(url)

Cette technique, simple à mettre en œuvre, permet d'envisager un traitement « par lots ». Elle oblige néanmoins à spécifier individuellement l'adresse de chaque page visitée. S'il s'agit d'explorer un même site sous différents angles, une possibilité consiste à séappuyer sur le fait que les adresses de sites comprennent souvent une ou plusieurs variables (signalées par des points d'interrogation). Un site commercial tel que celui du vendeur d'instruments de musique Thomann, par exemple, permet d'effectuer uen recherche sur des mots-clés, avec une adresse de la forme https://www.thomann.fr/search.html?sw=petrucci (si petrucci est le terme recherché). Pour automatiser un scraping sur une liste de termes recherchés sur le site Thomann, on pourra donc écrire :

keywords=['petrucci', 'morse', 'may', 'santana', 'vai']
for nom in keywords:
   url = 'https://www.thomann.fr/search.html?sw=' + nom
   page = requests.get(url)
   # traitement de chaque page

Ex 3 : Les plus cités

Toujours avec le même site, écrivez le code permettant d'explorer l'ensemble des pages afin de compter combien chaque auteur compte de citations. Il faudra ensuite afficher, par ordre décroissant, les noms des dix auteurs les plus cités.

NB : on ne sait pas par avance combien le site compte de pages.

Méthode automatique (le « webcrawler »)

Après le fusil à un coup, nous venons donc de voir le fusil à répétition. Il existe cependant une méthode encore plus radicale (ou invasive), consistant à explorer systématiquement toutes les pages d'un site donné. Cette stratégie consiste, pour chaque page explorée, à noter les liens pointant vers d'autres pages du même site, puis à aller explorer ces liens si ce n'est pas déjà fait. Une telle stratégie suppose de la programmation récursive c'est-à-dire, je le rapelle, l'écriture de fonctions qui, pour retourner leur résultat, s'appellent elles-mêmes.

Voici un exemple simple, qui ne mobilise pas de packages spécialisés, et qui ne gère pas diverses situations telles que les sites web où les url sont générées par javascript. On suppose que les packages requests et re sont installés, et que l'URL racine du site, par exemple http://exemple.com, est correctement renseignée danbs la variable start_url.

Étape 1 : on commence par créer un ensemble qui stockera au fur et à mesure les URLs visitées, et qui évitera à notre robot d'aller explorer plusieurs fois la même :

visited = set()

Étape 2 : on nettoie l'url de base de son préfixe (tout ce qui précède le double slash) et de son sufixe (tout ce qui suit le second slash) afin d'obtenir le nom de domaine stricto sensu :

start_url = "http://example.com"
domain = re.sub('.*//', '', start_url)
domain = re.sub('/.*', '', domain)

Étape 3 : on écrit une fonction qui renvoie tous les liens bruts figurant sur une page html donnée :

L'idée consiste d'une part à récupérer l'ensemble des liens présents sur la page (via le findall et l'utilisation d'une expression régulière), d'autre part de ne retenir que ceux d'entre eux qui pointent vers des pages html. Il serait évidemment possible d'atteindre ce résultat en perfectionnant l'expression régulière utilisée, mais celle-ci deviendrait alors d'une abominable complexité. La solution proposée ici est certes un peu plus laborieuse, mais ô combien plus simple et lisible.

def extraire_liens(pagehtml):
   liens = re.findall(r'href=["\'](.*?)["\']', pagehtml, re.IGNORECASE)
   lienshtml = []
   for lien in liens:
      if str(lien).endswith('.html'):
         lienshtml.append(lien)
   return lienshtml

Étape 4 : on écrit la fonction qui va explorer l'ensemble du site :

def crawl(url):
   # Si l'url a déjà été visitée, on s'arrête là
   if url in visited:
      return
   print("Exploration de : ", url)
   visited.add(url)

   # Url non encore visitée. On tente la connexion...
   try:
      r = requests.get(url, timeout=5)
      # On vérifie que la page est du bon type
      if "text/html" not in r.headers.get("Content-Type", ""):
         return
   except Exception as e:
      print(f"Erreur sur {url} : {e}")
      return

   # On récupère les liens présents sur la page
   liens = extraire_liens(r.text)
   # On les traite en vérifiant leur conformité
   for lien in liens:
      interne = domain in link or link.startswith("/")
      if interne:
         if link.startswith("/"):
            link = start_url.rstrip("/") + link
      # Si le lien est valide, on l'explore
      crawl(link)

Étape 5 : on lance la machine infernale :

crawl(start_url)