CoderCastrov logo
CoderCastrov
Парсер

Парсинг веб-страниц с помощью Java

Парсинг веб-страниц с помощью Java
просмотров
10 мин чтение
#Парсер

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

В этой статье мы рассмотрим, как мы можем использовать 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 огромна и имеет разнообразные применения. Целью этой статьи является предоставление основ парсинга, но мы только коснулись этой темы. Более продвинутые темы, такие как использование прокси, облачные драйверы, поиск по конкретному географическому местоположению и т. д., выходят за рамки этой статьи.