Назад к статьям
Статья

От SaaS-приложения к open-source библиотеке — история создания парсера сайтов на C# за 5 лет

Написать инструмент для парсинга сайтов на C# — идея, к которой я возвращался на протяжении пяти лет с двумя принципиально разными подходами. Что начиналось как no-code SaaS-приложение — с визуальным конструктором схем, аккаунтами пользователей, кошельком и доставкой по webhook — в итоге стало Laraue.Crawling: строго типизированной open-source .NET-библиотекой. Статья охватывает весь путь: идея, архитектура первой реализации с тестами, где всё сломалось и почему отказ от интерфейса в пользу библиотеки оказался правильным решением.


Идея: парсинг сайтов без программирования

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

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

Для проверки гипотезы я сделал минимальный прототип: API, получающий сырой HTML страницы, и JavaScript-скрипт, инжектируемый во фронтенд, — он рисовал красный прямоугольник вокруг элемента при наведении и логировал клики в консоль. На простых статических сайтах это работало — достаточно, чтобы начать полноценную реализацию.


Первое приложение: архитектура и функциональность

После многих итераций приложение приняло форму трёхшагового процесса:

Шаг 1: Создать схему. Пользователь открывал URL страницы в приложении, наводил курсор на элементы и кликал, отмечая поля данных. Приложение автоматически захватывало CSS-селекторы и строило ParsingSchemeInfo — структурированное описание того, что и как извлекать.

Шаг 2: Выбрать страницы. После создания схемы приложение загружало sitemap сайта в фоне. Пользователи выбирали страницы по паттерну — например, обойти все URL вида /products/* — вместо ручного указания каждой.

Шаг 3: Запустить и получить результат. Парсинг запускался вручную или по расписанию. Результаты доставлялись в виде CSV, JSON или через webhook на произвольный endpoint.

Создание схемы парсинга

Выбор страниц для парсинга

Запуск и скачивание результатов


Как работал движок схем

Ядром приложения был движок схем парсинга. ParsingSchemeInfo хранил дерево блоков — каждый блок описывал одно правило извлечения. Три типа блоков:

ItemBlock — извлекает скалярное значение или список значений по CSS-селектору. Флаг IsSingle управляет тем, возвращается ли первое совпадение или все. Опциональное свойство Attribute извлекает атрибут элемента (например, src или href) вместо текстового содержимого.

ObjectBlock — извлекает структурированный объект или массив объектов. Дочерние ItemBlock-определения описывают свойства каждого объекта.

KeyBlock — группирует дочерние блоки под именованным ключом в итоговом JSON, не добавляя собственного селектора.

ParsingSchemeTests из оригинального репозитория (исходник) показывают, как они компонуются:

// ItemBlock: извлечь текст из всех совпадающих div
var scheme = new ParsingSchemeInfo
{
    Entities = new[] { new ItemBlock { Name = "F1", HtmlSelector = "div", IsSingle = false } }
};
var result = await scheme.ParseDataAsync("<div>12</div><div>13</div>");
// result["F1"] → JArray [12, 13]

// ItemBlock: извлечь атрибут
var scheme = new ParsingSchemeInfo
{
    Entities = new[] { new ItemBlock { Name = "Image", HtmlSelector = "img", IsSingle = true, Attribute = "src" } }
};
var result = await scheme.ParseDataAsync("<img src=\"test\" />");
// result["Image"] → "test"

// ObjectBlock: извлечь структурированные строки из таблицы
var scheme = new ParsingSchemeInfo
{
    Entities = new[]
    {
        new ObjectBlock
        {
            Name = "Value", HtmlSelector = "tr td", IsSingle = false,
            Entities = new ItemBlock[]
            {
                new() { Name = "H1", HtmlSelector = "h1", IsSingle = true },
                new() { Name = "H2", HtmlSelector = "h2", IsSingle = true },
            }
        }
    }
};
// result["Value"] → [{ H1: "Record1_h1", H2: "Record1_h2" }, { H1: "Record2_h1", H2: "Record2_h2" }]

Схема также поддерживала четыре варианта ParsingMode для управления тем, какой текст извлекается из найденного элемента:

Режим Возвращает
InnerText Только текстовое содержимое, теги убраны
InnerHtml HTML внутри элемента
OuterHtml Сам элемент вместе с тегом
InnerTextInOriginalTag Текст, обёрнутый в оригинальный тег элемента

Последний режим был полезен для сохранения семантического контекста при вставке спаршенного содержимого на другой сайт.


Система кошелька: кредиты за парсинг

Приложение было спроектировано как SaaS. Операции парсинга списывали кредиты с кошелька пользователя. Кошелёк имел два типа баланса: реальный баланс и бонусный баланс (промо-кредиты). Бонусы расходовались первыми — реальный баланс уменьшался только после полного расхода бонусов.

WalletTests (исходник) раскрывают паттерн транзакций reserve-commit-rollback:

// Зарезервировать средства перед запуском задачи парсинга
using var reservedTransaction = await _mediator.Send(
    new ReserveBalanceCommand(userId, transactionId, TransactionReason.ParsingWithdrawal, 30M));

// Если задача выполнена успешно — commit: баланс уменьшается окончательно
var balanceChange = await reservedTransaction.CommitAsync();
// balanceChange.Difference → -29.7M (реальный), balanceChange.BonusDifference → -0.3M (бонус расходован первым)

// Если задача провалилась — Dispose() откатывает: баланс восстанавливается
reservedTransaction.Dispose(); // идемпотентен — безопасно вызывать дважды

Шаг резервирования блокирует средства, чтобы пользователь не мог потратить их в другом месте пока задача выполняется. Шаг commit записывает постоянную транзакцию. Откат через Dispose идемпотентен — двойной вызов был явно протестирован.

Система также корректно обрабатывала конкурентный доступ. Стресс-тест отправлял 100 одновременных команд резервирования на разные суммы и проверял точность итогового баланса после всех коммитов:

var reservedTransactions = await Task.WhenAll(
    Enumerable.Range(1, 100)
        .Select(i => _mediator.Send(new ReserveBalanceCommand(
            userId, transactionId, TransactionReason.ParsingWithdrawal, 0.01M * i))));

await Task.WhenAll(reservedTransactions.Select(x => x.CommitAsync()));

var balance = await _mediator.Send(new GetBalanceCommand(userId));
Assert.Equal(49.8M, balance.Total); // 100 - sum(0.01..1.00) = 49.8

Реализация на MediatR с WebSocket-уведомлениями в браузер при изменении баланса — мок IUserWebSocketHandler в тестах фиксировал эти вызовы.


Где всё сломалось

Проблема 1: рендеринг JavaScript

Оригинальный конструктор схем работал путём инжекции JavaScript в страницу, рендеримую в iframe внутри фронтенда приложения. На статических HTML-сайтах это работало. Но многие реальные сайты загружают контент динамически — инжектированный скрипт выполнялся до того, как контент появлялся, и кликать было не на что.

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

Проблема 2: антибот-защита и управление прокси

Сайты, блокирующие краулеры, оказались сложнее. Облачные прокси-сервисы существовали, но стоили денег, а без выручки это не было оправдано. Я написал несколько реализаций ротаторов прокси, но проверить, действительно ли конкретный прокси работает и прошёл ли запрос через него успешно, — было по-настоящему сложно. Одни прокси выглядели рабочими, но возвращали кэшированные или подменённые ответы. Другие молча дропали запросы. Эту проблему я не знал, как решить надёжно в рамках ресурсов проекта.

Проблема 3: инструмент всё равно был для разработчиков

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

Приложение решало проблему пользователя, которого практически не существовало.


Переосмысление: концепция библиотеки

Перерыв в работе над проектом дал ясность. Что мне реально было нужно — для других пет-проектов — это надёжный C#-инструмент для извлечения структурированных данных как из статических, так и из JavaScript-страниц, без повторной реализации бойлерплейта каждый раз.

Новый дизайн: полностью отказаться от интерфейса. Написать C#-библиотеку. Пусть разработчики сами управляют ротацией прокси, расписанием и доставкой как удобно. Единственная задача библиотеки — определять схемы и запускать их.

Задуманный API:

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, а функции трансформации обрабатывают крайние случаи. Переход со статической схемы на динамическую должен требовать минимальных или нулевых изменений.


Библиотека: две реализации

Первая реализация

В первой версии были отдельные типы StaticCrawlingSchema и DynamicCrawlingSchema, построенные на 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);
}

С такой структурой замена AngleSharpSchemaBuilder на PuppeterSharpSchemaBuilder требовала только смены имени класса билдера — привязки свойств, иерархии объектов и функции трансформации оставались идентичными.

Поддержка XML

Необходимость парсить XML в одном из проектов потребовала небольшой обобщизации. Тип селектора стал generic-параметром:

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

Это разделило семантику CSS-селектора HTML и XPath без дублирования логики схем. Один паттерн билдера теперь работает для RSS-лент, sitemaps и XML API-ответов.


Библиотека сегодня

Laraue.Crawling — открытая библиотека, и мы продолжаем её поддерживать. Это был краулер приложения по недвижимости, которое собрало больше 100 000 объявлений с Авито и Циана; мы запускали его на локальной машине время от времени, сейчас не запускаем. В статье об этой системе разобрано, как там используются схема и паттерн раннего завершения.

Библиотека открыта (MIT), поддерживает современные версии .NET, доступна на NuGet:

dotnet add package Laraue.Crawling.Static.AngleSharp      # статический HTML
dotnet add package Laraue.Crawling.Dynamic.PuppeterSharp  # JavaScript-страницы
dotnet add package Laraue.Crawling.Static.Xml              # XML

Исходный код: github.com/win7user10/Laraue.Crawling


Выводы

Валидируйте, кто реально ваш пользователь, до постройки инфраструктуры для него. Кошелёк, вебхуки, UI расписания и ротаторы прокси — настоящая инженерная работа, написанная для пользователя, которым оказался разработчик, предпочитающий API.

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

Проблемы прокси и антибот-защиты заслуживают отдельного продукта. Надёжная ротация прокси — тяжёлая, постоянная операционная проблема. Встраивать её в инструмент определения схем было scope creep с самого начала.

Библиотека без UI обслуживает реального пользователя. Разработчики компонуют библиотеку с любыми подходящими им решениями для расписания, прокси, доставки и мониторинга. Никаких решений интерфейса, с которыми приходится бороться.