.. _internet: ******** 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 :mod:`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 :func:`~urllib.request.urlopen` permet d'ouvrir une URL distante. >>> import urllib.request >>> u = urllib.request.urlopen('https://www.esiee.fr/') L'objet retourné est de type :class:`HTTPResponse`. >>> type(u) Cet objet est de type **file-like** et dispose, directement ou par héritage de la classe :class:`~io.IOBase`, de différentes méthodes de lecture similaires à celles déjà vues pour `les fichiers `_: - :meth:`~http.client.HTTPResponse.read` ; - :meth:`~io.IOBase.readline` ; - :meth:`~io.IOBase.readlines`. >>> dir(u) [... 'read', 'readline', 'readlines', ...] Le code source de la page web peut être lu et stocké dans une liste de :class:`bytes` avec :meth:`~io.IOBase.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 `_ (:kbd:`Ctrl-u` pour obtenir le code source de la page) : >>> data[0] b'\r\n' L'affichage est préfixé par ``b`` caractéristique des objets de type :class:`bytes`. >>> type(data[0]) Encodage - décodage =================== Comme on vient de le voir, les méthodes de lecture retournent des :class:`bytes` qui doivent être convertis en :class:`str`. La conversion nécessite une table d'encodage-décodage. On travaillera essentiellement avec `utf8 `_. L'encodage consiste à transformer des :class:`str` en :class:`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 "", line 1, in UnicodeEncodeError: 'ascii' codec can't encode character '\xe9' in position 0: ordinal not in range(128) Le décodage consiste à transformer des :class:`bytes` en :class:`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 : .. code-block:: html ... ... Pour illustrer cette opération de décodage, prenons l'exemple de la balise ````: .. code-block:: python for line in data: if '<title>' in line.decode('utf8'): title = line break L'affichage sous forme de :class:`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\r\n' L'opération de décodage permet de les remplacer par des caractères imprimables:: >>> title.decode('utf8') ' ESIEE Paris, l’école de l’innovation technologique | Grande école d’ingénieurs | ESIEE Paris\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 :mod:`~html.parser` propose la classe :class:`~html.parser.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: - :meth:`~html.parser.HTMLParser.handle_starttag` se déclenche lorsque le parser rencontre une balise ouvrante ; - :meth:`~html.parser.HTMLParser.handle_endtag` se déclenche lorsque le parser rencontre une balise fermante ; - :meth:`~html.parser.HTMLParser.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 :class:`~html.parser.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: .. code-block:: python 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 :mod:`json` permettant de convertir les structures de données Python au format JSON et vice versa. La fonction :func:`~json.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 :func:`~json.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) :func:`~json.dump` et :func:`~json.load` réalisent la même opération avec des fichiers plutôt qu'avec des chaînes de caractères. .. tip:: Le travail avec les fichiers ``json`` est facilité dans Visual Studio Code par `l'extension Prettify JSON `_. Elle s'installe à partir du menu :command:`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 :download:`ex01_internet.py <../files/ex01_imdb.py>`. La fonction principale ---------------------- Ecrire une fonction :func:`main` : - qui ne prend aucun argument ; - et ne retourne rien. Cependant cette fonction devra : - utiliser le module :mod:`urllib.request` pour 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 :func:`scrap_imdb` décrite ci dessous. .. hint:: 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('http://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 :func:`scrap_imdb` : - prend en argument le contenu de la `page IMDb `_ ; - et retourne la liste des films. Elle devra instancier une classe :class:`IMDBParser` (décrite ci dessous) et appeler la méthode :meth:`~html.parser.HTMLParser.feed` sur cette instance. Le parser --------- La classe :class:`IMDBParser` : - devra hériter de :class:`~html.parser.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 :meth:`~html.parser.HTMLParser.handle_starttag` ; - redéfinir la méthode :meth:`~html.parser.HTMLParser.handle_endtag` ; - redéfinir la méthode :meth:`~html.parser.HTMLParser.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 :class:`IMDBParser`. Une fois la fonction :func:`main` opérationnelle, lancer les doctests. Ceux ci fonctionnent avec un fichier statique :download:`IMDB.html <../data/IMDb.html>` qui doit être présent dans le répertoire.