Парсер извлекает поля с одной страницы, 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.

xml
<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, который блокируется до получения ответа. Таймаут запроса не даёт медленному или недоступному хосту подвесить весь обход.

java
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 относительно страницы, на которой они были найдены, что именно то, что нужно фронтиру.

java
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 предотвращает циклы, а проверка одного домена ограничивает обход одним хостом.

java
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, глубины и задержки.

java
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

Перед обходом сайта, которым вы не владеете, прочитайте его 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.

java
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, а не вашим краулером. Более длинный клиентский таймаут учитывает, что шаг рендеринга занимает больше времени, чем простой запрос.

Crawlbase Crawling API

Держите 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 запросов бесплатно, без карты.

Самообслуживание · Звонок отдела продаж не требуется · Доступны корпоративные объёмы краулинга