Обзорная статья о том, как собирать данные с веб-страниц на Java — от простой загрузки одной страницы до многопоточного краулера с прокси, TOR и обходом типовых проблем. Структура построена «от простого к сложному»: каждый следующий раздел опирается на предыдущий.
Оглавление
- Введение: что такое парсинг и правовые рамки
- Как забираем страницу: HTTP-клиенты
- Библиотеки для парсинга содержимого
- Решение проблем с кириллицей (кодировки)
- Получение статуса ответа и заголовков
- Работа с cookie
- Работа с HTTPS/SSL
- Использование прокси
- Парсинг через TOR
- Многопоточность
- JavaScript-рендеринг: динамические сайты
- Антибот-защита: User-Agent, задержки, ретраи
- Хранение URL и очереди
- Готовые фреймворки-краулеры
- Плюсы и минусы реализации на 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, синхронный и асинхронный режимы.
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. Чтобы этого избежать, часто берут байты, а кодировку определяют отдельно:
HttpResponse<byte[]> resp =
client.send(request, HttpResponse.BodyHandlers.ofByteArray());
byte[] raw = resp.body(); // декодируем позже, зная charset
2.2. OkHttp
Популярная сторонняя библиотека (Square). Удобный API, пулы соединений, интерсепторы, простая работа с прокси и cookie. Хороший выбор «по умолчанию» для серьёзного парсинга.
// 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
Зрелая, мощная, чуть более многословная библиотека. Тонкая настройка соединений, пулов, аутентификации. Часто встречается в энтерпрайзе.
// 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.
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.
// 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");
Несколько частых приёмов с селекторами:
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. Всегда проверяйте этот путь первым.
// 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 отдаёт байты. Чтобы получить строку, их надо декодировать в правильной кодировке. Кодировка может быть указана:
- в HTTP-заголовке
Content-Type: text/html; charset=windows-1251; - в
<meta charset="...">или<meta http-equiv="Content-Type">внутри HTML; - нигде (тогда приходится угадывать).
Если вы прочитали тело как UTF-8, а сайт в windows-1251 (классика старого Рунета) — получите мусор.
Правило: работайте с байтами, кодировку определяйте явно
jsoup решает это почти автоматически, если ему дать байты или InputStream, а не готовую строку — он сам прочитает charset из заголовка/<meta>:
// Правильно: 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 известен заранее, передайте его явно:
Document doc = Jsoup.parse(in, "windows-1251", "https://example.com");
Ручное декодирование
Когда парсите не через jsoup, декодируйте байты сами:
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).
// 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. Статус ответа и заголовки
Бездумно парсить тело нельзя — сначала проверьте, что сервер реально отдал страницу, а не редирект, капчу или ошибку.
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().
6. Работа с cookie
Cookie нужны для сессий, авторизации, прохождения «проверки на бота» и сохранения состояния между запросами.
6.1. Встроенный HttpClient + CookieManager
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
// Самый простой путь — готовый PersistentCookieJar или свой in-memory CookieJar
OkHttpClient client = new OkHttpClient.Builder()
.cookieJar(new JavaNetCookieJar(cookieManager))
.build();
6.3. jsoup — ручное прокидывание
jsoup хранит cookie в объекте Connection.Response, и их можно передавать дальше вручную:
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 и «проблемные» сертификаты
Иногда у целевого сайта самоподписанный или просроченный сертификат. Бывает соблазн отключить проверку:
// ⚠️ ОПАСНО: отключает всю проверку 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:
keytool -import -alias mysite -file mysite.crt -keystore custom.jks
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
import java.net.*;
HttpClient client = HttpClient.newBuilder()
.proxy(ProxySelector.of(new InetSocketAddress("proxy.host", 8080)))
.build();
С авторизацией прокси по логину/паролю:
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 (часто удобнее для прокси)
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. Ротация прокси
Для массового парсинга держат пул прокси и меняют их по кругу или при бане. Простейшая ротация:
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. Подготовка
- Установите TOR (пакет
torили Tor Browser). - TOR поднимает SOCKS5-прокси локально, обычно на
127.0.0.1:9050(у Tor Browser —9150). - Для смены цепочки (нового IP) используется ControlPort (
9051) с командойNEWNYM.
Минимальный torrc:
SocksPort 9050
ControlPort 9051
CookieAuthentication 1
9.2. Запросы через TOR (SOCKS5)
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
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 — классика
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 идеально
Виртуальные потоки делают тысячи «дешёвых» потоков, простаивающих на сети, — то, что нужно для парсинга:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (String url : urls) {
executor.submit(() -> fetchAndParse(url));
}
} // close() дожидается завершения всех задач
10.3. CompletableFuture + асинхронный HttpClient
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 их не будет. Варианты:
- Найти внутренний API (см. 3.3) — почти всегда лучший путь: быстрее, стабильнее, без браузера.
- Selenium WebDriver — управление настоящим браузером (Chrome/Firefox через WebDriver). Тяжело, медленно, но видит всё, что видит пользователь.
- Playwright for Java — современная альтернатива Selenium от Microsoft: быстрее, удобнее API, хорошо работает с динамикой и ожиданиями.
- HtmlUnit — лёгкий встроенный браузер на Java, исполняет JS ограниченно, но без внешних бинарников.
// 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) или специализированные обходные библиотеки.
Каркас ретраев:
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 (для небольших задач)
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:
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-фильтр (GuavaBloomFilter) или вынос множества в 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 мог измениться.