Produire un corpus web format XML en 15 lignes de code

Pour effectuer certaines tâches, disposer d’un corpus textuel est très utile, voire indispensable : faire des essais pour un projet dont le corpus de travail est en cours de constitution, tester des scripts et traitements, prendre en main un nouvel outil et/ou le comparer à d’autres outils similaires…

Ce sont des usages qui relèvent principalement des phases préparatoires d’un projet, et pour lesquels il est surtout important de pouvoir construire un corpus rapidement et sans trop d’effort. Ce n’est pas grave si sa constitution n’est pas particulièrement précise puisqu’elle ne nécessite qu’un investissement limité et que les résultats obtenus n’ont pas vocation à être exploités en conditions réelles.

Je vous présente ici un script Python de web scraping très simple, qui vous permettra d’obtenir un corpus textuel au format XML tout à fait adapté à ce type d’utilisation.

Le script ne sera pas reproduit intégralement dans cet article. Il est librement téléchargeable sur Github et Gitlab. Un exemple de fichier de sortie est également disponible (Github et Gitlab).

Le corpus XML

Le but sera ici d’obtenir un corpus au format XML regroupant le contenu textuel d’une sélection de pages web. Pour plus de facilité lors de l’utilisation d’outils de traitement de corpus, un identifiant sera ajouté, et l’URL sera elle aussi reportée dans le corpus.

La structure XML est aussi simple que possible. Elle comporte :

  • Un élément racine appelé CORPUS.
  • Pour chaque page web scrapée, un élément Page porteur d’un attribut id.
  • Pour chaque élément Page, un sous-élément URL reprenant l’URL de la page web correspondante, et un sous-élément Text contenant l’ensemble du texte extrait de la page.
<?xml version="1.0" encoding="utf-8"?>
<CORPUS>
  <Page id="1">
    <URL>
      <!-- URL de la page web -->
    </URL>
    <Text>
      <!-- Contenu textuel de la page web -->
    </Text>
  </Page>
</CORPUS>

Prérequis

Le point de départ du traitement est une liste d’URL : il vous faudra simplement un peu de prospection pour sélectionner les pages dont vous voulez récupérer le contenu textuel pour constituer votre corpus. Cette liste correspond à la variable url_list dans le script téléchargeable.

Pour l’exemple, j’ai associé à url_list les URL de quatre pages Wikipédia.

url_list = ["https://ja.wikipedia.org/wiki/YOSAKOI", "https://ja.wikipedia.org/wiki/YOSAKOI%E3%82%BD%E3%83%BC%E3%83%A9%E3%83%B3%E7%A5%AD%E3%82%8A", "https://ja.wikipedia.org/wiki/%E9%B3%B4%E5%AD%90_(%E9%9F%B3%E5%85%B7)", "https://ja.wikipedia.org/wiki/%E3%82%88%E3%81%95%E3%81%93%E3%81%84%E7%A5%AD%E3%82%8A"]

Ces pages, relatives au thème du yosakoi1, sont en japonais. La langue du texte n’a pas d’incidence sur le fonctionnement du script.

Concernant l’installation Python 3, il faudra installer et importer :

Le script Python

Comme annoncé dans le titre de cet article, une fois les librairies importées et votre liste d’URL déclarée, le traitement à proprement parler ne nécessite qu’une quinzaine de lignes de code.

Créer l’arborescence XML

Recréer la structure XML montrée précédemment constitue la majeure partie du traitement effectué par le script. L’élément racine, arbitrairement appelé CORPUS, est à déclarer en premier avec une instance d’ET.Element()2. Il est nécessaire de l’associer à une variable, ce qui permettra ensuite de créer des sous-éléments :

root = ET.Element("CORPUS")

Si comme dans l’exemple, vous voulez utiliser un identifiant chiffré pour chaque page, c’est le moment de le déclarer et lui attribuer sa valeur initiale.

On peut maintenant alimenter le corpus, en parcourant la liste url_list avec une boucle for pour créer un élément Page pour chaque URL. Cet élément Page est un sous-élément de l’élément racine CORPUS (= variable root), à déclarer avec ET.SubElement() :

page = ET.SubElement(root, "Page")

L’attribut id est ajouté avec la fonction set(). Attention, comme ici sa valeur (stockée dans la variable id) est un entier, il faut la convertir en chaîne de caractères3.

page.set("id", f"{id}")

Il suffit maintenant de répéter l’opération pour créer les sous-éléments URL et Text, en prenant soin d’indiquer l’élément parent approprié, soit page (correspondant à l’élément Page dans la structure XML). Ces sous-éléments n’ont pas d’attribut mais comportent du texte : l’URL de la page pour URL, le contenu textuel extrait de la page pour Text. Ce texte peut être ajouté en attribuant une valeur à l’attribut .text de l’élément, soit par exemple pour URL :

page_url = ET.SubElement(page, "URL")
page_url.text = url

Le sous-élément Text est également déclaré via une instance de .SubElement()4. Il doit recevoir le contenu textuel de la page web, qu’il va maintenant falloir extraire.

Extraire le contenu textuel de la page web

La récupération du texte intégral de la page web courante, débarrassé de son balisage HTML, s’effectue en ajoutant la ligne suivante :

page_text.text = soup(requests.get(url).text, "html.parser").text

Comme pour l’URL, le contenu est assigné à l’élément en associant une valeur à son attribut .text, ici page_text.text. Voici comment fonctionne cette commande qui permet de récupérer le texte d’une page web :

  • La fonction requests.get(url) envoie une requête à l’adresse associée à la variable url, et renvoie la réponse reçue. En d’autres termes, elle permet d’accéder à l’URL.
  • requests.get(url).text semble correspondre au texte de la page. C’est le cas, dans une certaine mesure : il s’agit du texte avec tout son balisage HTML. Ce n’est pas exactement ce qu’on cherche à récupérer ici, il va falloir supprimer les balises HTML.
  • C’est une fonction de la librairie bs4 qui va retirer le balisage, mais elle ne peut pas traiter directement requests.get(url).text qui est une chaîne de caractères. Cette fonction étant en fait un attribut de la classe BeautifulSoup, il faut convertir requests.get(url) en instance d’objet BeautifulSoup, ce qui est fait avec soup() dans le script téléchargeable. Concrètement, bs4 parse la structure HTML de la chaîne de caractères, il faut alors désigner également quel parser utiliser. Ici soup(requests.get(url).text, "html.parser")5 contient toujours le texte intégral de la page avec son balisage, sauf qu’il ne s’agit plus d’une chaîne de caractères (str) mais bien d’un objet BeautifulSoup. On peut maintenant appeler l’attribut correspondant au texte sans balises… qui lui aussi s’appelle .text. Le texte brut d’une page web s’obtient donc avec soup(requests.get(url).text, "html.parser").text.

Cette commande est à inclure dans la boucle traitant chaque URL de la liste, puisque qu’elle est à appliquer à chaque page web qui constituera le corpus.

Voici la boucle complète :

for url in url_list:
    page = ET.SubElement(root, "Page")
    page.set("id", f"{id}")
    id += 1
    page_url = ET.SubElement(page, "URL")
    page_url.text = url
    page_text = ET.SubElement(page, "Text")
    page_text.text = soup(requests.get(url).text, "html.parser").text

Écrire le fichier de sortie

La structure XML et son contenu sont maintenant prêts et stockés dans la variable root, correspondant à la racine de l’arborescence. Il n’est toutefois pas possible de l’enregistrer dans un fichier, car son type ne le permet pas. La valeur retournée par print(type(root)) est la suivante :

<class 'xml.etree.ElementTree.Element'>

Afin d’être écrit dans un fichier, cet élément doit être converti en chaîne de caractères (classe str). C’est ce que permet de faire la fonction tostring() de xml.etree.ElementTree. Celle-ci prend en argument l’élément racine de l’arbre XML (root dans le cas présent).

xml_output = ET.tostring(root)

Avec la liste d’URL fournie dans l’exemple, ET.tostring(root)6 renvoie quelque chose qui ressemble à une très longue chaîne de caractères en une seule ligne. Sa longueur varie selon le nombre d’URL que comporte votre liste et de la quantité de texte sur chaque page. Il ne sera pas possible de l’écrire dans un fichier en l’état, car il ne s’agit pas vraiment d’une chaîne de caractères (str) mais d’une séquence d’octets appartenant donc à la classe bytes7.

Ajouter l’argument optionnel encoding="unicode" suffit à corriger le problème. Le XML a bien été converti en chaîne de caractères.

xml_output = ET.tostring(root, encoding="unicode")

Un autre argument optionnel qui peut être ajouté est method ; dans le script téléchargeable, la valeur "xml" lui a été attribuée. Sauf erreur de ma part, il s’agit de sa valeur par défaut et en l’état il n’est pas indispensable de le spécifier. Si, pour une raison ou une autre, vous voulez obtenir une version de votre corpus en texte brut, qui comportera le même contenu que l’arborescence XML mais sans aucune balise, vous pouvez lui attribuer la valeur "text".

Avant d’enregistrer le corpus dans un fichier, je vous conseille de lui appliquer la fonction prettify() de bs4. Encore une fois, il faut convertir la chaîne de caractères en instance d’objet BeautifulSoup, en indiquant cette fois "xml" pour le parser :

pretty_xml = soup(xml_output, "xml").prettify()

Cette étape de traitement supplémentaire vous permettra d’obtenir un fichier de sortie beaucoup plus lisible pour un œil humain, avec l’ajout de sauts de ligne et d’indentation suivant la structure. La déclaration XML sera aussi ajoutée automatiquement, ce qui est très appréciable.

Le résultat de prettify() est une chaîne de caractères (str), qui peut être enregistrée dans un fichier avec write().

Si vous avez utilisé ou recréé le script mis à disposition sans modifier la liste d’URL, vous devriez avoir obtenu un fichier XML semblable à l’exemple de fichier de sortie téléchargeable sur Github et Gitlab.

Obtenir un “meilleur” corpus

Le script présenté ici a l’avantage d’être très simple et de permettre de produire très facilement un corpus textuel issu du web. Le résultat obtenu est vraiment très basique : voici quelques suggestions d’amélioration ainsi que des pistes pour les mettre en place.

Prendre en entrée une (très) longue liste d’URLSi vous avez un grand nombre d’URL à traiter, il peut être plus confortable de les stocker dans un fichier séparé. Vous pouvez par exemple les stocker dans un fichier en texte brut avec une URL par ligne qui sera ensuite lu ligne par ligne pour reconstituer la liste. Ou encore, constituer un fichier CSV et exploiter le module csv pour en lire le contenu (cet article pourrait vous aider).
Suivre la progression du traitementAvec un petit nombre d’URL le traitement est très rapide, mais avec une liste plus longue c’est assez pratique d’en connaître l’avancée. Le plus simple est certainement d’ajouter une instruction print(url) dans la boucle for pour afficher l’URL courante.
Supprimer les éléments de navigationIl existe des librairies Python consacrées au retrait de boilerplate, comme jusText ou boilerpy3. Vous pouvez aussi exploiter les fonctions de bs4 pour filtrer les éléments HTML en fonction de leur tag.
Supprimer les lignes videsUne solution possible est de mettre en place une boucle sur soup(requests.get(url).text, "html.parser").text.split('\n') pour filtrer les lignes sans texte avant d’associer une valeur à page_text.text. Si vous ne conservez que certains éléments HTML (cf ci-dessus), le filtrage des éléments sans texte peut aussi être effectué à ce moment-là.
Ajouter des métadonnéesRien n’empêche de créer une structure XML plus complexe en ajoutant des (sous-)éléments avec .SubElement() et/ou des attributs avec .set() pour ajouter des métadonnées telles que la source, l’auteur, la date, la langue, etc. Si ces métadonnées ont été recueillies en amont, elles peuvent être lues à partir d’un fichier d’entrée (par exemple si vous utilisez un fichier CSV comme source de la liste d’URL, des colonnes peuvent être consacrées aux métadonnées).
Rendre les identifiants plus explicitesUn identifiant sous forme de chiffres est facile à mettre en place mais pas particulièrement explicite. En fonction de votre projet, il sera peut-être nécessaire de trouver une autre forme d’identifiant. Une solution pourrait être de déclarer la liste d’URL sous forme d’un dictionnaire pour produire des identifiants de type wikipedia_yosakoi_1, wikipedia_yosakoi_2 (etc.), par exemple :
url_list = {"wikipedia_yosakoi_": ["url1", "url2", "url3"]}
Vérifier l’encodage des caractèresLe fichier de sortie est en UTF-8. Si votre corpus XML comporte des caractères mal reconnus, vérifier l’encodage d’origine peut aider à identifier d’où vient le problème. Vous pouvez récupérer l’encodage d’une page web avec requests.get(url).encoding.
Ajouter un étiquetage morphosyntaxiqueLe corpus construit ici comporte du texte non étiqueté, mais il est tout à fait possible d’exploiter les résultats d’un étiqueteur automatique pour mettre en place une structure XML qui intègre l’étiquetage du texte. La structure XML d’un token étiqueté pourrait être la suivante :
<tagged_token>
<token>よさこい祭り</token>
<tag>名詞</tag>
</tagged_token>

Vous trouverez sur ce carnet des explications pour utiliser certains étiqueteurs automatiques en Python : SudachiPy et spaCy pour le japonais ; PyNLPIR, Jieba et spaCy pour le chinois.
Citer ce billet : “Produire un corpus web format XML en 15 lignes de code”, par Alexander Delaporte. Publié sur Tekipaki le 04/05/2021. Lien : https://tekipaki.hypotheses.org/1758.
  1. Le texte des pages Wikipedia est disponible sous licence CC BY-SA 3.0. Pour davantage d’informations, en français, sur le yosakoi (よさこい) : site web, page Facebook et chaîne Youtube de l’association Yosakoi Paris Hinodemai, page de la Fédération de Yosakoi de France (FYF). []
  2. ET désigne xml.etree.ElementTree, qui a été importé comme suit : import xml.etree.ElementTree as ET. []
  3. Conserver un entier ne poserait pas de problème à cette étape mais ferait planter le script lors de la préparation du XML pour l’écriture du fichier de sortie. []
  4. page_text = ET.SubElement(page, "Text") []
  5. L’objet est instancié avec soup parce que l’import a été fait de cette façon : from bs4 import BeautifulSoup as soup. Si on utilise import bs4 l’équivalent serait bs4.BeautifulSoup(requests.get(url), "html.parser"). []
  6. Comme avec soup, c’est la façon dont l’import a été fait qui permet d’utiliser ET pour xml.etree.ElementTree. Cette commande est équivalente à xml.etree.ElementTree.tostring(root). []
  7. D’ailleurs, si vous l’affichez dans la console elle sera précédée de b, indiquant bien le type bytes. De même, print( type(xml_output) ) renverra <class 'bytes'>. []

Auteur/autrice : Alexander Delaporte

Ingénieur d'études au Centre de recherches linguistiques sur l'Asie Orientale et chercheur associé BnF 2019-2021

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.