Un scraper extrait des champs d'une page dont vous avez déjà l'URL. Un crawler est ce qui trouve ces URL en premier lieu : il part d'une page de départ, suit les liens qu'il découvre, et continue jusqu'à avoir couvert la partie d'un site qui vous intéresse. Si vous voulez cartographier une arborescence de documentation, collecter toutes les pages produit d'une catégorie, ou alimenter un index de recherche, vous avez besoin du second type de programme.
Ce guide vous montre comment construire un crawler web en Java depuis le début. Vous utiliserez le HttpClient moderne (Java 11+) pour récupérer des pages, Jsoup pour les parser et en extraire les liens, et une file d'attente de frontier avec un ensemble de pages visitées pour piloter un crawl en largeur d'abord. Nous ajoutons les contrôles qui distinguent un jouet de quelque chose que vous pouvez réellement exécuter : une limite de profondeur, une restriction au même domaine et des délais de politesse. Ensuite, nous couvrons la partie que tout crawl réel rencontre, le rendu JavaScript et les blocages IP, et comment les gérer sans réécrire le crawler.
Crawler vs. scraper : quelle est la vraie différence
Les deux termes sont utilisés de manière interchangeable, mais les programmes ont des formes différentes. Un scraper prend une URL connue, la récupère une fois et en extrait des données. Un crawler prend une URL de départ, la récupère, extrait les liens et ajoute les nouveaux à une file d'attente de pages à visiter. Il répète cette boucle, de sorte que l'ensemble des pages qu'il touche grandit au fur et à mesure. L'extraction fait toujours partie du travail, mais la caractéristique définissante est la découverte et le parcours de liens.
Cette différence guide chaque décision de conception ci-dessous. Parce qu'un crawler découvre son propre travail, il a besoin d'une file d'attente (la frontier) pour contenir les URL en attente, d'un ensemble pour se souvenir de ce qu'il a déjà vu afin de ne pas boucler indéfiniment, et de limites pour ne pas s'aventurer sur tout internet. Obtenez ces trois éléments correctement et le reste n'est que récupération et parsing.
Ce que vous allez construire
Une seule classe Java exécutable qui prend une URL de départ et crawle vers l'extérieur au sein d'un domaine. Elle effectue un parcours en largeur d'abord, s'arrête à une profondeur configurable, saute les pages déjà visitées et fait des pauses polies entre les requêtes. Pour chaque page, elle affiche l'URL et le titre, que vous pouvez remplacer par n'importe quelle extraction dont vous avez besoin.
- File d'attente frontier une file FIFO d'URL en attente d'être récupérées, associée à la profondeur à laquelle chacune a été trouvée.
-
Ensemble des pages visitées un
Setd'URL déjà traitées, pour que le crawler ne récupère jamais deux fois la même page. - Limite de profondeur un plafond strict sur le nombre de sauts de lien depuis le départ que le crawler suivra.
- Garde du même domaine une vérification qui maintient le crawl sur l'hôte depuis lequel vous avez commencé.
- Délai de politesse une pause entre les requêtes pour ne pas marteler la cible.
Prérequis
Vous avez besoin de quelques éléments en place avant d'écrire du code, et aucun d'eux ne prend longtemps.
Java 11 ou ultérieur. Le crawler utilise l'API java.net.http.HttpClient introduite dans Java 11, donc confirmez votre version avec java -version. N'importe quelle distribution JDK 11+ fonctionne.
Jsoup. Jsoup est la bibliothèque Java standard pour parser le HTML et sélectionner des éléments avec des sélecteurs CSS. Avec Maven, ajoutez la dépendance ci-dessous ; avec Gradle ou un classpath simple, récupérez le jar équivalent.
<dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.17.2</version> </dependency>
Java de base. Vous devez être à l'aise pour compiler et exécuter une classe et lire une trace de pile. Rien ici n'utilise de frameworks au-delà du JDK et de Jsoup.
Étape 1 : Récupérer une page avec HttpClient
Commencez par la pièce la plus petite et utile : une méthode qui prend une URL et renvoie le HTML sous forme de chaîne. L'API HttpClient utilise un pattern builder, prend en charge HTTP/2 par défaut et expose un appel synchrone send qui bloque jusqu'à l'arrivée de la réponse. Un délai d'expiration de requête empêche un hôte lent ou mort de bloquer tout le crawl.
import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; private static final HttpClient CLIENT = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); static String fetch(String url) throws Exception { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(20)) .header("User-Agent", "MyJavaCrawler/1.0") .GET() .build(); HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 200) { return response.body(); } System.out.println("Skipping " + url + " -> " + response.statusCode()); return null; }
Le client statique unique est intentionnel : HttpClient est immuable et thread-safe, donc vous en créez un et le réutilisez pour chaque requête plutôt que d'en construire un par récupération. Définir un User-Agent est une marque de politesse et souvent requis, et vérifier le code de statut avant de renvoyer le corps maintient les échecs visibles au lieu d'alimenter du HTML vide dans le parser.
Étape 2 : Parser les liens avec Jsoup
La récupération vous donne une chaîne de HTML. Pour crawler, vous avez besoin des liens à l'intérieur. Jsoup parse le HTML dans un document et vous permet de sélectionner des éléments avec des sélecteurs CSS. Le sélecteur a[href] récupère chaque ancre avec un href, et la méthode absUrl de Jsoup résout les liens relatifs comme /about en URL absolus par rapport à la page sur laquelle ils ont été trouvés, ce qui est exactement ce dont la frontier a besoin.
import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import java.util.ArrayList; import java.util.List; static List<String> extractLinks(String html, String baseUrl) { List<String> links = new ArrayList<>(); Document doc = Jsoup.parse(html, baseUrl); System.out.println("Title: " + doc.title()); Elements anchors = doc.select("a[href]"); for (Element a : anchors) { String absolute = a.absUrl("href"); if (!absolute.isBlank()) { links.add(absolute); } } return links; }
Passer baseUrl à Jsoup.parse est ce qui fait fonctionner absUrl ; sans cela, les hrefs relatifs ne se résolvent à rien. Le même document est l'endroit où vous feriez votre vraie extraction, avec des sélecteurs comme doc.select("h1") ou doc.select(".price"). Ici, nous affichons le titre pour que vous puissiez observer le crawl se déplacer de page en page. Si vous voulez un regard plus approfondi sur la sélection et l'extraction de champs en Java, le guide sur le scraping web avec Java couvre le côté parsing en détail.
Étape 3 : Piloter le crawl avec une frontier et un ensemble de pages visitées
Voici le noyau. Le crawl en largeur d'abord signifie que vous visitez les pages par vagues : d'abord le départ, puis tout ce qui se trouve à un saut, puis deux sauts, et ainsi de suite. Une file FIFO vous donne cet ordre gratuitement. Chaque entrée de file associe une URL à la profondeur à laquelle elle a été découverte, afin que vous sachiez quand arrêter de suivre ses enfants. Un HashSet d'URL visitées prévient les cycles, et une vérification du même domaine maintient le crawl limité à un seul hôte.
import java.net.URI; import java.util.ArrayDeque; import java.util.HashSet; import java.util.Queue; import java.util.Set; static class Task { final String url; final int depth; Task(String url, int depth) { this.url = url; this.depth = depth; } } static boolean sameHost(String url, String host) { try { return host.equalsIgnoreCase(URI.create(url).getHost()); } catch (Exception e) { return false; } } static void crawl(String seed, int maxDepth, long delayMs) throws Exception { String host = URI.create(seed).getHost(); Queue<Task> frontier = new ArrayDeque<>(); Set<String> visited = new HashSet<>(); frontier.add(new Task(seed, 0)); visited.add(seed); while (!frontier.isEmpty()) { Task task = frontier.poll(); System.out.println("[" + task.depth + "] " + task.url); String html = fetch(task.url); if (html == null || task.depth >= maxDepth) continue; for (String link : extractLinks(html, task.url)) { if (sameHost(link, host) && visited.add(link)) { frontier.add(new Task(link, task.depth + 1)); } } Thread.sleep(delayMs); } }
Quelques détails portent leur poids ici. visited.add(link) renvoie false si l'URL était déjà présente, donc un seul appel vérifie à la fois l'appartenance et enregistre l'URL, ce qui explique pourquoi la garde lit sameHost(link, host) && visited.add(link). La vérification de profondeur se produit après la récupération mais avant la mise en file d'attente des enfants, donc les pages à maxDepth sont quand même récupérées et parsées, elles ne contribuent simplement pas de nouveaux liens. Et Thread.sleep(delayMs) est la pause de politesse : c'est la différence entre un crawler qu'un site tolère et un qu'il bloque.
Étape 4 : Un crawler complet et exécutable
Assemblez les pièces en une seule classe avec une méthode main. Compilez-la avec Jsoup dans le classpath et exécutez-la contre une URL de départ, une profondeur et un délai.
import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.*; public class WebCrawler { private static final HttpClient CLIENT = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); record Task(String url, int depth) {} static String fetch(String url) throws Exception { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(20)) .header("User-Agent", "MyJavaCrawler/1.0") .GET() .build(); HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); return response.statusCode() == 200 ? response.body() : null; } static List<String> extractLinks(String html, String baseUrl) { List<String> links = new ArrayList<>(); Document doc = Jsoup.parse(html, baseUrl); System.out.println(" Title: " + doc.title()); Elements anchors = doc.select("a[href]"); for (Element a : anchors) { String absolute = a.absUrl("href"); if (!absolute.isBlank()) links.add(absolute); } return links; } static boolean sameHost(String url, String host) { try { return host.equalsIgnoreCase(URI.create(url).getHost()); } catch (Exception e) { return false; } } static void crawl(String seed, int maxDepth, long delayMs) throws Exception { String host = URI.create(seed).getHost(); Queue<Task> frontier = new ArrayDeque<>(); Set<String> visited = new HashSet<>(); frontier.add(new Task(seed, 0)); visited.add(seed); while (!frontier.isEmpty()) { Task task = frontier.poll(); System.out.println("[" + task.depth() + "] " + task.url()); String html = fetch(task.url()); if (html == null || task.depth() >= maxDepth) continue; for (String link : extractLinks(html, task.url())) { if (sameHost(link, host) && visited.add(link)) { frontier.add(new Task(link, task.depth() + 1)); } } Thread.sleep(delayMs); } } public static void main(String[] args) throws Exception { crawl("https://books.toscrape.com/", 2, 1000); } }
C'est un crawler en largeur d'abord fonctionnel en bien moins de 100 lignes. Il utilise un record Java pour la paire de tâches, récupère avec un client réutilisé, parse avec Jsoup, reste sur un hôte, déduplique avec un ensemble, s'arrête à la profondeur 2 et attend une seconde entre les requêtes. Pointez-le d'abord vers un bac à sable comme books.toscrape.com, puis remplacez par votre propre départ et extraction.
Avant de crawler un site que vous ne possédez pas, lisez son robots.txt et ses conditions d'utilisation, et traitez-les comme la limite. Respectez les directives de délai de crawl, ignorez les chemins interdits, et maintenez votre taux de requêtes suffisamment bas pour ne pas surcharger le serveur. Un crawler poli qui recule est celui qui continue de fonctionner ; un crawler agressif se fait bloquer et peut causer de réels dommages.
Où un crawler fait maison se heurte à un mur
Le crawler ci-dessus est correct, et sur un site statique et bien comporté il fonctionnera bien. Deux choses le brisent sur le web moderne, et toutes deux sont assez courantes pour que vous les rencontriez rapidement.
Rendu JavaScript. Votre récupération renvoie le HTML brut que le serveur envoie. De nombreux sites construisent leur contenu dans le navigateur avec React, Angular ou Vue, donc ce HTML brut est une coquille presque vide. Jsoup parse exactement ce qu'on lui donne et ne peut pas exécuter JavaScript, donc sur ces pages a[href] trouve peu ou pas de liens et le crawl meurt au départ. Vous auriez besoin d'un navigateur headless pour rendre la page d'abord. Le crawling de sites qui dépendent du rendu côté client est son propre sujet, couvert dans le guide sur comment crawler les sites web lourds en JavaScript.
Blocages IP. Un crawler effectue de nombreuses requêtes depuis une adresse en peu de temps, ce qui est exactement le schéma que les systèmes anti-bot recherchent. Les IP de centres de données sont challengées, limitées en débit ou bannies, et une fois votre adresse signalée, le crawl s'arrête peu importe la politesse de votre délai. Répartir les requêtes sur un pool d'IP résidentielles évite la signature d'une adresse unique, mais construire et maintenir ce pool représente la majorité du travail. Le guide plus large se trouve dans comment scraper des sites web sans se faire bloquer.
Le tournant d'échelle : rendre et faire pivoter côté serveur
Vous pouvez résoudre les deux problèmes sans complexifier le crawler. Au lieu de récupérer directement l'URL cible, acheminez la récupération via un endpoint API qui rend la page dans un vrai navigateur et la sert depuis une IP résidentielle rotative, puis vous remet le HTML final. Votre logique de crawler, la frontier, l'ensemble des pages visitées, la limite de profondeur, la garde du même domaine, reste exactement la même. Seule la méthode fetch change.
La Crawling API est un simple GET HTTP : vous passez votre token et l'URL cible comme paramètres de requête, ajoutez un indicateur pour demander le rendu JavaScript, et lisez le corps de la réponse. Parce que c'est juste un GET, il s'intègre directement dans le code HttpClient que vous avez déjà écrit.
import java.net.URLEncoder; import java.nio.charset.StandardCharsets; private static final String TOKEN = "YOUR_CRAWLBASE_JS_TOKEN"; static String fetch(String url) throws Exception { String target = URLEncoder.encode(url, StandardCharsets.UTF_8); String endpoint = "https://api.crawlbase.com/?token=" + TOKEN + "&page_wait=3000&url=" + target; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(endpoint)) .timeout(Duration.ofSeconds(90)) .GET() .build(); HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); return response.statusCode() == 200 ? response.body() : null; }
Deux changements portent la charge. L'URL cible est encodée avec URLEncoder avant d'entrer dans la chaîne de requête, car Crawlbase exige que l'URL interne soit encodée pour que ses propres paramètres soient parsés proprement. Et page_wait=3000 indique à l'API d'attendre trois secondes après le chargement pour que le contenu à rendu tardif soit capturé avant la saisie du HTML, ce qui transforme une coquille JavaScript en la page entièrement construite dont votre parser a besoin. Le token JS active le rendu ; la rotation se produit côté serveur, donc l'adresse que la cible voit est une vraie IP résidentielle, pas celle de votre crawler. Un délai d'expiration client plus long tient compte du fait que l'étape de rendu prend plus de temps qu'une simple récupération.
Gardez votre crawler Java simple et laissez le rendu et la rotation IP se produire côté serveur. La Crawling API prend un token et une URL cible via un simple GET, exécute la page dans un vrai navigateur, fait pivoter les IP résidentielles, et renvoie le HTML final, de sorte que votre frontier, ensemble de pages visitées et parser ne changent jamais. Intégrez-la dans la méthode fetch et crawlez les sites lourds en JavaScript sans faire tourner une flotte headless ou un pool de proxies vous-même.
Durcir le crawler pour de vraies exécutions
Quelques ajouts amènent la démo vers la production. Aucun ne change la boucle principale ; ils lui permettent de survivre au contact de sites désordonnés.
-
Normaliser les URL avant la déduplication. Supprimez les fragments (
#section) et les barres obliques finales afin que/pageet/page#topcomptent comme une seule URL. Sans cela, votre ensemble de pages visitées se gonfle et vous récupérez le même contenu plusieurs fois. -
Filtrer les liens non HTML. Ignorez les hrefs se terminant par
.pdf,.jpg,.zipet similaires avant la mise en file d'attente, afin que le crawler n'essaie pas de parser des binaires comme du HTML. - Plafonner le nombre total de pages. La profondeur limite la portée en largeur d'abord, mais un site large peut quand même mettre en file d'attente des milliers de pages. Un plafond strict sur la taille des pages visitées est une simple soupape de sécurité.
- Gérer les erreurs par page. Encapsulez chaque récupération-et-parse dans un try/catch afin qu'une mauvaise URL soit journalisée et que le crawler continue plutôt que de tuer tout le crawl.
- Persister la frontier. Pour les longs crawls, sauvegardez la file d'attente et l'ensemble des pages visitées dans un fichier ou une base de données afin qu'un crash ne fasse pas perdre la progression et que vous puissiez reprendre.
Si vous voulez un vrai parallélisme, la même structure s'étend à un pool de threads : remplacez le HashSet par un ConcurrentHashMap.newKeySet() et l'ArrayDeque par une ConcurrentLinkedQueue, puis exécutez plusieurs threads de travail qui puisent dans la frontier. Gardez le délai de politesse par hôte pour que la concurrence ne se transforme pas en déluge. Pour les charges de travail où vous soumettez de nombreuses URL et collectez les résultats au fur et à mesure qu'ils se terminent plutôt que de bloquer sur chacun, le Crawler asynchrone gère la mise en file d'attente et les callbacks pour vous.
Points clés
- Un crawler découvre ses propres URL. Les éléments définissants sont une file d'attente frontier, un ensemble de pages visitées et des limites, pas l'extraction.
-
HttpClient plus Jsoup est la stack principale. Le
HttpClientde Java 11 récupère, Jsoup parse le HTML et résout les liens avecabsUrl. -
Le parcours en largeur d'abord nécessite trois gardes. Une frontier FIFO avec profondeur, un
HashSetd'URL visitées et une vérification du même domaine maintiennent le crawl limité et sans boucles. -
Soyez poli. Ajoutez un délai entre les requêtes, définissez un
User-Agenthonnête, et respectezrobots.txtet les conditions d'utilisation. - Le rendu JS et les blocages IP sont là où le fait maison échoue. Acheminer la récupération via la Crawling API gère les deux côté serveur et laisse la logique du crawler intacte.
Foire aux questions
Quelle est la différence entre un crawler web et un scraper web ?
Un scraper extrait des données depuis des URL que vous avez déjà. Un crawler part d'une URL de départ, suit les liens qu'il trouve et découvre de nouvelles pages au fur et à mesure, de sorte que l'ensemble des pages qu'il visite grandit avec le temps. L'extraction fait toujours partie du crawling, mais les caractéristiques définissantes sont la découverte et le parcours de liens, ce qui explique pourquoi un crawler a besoin d'une file d'attente frontier et d'un ensemble de pages visitées qu'un simple scraper n'a pas.
Dois-je utiliser HttpClient ou Jsoup pour récupérer des pages en Java ?
Utilisez les deux pour ce que chacun fait le mieux. Le HttpClient de Java 11 vous donne un contrôle précis sur la requête : délais d'expiration, en-têtes, politique de redirection et envois synchrones ou asynchrones. Jsoup est conçu pour parser le HTML renvoyé et sélectionner des éléments avec des sélecteurs CSS. Jsoup peut récupérer par lui-même, mais associer un client HTTP dédié à Jsoup comme parser sépare les deux préoccupations et est plus facile à étendre, par exemple pour acheminer les récupérations via une API plus tard.
Comment empêcher mon crawler de boucler indéfiniment ?
Maintenez un Set des URL que vous avez déjà mises en file d'attente et vérifiez-le avant d'ajouter un nouveau lien. Dans l'exemple, visited.add(link) renvoie false quand l'URL est déjà présente, donc un seul appel teste à la fois l'appartenance et enregistre l'URL. Combiné avec une limite de profondeur et une garde du même domaine, cela empêche les cycles et arrête le crawl de vagabonder sur tout le web.
Pourquoi mon crawler Java renvoie-t-il des liens vides ou manquants ?
La cause la plus probable est le rendu JavaScript. Votre récupération renvoie le HTML brut que le serveur envoie, et de nombreux sites construisent leur contenu dans le navigateur avec des frameworks comme React ou Angular, donc ce HTML brut est une coquille presque vide avec peu ou pas d'ancres. Jsoup ne peut pas exécuter JavaScript, donc il ne trouve rien à suivre. Rendez la page d'abord avec un navigateur headless, ou acheminez la récupération via la Crawling API avec le rendu activé pour que le HTML soit entièrement construit avant de le parser.
Comment éviter d'être bloqué pendant le crawling ?
Cadencez vos requêtes avec un délai, définissez un User-Agent honnête, respectez robots.txt et les directives de délai de crawl, et maintenez le volume par hôte à un niveau raisonnable. Le problème plus difficile est la réputation IP : de nombreuses requêtes depuis une adresse de centre de données sont signalées rapidement. Répartir les requêtes sur des IP résidentielles rotatives évite cette signature. La Crawling API gère la rotation pour vous ; si vous construisez votre propre stack, c'est la partie sur laquelle investir.
Puis-je faire fonctionner un crawler Java en parallèle ?
Oui. La structure en largeur d'abord s'étend à plusieurs threads : remplacez le HashSet par ConcurrentHashMap.newKeySet() et l'ArrayDeque par une file thread-safe comme ConcurrentLinkedQueue, puis exécutez plusieurs workers qui puisent dans la frontier. Gardez un délai de politesse par hôte pour que la concurrence ne se transforme pas en déluge, et envisagez le Crawler asynchrone pour les charges de travail où vous soumettez de nombreuses URL et rassemblez les résultats au fur et à mesure qu'ils se terminent.
Crawlez n'importe quel site à grande échelle, sans combattre l'infrastructure.
Crawlbase gère les proxies, les empreintes et les CAPTCHA afin que votre équipe livre des pipelines de données au lieu de maintenir la plomberie de crawl. 1 000 requêtes gratuites, sans carte requise.
