Парсинг на Java: полное руководство

Полное руководство по парсингу на Java: Jsoup, HtmlUnit, Selenium, многопоточность и промышленная архитектура сборщиков.

КP
Команда Parsing.agency
Сбор данных под задачи бизнеса
Опубликовано: 26 марта 2025

Обзорная статья о том, как собирать данные с веб-страниц на Java — от простой загрузки одной страницы до многопоточного краулера с прокси, TOR и обходом типовых проблем. Структура построена «от простого к сложному»: каждый следующий раздел опирается на предыдущий.


Оглавление

  1. Введение: что такое парсинг и правовые рамки
  2. Как забираем страницу: HTTP-клиенты
  3. Библиотеки для парсинга содержимого
  4. Решение проблем с кириллицей (кодировки)
  5. Получение статуса ответа и заголовков
  6. Работа с cookie
  7. Работа с HTTPS/SSL
  8. Использование прокси
  9. Парсинг через TOR
  10. Многопоточность
  11. JavaScript-рендеринг: динамические сайты
  12. Антибот-защита: User-Agent, задержки, ретраи
  13. Хранение URL и очереди
  14. Готовые фреймворки-краулеры
  15. Плюсы и минусы реализации на Java

1. Введение

Парсинг — автоматизированное извлечение данных из веб-страниц. Условно процесс делится на два этапа, которые важно не путать:

  • Загрузка (fetching / crawling) — получение HTML (или JSON/XML) по HTTP. Этим занимается HTTP-клиент.
  • Разбор (parsing / extraction) — превращение «сырого» HTML в структуру и выбор нужных данных по селекторам. Этим занимается парсер.

Правовые и этические рамки

Прежде чем писать код, стоит держать в голове несколько вещей — это не юридическая консультация, а минимум гигиены:

  • robots.txt — файл в корне сайта (https://site.com/robots.txt), где владелец описывает, что разрешено обходить роботам. Юридической силы он почти нигде не имеет, но его игнорирование — плохой тон и частый триггер для бана.
  • Условия использования (ToS) сайта могут прямо запрещать автоматический сбор. Нарушение ToS — это договорный, а в ряде юрисдикций и более серьёзный риск.
  • Персональные данные. Сбор персональных данных регулируется законами. Будьте осторожны.
  • Нагрузка. Агрессивный парсинг = DoS для чужого сервера. Ставьте задержки, ограничивайте число потоков, уважайте Retry-After и коды 429/503.

Технически Java позволяет почти всё. Ответственность за «что именно» и «с какой целью» — на вас.


2. Как забираем страницу

Это фундамент: без корректно полученного ответа парсить нечего. У Java есть несколько вариантов HTTP-клиента.

2.1. Встроенный java.net.http.HttpClient (Java 11+)

Современный штатный клиент. Не требует зависимостей, поддерживает HTTP/2, синхронный и асинхронный режимы.

java
import java.net.URI;
import java.net.http.*;
import java.time.Duration;

HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(10))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .version(HttpClient.Version.HTTP_2)
        .build();

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://example.com"))
        .timeout(Duration.ofSeconds(15))
        .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
        .header("Accept-Language", "ru-RU,ru;q=0.9")
        .GET()
        .build();

HttpResponse<String> response =
        client.send(request, HttpResponse.BodyHandlers.ofString());

System.out.println("Status: " + response.statusCode());
String html = response.body();

Важный нюанс с кодировкой: BodyHandlers.ofString() без аргумента декодирует тело как UTF-8. Если сайт в другой кодировке (например, windows-1251), здесь будет «крокозябра» — об этом раздел 4. Чтобы этого избежать, часто берут байты, а кодировку определяют отдельно:

java
HttpResponse<byte[]> resp =
        client.send(request, HttpResponse.BodyHandlers.ofByteArray());
byte[] raw = resp.body();   // декодируем позже, зная charset

2.2. OkHttp

Популярная сторонняя библиотека (Square). Удобный API, пулы соединений, интерсепторы, простая работа с прокси и cookie. Хороший выбор «по умолчанию» для серьёзного парсинга.

java
// build.gradle: implementation("com.squareup.okhttp3:okhttp:4.12.0")
import okhttp3.*;

OkHttpClient client = new OkHttpClient.Builder()
        .connectTimeout(Duration.ofSeconds(10))
        .readTimeout(Duration.ofSeconds(15))
        .build();

Request request = new Request.Builder()
        .url("https://example.com")
        .header("User-Agent", "Mozilla/5.0 ...")
        .build();

try (Response response = client.newCall(request).execute()) {
    int code = response.code();
    byte[] bytes = response.body().bytes();   // снова: лучше байты
}

2.3. Apache HttpClient 5

Зрелая, мощная, чуть более многословная библиотека. Тонкая настройка соединений, пулов, аутентификации. Часто встречается в энтерпрайзе.

java
// org.apache.httpcomponents.client5:httpclient5:5.x
try (CloseableHttpClient httpclient = HttpClients.createDefault()) {
    HttpGet httpGet = new HttpGet("https://example.com");
    httpclient.execute(httpGet, response -> {
        int status = response.getCode();
        byte[] body = EntityUtils.toByteArray(response.getEntity());
        return body;
    });
}

2.4. Загрузка прямо через jsoup

jsoup (см. раздел 3) умеет сам качать страницу. Это удобно для прототипов, но его встроенный HTTP-движок слабее по гибкости (прокси, пулы, тонкая настройка), поэтому для боевых задач связку часто делают так: качаем мощным клиентом → парсим в jsoup.

java
Document doc = Jsoup.connect("https://example.com")
        .userAgent("Mozilla/5.0 ...")
        .timeout(15_000)
        .get();

Что выбрать

Клиент Когда брать
java.net.http.HttpClient Нет желания тащить зависимости, Java 11+, нужен HTTP/2
OkHttp Универсальный выбор, удобные прокси/cookie/интерсепторы
Apache HttpClient 5 Энтерпрайз, тонкий контроль, сложная аутентификация
jsoup .connect() Прототипы и простые задачи «качнул-распарсил»

3. Библиотеки для парсинга

Получив HTML, нужно извлечь данные. Парсить регулярными выражениями не надо — HTML не регулярный язык, и такой парсер ломается на любой нестандартной разметке.

3.1. jsoup — рабочая лошадка

jsoup (актуальная версия на 2026 год — 1.22.2) реализует спецификацию WHATWG HTML5 и строит тот же DOM, что и браузеры. Поддерживает CSS-селекторы и XPath, прощает «грязный» HTML.

java
// implementation("org.jsoup:jsoup:1.22.2")
import org.jsoup.Jsoup;
import org.jsoup.nodes.*;
import org.jsoup.select.Elements;

Document doc = Jsoup.parse(html, "https://example.com"); // 2-й арг — baseUri для абсолютных ссылок

// CSS-селекторы
Elements links = doc.select("a[href]");
for (Element link : links) {
    String text = link.text();
    String absUrl = link.absUrl("href");  // абсолютный URL
}

// Точечный выбор
Element title = doc.selectFirst("h1.article-title");
String price = doc.select("span.price").text();

// Атрибуты
String img = doc.selectFirst("img").attr("src");

Несколько частых приёмов с селекторами:

java
doc.select("div.product");              // по классу
doc.select("#main-content");            // по id
doc.select("ul.menu > li");             // прямые потомки
doc.select("a[href^=https]");           // атрибут начинается с
doc.select("table tr:nth-child(2n)");   // псевдоселекторы
doc.select("p:contains(Цена)");         // по тексту

3.2. HtmlUnit — «безголовый браузер»

HtmlUnit — это GUI-less браузер на Java. Он умеет исполнять JavaScript (ограниченно), что иногда позволяет получить данные с динамических сайтов без тяжёлого Selenium. Медленнее jsoup, но мощнее.

3.3. Когда HTML не нужен

Очень часто данные на странице подгружаются отдельным запросом к внутреннему API (JSON). Откройте DevTools → вкладка Network, найдите нужный XHR/fetch — и парсите чистый JSON через Jackson или Gson. Это на порядок надёжнее и быстрее, чем разбирать HTML. Всегда проверяйте этот путь первым.

java
// com.fasterxml.jackson.core:jackson-databind
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(jsonString);
String name = root.path("data").path("name").asText();

4. Кириллица и кодировки

Самая частая боль в Рунете — «крокозябры» (привет или ���). Причина всегда одна: байты декодировали не в той кодировке.

Почему так происходит

HTTP отдаёт байты. Чтобы получить строку, их надо декодировать в правильной кодировке. Кодировка может быть указана:

  1. в HTTP-заголовке Content-Type: text/html; charset=windows-1251;
  2. в <meta charset="..."> или <meta http-equiv="Content-Type"> внутри HTML;
  3. нигде (тогда приходится угадывать).

Если вы прочитали тело как UTF-8, а сайт в windows-1251 (классика старого Рунета) — получите мусор.

Правило: работайте с байтами, кодировку определяйте явно

jsoup решает это почти автоматически, если ему дать байты или InputStream, а не готовую строку — он сам прочитает charset из заголовка/<meta>:

java
// Правильно: jsoup сам определит кодировку из meta/charset
byte[] bytes = response.body();  // из HttpClient/OkHttp как byte[]
InputStream in = new ByteArrayInputStream(bytes);
Document doc = Jsoup.parse(in, null, "https://example.com");
//                              ^^^^ null = автоопределение charset

Если же charset известен заранее, передайте его явно:

java
Document doc = Jsoup.parse(in, "windows-1251", "https://example.com");

Ручное декодирование

Когда парсите не через jsoup, декодируйте байты сами:

java
import java.nio.charset.Charset;

// если знаете кодировку
String html = new String(bytes, Charset.forName("windows-1251"));

// для UTF-8
String html2 = new String(bytes, StandardCharsets.UTF_8);

Автоопределение кодировки

Если кодировка нигде не указана, помогают библиотеки-детекторы: juniversalchardet (порт universalchardet от Mozilla) или ICU4J (CharsetDetector).

java
// org.apache.tika:tika-core тоже умеет детектить charset
import org.apache.tika.parser.txt.CharsetDetector;

CharsetDetector detector = new CharsetDetector();
detector.setText(bytes);
String charset = detector.detect().getName(); // напр. "windows-1251"
String html = new String(bytes, charset);

Чеклист по кириллице

  • Никогда не приводите ответ к строке в UTF-8 «вслепую».
  • Отдавайте jsoup InputStream/byte[], а не String.
  • Файлы сохраняйте с явной кодировкой: Files.write(path, html.getBytes(StandardCharsets.UTF_8)).
  • В консоли тоже бывают артефакты — задайте -Dfile.encoding=UTF-8 и проверьте кодировку терминала.

5. Статус ответа и заголовки

Бездумно парсить тело нельзя — сначала проверьте, что сервер реально отдал страницу, а не редирект, капчу или ошибку.

java
HttpResponse<byte[]> resp = client.send(request, BodyHandlers.ofByteArray());

int status = resp.statusCode();
HttpHeaders headers = resp.headers();

// чтение конкретных заголовков
String contentType = headers.firstValue("Content-Type").orElse("");
String server      = headers.firstValue("Server").orElse("");
long length        = headers.firstValueAsLong("Content-Length").orElse(-1);

// все заголовки
headers.map().forEach((k, v) -> System.out.println(k + ": " + v));

Что важно отслеживать:

Код Значение Реакция парсера
200 OK парсим
301/302 редирект идём по Location (или включаем follow-redirects)
403 доступ запрещён вероятно, антибот — меняем UA/прокси
404 не найдено помечаем URL как мёртвый
429 слишком много запросов ждём Retry-After, снижаем темп
5xx ошибка сервера ретрай с экспоненциальной задержкой

Полезные заголовки ответа: Content-Type (тип и кодировка), Set-Cookie (см. раздел 6), Location (редирект), Retry-After (пауза при 429/503), ETag/Last-Modified (для условных запросов и инкрементального обхода).

В OkHttp всё аналогично: response.code(), response.header("Content-Type"), response.headers().


Cookie нужны для сессий, авторизации, прохождения «проверки на бота» и сохранения состояния между запросами.

6.1. Встроенный HttpClient + CookieManager

java
import java.net.*;

CookieManager cookieManager = new CookieManager();
cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL);

HttpClient client = HttpClient.newBuilder()
        .cookieHandler(cookieManager)   // cookie теперь сохраняются автоматически
        .build();

// после нескольких запросов посмотреть хранилище:
CookieStore store = cookieManager.getCookieStore();
store.getCookies().forEach(c ->
        System.out.println(c.getName() + "=" + c.getValue()));

Теперь между запросами cookie передаются сами — удобно для логина: сначала POST с логином/паролем, потом запросы к защищённым страницам в том же client.

6.2. OkHttp + CookieJar

java
// Самый простой путь — готовый PersistentCookieJar или свой in-memory CookieJar
OkHttpClient client = new OkHttpClient.Builder()
        .cookieJar(new JavaNetCookieJar(cookieManager))
        .build();

6.3. jsoup — ручное прокидывание

jsoup хранит cookie в объекте Connection.Response, и их можно передавать дальше вручную:

java
Connection.Response login = Jsoup.connect("https://site.com/login")
        .data("username", "user", "password", "pass")
        .method(Connection.Method.POST)
        .execute();

Map<String, String> cookies = login.cookies();

Document page = Jsoup.connect("https://site.com/profile")
        .cookies(cookies)      // передаём сессию
        .get();

Подводный камень: при многопоточном парсинге одна общая сессия может «гонять» состояние между потоками. Для разных аккаунтов/прокси заводите отдельные хранилища cookie на каждый поток или воркер.


7. HTTPS/SSL

Большинство сайтов на HTTPS. В норме встроенный HttpClient/OkHttp всё делает сам: проверяет сертификат по доверенным корневым CA из JDK. Отдельный код нужен в двух случаях.

7.1. Self-signed и «проблемные» сертификаты

Иногда у целевого сайта самоподписанный или просроченный сертификат. Бывает соблазн отключить проверку:

java
// ⚠️ ОПАСНО: отключает всю проверку SSL — только для теста/доверенной сети!
TrustManager[] trustAll = new TrustManager[]{
    new X509TrustManager() {
        public void checkClientTrusted(X509Certificate[] c, String a) {}
        public void checkServerTrusted(X509Certificate[] c, String a) {}
        public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
    }
};
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, trustAll, new SecureRandom());

HttpClient client = HttpClient.newBuilder()
        .sslContext(sc)
        .build();

Так делать в продакшене нельзя — это открывает дверь для MITM-атак. Правильнее добавить конкретный сертификат в кастомный truststore:

bash
keytool -import -alias mysite -file mysite.crt -keystore custom.jks
java
KeyStore ks = KeyStore.getInstance("JKS");
try (InputStream in = Files.newInputStream(Path.of("custom.jks"))) {
    ks.load(in, "changeit".toCharArray());
}
TrustManagerFactory tmf =
        TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(ks);
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, tmf.getTrustManagers(), null);

7.2. TLS-фингерпринт (JA3) — продвинутый антибот

Серьёзные защиты (Cloudflare и др.) различают клиентов по TLS-фингерпринту (JA3/JA4): набору шифров и расширений в ClientHello. У стандартного Java-клиента он «не браузерный», и по нему легко вычислить бота. Полноценно подделать его на чистом JDK сложно; на практике используют инструменты вроде curl-impl/utls-подобных обёрток или прогоняют трафик через реальный браузер (раздел 11). Это нишевая, но важная для «тяжёлых» сайтов тема.


8. Прокси

Прокси нужны, чтобы: распределить нагрузку по разным IP, обойти гео-ограничения и снизить риск бана по IP при массовом парсинге. Типы: datacenter (дёшево, легко детектится), residential (дорого, выглядит как реальный пользователь), mobile (самые «чистые»).

8.1. Встроенный HttpClient

java
import java.net.*;

HttpClient client = HttpClient.newBuilder()
        .proxy(ProxySelector.of(new InetSocketAddress("proxy.host", 8080)))
        .build();

С авторизацией прокси по логину/паролю:

java
Authenticator auth = new Authenticator() {
    @Override protected PasswordAuthentication getPasswordAuthentication() {
        return new PasswordAuthentication("user", "pass".toCharArray());
    }
};
HttpClient client = HttpClient.newBuilder()
        .proxy(ProxySelector.of(new InetSocketAddress("proxy.host", 8080)))
        .authenticator(auth)
        .build();

Нюанс JDK: для проксирования может потребоваться системное свойство -Djdk.http.auth.tunneling.disabledSchemes="", иначе Basic-аутентификация на CONNECT-туннеле молча не сработает.

8.2. OkHttp (часто удобнее для прокси)

java
Proxy proxy = new Proxy(Proxy.Type.HTTP,
        new InetSocketAddress("proxy.host", 8080));

OkHttpClient client = new OkHttpClient.Builder()
        .proxy(proxy)
        .proxyAuthenticator((route, response) -> {
            String credential = Credentials.basic("user", "pass");
            return response.request().newBuilder()
                    .header("Proxy-Authorization", credential)
                    .build();
        })
        .build();

OkHttp поддерживает и Proxy.Type.SOCKS — это нам понадобится для TOR.

8.3. Ротация прокси

Для массового парсинга держат пул прокси и меняют их по кругу или при бане. Простейшая ротация:

java
List<Proxy> pool = loadProxies();
AtomicInteger idx = new AtomicInteger();

Proxy next() {
    return pool.get(idx.getAndIncrement() % pool.size());
}

На практике добавляют: проверку «живости» прокси, бан-лист «сгоревших», привязку сессии/cookie к конкретному прокси, и под каждый прокси — свой собранный OkHttpClient (они дешёвые при общем пуле соединений, но лучше переиспользовать).


9. Парсинг через TOR

TOR даёт анонимность и бесплатную смену IP, но он медленный и многие сайты блокируют выходные узлы. Подходит для точечных задач, не для высокоскоростного парсинга.

9.1. Подготовка

  1. Установите TOR (пакет tor или Tor Browser).
  2. TOR поднимает SOCKS5-прокси локально, обычно на 127.0.0.1:9050 (у Tor Browser — 9150).
  3. Для смены цепочки (нового IP) используется ControlPort (9051) с командой NEWNYM.

Минимальный torrc:

code
SocksPort 9050
ControlPort 9051
CookieAuthentication 1

9.2. Запросы через TOR (SOCKS5)

java
Proxy torProxy = new Proxy(Proxy.Type.SOCKS,
        new InetSocketAddress("127.0.0.1", 9050));

OkHttpClient client = new OkHttpClient.Builder()
        .proxy(torProxy)
        .build();

Request req = new Request.Builder()
        .url("https://check.torproject.org/api/ip")  // проверка, что мы в TOR
        .build();

try (Response resp = client.newCall(req).execute()) {
    System.out.println(resp.body().string()); // {"IsTor":true,...}
}

Важно: для SOCKS5 разрешение DNS должно идти через прокси (remote DNS), иначе DNS-запрос «утечёт» с вашего реального IP. OkHttp с Proxy.Type.SOCKS это учитывает; при использовании чистого JDK будьте внимательны к утечкам DNS.

9.3. Смена IP командой NEWNYM

java
import java.io.*;
import java.net.Socket;

void newTorIdentity() throws IOException {
    try (Socket s = new Socket("127.0.0.1", 9051);
         PrintWriter out = new PrintWriter(s.getOutputStream(), true);
         BufferedReader in = new BufferedReader(
                 new InputStreamReader(s.getInputStream()))) {

        out.println("AUTHENTICATE \"\"");   // или пароль, если задан
        in.readLine();                       // ждём 250 OK
        out.println("SIGNAL NEWNYM");        // новая цепочка
        in.readLine();
    }
}

После NEWNYM стоит выдержать паузу (TOR строит новую цепочку несколько секунд) и не дёргать команду слишком часто.


10. Многопоточность

Парсинг — задача I/O-bound (большую часть времени мы ждём сеть), поэтому распараллеливание даёт огромный прирост. Главное — не «положить» целевой сайт и не словить бан.

10.1. ExecutorService — классика

java
import java.util.concurrent.*;

ExecutorService pool = Executors.newFixedThreadPool(10);
List<Future<String>> futures = new ArrayList<>();

for (String url : urls) {
    futures.add(pool.submit(() -> fetchAndParse(url)));
}

for (Future<String> f : futures) {
    try {
        String result = f.get(30, TimeUnit.SECONDS);
        // сохранить результат
    } catch (Exception e) {
        // лог + ретрай/пропуск
    }
}
pool.shutdown();

10.2. Виртуальные потоки (Java 21+) — для I/O идеально

Виртуальные потоки делают тысячи «дешёвых» потоков, простаивающих на сети, — то, что нужно для парсинга:

java
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (String url : urls) {
        executor.submit(() -> fetchAndParse(url));
    }
} // close() дожидается завершения всех задач

10.3. CompletableFuture + асинхронный HttpClient

java
List<CompletableFuture<HttpResponse<byte[]>>> calls = urls.stream()
        .map(u -> client.sendAsync(buildRequest(u), BodyHandlers.ofByteArray()))
        .toList();

CompletableFuture.allOf(calls.toArray(new CompletableFuture[0])).join();

10.4. Что обязательно учесть

  • Лимит конкурентности на домен. 200 потоков в один сайт = DoS и мгновенный бан. Ограничивайте, например Semaphore на домен.
  • Троттлинг. Ставьте задержки/джиттер между запросами (см. раздел 12).
  • Потокобезопасность. jsoup Document лучше не шарить между потоками; общие коллекции — ConcurrentHashMap, ConcurrentLinkedQueue; счётчики — AtomicInteger/LongAdder.
  • Backpressure. Не загружайте в очередь миллион задач разом — используйте ограниченную очередь (ArrayBlockingQueue) и CallerRunsPolicy, чтобы продюсер притормаживал.
  • Один OkHttpClient/HttpClient на всё приложение. Они потокобезопасны и держат пул соединений; плодить их на каждый запрос — антипаттерн.

11. JavaScript-рендеринг

jsoup видит только тот HTML, что пришёл в ответе. Если сайт — SPA (React/Vue/Angular) и данные дорисовываются JavaScript-ом в браузере, в «сыром» HTML их не будет. Варианты:

  1. Найти внутренний API (см. 3.3) — почти всегда лучший путь: быстрее, стабильнее, без браузера.
  2. Selenium WebDriver — управление настоящим браузером (Chrome/Firefox через WebDriver). Тяжело, медленно, но видит всё, что видит пользователь.
  3. Playwright for Java — современная альтернатива Selenium от Microsoft: быстрее, удобнее API, хорошо работает с динамикой и ожиданиями.
  4. HtmlUnit — лёгкий встроенный браузер на Java, исполняет JS ограниченно, но без внешних бинарников.
java
// Selenium (headless Chrome)
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless=new");
WebDriver driver = new ChromeDriver(options);
driver.get("https://spa-site.com");

// дождаться появления элемента
new WebDriverWait(driver, Duration.ofSeconds(10))
    .until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(".item")));

String renderedHtml = driver.getPageSource();
// дальше можно отдать в jsoup для удобного парсинга:
Document doc = Jsoup.parse(renderedHtml);
driver.quit();

Браузерные движки заодно решают часть антибот-проблем (правильный TLS-фингерпринт, исполнение JS-челленджей), но платят за это ресурсами: один экземпляр Chrome ест сотни мегабайт RAM, поэтому масштабировать их дорого.


12. Антибот-защита

Чем массовее парсинг, тем выше шанс блокировки. Базовая «вежливость» и маскировка:

  • User-Agent. Ставьте реалистичный UA настоящего браузера; для массовых задач — ротируйте список UA. Дефолтный Java-UA — красный флаг для сайта.
  • Полный набор заголовков. Реальный браузер шлёт Accept, Accept-Language, Accept-Encoding, Referer, Sec-Fetch-*. Имитируйте их.
  • Задержки и джиттер. Случайная пауза (например, 1–5 с) между запросами вместо ровного «пулемёта».
  • Ретраи с экспоненциальной задержкой на 429/5xx, с уважением к Retry-After.
  • Ротация IP (раздел 8) — главный инструмент против банов по IP.
  • Капча. При появлении капчи варианты: снизить темп, сменить IP, использовать сервисы распознавания (2Captcha/Anti-Captcha) — но это уже серая зона, оценивайте риски.
  • Cloudflare и JS-челленджи. Часто берутся только через реальный браузер (раздел 11) или специализированные обходные библиотеки.

Каркас ретраев:

java
int attempts = 0;
while (attempts < MAX_RETRIES) {
    HttpResponse<byte[]> r = client.send(req, BodyHandlers.ofByteArray());
    int code = r.statusCode();
    if (code == 200) return r.body();
    if (code == 429 || code >= 500) {
        long backoff = (long) (Math.pow(2, attempts) * 1000)
                + ThreadLocalRandom.current().nextInt(500); // джиттер
        Thread.sleep(backoff);
        attempts++;
    } else {
        break; // 403/404 — ретрай не поможет
    }
}

13. Хранение URL и очереди

Краулер — это, по сути, обход графа ссылок. Нужны две структуры:

  • Очередь (frontier) — URL, которые ещё предстоит обойти.
  • Множество посещённых (visited) — чтобы не ходить по одному URL дважды и не зациклиться.

In-memory (для небольших задач)

java
Queue<String> frontier = new ConcurrentLinkedQueue<>();
Set<String> visited = ConcurrentHashMap.newKeySet();

if (visited.add(url)) {       // add вернёт false, если уже был
    frontier.offer(url);
}

Парсинг URL

Прежде чем нормализовать или фильтровать ссылку, её нужно разобрать на составные части (схема, хост, порт, путь, query, фрагмент). В Java для этого есть штатный java.net.URI:

java
import java.net.URI;

URI uri = URI.create("https://Site.com:443/catalog/item?id=7&ref=a#section");

String scheme   = uri.getScheme();    // https
String host     = uri.getHost();      // Site.com
int    port     = uri.getPort();      // 443
String path     = uri.getPath();      // /catalog/item
String query    = uri.getQuery();     // id=7&ref=a
String fragment = uri.getFragment();  // section

// разрешение относительной ссылки в абсолютную:
URI abs = uri.resolve("../other");    // https://Site.com:443/other

jsoup делает то же самое для ссылок со страницы автоматически — link.absUrl("href") (см. раздел 3) возвращает уже абсолютный URL, опираясь на baseUri. Разбор на компоненты через URI нужен именно для последующей нормализации.

Те же принципы разбора URL применимы и в других стеках — см. отдельную статью «Парсинг URL на других языках».

Что важно

  • Нормализация URL перед дедупликацией (опираясь на Парсинг URL): убрать якоря #..., отсортировать query-параметры, привести регистр хоста, решить про http/https и завершающий слеш. Иначе site.com/a и site.com/a/ посчитаются разными.
  • Дедупликация при больших объёмах. Держать сотни миллионов URL в HashSet нереально по памяти — применяют Bloom-фильтр (Guava BloomFilter) или вынос множества в Redis/БД.
  • Персистентность. Для долгих обходов очередь и visited должны переживать перезапуск: Redis (List/Set), Kafka/RabbitMQ как очередь задач, реляционная БД или embedded-хранилище.
  • Приоритеты. Иногда нужен PriorityBlockingQueue (сначала важные разделы) или ограничение глубины обхода.
  • Распределённость. При масштабе на много машин очередь и дедуп выносят во внешний брокер/Redis, чтобы воркеры не дублировали работу.

Типовая архитектура: продюсер достаёт URL из frontier → пул воркеров качает и парсит → извлечённые ссылки фильтруются, нормализуются, проверяются по visited и кладутся обратно во frontier; данные пишутся в хранилище.


14. Готовые фреймворки

Чтобы не писать краулер с нуля, есть готовые решения:

  • crawler4j — простой многопоточный краулер на Java, быстрый старт.
  • webmagic — гибкий фреймворк (вдохновлён Scrapy) с пайплайнами и планировщиком.
  • Apache Nutch — тяжёлый масштабируемый краулер промышленного уровня (часто в связке с Hadoop/Solr).
  • StormCrawler — распределённый краулер поверх Apache Storm для потоковой обработки.

Для большинства прикладных задач связки «OkHttp/HttpClient + jsoup + свой пул потоков + Redis-очередь» более чем достаточно, и она прозрачнее «магии» фреймворков.


15. Плюсы и минусы

Плюсы реализации на Java

  • Производительность и многопоточность. JVM отлично держит высокую конкурентность; виртуальные потоки (Java 21+) выводят I/O-bound парсинг на новый уровень.
  • Зрелая экосистема. jsoup, OkHttp, Apache HttpClient, Selenium/Playwright, Jackson — всё стабильно и хорошо документировано.
  • Надёжность и типобезопасность. Строгая типизация ловит ошибки на компиляции; удобно для больших долгоживущих краулеров.
  • Интеграция с энтерпрайз-стеком. Легко встроить в Spring-сервис, подключить Kafka, базы, мониторинг.
  • Кроссплатформенность. Один JAR — запуск где угодно.

Минусы

  • Многословность. Кода больше, чем на Python; быстрый прототип на requests + BeautifulSoup пишется короче.
  • Меньше «парсинг-специфичных» инструментов. В Python экосистема парсинга (Scrapy и пр.) богаче и популярнее.
  • JavaScript-сайты — боль. Без браузера (Selenium/Playwright) динамику не взять, а браузеры тяжёлые и прожорливые.
  • Антибот-гонка вооружений. TLS-фингерпринт, поведенческий анализ, капчи — всё это требует постоянной поддержки; стандартный Java-клиент легко детектируется.
  • Кодировки. Кириллица и windows-1251 требуют аккуратности (раздел 4) — типовой источник багов.
  • Хрупкость. Любой парсер ломается при изменении вёрстки сайта; селекторы приходится поддерживать.

Когда Java — хороший выбор

Большой, долгоживущий, высоконагруженный краулер; уже Java-стек в команде; нужна жёсткая многопоточность и интеграция с инфраструктурой. Для разового «достать табличку с одной страницы» Python обычно быстрее по времени разработки.


Версии библиотек указаны по состоянию на 2026 год (jsoup 1.22.2). Перед использованием сверяйтесь с актуальными версиями на Maven Central — API мог измениться.