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