Extraction de données de pages web à l'aide de HTML Agility Pack

Le web scraping sans prise de tête

Thomas Levesque

Il est souvent utile de récupérer automatiquement des données à partir d'une page web, en analysant le code HTML de la page pour extraire les informations qui nous intéressent. Si on n'utilise pas les outils adéquats, écrire du code pour faire ce genre de chose peut vite devenir fastidieux… Ce tutoriel présente un outil très facile à utiliser pour extraire des données d'une page web : HTML Agility Pack.

Commentez cet article : Commentez ♪

- Titre : Extraction de données de pages web à l'aide de HTML Agility Pack

- Auteur : Thomas Levesque

- Parution : 12 août 2012

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2012 Thomas Levesque. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts. Droits de diffusion permanents accordés à Developpez LLC.

Sommaire

- Introduction

- I. Télécharger Html Agility Pack et l'ajouter à votre projet

- I-A. En utilisant Nuget

- I-B. Installation manuelle

- II. Charger un document HTML

- III. Extraire des informations

- III-A. La méthode Element

- III-B. La méthode Elements

- III-C. La méthode Descendants

- III-D. La propriété Attributes et la méthode GetAttributeValue

- III-E. La méthode GetElementById

- III-F. La méthode Ancestors et la propriété ParentNode

- III-G. Utilisation de Xpath

- III-H. Un exemple (un peu) plus élaboré

- Conclusion

- Remerciements

Introduction

Avant d'aller plus loin, une petite mise en garde d'ordre légal : de nombreux sites interdisent le web scraping dans leurs conditions d'utilisation. Pour éviter tout risque de poursuites, renseignez-vous avant d'extraire automatiquement des données depuis un site web, et respectez la propriété intellectuelle des auteurs.

Si vous avez déjà extrait des données depuis une page web, vous vous dites sans doute « c'est facile, je sais faire ». Vous avez probablement adopté une des approches classiques :

- analyse manuelle : à coups de IndexOf et de Substring, il est bien sûr possible d'arriver au résultat voulu, mais avec cette approche, combien de temps vous faut-il pour extraire de façon fiable un malheureux morceau de texte à un endroit précis de la page ? Trente minutes ? Allez, dix si c'est un cas simple ? Et regardez votre code une fois que vous avez le résultat voulu : est-il facile à lire, est-ce que l'on comprend tout de suite ce qu'il fait ? Sans doute pas… De plus, il y a probablement beaucoup de cas particuliers que vous n'avez pas pensé à gérer et qui sont autant de bugs potentiels ;

- les expressions régulières : ah, les regex… un outil fantastique ! Quand on vient de les découvrir, on a l'impression de pouvoir tout faire avec, on se prend pour un super héros… Malheureusement, même si l'utilité des regex est indéniable dans de nombreuses situations, elles ne sont tout simplement pas adaptées pour analyser du HTML. Je ne vais pas entrer dans les détails du pourquoi, Jeff Atwood a déjà expliqué cela beaucoup mieux que je ne saurais le faire. Bien sûr, dans certains cas précis on peut s'en sortir avec des expressions régulières, mais là encore, ce sera au prix d'un code illisible, peu fiable, difficile à déboguer… et au risque d'y laisser sa santé mentale ;).

En plus des inconvénients déjà cités, ces deux approches ont un défaut majeur : elles reviennent à réinventer la roue, ce qui est généralement considéré comme une mauvaise pratique… L'analyse de code HTML est un problème résolu depuis longtemps, tous les navigateurs web le font !

Le langage HTML est très similaire à XML : on pourrait donc être tenté de l'analyser à l'aide d'une API dédiée à XML (par exemple XmlReader, XmlDocument ou Linq to XML), et dans certains cas cela fonctionnerait… Malheureusement, si on fait exception du cas particulier de XHTML, la plupart des pages HTML ne sont pas des documents XML valides, car HTML est beaucoup moins strict que XML. Un parseur XML normal ne pourra donc pas analyser une page HTML, car il échouera à la première erreur rencontrée (par exemple un attribut sans guillemets ou un élément non refermé).

C'est là qu'entre en jeu HTML Agility Pack : c'est une bibliothèque open source qui permet d'analyser le contenu d'une page web via le DOM (Document Object Model), avec une API similaire à Linq to XML, et qui permet aussi l'utilisation de Xpath.

I - Télécharger Html Agility Pack et l'ajouter à votre projet

I-A - En utilisant Nuget

Si vous utilisez Visual Studio 2010 ou plus récent, le plus simple est d'ajouter le package à votre projet à l'aide du gestionnaire de package Nuget (voir ce tutoriel sur l'utilisation de Nuget). Dans l'explorateur de solutions, faites un clic droit sur le nœud Références de votre projet, et choisissez Manage Nuget Packages (si vous ne voyez pas cette option, c'est que Nuget n'est pas installé ; suivez les instructions du tutoriel sur Nuget pour l'installer).

Dans la boîte de dialogue qui s'affiche, sélectionnez la catégorie Online dans la zone de gauche, et tapez HtmlAgilityPack dans le champ de recherche :

I-B - Installation manuelle

Téléchargez Nuget depuis le site officiel en cliquant sur Download et décompressez le fichier zip sur votre disque dur. Dans l'explorateur de solution faites un clic droit sur le nœud Références de votre projet, et choisissez Ajouter une référence. Choisissez l'option Parcourir et sélectionnez le fichier HtmlAgilityPack.dll.

II - Charger un document HTML

Maintenant que Html Agility Pack (HAP) est ajouté à votre projet, on va pouvoir entrer dans le vif du sujet… On utilisera cette page web comme exemple pour la suite du tutoriel.

Voyons d'abord comment charger un document HTML avec HAP. Commencez par importer le namespace HtmlAgilityPack en ajoutant cette ligne au début de votre fichier de code :

using HtmlAgilityPack;

Un document HTML est représenté dans HAP par la classe HtmlDocument (comme c'est original…). Pour charger un fichier XML qui se trouve sur votre système de fichiers local, vous pouvez utiliser le code suivant :

HtmlDocument doc = new HtmlDocument();
doc.Load(cheminDuFichier);

Il est également possible de passer à la méthode Load un Stream ou TextReader, et de spécifier l'encodage du fichier. La méthode LoadHtml permet de charger un document HTML contenu dans une chaine de caractères.

Cependant, en pratique le document HTML à charger se trouve généralement sur un site web, il faut donc le télécharger. HAP fournit une classe HtmlWeb pour faciliter le chargement d'un document à partir d'Internet :

string url = "http://tlevesque.developpez.com/tutoriels/dotnet/extraction-donnees-web-html-agility-pack/fichiers/TestPage.html";
HtmlWeb web = new HtmlWeb();
HtmlDocument doc = web.Load(url);

La méthode Load de la classe HtmlWeb possède également des surcharges permettant de spécifier un utilisateur et un mot de passe, ou encore un proxy. Cela devrait couvrir la plupart des cas d'utilisation ; si vous avez besoin de plus de contrôle sur la requête HTTP, il est bien sûr possible d'utiliser WebClient ou HttpWebRequest, et de charger le document à partir du flux de la réponse.

III - Extraire des informations

Maintenant que le document HTML est chargé, voyons comment on peut en extraire des données. HAP permet de parcourir le DOM (Document Object Model), c'est-à-dire l'arborescence du document. Le DOM est constitué de « nœuds » imbriqués, qui représentent des éléments (ou balises) HTML, du texte, ou des commentaires. Dans HAP, les nœuds sont représentés par la classe HtmlNode. Le nœud racine représente le document lui-même ; on y accède via la propriété DocumentNode de HtmlDocument. À partir de cette racine, on va « descendre » dans le document pour trouver les nœuds qui nous intéressent.

III-A - La méthode Element

Commençons par un cas simple : récupérer le titre de la page. Cette information se trouve dans la balise <title>, qui se trouve dans la balise <head>, elle-même située dans la balise <html> :

<!DOCTYPE html>
<html>
<head>
<title>Page de test pour HTML Agility Pack</title>
...

En partant de la racine, on va donc chercher l'élément html, puis l'élément head, puis l'élément title. La méthode Element de HtmlNode permet d'obtenir le premier élément enfant qui a le nom spécifié à partir de son parent. Par exemple, pour récupérer l'élément html, on va faire comme ceci :

var htmlElement = doc.DocumentNode.Element("html");

Puisque la méthode Element renvoie également un HtmlNode, on peut chaîner les appels pour obtenir directement l'élément voulu :

var titleElement =
doc.DocumentNode
.Element("html")
.Element("head")
.Element("title");

Une fois qu'on a trouvé l'élément recherché, il suffit de récupérer son texte avec la propriété InnerText :

if (titleElement != null)
{
string title = titleElement.InnerText;
Console.WriteLine("Titre: {0}", title);
}
else
{
Console.WriteLine("Pas de titre");
}

Notez que la méthode Element renvoie null si l'élément recherché n'existe pas : il faut donc vérifier si l'élément existe avant d'essayer de l'utiliser. D'ailleurs, le code précédent n'était pas très sûr, puisqu'il ne vérifiait pas l'existence des éléments html et head. En pratique, tous les documents HTML devraient comporter ces balises, donc le risque est limité…

III-B - La méthode Elements

Supposons maintenant qu'on veut récupérer tous les éléments <h2> (titre de niveau 2) qui se trouvent directement dans le corps (<body>) de la page.

Récupérons d'abord l'élément <body> :

var body =
doc.DocumentNode
.Element("html")
.Element("body");

Mais maintenant, comment récupérer tous les enfants <h2> de cet élément ? La méthode Element ne convient pas, car elle renvoie un seul élément… on va donc utiliser Elements, qui fait presque la même chose, mais permet de récupérer plusieurs éléments :

var titles = body.Elements("h2");
foreach (var title in titles)
{
Console.WriteLine(title.InnerHtml);
}

Notez qu'on a utilisé ici InnerHtml et non InnerText. La différence entre les deux est que InnerHtml renvoie le code HTML contenu dans l'élément, alors que InnerText ne renvoie que du texte (les balises HTML sont enlevées).

Contrairement à Element, qui renvoie null si l'élément n'est pas trouvé, Elements renvoie une séquence qui ne contient aucun élément.

Notez que Element et Elements ne cherchent que dans les enfants directs de l'élément actuel : les enfants des enfants ne sont pas trouvés. Par exemple, un élément <h2> placé dans un <div> et non directement dans <body> ne serait pas trouvé par le code précédent.

III-C - La méthode Descendants

Essayons maintenant de récupérer les textes de tous les liens (<a>) qui se trouvent dans la page, quel que soit leur niveau. On a vu plus haut que Elements ne cherche que parmi les enfants directs ; si on ne sait pas à l'avance quels sont les parents des éléments recherchés, il faut donc utiliser autre chose… C'est là qu'intervient la méthode Descendants : elle cherche récursivement parmi tous les descendants d'un élément, quel que soit leur niveau :

var links = doc.DocumentNode.Descendants("a");
foreach (var link in links)
{
Console.WriteLine(link.InnerText);
}

Ici on a utilisé la surcharge de Descendants qui prend en paramètre le nom de la balise, car on cherche seulement les liens. Sachez qu'il existe aussi une surcharge sans paramètre, qui récupère tous les descendants, quel que soit leur nom.

III-D - La propriété Attributes et la méthode GetAttributeValue

Voyons maintenant comment récupérer les adresses des liens de la page. L'adresse d'un lien ne se trouve pas dans le contenu de l'élément <a>, mais dans un attribut href. Pour récupérer la valeur d'un élément, on peut utiliser la propriété Attributes, qui renvoie une collection des attributs de l'élément, ou la méthode GetAttributeValue, qui renvoie directement la valeur de l'élément voulu.

Voilà comment utiliser Attributes :

var links = doc.DocumentNode.Descendants("a");
foreach (var link in links)
{
var href = link.Attributes["href"];
if (href != null)
Console.WriteLine(href.Value);
}

Attributes renvoie une collection clés/valeurs d'objets HtmlAttribute. Attributes["x"] renvoie l'attribut x. Si l'attribut x n'existe pas, alors Attributes["x"] renvoie null. Pour obtenir la valeur d'un attribut, on utilise la propriété Value de l'objet HtmlAttribute.

Dans certains cas, il est plus pratique d'utiliser la méthode GetAttributeValue, qui prend en paramètre le nom de l'attribut et la valeur par défaut qui sera renvoyée si l'attribut n'existe pas :

var links = doc.DocumentNode.Descendants("a");
foreach (var link in links)
{
string href = link.GetAttributeValue("href", "(pas d'adresse)");
if (href != null)
Console.WriteLine(href);
}

Notez que, puisque Descendants renvoie une collection de HtmlNode, il est possible d'utiliser Linq pour extraire directement les adresses :

var linkAddresses =
from link in doc.DocumentNode.Descendants("a")
select link.GetAttributeValue("href", "(pas d'adresse)");

Ou encore, si on veut seulement les liens qui ont vraiment une adresse :

var linkAddresses =
from link in doc.DocumentNode.Descendants("a")
let href = link.GetAttributeValue("href", null)
where href != null
select href;

III-E - La méthode GetElementById

Bien souvent, si on sait exactement ce qu'on cherche dans le document, on n'a pas vraiment besoin de le parcourir en entier ; si l'élément recherché a un identifiant, on peut le récupérer directement à partir de cet identifiant, grâce à la méthode GetElementById du HtmlDocument.

Par exemple, si on veut récupérer le tableau dont l'identifiant est Contacts, on peut faire comme ceci :

var table = doc.GetElementbyId("Contacts");

III-F - La méthode Ancestors et la propriété ParentNode

Jusqu'ici, on a parcouru le document HTML en « descendant » depuis la racine du document. Mais si on voulait faire le contraire ? Par exemple, si on voulait savoir quel est l'élément parent de la table Contacts (en l'occurrence l'élément <body>), on pourrait utiliser la propriété ParentNode :

var table = doc.GetElementbyId("Contacts");
var parent = table.ParentNode;

Il existe aussi une méthode Ancestors qui permet de récupérer tous les éléments « au-dessus » de l'élément courant qui ont le nom spécifié :

// Récupère tous les <div> dans lesquels
var table = doc.GetElementbyId("Contacts");
var parentDivs = doc.Ancestors("div") ;

Dans la version actuelle de HAP (1.4.6), il y a un bug dans la surcharge de Ancestors qui ne prend pas de paramètre : elle ne renvoie pas le parent direct, mais seulement les ancêtres à partir du grand-parent. Une solution est d'utiliser à la place AncestorsAndSelf.Skip(1).

III-G - Utilisation de Xpath

L'API de HTML Agility Pack est très simple à utiliser, notamment grâce à sa similitude avec Linq to XML. Cependant, certaines personnes préfèrent utiliser XPath ; et ça tombe bien, HAP supporte aussi cette méthode !

L'API XPath est similaire à ce qu'on trouve dans la classe XmlDocument :

- une méthode SelectSingleNode pour récupérer un élément unique :

// Récupération de la table dont l'id est 'Contacts'
var table = doc.DocumentNode.SelectSingleNode("//table[@id='Contacts']");

- une méthode SelectNodes pour récupérer une collection d'éléments :

// Récupération des liens qui ont la classe 'toto'
var links = doc.DocumentNode.SelectNodes("//a[@class='toto']")

Je n'entrerai pas dans les détails de la syntaxe XPath, puisque c'est standard et qu'il y a déjà de nombreux tutoriels à ce sujet.

III-H - Un exemple (un peu) plus élaboré

Maintenant qu'on connaît les principales méthodes qui permettent de retrouver et d'extraire des informations dans une page web, mettons cela en pratique avec une tâche un peu plus complexe… À la fin de la page d'exemple se trouve un tableau avec une liste de contacts. Supposons qu'on souhaite extraire les contacts pour les mettre dans une liste d'objets.

Définissons tout d'abord une classe Contact :

class Contact
{
public string LastName { get; set; }
public string FirstName { get; set; }
public int Age { get; set; }
}

Observons ensuite la structure HTML de la table :

<table id="Contacts" border="1">
<thead>
<tr>
<th>Nom</th>
<th>Prénom</th>
<th>Age</th>
</tr>
</thead>
<tbody>
<tr>
<td>Tartempion</td>
<td>Gérard</td>
<td>60</td>
</tr>
<tr>
<td>Abitbol</td>
<td>George</td>
<td>42</td>
</tr>
<tr>
<td>Potter</td>
<td>Harry</td>
<td>17</td>
</tr>
<tr>
<td>Wayne</td>
<td>Bruce</td>
<td>30</td>
</tr>
</tbody>
</table>

On note que la table a un identifiant, ce qui va simplifier un peu la recherche ; on peut récupérer la table à l'aide de GetElementById :

var table = doc.GetElementbyId("Contacts");

Puisque la table utilise les balises <thead> et <tbody>, on peut facilement éliminer la ligne d'en-tête en ne prenant en compte que les lignes (<tr>) qui se trouvent dans <tbody> :

var rows = table.Element("tbody").Elements("tr");

Maintenant que nous avons les lignes, il ne nous reste qu'à extraire les contenus des cellules (<td>) de chaque ligne pour construire nos objets Contact. On peut facilement le faire à l'aide d'une requête Linq :

var contacts =
from r in rows
let values = r.Elements("td").Select(c => c.InnerText).ToArray()
select new Contact
{
LastName = values[0],
FirstName = values[1],
Age = int.Parse(values[2])
};

Affichons maintenant le résultat pour vérifier :

foreach (var c in contacts)
{
Console.WriteLine(
"{0} {1} ({2} ans)",
c.FirstName,
c.LastName,
c.Age);
}

Et on obtient bien la même chose que dans le tableau :

Gérard Tartempion (60 ans)
George Abitbol (42 ans)
Harry Potter (17 ans)
Bruce Wayne (30 ans)

Essayez maintenant d'imaginer le code que vous auriez dû écrire pour faire la même chose avec des expressions régulières ou des manipulations de chaines. Ça aurait sans doute pris un temps fou, le code serait illisible, et contiendrait probablement des bugs…

Conclusion

Ainsi se termine cette présentation de HTML Agility Pack. J'espère que le dernier exemple vous aura convaincu de l'intérêt d'utiliser cet outil pour extraire des données d'une page web. Notez que, bien que l'article n'ait pas abordé cet aspect, HAP permet également de modifier des pages HTML. Ça pourra éventuellement faire l'objet d'un futur article !

Remerciements

Je tiens à remercier Chtulus et Jean-Michel Ormes pour leurs remarques, ainsi que Torgar pour la correction orthographique.