Назад к articles
article

История создания библиотеки для парсинга

Об идее

Идея автоматизировать создание краулеров пришла ко мне почти сразу после того, как я начал работать программстом. Многие заказчики заказывали инструменты для извлечения данных из публичных источников для использования на своих сайтах. Создание каждого краулера было похожим: изучить структуру сайта, написать шаблонный код для получения данных из определенных частей страницы. Я хотел оптимизировать этот процесс, но, будучи сотрудником, у меня не было времени писать библиотеки, и от меня ожидали выполнения проектов здесь и сейчас.

Спустя некоторое время я сменил работу и язык программирования на C# с платформой ASP.NET Core (версия 3). Я хотел быстро улучшить свои навыки C#, поэтому решил реализовать пет-проект, используя новый язык. Старая идея упрощения создания краулеров заиграла новыми красками. Я посмотрел на некоторые сайты и подумал: "Если бы кто-то мог описать, какие блоки содержат нужную информацию, нам вообще не нужно было бы писать код. Код можно было бы генерировать автоматически."

Первая концепция

Я решил исследовать, можно ли открывать веб-страницы в окне и перехватывать селектор элемента, на который пользователь навел курсор или кликнул. Чтобы проверить эту теорию, я создал минимальное API, которое запрашивало всё содержимое страницы по указанному URL. Интерфейс приложения запрашивал страницу, введенную пользователем, и внедрял JavaScript-скрипт, который рисовал красный прямоугольник вокруг элемента при наведении и логировал клики в консоль. Это работало на простых HTML-сайтах, поэтому я решил приступить к реализации проекта.

Первая реализация и возникшие проблемы

После множества итераций я создал проект "web-crawler" со следующей концепцией:

  1. Пользователи должны создавать "схемы краулера", чтобы описывать, как извлекать данные со страницы, используя удобный интерфейс.
  2. Затем пользователь должен выбрать, с каких страниц извлекать данные. Когда пользователь впервые вводил адрес страницы, в фоне начиналась загрузка карт сайта (sitemaps). Предполагалось, что после создания схемы обхода "web-crawler" будет достаточно знать о страницах сайта. Пользователь мог затем выбрать интересующие страницы, используя шаблоны.
  3. Как только схема готова, пользователь мог запустить процесс обхода вручную или настроить расписание.
  4. Результаты можно было выгружать вручную или отправлять через вебхук в формате CSV или JSON.

Я попросил своего друга помочь мне создать красивый интерфейс для этого приложения, и вскоре у нас появилось приложение, которое строит схему обхода и запускает её:

Шаг 1: Построение схемы

Схема обхода

Шаг 2: Выбор страниц

Страницы схемы обхода

Шаг 3: Запуск и получение результата

Результат схемы обхода

Основные проблемы, с которыми мы столкнулись:

  1. Некоторые страницы требовали рендеринга JavaScript. Включение JavaScript могло нарушить работу окна, в котором открывалась страница. После нескольких итераций я создал веб- и десктоп-версии приложения. Веб-версия позволяла создавать схемы для простых парсеров, а десктопная версия позволяла включать JavaScript и помечать данные.
  2. Загрузка страниц часто была проблематичной. Существует множество сервисов, помогающих с рендерингом JavaScript и позволяющих избежать обнаружения краулеров, но проект не приносил дохода, и использование этих сервисов было неоправданно. Я написал несколько ротаторов прокси, но часто было трудно определить, работает ли прокси и был ли успешен запрос с его использованием. Это была проблема, которую я не знал, как решить.

Переосмысление концепции

Затем наступил период, когда у меня не было свободного времени, и я на год забыл о проекте. Я вспомнил о нем, когда начал разрабатывать другой пет-проект. Мне все еще нужен был инструмент для парсинга сайтов с рендерингом JavaScript и хорошей защитой от краулеров. У меня возникла новая идея: разделить загрузку страницы и определение схемы. Загрузка страницы могла быть сложной – решение капч, ротация прокси – и не всегда было возможно создать полный конвейер через интерфейс. Я решил пожертвовать простотой, отказаться от интерфейса и создать библиотеку на C#. Я представлял себе нечто подобное:

var schema = new StaticCrawlingSchema() // или DynamicCrawlingSchema, в зависимости от того, нужен ли JS для загрузки страницы
    .HasProperty("title", Types.String, ".title")
    .HasObjectProperty("user", ".user", userBuilder =>
    {
        userBuilder.HasProperty("name", Types.String, ".name")
            .HasProperty("age", Types.Int, ".age")
            .HasArrayProperty("dogs", ".dog", dogsBuilder =>
            {
                dogsBuilder.HasProperty("age", Types.Int, ".age")
                    .HasProperty("name", Types.String, ".name");
            });
    })

Схема должна быть строго типизированной, а краулер должен был автоматически обрабатывать все преобразования типов: целочисленные поля должны парситься как Int32, даты как DateTime и т.д. Если преобразование не было тривиальным, пользователь мог использовать перегрузку, принимающую функцию преобразования. Схема также должна была позволять выполнять сложные действия, такие как клик или наведение курсора, перед извлечением значения элемента.

Первая реализация библиотеки

Библиотека была реализована близко к первоначальному плану. Пользователи определяли класс для данных, которые они хотели извлечь, и схему, описывающую, как получить эти данные со страницы. Затем они выбирали краулер, который мог работать с этой схемой, и запускали обработку. Изначально было две реализации, которые могли работать со схемой: AngleSharp для статических схем и PuppeteerSharp для динамических.

Вторая реализация

Проблема первой версии заключалась в том, что динамические и статические схемы были почти несовместимы. Типичный рабочий процесс заключался в том, чтобы начать со статической схемы и переключиться на динамическую, если требовался рендеринг JavaScript. Это требовало значительных изменений в схеме. Я решил максимально унифицировать определение схемы, что привело к созданию нового класса билдера:

public class DocumentSchemaBuilder<TElement, TModel>
where TModel : class, ICrawlingModel
{
}

с наследниками AngleSharpSchemaBuilder<TModel> и PuppeterSharpSchemaBuilder<TModel>. У каждого из них была своя реализация:

interface ICrawlingAdapter<in TNode>
{
    TDestination? MapValue<TDestination>(string? element);
    Task<object?> GetValueAsync(TNode? element, Type destinationType);
    Task<string?> GetInnerTextAsync(TNode? element);
    Task<string?> GetAttributeTextAsync(TNode? element, string attributeName);
}

для извлечения данных из элементов. Этот подход покрывал большинство случаев, связанных с несовместимостью схем, и переключение между схемами стало проще (изменения от статической к динамической обычно не требовали модификации схемы).

Парсинг чего угодно

Однажды мне понадобилось извлечь данные из XML-файла. XML очень похож на HTML, поэтому я добавил небольшие улучшения в библиотеку для поддержки парсинга XML. Класс билдера документов был расширен для поддержки различных селекторов: HTML и XML.

public class DocumentSchemaBuilder<TElement, TSelector, TModel>
where TModel : class, ICrawlingModel
{
}

Архитектура библиотеки получилась достаточно гибкой, добавить поддержку новых условий, если они возникнут, не составит труда.

Заключение

Это было интересное приключение по созданию библиотеки, которая помогает (по крайней мере мне) извлекать данные из интернета. Я думаю, что объединение краулинга с ИИ могло бы дать интересные результаты. Например, схема обхода могла бы создаваться автоматически на основе содержимого страницы. Но это уже история для будущих итераций.