Парсинг веб-страниц с помощью Java
Table Of Content
- Содержание
- Концепция: Как это работает?
- Настройка кода
- Шаг 1: Создайте новый проект Java Maven с выбранной вами средой разработки.
- Шаг 2: Вставьте следующий код под тегом **`<dependencies>`** в файле **pom.xml**.
- Почему Jsoup?
- Шаг 3: Парсинг данных
- Переход по ссылкам
- Ограничения статического парсинга !!
- Динамический парсинг веб-страниц
- Шаг 1: Написание метода для навигации и поиска по URL
- Шаг 2: Парсинг данных о продукте
- Параллелизация: Улучшение производительности.
- **Создание и использование пула потоков**
- **Асинхронный вызов**
- Заключение
Парсинг веб-страниц, сбор данных или извлечение данных - это техника извлечения целевых данных с веб-страниц или других онлайн-ресурсов. Парсинг веб-страниц, если выполнен правильно, может быть мощным инструментом для различных задач, таких как индексация контента для поисковых систем, создание ботов для сравнения цен, сбор данных из социальных сетей для маркетинговых исследований и функциональное тестирование для разработчиков.
В этой статье мы рассмотрим, как мы можем использовать Java для начала работы с парсингом веб-страниц. Мы изучим статический и динамический парсинг, распространенные ошибки, оптимизацию производительности и лучшие практики.
Содержание
Концепция: Как это работает?
Парсинг в своей простейшей форме очень просто понять. Цель состоит в том, чтобы получить данные, находящиеся на веб-странице, в нашу программу, чтобы мы могли выполнить некоторую логику преобразования данных, сохранить их или выполнить любую бизнес-логику. Конечная цель, однако, состоит в том, чтобы сделать данные доступными в программе для их использования.
Информация, которая нам нужна, как мы знаем, находится где-то в HTML-коде веб-страницы. Поэтому нам просто нужно получить HTML в качестве ответа на веб-запрос, и затем мы можем получить необходимые данные оттуда (звучит достаточно просто!!, на самом деле нет).
Настройка кода
Для настройки кода вам потребуется установленная Java на вашем компьютере. Я использую Java 11 на момент написания этого текста, но любая версия Java выше 11 должна работать нормально.
Шаг 1: Создайте новый проект Java Maven с выбранной вами средой разработки.
Шаг 2: Вставьте следующий код под тегом <dependencies>
в файле pom.xml.
<!-- [https://mvnrepository.com/artifact/org.jsoup/jsoup](https://mvnrepository.com/artifact/org.jsoup/jsoup) -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.14.3</version>
</dependency>
Если вам нужна другая версия, вы можете найти все версии здесь.
Почему Jsoup?
Библиотека Jsoup предоставляет удобный API для парсинга HTML-ответа в виде активного DOM-дерева, чтобы вы могли запрашивать, фильтровать, изменять и извлекать элементы по их классу, идентификатору, тегу и т.д. Точно так же, как вы делаете это в JS/Jquery.
**Поиск элементов**
getElementById(String id)
getElementsByTag(String tag)
getElementsByClass(String className)
getElementsByAttribute(String key) (и связанные методы)
Элементы-соседи: siblingElements(), firstElementSibling(), lastElementSibling(); nextElementSibling(), previousElementSibling()
Граф: parent(), children(), child(int index)**Данные элемента**
attr(String key) для получения и attr(String key, String value) для установки атрибутов
attributes() для получения всех атрибутов
id(), className() и classNames()
text() для получения и text(String value) для установки текстового содержимого
html() для получения и html(String value) для установки внутреннего HTML-содержимого
outerHtml() для получения внешнего HTML-значения
data() для получения содержимого данных (например, тегов script и style)
tag() и tagName()
Вы можете узнать больше об этом в официальной документации Jsoup.
Шаг 3: Парсинг данных
Для целей этой статьи мы будем использовать фиктивный веб-сайт scrapeme.live. Но основы можно применить к любому веб-сайту.
Цель: Получить название, цену и ссылку для покупки каждого товара.
Давайте начнем!
Напишем простой оберточный класс для выполнения этой задачи.
import Enums.IdentifierType;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
public enum IdentifierType {
_ATTRIBUTE_,
_ID_,
_CLASS_,
_TAG_
}
public class StaticScraper {
public Document getDocumentFromURL(URL resourceUrl) throws IOException {
return Jsoup.connect(resourceUrl.toString()).get();
}
public List<Element> getElementsByIdentifier(Document document, String identifier, IdentifierType identifiertype) {
List<Element> elements = new ArrayList<>();
switch (identifiertype) {
case _ID_:
elements.add(document.getElementById(identifier));
return elements;
case _TAG_:
return document.getElementsByTag(identifier);
case _ATTRIBUTE_:
return document.getElementsByAttribute(identifier);
case _CLASS_:
return document.getElementsByClass(identifier);
default:
System.out.println("Not a valid Identifier type");
}
return elements;
}
}
Как мы можем видеть в инструментах разработчика (для открытия используйте Ctrl+Shift+I), карточка товара имеет CSS-класс "product". Теперь мы можем выбрать карточки на основе CSS-класса следующим образом.
Основной класс.
public class Main {
public static void main(String args[]) throws IOException {
// создаем новый экземпляр парсера
StaticScraper scraper = new StaticScraper();
String urlToScrape = "https://scrapeme.live/shop/";
// получаем HTML-документ по целевому URL-адресу
Document htmlDocument = scraper.getDocumentFromURL(new URL(urlToScrape));
String classNameForProductCard = "product";
List<Element> productCards = scraper.getElementsByIdentifier(htmlDocument, classNameForProductCard, IdentifierType._CLASS_);
System.out.println(productCards);
}
}
При запуске этого кода будет выведен список тегов <li>
для всех товаров. Мы уже уточнили данные до списка товаров, которые нам нужны. Теперь давайте дополнительно отфильтруем его, чтобы вывести только цену и название товара.
Мы можем дальше фильтровать тег <li>
для названия товара и его цены, посмотрев на идентификаторы для элемента названия и элемента цены.
Мы можем заметить, что заголовок товара внутри тега <h2>
имеет CSS-класс "woocommerce-loop-product__title", а информация о цене имеет CSS-класс "woocommerce-Price-amount amount". Используя эту информацию, мы можем извлечь нужную информацию следующим образом.
public static void main(String args[]) throws IOException {
StaticScraper scraper = new StaticScraper();
String urlToScrape = "https://scrapeme.live/shop/";
Document htmlDocument = scraper.getDocumentFromURL(new URL(urlToScrape));
String classNameForProductCard = "product";
List<Element> productCards = scraper.getElementsByIdentifier(htmlDocument, classNameForProductCard, IdentifierType._CLASS_);
// извлекаем и выводим нужную информацию
for (Element productCard : productCards) {
String productNameClassName = "woocommerce-loop-product__title";
String productPriceClassName = "woocommerce-Price-amount amount";
String extractedProductName = productCard.getElementsByClass(productNameClassName).text();
String extractedProductPrice = productCard.getElementsByClass(productPriceClassName).text();
System.out.println(extractedProductName + " - " + extractedProductPrice);
}
}
При запуске этого кода вы увидите вывод в виде названия товара и его цены. Вы успешно спарсили информацию о товарах и ценах. Круто!
Переход по ссылкам
До сих пор мы только собирали данные о продуктах с первой страницы. Всего 16 продуктов, что можно проверить по выделенному выводу. Теперь мы хотим собирать продукты со всех страниц. Для этого нам нужно переходить по ссылкам. Это включает два шага: сначала получить список всех ссылок на страницы, затем посетить/собрать данные с каждой ссылки и получить ссылку на следующую страницу, и повторить процесс.
- Получение ссылок на страницы
Для получения ссылок на страницы мы используем подход, аналогичный получению карточек продуктов.
// это дает нам все теги <a> с ссылками
String pageLinkCSSQuery = ".page-numbers>li>a";
Document currPageHtml = getDocumentFromURL(new URL(currUrl));
List<Element> pageLinks = currPageHtml.select(pageLinkCSSQuery);// извлечение фактической ссылки
for(Element link: pageLinks){
String linkUrl = link.attr("href");
}
- Обход страниц
Логика здесь довольно проста: посетить/собрать данные с первой страницы и получить все ссылки на следующие страницы, затем посетить следующую страницу и повторить этот процесс.
Примечание: Поскольку ссылки дублируются, мы хотим избежать повторного посещения одной и той же ссылки. Для этого мы отслеживаем посещенные ссылки и, если ссылка уже посещена, мы не посещаем ее снова.
// API функции обхода
public List<Element> Crawl(URL initialUrl, int maxVisits, String pageLinkSelectorQuery) throws IOException {
List<Element> scrapedElements = new ArrayList<>();
Set<String> visitedPages = new HashSet<>();
crawlPages(initialUrl.toString(),visitedPages,maxVisits,pageLinkSelectorQuery,scrapedElements);
return scrapedElements;
}// Вспомогательная функция для рекурсивного посещения страниц
private void crawlPages(String currUrl, Set<String> visited, int maxVisits, String pageLinkSelectorQuery, List<Element> elements) throws IOException {
if(visited.size()==maxVisits){
return ;
}
// отметить посещенный URL
visited.add(currUrl);
// получить ссылки на страницы
Document currPageHtml = getDocumentFromURL(new URL(currUrl));
List<Element> pageLinks = currPageHtml.select(pageLinkSelectorQuery);
// заполнить элементы
String classNameForProductCard = "product";
List<Element> productCards = getElementsByIdentifier(currPageHtml,classNameForProductCard, IdentifierType._CLASS_);
// добавить элементы текущей страницы в список элементов
elements.addAll(productCards);
for(Element link: pageLinks){
String nextUrl = link.attr("href");
if(!visited.contains(nextUrl)){
crawlPages(nextUrl,visited,maxVisits, pageLinkSelectorQuery,elements);
}
}
return;
}
Рекурсивную функцию можно написать разными способами для оптимизации аргументов и возвращаемых объектов, для демонстрационных целей это простая функция, которая делает свою работу.
Мы можем найти уникальный идентификатор для ссылки на продукт из инструментов разработчика браузера, как и раньше.
мы видим, что ссылка на страницу находится внутри класса .page-number, затем в теге lst и затем в теге <a>
. Фактически это “.page-numbers>li>a” в качестве запроса селектора CSS.
Вызов функции обхода из метода Main.
// получить ссылки на страницы из запроса селектора CSS
String pageLinkCSSQuery = ".page-numbers>li>a";
int maxVisits = 4;
String firstPageUrl = "https://scrapeme.live/shop/page/1/";
List<Element> elements = scraper.Crawl(new URL(firstPageUrl),maxVisits,pageLinkCSSQuery);
for(Element element: elements){
String productNameClassName = "woocommerce-loop-product__title";
String productPriceClassName = "woocommerce-Price-amount amount";
String extractedProductName = element.getElementsByClass(productNameClassName).text();
String extractedProductPrice = element.getElementsByClass(productPriceClassName).text();
System._out_.println(extractedProductName + " - "+ extractedProductPrice);
}
System._out_.println("Всего собрано продуктов при обходе " + maxVisits + " страниц: " + elements.size());
Ограничения статического парсинга !!
Хотя парсинг данных таким образом замечателен, так как возвращаемый HTML содержит все заполненные данные и не требует обработки JS. Но, как и в случае со всеми современными веб-сайтами, они используют JS для изменения DOM на лету, а Jsoup является только парсером, у него нет движка JS для обработки JS. Вот где нам на помощь приходят браузеры без графического интерфейса, у них есть все возможности браузера.
Рассмотрим простой пример, где мы запрашиваем документ такого вида.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Пример страницы</title>
</head>
<body>
<h1 id="myheading"> Привет, мир! </h1
<script>
document.getElementById("myheading").innerText = "Привет, мир! Парсинг - это здорово";
</script>
</body>
</html>
Как мы знаем, как только тег <script>
загрузится, он изменит текст в <h1>
, но из-за того, что Jsoup и статические методы парсинга не могут обрабатывать JS, он покажет Привет, мир! вместо Привет, мир! Парсинг - это здорово.
Единственный способ преодолеть это - это обработать JS. Вот где на помощь приходят браузеры без графического интерфейса.
В Java существует множество таких реализаций браузеров, таких как Selenium, Playwright, HtmlUnit и многие другие. Все они предоставляют логику обработки JS, чтобы в ответ возвращался полностью обработанный HTML DOM.
Динамический парсинг веб-страниц
Мы подошли к самой захватывающей части! В качестве примера давайте спарсим все товары и цены на "корм для кошек" на amazon.com.
Для этого нам сначала нужно перейти на amazon.com, выполнить поиск корма для кошек в строке поиска, а затем спарсить название товара и его цену.
Как мы будем рендерить JS?
Познакомьтесь с Playwright - кросс-языковой библиотекой для управления Chrome, Firefox и Webkit. API Playwright простое и предоставляет возможность управлять самыми популярными браузерами.
Чтобы добавить это в ваш проект, вставьте следующий код под тегом <dependencies>
в вашем файле pom.xml:
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.25.0</version>
</dependency>
Заметили что-то странное? Да, с помощью playwright он автоматически загружает Chromium и все необходимые драйверы, поэтому вам не нужно поддерживать их, в отличие от Selenium, который требует указания пути к бинарному файлу драйвера Chromium.
Шаг 1: Написание метода для навигации и поиска по URL
public Document searchOnPage(String url, String searchbarCSSSelectorQuery, String searchButtonSelectorQuery, String searchText){
try (Playwright playwright = Playwright._create_()) {
final BrowserType chromium = playwright.chromium();
final Browser browser = chromium.launch();
final Page page = browser.newPage();
page.navigate(url);
page.fill(searchbarCSSSelectorQuery, searchText);
page.click(searchButtonSelectorQuery);
page.waitForTimeout(_PAGELOADTIMEOUT_);
Document doc = Jsoup._parse_(page.content());
browser.close();
return doc;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
В приведенной выше функции я передаю все входные данные в качестве аргументов функции, но вам следует создать отдельный объект для улучшения повторного использования кода и следовать лучшим практикам разработки программного обеспечения.
Шаг 2: Парсинг данных о продукте
String dynamicScrapingUrl = "https://amazon.com/";
String searchbarCSSSelectorQuery = "input#twotabsearchtextbox";
String searchButtonSelectorQuery ="input#nav-search-submit-button";
String searchText = "корм для кошек";
DynamicScraper dynamicScraper = new DynamicScraper();
Document searchedHTMLResult = dynamicScraper.searchOnPage(dynamicScrapingUrl, searchbarCSSSelectorQuery, searchButtonSelectorQuery, searchText);
String prodcuctDataCSSSelector = "div.a-section.a-spacing-small.puis-padding-left-small.puis-padding-right-small";
List<Element> productDetails = dynamicScraper.getElementsByCSSQuery(searchedHTMLResult, prodcuctDataCSSSelector);
String productTextSelector = "span.a-size-base-plus.a-color-base.a-text-normal";
String productPriceSelector = "span.a-price-whole";
String productCurrencySelector = "span.a-price-symbol";
for (Element productDetail : productDetails) {
String productName = productDetail.select(productTextSelector).text();
String productPrice = productDetail.select(productPriceSelector).text();
String currency = productDetail.select(productCurrencySelector).text();
System._out_.println(productName + " - " + currency + productPrice);
}
После запуска этого кода мы получаем ожидаемый результат.
Параллелизация: Улучшение производительности.
С нашими простыми примерами парсинга нам не очень важна производительность, но для сложного парсера производительность становится проблемой.
Рассмотрим сценарий парсинга, где мы парсим одну страницу, а затем следующую и так далее. Если данные, которые мы парсим, большие, это займет много времени. Мы можем улучшить производительность, параллелизуя задачи с помощью многопоточности.
Не волнуйтесь, мы не будем использовать старый стиль многопоточности в Java, так как это подвержено ошибкам, если делать неправильно, и требует хорошего понимания коммуникации между потоками. Вместо этого мы будем использовать пулы потоков и FutureTasks (новые с Java 11+).
Создание и использование пула потоков
// С Java 11+ мы можем создать пул потоков следующим образом -> этот пул // имеет 10 потоковExecutorService threadPool = Executors._newFixedThreadPool_(10);// Реализация асинхронной функции
public CompletableFuture<Document> searchOnPageAsync(String url ,String searchbarCSSSelectorQuery, String searchButtonSelectorQuery, String searchText){
CompletableFuture<Document> futureDocument;
// Асинхронно выполняем запрос и затем возвращаем результаты
futureDocument = CompletableFuture._supplyAsync_(()-> {
try {
return searchOnPage(url,searchbarCSSSelectorQuery,searchButtonSelectorQuery,searchText);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}, **threadPool**);
return futureDocument;
}
Как видно, с использованием современных функций Java нам не нужно заниматься управлением потоками и коммуникацией, все это делается за нас с помощью встроенного Executor Service.
Мы можем использовать эту функцию для парсинга продуктов на amazon.com, но асинхронно.
Асинхронный вызов
// Асинхронный вызов
CompletableFuture future = dynamicScraper.searchOnPageAsync(dynamicScrapingUrl,searchbarCSSSelectorQuery,searchButtonSelectorQuery, searchText)
.thenApply(page -> dynamicScraper.getElementsByCSSQuery(page,prodcuctDataCSSSelector))
.thenAccept(products ->{
for(Element product: products){
String productName = product.select("span.a-size-base-plus.a-color-base.a-text-normal").text();
String productPrice = product.select("span.a-price-whole").text();
String currency = product.select("span.a-price-symbol").text();
System._out_.println(productName + " - "+ currency+productPrice);
}
});
System._out_.println("\nЭто должно быть напечатано первым, так как вызов выше является асинхронным и не блокирующим\n");
future.join(); // присоединяет поток, выполняющийся
Заключение
Тема парсинга веб-страниц на Java огромна и имеет разнообразные применения. Целью этой статьи является предоставление основ парсинга, но мы только коснулись этой темы. Более продвинутые темы, такие как использование прокси, облачные драйверы, поиск по конкретному географическому местоположению и т. д., выходят за рамки этой статьи.