Парсер извлекает поля с одной страницы, URL которой у вас уже есть. Краулер, это то, что находит эти URL: он начинает с начальной страницы, следует по ссылкам, которые обнаруживает, и продолжает до тех пор, пока не охватит нужную вам часть сайта. Если вы хотите составить карту дерева документации, собрать все страницы товаров в категории или наполнить поисковый индекс, вам нужна программа второго типа.
Это руководство показывает, как построить веб-краулер на Java с нуля. Вы будете использовать современный HttpClient (Java 11+) для получения страниц, Jsoup для их парсинга и извлечения ссылок, а также очередь обхода с множеством посещённых страниц для управления поиском в ширину. Мы добавим элементы управления, которые отличают игрушку от чего-то, что можно реально запустить: ограничение глубины, ограничение одним доменом и задержки для вежливости. Затем рассмотрим то, с чем сталкивается каждый реальный обход: рендеринг JavaScript и IP-блокировки, и как с этим справиться без переписывания краулера.
Краулер vs парсер: в чём реальная разница
Эти два слова используются как синонимы, но программы имеют разную форму. Парсер берёт известный URL, запрашивает его один раз и извлекает данные. Краулер берёт начальный URL, запрашивает его, извлекает ссылки и добавляет новые в очередь страниц, которые ещё нужно посетить. Он повторяет этот цикл, поэтому набор страниц, которые он затрагивает, растёт по мере работы. Извлечение по-прежнему является частью задачи, но определяющей чертой является обнаружение и обход ссылок.
Это различие определяет каждое дизайнерское решение ниже. Поскольку краулер сам обнаруживает работу, ему нужна очередь (фронтир) для хранения ожидающих URL, множество для запоминания уже посещённого, чтобы не зацикливаться, и ограничения, чтобы не блуждать по всему интернету. Правильно реализуйте эти три компонента, и остальное, только получение данных и парсинг.
Что вы построите
Единственный запускаемый класс Java, который принимает начальный URL и обходит сайт в пределах одного домена. Он выполняет обход в ширину, останавливается на настраиваемой глубине, пропускает уже посещённые страницы и делает вежливые паузы между запросами. Для каждой страницы он выводит URL и заголовок, которые вы можете заменить любым нужным извлечением.
- Очередь фронтира, FIFO-очередь URL, ожидающих получения, в паре с глубиной, на которой каждый был найден.
-
Множество посещённых,
Setуже обработанных URL, чтобы краулер никогда не запрашивал одну и ту же страницу дважды. - Ограничение глубины, жёсткое ограничение на количество переходов по ссылкам от начальной страницы, которые краулер будет следовать.
- Ограничение одним доменом, проверка, которая удерживает обход на хосте, с которого вы начали.
- Задержка вежливости, пауза между запросами, чтобы не перегружать цель.
Предварительные требования
Перед написанием кода нужно подготовить несколько вещей, и это не займёт много времени.
Java 11 или выше. Краулер использует API java.net.http.HttpClient, представленный в Java 11, поэтому проверьте свою версию командой java -version. Подойдёт любой дистрибутив JDK 11+.
Jsoup. Jsoup, стандартная библиотека Java для парсинга HTML и выбора элементов с помощью CSS-селекторов. С Maven добавьте зависимость ниже; с Gradle или простым classpath подтяните эквивалентный jar.
<dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.17.2</version> </dependency>
Базовые знания Java. Вы должны уметь компилировать и запускать класс и читать трассировку стека. Здесь не используется ничего, кроме JDK и Jsoup.
Шаг 1: Получение страницы с помощью HttpClient
Начните с наименьшего полезного компонента: метода, который принимает URL и возвращает HTML в виде строки. API HttpClient использует паттерн строителя, по умолчанию поддерживает HTTP/2 и предоставляет синхронный вызов send, который блокируется до получения ответа. Таймаут запроса не даёт медленному или недоступному хосту подвесить весь обход.
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; }
Единственный статический клиент создан намеренно: HttpClient неизменяем и потокобезопасен, поэтому вы создаёте его один раз и переиспользуете для каждого запроса, а не строите по одному на каждое получение данных. Установка User-Agent, это хороший тон, и она нередко обязательна, а проверка кода статуса перед возвратом тела позволяет сбоям оставаться видимыми, а не передавать пустой HTML парсеру.
Шаг 2: Парсинг ссылок с помощью Jsoup
Получение данных даёт вам строку HTML. Для обхода вам нужны содержащиеся в ней ссылки. Jsoup парсит HTML в документ и позволяет выбирать элементы с помощью CSS-селекторов. Селектор a[href] захватывает каждый якорный элемент с href, а метод absUrl Jsoup преобразует относительные ссылки, такие как /about, в абсолютные URL относительно страницы, на которой они были найдены, что именно то, что нужно фронтиру.
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; }
Передача baseUrl в Jsoup.parse, это то, что заставляет absUrl работать; без неё относительные href ни к чему не разрешаются. В том же документе вы бы выполняли реальное извлечение с помощью таких селекторов, как doc.select("h1") или doc.select(".price"). Здесь мы выводим заголовок, чтобы вы могли наблюдать, как краулер переходит со страницы на страницу. Если вы хотите более подробно рассмотреть выбор и извлечение полей на Java, гид по веб-скрапингу на Java подробно охватывает сторону парсинга.
Шаг 3: Управление обходом с помощью фронтира и множества посещённых
Теперь ядро. Обход в ширину означает посещение страниц волнами: сначала начальная, затем всё на расстоянии одного перехода, затем двух и так далее. FIFO-очередь даёт вам такой порядок бесплатно. Каждая запись очереди содержит URL в паре с глубиной, на которой он был обнаружен, чтобы вы знали, когда прекратить следовать его дочерним страницам. HashSet посещённых URL предотвращает циклы, а проверка одного домена ограничивает обход одним хостом.
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); } }
Несколько деталей здесь выполняют важную работу. visited.add(link) возвращает false, если URL уже присутствует, поэтому один вызов и проверяет принадлежность, и записывает URL, именно поэтому условие читается как sameHost(link, host) && visited.add(link). Проверка глубины происходит после получения данных, но до постановки дочерних страниц в очередь, поэтому страницы на maxDepth всё ещё получаются и парсятся, они просто не добавляют новые ссылки. А Thread.sleep(delayMs), это пауза вежливости: она отличает краулер, который сайт терпит, от того, который он блокирует.
Шаг 4: Полный запускаемый краулер
Соберите части в один класс с методом main. Скомпилируйте его с Jsoup в classpath и запустите против начального URL, глубины и задержки.
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); } }
Это рабочий краулер с обходом в ширину менее чем в 100 строках. Он использует Java record для пары задачи, получает данные с помощью переиспользуемого клиента, парсит с помощью Jsoup, остаётся на одном хосте, дедуплицирует с помощью множества, останавливается на глубине 2 и ждёт одну секунду между запросами. Сначала направьте его на песочницу вроде books.toscrape.com, а затем замените начальный URL и извлечение своими.
Перед обходом сайта, которым вы не владеете, прочитайте его robots.txt и условия использования, и относитесь к ним как к границе. Соблюдайте директивы crawl-delay, пропускайте запрещённые пути и поддерживайте достаточно низкую частоту запросов, чтобы не нагружать сервер. Вежливый краулер, который делает паузы, продолжает работу; агрессивный, блокируется и может причинить реальный вред.
Где самодельный краулер упирается в стену
Описанный выше краулер корректен, и на статичном, хорошо ведущем себя сайте он будет работать нормально. Две вещи ломают его в современном вебе, и обе достаточно распространены, чтобы вы с ними быстро столкнулись.
Рендеринг JavaScript. Ваш запрос возвращает необработанный HTML, который отправляет сервер. Многие сайты строят свой контент в браузере с помощью React, Angular или Vue, поэтому этот необработанный HTML является почти пустым шаблоном. Jsoup парсит именно то, что ему дают, и не может выполнять JavaScript, поэтому на таких страницах a[href] находит мало ссылок или вообще не находит, и обход останавливается на начальной странице. Вам понадобится headless-браузер для предварительного рендеринга страницы. Обход сайтов, зависящих от клиентского рендеринга, отдельная тема, рассмотренная в гиде о том, как обходить сайты с интенсивным использованием JavaScript.
IP-блокировки. Краулер делает множество запросов с одного адреса в короткий промежуток времени, что именно тот паттерн, который ищут антибот-системы. IP дата-центров получают вызовы, ограничиваются по скорости или блокируются, и как только ваш адрес помечен, обход останавливается независимо от того, насколько вежливая у вас задержка. Распределение запросов по пулу резидентных IP позволяет избежать подписи одного адреса, но создание и поддержание этого пула, большая часть работы. Более широкое руководство находится в как парсить сайты без блокировок.
Поворот к масштабированию: рендеринг и ротация на стороне сервера
Вы можете решить обе проблемы, не усложняя краулер. Вместо того чтобы напрямую запрашивать целевой URL, направьте запрос через эндпоинт API, который рендерит страницу в реальном браузере и обслуживает её с ротирующего резидентного IP, а затем возвращает вам готовый HTML. Ваша логика краулера (фронтир, множество посещённых, ограничение глубины, ограничение одним доменом) остаётся точно такой же. Меняется только метод fetch.
Crawling API, это простой HTTP GET: вы передаёте свой токен и целевой URL в качестве параметров запроса, добавляете флаг для запроса рендеринга JavaScript и читаете тело ответа. Поскольку это просто GET, он органично вписывается в уже написанный вами код HttpClient.
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; }
Два изменения несут основную нагрузку. Целевой URL кодируется с помощью URLEncoder перед добавлением в строку запроса, потому что Crawlbase требует кодирования внутреннего URL, чтобы его собственные параметры корректно парсились. А page_wait=3000 говорит API ждать три секунды после загрузки для контента с поздним рендерингом, прежде чем захватить HTML, что превращает JavaScript-шаблон в полностью построенную страницу, необходимую вашему парсеру. JS-токен включает рендеринг; ротация происходит на стороне сервера, поэтому адрес, который видит цель, является реальным резидентным IP, а не вашим краулером. Более длинный клиентский таймаут учитывает, что шаг рендеринга занимает больше времени, чем простой запрос.
Держите Java-краулер простым и позвольте рендерингу и ротации IP происходить на стороне сервера. Crawling API принимает токен и целевой URL через простой GET, запускает страницу в реальном браузере, ротирует через резидентные IP и возвращает готовый HTML, так что ваш фронтир, множество посещённых и парсер никогда не меняются. Подключите его к методу fetch и обходите JavaScript-сайты без запуска headless-парка или пула прокси самостоятельно.
Закалка краулера для реальных запусков
Несколько дополнений приближают демо к продакшну. Ни одно из них не меняет основной цикл; они заставляют его выдерживать контакт с проблемными сайтами.
-
Нормализуйте URL перед дедупликацией. Удаляйте фрагменты (
#section) и завершающие слэши, чтобы/pageи/page#topсчитались одним URL. Без этого ваше множество посещённых разрастается, и вы повторно запрашиваете тот же контент. -
Фильтруйте не-HTML ссылки. Пропускайте href, заканчивающиеся на
.pdf,.jpg,.zipи тому подобные перед постановкой в очередь, чтобы краулер не пытался парсить бинарные файлы как HTML. - Ограничьте общее количество страниц. Глубина ограничивает охват в ширину, но широкий сайт всё равно может ставить в очередь тысячи страниц. Жёсткий потолок на размере посещённых, простой предохранительный клапан.
- Обрабатывайте ошибки на уровне страницы. Оборачивайте каждый запрос-парсинг в try/catch, чтобы один плохой URL записывался в лог и продолжалось выполнение, а не убивал весь обход.
- Сохраняйте фронтир. Для длительных обходов поддерживайте очередь и множество посещённых с помощью файла или базы данных, чтобы сбой не потерял прогресс и вы могли возобновить работу.
Если вам нужен настоящий параллелизм, та же структура расширяется до пула потоков: замените HashSet на ConcurrentHashMap.newKeySet(), а ArrayDeque на ConcurrentLinkedQueue, затем запустите несколько рабочих потоков, извлекающих из фронтира. Соблюдайте задержку вежливости на уровне хоста, чтобы параллелизм не превращался в поток. Для рабочих нагрузок, где вы отправляете множество URL и собираете результаты по мере их завершения, а не блокируетесь на каждом, асинхронный Crawler обрабатывает постановку в очередь и коллбэки за вас.
Ключевые выводы
- Краулер сам обнаруживает свои URL. Определяющие части, это очередь фронтира, множество посещённых и ограничения, а не извлечение.
-
HttpClient плюс Jsoup, основной стек.
HttpClientиз Java 11 получает данные, Jsoup парсит HTML и разрешает ссылки с помощьюabsUrl. -
Для обхода в ширину нужны три защиты. FIFO-фронтир с глубиной,
HashSetпосещённых URL и проверка одного домена удерживают обход ограниченным и без циклов. -
Будьте вежливы. Делайте паузы между запросами, устанавливайте честный
User-Agentи соблюдайтеrobots.txtи условия использования. - Рендеринг JS и IP-блокировки, где самодельный подход ломается. Направление запроса через Crawling API решает обе проблемы на стороне сервера и оставляет логику краулера нетронутой.
Часто задаваемые вопросы
В чём разница между веб-краулером и веб-парсером?
Парсер извлекает данные из URL, которые у вас уже есть. Краулер начинает с начального URL, следует по найденным ссылкам и обнаруживает новые страницы по мере работы, поэтому набор страниц, которые он посещает, растёт со временем. Извлечение по-прежнему является частью обхода, но определяющими чертами являются обнаружение ссылок и их обход, именно поэтому краулеру нужны очередь фронтира и множество посещённых, которых не требует одиночный парсер.
Что лучше использовать для получения страниц в Java: HttpClient или Jsoup?
Используйте оба для того, в чём каждый лучше. HttpClient из Java 11 даёт вам тонкий контроль над запросом: таймауты, заголовки, политика перенаправлений и синхронная или асинхронная отправка. Jsoup предназначен для парсинга возвращённого HTML и выбора элементов с помощью CSS-селекторов. Jsoup может получать данные самостоятельно, но сочетание выделенного HTTP-клиента с Jsoup в качестве парсера разделяет две задачи и упрощает расширение, например направление запросов через API позднее.
Как предотвратить бесконечный цикл в краулере?
Поддерживайте Set URL, которые вы уже поставили в очередь, и проверяйте его перед добавлением новой ссылки. В примере visited.add(link) возвращает false, когда URL уже присутствует, поэтому один вызов и проверяет принадлежность, и записывает URL. В сочетании с ограничением глубины и проверкой одного домена это предотвращает циклы и останавливает обход от блужданий по всему вебу.
Почему мой Java-краулер возвращает пустые или отсутствующие ссылки?
Наиболее вероятная причина, рендеринг JavaScript. Ваш запрос возвращает необработанный HTML, который отправляет сервер, и многие сайты строят свой контент в браузере с помощью таких фреймворков, как React или Angular, поэтому этот необработанный HTML является почти пустым шаблоном без якорей или с очень малым их количеством. Jsoup не может выполнять JavaScript, поэтому не находит ничего для следования. Сначала отрендерите страницу с помощью headless-браузера или направьте запрос через Crawling API с включённым рендерингом, чтобы HTML был полностью построен перед его парсингом.
Как избежать блокировки при обходе?
Делайте паузы между запросами, устанавливайте честный User-Agent, соблюдайте robots.txt и директивы crawl-delay, поддерживайте разумный объём на хост. Более сложная проблема, репутация IP: множество запросов с одного адреса дата-центра быстро помечается. Распределение запросов по ротирующим резидентным IP позволяет избежать этой подписи. Crawling API обрабатывает ротацию за вас; если вы строите собственный стек, именно сюда стоит инвестировать.
Можно ли запустить Java-краулер параллельно?
Да. Структура обхода в ширину расширяется до нескольких потоков: замените HashSet на ConcurrentHashMap.newKeySet(), а ArrayDeque на потокобезопасную очередь, такую как ConcurrentLinkedQueue, затем запустите несколько рабочих потоков, извлекающих из фронтира. Соблюдайте задержку вежливости на уровне хоста, чтобы параллелизм не превращался в поток, и рассмотрите асинхронный Crawler для рабочих нагрузок «отправить и собрать», где вы передаёте много URL и собираете результаты по мере их завершения.
Обходите любой сайт в масштабе, без борьбы с инфраструктурой.
Crawlbase берёт на себя прокси, отпечатки и CAPTCHA, чтобы ваша команда выпускала конвейеры данных вместо поддержки обвязки краулинга. 1 000 запросов бесплатно, без карты.
