Internet
Python est un langage universel, permettant une utilisation simple d’opérations complexes en les encapsulant dans des modules de haut niveau. Ce chapître traite de l’accès à des ressources distantes, et plus particulièrement de l’accès à des ressources disponibles sur le web.
Effectuer une requête
Le module standard urllib.request comporte un grand nombre de fonctions et de classes permettant de traiter les requêtes HTTP et HTTPS. Nous allons nous concentrer ici sur l’essentiel, la récupération des données contenues sur une page web.
La fonction urlopen() permet d’ouvrir une URL distante.
>>> import urllib.request
>>> u = urllib.request.urlopen('https://www.esiee.fr/')
L’objet retourné est de type HTTPResponse.
>>> type(u)
<class 'http.client.HTTPResponse'>
Cet objet est de type file-like et dispose, directement ou par héritage de la classe IOBase, de différentes méthodes de lecture similaires à celles déjà vues pour les fichiers:
read();-
>>> dir(u) [... 'read', 'readline', 'readlines', ...]
Le code source de la page web peut être lu et stocké dans une liste de bytes avec readlines() :
>>> data = u.readlines()
>>> len(data)
1087
Au moment de la rédaction de ce document, la première ligne du fichier HTML du site web d”ESIEE Paris (Ctrl-u pour obtenir le code source de la page) :
>>> data[0]
b'<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->\r\n'
L’affichage est préfixé par b caractéristique des objets de type bytes.
>>> type(data[0])
<class 'bytes'>
Encodage - décodage
Comme on vient de le voir, les méthodes de lecture retournent des bytes qui doivent être convertis en str.
La conversion nécessite une table d’encodage-décodage. On travaillera essentiellement avec utf8.
L’encodage consiste à transformer des str en bytes:
>>> 'é'.encode('utf8') # utf8 code
b'\xc3\xa9'
Si le caractère n’a pas de représentation dans le charset considéré une erreur est produite:
>>> "é".encode('ascii')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
UnicodeEncodeError: 'ascii' codec can't encode character '\xe9' in position 0: ordinal not in range(128)
Le décodage consiste à transformer des bytes en str.
>>> b'\xc3\xa9'.decode('utf8')
'é'
Important
Une mauvaise représentation des caractères accentués est souvent un problème de charset différent utilisé pour les opérations d’encodage puis de décodage.
>>> e = "éphémère".encode('utf8')
>>> e
b'\xc3\xa9ph\xc3\xa9m\xc3\xa8re'
>>> e.decode('latin1')
'éphémère'
>>> e.decode('utf8')
'éphémère'
>>>
L’information d’encodage n’est pas systématiquement contenue dans la ressource elle même. C’est notamment le cas pour les fichiers. Mais les pages Internet comportent généralement une indication sur le charset utilisé. Pour le site d”ESIEE Paris, le code source de la page contient cette information :
...
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
...
Pour illustrer cette opération de décodage, prenons l’exemple de la balise <title>:
for line in data:
if '<title>' in line.decode('utf8'):
title = line
break
L’affichage sous forme de bytes utilise le code UTF8 des caractères.
>>> title
b' <title>ESIEE Paris, l\xe2\x80\x99\xc3\xa9cole de l\xe2\x80\x99innovation technologique | Grande \xc3\xa9cole d\xe2\x80\x99ing\xc3\xa9nieurs | ESIEE Paris</title>\r\n'
L’opération de décodage permet de les remplacer par des caractères imprimables:
>>> title.decode('utf8')
' <title>ESIEE Paris, l’école de l’innovation technologique | Grande école d’ingénieurs | ESIEE Paris</title>\r\n'
>>>
Parser une page HTML
Les informations contenues dans une page HTML sont contenues dans des balises structurées qu’il est possible d’explorer automatiquement avec un parser.
Pour cette opération, le module standard parser propose la classe HTMLParser. Cette classe est une classe générique qui propose des méthodes se déclenchant sur des évènements rencontrés lors du parcours de la page HTML. En particulier:
handle_starttag()se déclenche lorsque le parser rencontre une balise ouvrante ;handle_endtag()se déclenche lorsque le parser rencontre une balise fermante ;handle_data()se déclenche lorsque le parser se trouve entre une balise ouvrante et une balise fermante.
Bien sûr, il faut contextualiser le comportement de ces méthodes en fonction du problème à traiter. La solution est de mettre en oeuvre le principe d’héritage en sous classant HTMLParser et en redéfinissant les méthodes précédentes conformément au cahier des charges. Dans l’exemple ci dessous, les méthodes ont juste été redéfinies pour afficher à l’écran la structure de la page web:
import urllib.request
from html.parser import HTMLParser
class MyHTMLParser(HTMLParser):
def __init__(self):
super().__init__()
def handle_starttag(self, tag, attrs):
print("<< Trouvé balise ouvrante :", tag)
def handle_endtag(self, tag):
print(">> Trouvé balise fermante :", tag)
def handle_data(self, data):
if data.strip(): print(" Trouvé contenu :", data)
Pour utiliser cette classe, on crée une instance:
parser = MyHTMLParser()
On récupère des données à parser sous la forme d’une seule chaine de caractères:
u = urllib.request.urlopen('https://httpbin.org/html')
data = u.read().decode('utf8')
Et on alimente le parser avec ces données:
parser.feed(data)
L’affichage (tronqué) correspondant:
<< Trouvé balise ouvrante : html
<< Trouvé balise ouvrante : head
>> Trouvé balise fermante : head
<< Trouvé balise ouvrante : body
<< Trouvé balise ouvrante : h1
Trouvé contenu : Herman Melville - Moby-Dick
>> Trouvé balise fermante : h1
<< Trouvé balise ouvrante : div
<< Trouvé balise ouvrante : p
Trouvé contenu :
Availing himself of the mild, ... and all of them a care-killing competency.
>> Trouvé balise fermante : p
>> Trouvé balise fermante : div
>> Trouvé balise fermante : body
>> Trouvé balise fermante : html
Le format JSON
JSON est un format permettant de représenter de l’information structurée comme XML mais présente l’avantage par rapport à celui ci d’être beaucoup moins verbeux. C’est devenu un standard de fait dans l’échange de données sur Internet.
Python dispose du module standard json permettant de convertir les structures de données Python au format JSON et vice versa.
La fonction dumps() retourne une chaîne de caractère contenant l’objet JSON correspondant à la structure de données Python passée en argument. Les deux formats sont très proches.
Créons une liste Python:
>>> import string
>>> l = [ c for c in string.digits]
>>> l
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
Sa représentation est quasi identique:
>>> json.dumps(l)
'["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]'
Idem pour un dictionnaire:
>>> d = { c:ord(c) for c in string.digits}
>>> d
{'6': 54, '0': 48, '2': 50, '8': 56, '7': 55, '1': 49, '4': 52, '5': 53, '9': 57, '3': 51}
dont la représentation est très proche à un délimiteur de chaine de caractères près:
>>> json.dumps(d)
'{"6": 54, "0": 48, "2": 50, "8": 56, "7": 55, "1": 49, "4": 52, "5": 53, "9": 57, "3": 51}'
La fonction loads() réalise l’opération inverse en transformant un objet JSON en une structure de données Python:
>>> d = json.loads('{"results" : [{"geometry" : {"location" : {"lat" : 48.8398094,"lng" : 2.5840685}}}]}')
>>> type(d)
<class 'dict'>
dump() et load() réalisent la même opération avec des fichiers plutôt qu’avec des chaînes de caractères.
Astuce
Le travail avec les fichiers json est facilité dans Visual Studio Code par l’extension Prettify JSON. Elle s’installe à partir du menu Affichage > Extensions.
Exercice
IMDb recense un grand nombre d’informations sur les films et les séries. En particulier, la liste des 250 meilleurs films selon les votes de ses utilisateurs. Cette liste évolue au fil du temps mais le résultat devrait être proche de ceci:
1 : Les évadés
2 : Le parrain
3 : The Dark Knight: Le chevalier noir
4 : Le parrain, 2ème partie
5 : 12 hommes en colère
…
246 : Aladdin
247 : La couleur des sentiments
248 : La Belle et la Bête
249 : Du rififi chez les hommes
250 : Danse avec les loups
L’objectif est d’écrire un parser qui récupère dynamiquement les données de cette page et affiche la liste du film le moins populaire vers le film le plus populaire.
Pour cet exercice, vous devez utiliser en priorité le fichier squelette ex01_internet.py.
La fonction principale
Ecrire une fonction main() :
qui ne prend aucun argument ;
et ne retourne rien.
Cependant cette fonction devra :
utiliser le module
urllib.requestpour récupérer automatiquement le contenu de la page IMDb sous la forme d’une seule chaine de caractères ;mettre en oeuvre la gestion d’exception pour l’accès à la ressource distante ;
et faire un appel à une fonction
scrap_imdb()décrite ci dessous.
Indication
La plupart des serveurs rejettent les requêtes lorsqu’elles n’émanent pas d’un navigateur. Pour éviter ce problème, il faut ajouter un en-tête HTTP User-Agent à la requête. La valeur de cet en-tête doit être une chaine de caractères qui identifie le navigateur. On trouvera les User-Agent les plus courants dans cette liste.
La requête se construit alors comme suit:
import urllib.request
firefox = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:101.0) Gecko/20100101 Firefox/101.0'
req = urllib.request.Request('https://www.example.com/')
# Customize the default User-Agent header value:
req.add_header('User-Agent', firefox)
r = urllib.request.urlopen(req)
Scraping
Ecrire la fonction scrap_imdb() :
prend en argument le contenu de la page IMDb ;
et retourne la liste des films.
Elle devra instancier une classe IMDBParser (décrite ci dessous) et appeler la méthode feed() sur cette instance.
Le parser
La classe IMDBParser :
devra hériter de
HTMLParser;utiliser deux attributs : un conteneur pour le stockage des films, l’autre pour contrôler la logique du processus ;
- et en fonction de la structure du document HTML parsé :
redéfinir la méthode
handle_starttag();redéfinir la méthode
handle_endtag();
redéfinir la méthode
handle_data().
Cette logique devra déclencher l’ajout de données à un conteneur seulement sur certaines conditions. Le conteneur et les conditions seront des attributs de la classe IMDBParser.
Une fois la fonction main() opérationnelle, lancer les doctests. Ceux ci fonctionnent avec un fichier statique IMDB.html qui doit être présent dans le répertoire.
Ce qu’il faut retenir
Le module
jsonne peut convertir que des listes en JSONLe décodage n’est nécessaire que pour les pages en anglais
Les sites web acceptent toujours les requêtes sans User-Agent
Les méthodes
handle_starttag(),handle_endtag()ethandle_data()sont utilisées pour parser du HTMLLa classe
HTMLParserpermet de parcourir et analyser le contenu d’une page HTMLLa fonction
urlopen()retourne toujours une chaîne de caractèresLe format JSON est très différent des structures de données Python
L’encodage utf-8 ne permet pas de gérer les caractères accentués
L’en-tête User-Agent est souvent nécessaire pour les requêtes HTTP vers les sites web
Il est impossible d’hériter de la classe
HTMLParserLe module
urllib.requestpermet d’accéder à des ressources distantes via HTTP et HTTPSLa conversion JSON vers Python perd toujours des données
json.dump()etjson.load()ne peuvent pas travailler avec des fichiersHTMLParserfonctionne bien avec des pages statiquesLes données lues depuis une URL sont retournées sous forme de bytes qui doivent être décodés
Les méthodes
handle_starttag(),handle_endtag()ethandle_data()doivent toujours être utilisées dans cet ordre précisLe User-Agent doit toujours correspondre à un navigateur réel
Il est impossible de personnaliser le comportement de HTMLParser
Les méthodes
read()etreadline()ne sont pas disponibles avecurllib.requestLa conversion JSON ne préserve pas les types de données Python
Les pages web n’indiquent jamais leur encodage de caractères
Les dictionnaires Python ne peuvent pas être convertis en JSON
Les méthodes
json.dumps()etjson.loads()travaillent avec des chaînes de caractèresurllib.requestne peut pas ouvrir des URLs sécurisées (HTTPS)JSON est un format d’échange de données moins verbeux que XML
Le User-Agent ne peut pas être modifié dans
urllib.requestHTMLParserne peut pas détecter les balises fermanteshandle_data()ne peut traiter que du texte ASCII