Когда Scrapy тесно, а Python медленный: собираем данные на Elixir с помощью Crawly
Репозиторий давно не обновлялся
Последнее обновление было 1 год назад.
Когда речь заходит о парсинге сайтов и сборе данных в промышленных масштабах, почти каждый разработчик сразу вспоминает Python и Scrapy. Это стандарт индустрии, проверенный годами. Но у Python есть особенность: когда нужно выкачивать сотни страниц параллельно, удерживать тысячи открытых соединений и сразу на лету чистить данные, приходится упираться в потоки, асинхронность и GIL.
В мире функционального программирования для таких задач идеально подходит виртуальная машина BEAM. Процессы в Erlang и Elixir изолированы, весят считанные килобайты и создаются миллионами. Фреймворк Crawly берет лучшую архитектурную концепцию из Scrapy (пауки, мидлвары, пайплайны обработки) и переносит ее на рельсы Elixir. Получается инструмент, который из коробки готов держать огромную нагрузку по сетевым запросам без сложного тюнинга асинхронного рантайма.
Что внутри и как это работает
Если вы хоть раз писали парсер на Scrapy, архитектура Crawly покажется родной. Вся работа делится на три понятные части: пауки (Spiders), промежуточные слои (Middlewares) и конвейеры обработки элементов (Pipelines).
Паук описывает начальные точки входа и логику извлечения данных со страниц.
Вот типичный пример паука, который ходит по каталогу книг, собирает названия с ценами и переходит по страницам пагинации:
defmodule BooksToScrape do
use Crawly.Spider
@impl Crawly.Spider
def base_url(), do: "https://books.toscrape.com/"
@impl Crawly.Spider
def init() do
[start_urls: ["https://books.toscrape.com/"]]
end
@impl Crawly.Spider
def parse_item(response) do
{:ok, document} = Floki.parse_document(response.body)
items =
document
|> Floki.find(".product_pod")
|> Enum.map(fn x ->
%{
title: Floki.find(x, "h3 a") |> Floki.attribute("title") |> Floki.text(),
price: Floki.find(x, ".product_price .price_color") |> Floki.text(),
url: response.request_url
}
end)
next_requests =
document
|> Floki.find(".next a")
|> Floki.attribute("href")
|> Enum.map(fn url ->
Crawly.Utils.build_absolute_url(url, response.request.url)
|> Crawly.Utils.request_from_url()
end)
%Crawly.ParsedItem{items: items, requests: next_requests}
end
end
Код чистый и декларативный. Парсинг HTML делается через библиотеку Floki с привычными CSS-селекторами. Из функции parse_item мы возвращаем структуру, где лежат готовые данные и пачка новых запросов для планировщика.
Настройка пайплайнов и защита от блокировок
Парсер редко состоит только из HTTP-клиента и парсера HTML. Нужно следить за уникальностью URL, ограничивать число одновременных запросов к домену, менять заголовки, фильтровать дубликаты и валидировать схему перед сохранением.
В Crawly все эти вещи вынесены в конфигурацию:
import Config
config :crawly,
closespider_timeout: 10,
concurrent_requests_per_domain: 8,
closespider_itemcount: 100,
middlewares: [
Crawly.Middlewares.DomainFilter,
Crawly.Middlewares.UniqueRequest,
{Crawly.Middlewares.UserAgent, user_agents: ["Crawly Bot"]}
],
pipelines: [
{Crawly.Pipelines.Validate, fields: [:url, :title, :price]},
{Crawly.Pipelines.DuplicatesFilter, item_id: :title},
Crawly.Pipelines.JSONEncoder,
{Crawly.Pipelines.WriteToFile, extension: "jl", folder: "/tmp"}
]
Что здесь происходит? Запросы сначала проходят через цепочку мидлваров: фильтр чужих доменов не даст пауку случайно уйти сканировать весь интернет, а UniqueRequest отсечет повторные заходы на одни и те же страницы. Когда паук вытащил данные, они попадают в пайплайн. Там проверяются обязательные поля, отсеиваются дубликаты по заголовку, а валидный результат пакуется в JSON Lines и ложится на диск.
Генераторы экономят время при старте: команда mix crawly.gen.spider создаст заготовку паука со всеми нужными коллбеками, а mix crawly.gen.config подготовит дефолтный конфиг.
Встроенная панель управления
Интересная деталь: начиная с версии 0.15.0 в проект добавили веб-интерфейс управления прямо из коробки. Он доступен по адресу localhost:4001.
Через эту админку можно:
- запускать и останавливать пауков вручную,
- смотреть очередь запланированных запросов,
- скачивать собранные элементы и просматривать логи выполнения,
- отслеживать состояние краулера в реальном времени.

Если вы встраиваете Crawly в существующее веб-приложение на Phoenix или Plug, админку не обязательно держать на отдельном порту. Ее можно просто пробросить в общий роутер через forward "/admin", Crawly.API.Router.
Рендеринг динамических страниц и запуск без Elixir
Современный веб перегружен JavaScript. Если контент подгружается асинхронно через AJAX или приложение собрано на React, обычный HTTP GET вернет пустой скелет страницы. Crawly умеет работать с внешними рендерерами вроде Chrome или Splash. Вы настраиваете headless-браузер, и фреймворк забирает уже отрендеренный DOM со всеми выполненными скриптами.
Еще одна неочевидная фича — автономный режим (Standalone). Если в вашей команде никто не пишет на Elixir, поднимать ради краулера полноценную кодовую базу не нужно. Crawly умеет работать в минималистичном Docker-контейнере, где правила сбора страниц описываются простыми YAML-файлами. Это редкий пример, когда эликсировский инструмент открыт для разработчиков из других стеков.
Практические сценарии
Где Crawly раскрывается лучше всего:
- Мониторинг цен в интернет-магазинах. Когда нужно регулярно обходить тысячи карточек товаров, проверять изменения остатков и скидок.
- Сбор обучающих выборок для ML. Парсинг статей, отзывов и форумов с сохранением в форматы jsonl.
- Агрегаторы объявлений. Сбор предложений по недвижимости или автомобилям с десятков региональных досок.
- Историческое архивирование контента. Быстрая выкачка блогов и документов с фильтрацией битых ссылок.
Кому проект пригодится
Если ваш основной язык Elixir или Erlang, Crawly — безальтернативно лучший выбор для веб-скрапинга. Вам не придется городить костыли на Python рядом с основным сервисом и настраивать межсервисное общение через очереди.
Если вы пишете на Python и устали бороться с производительностью Scrapy на больших объемах данных, на Crawly точно имеет смысл взглянуть. Порог входа здесь низкий: концепции совпадают практически один в один, а синтаксис Elixir после Python читается очень легко. Начать можно с официальной документации на HexDocs и быстрого туториала на тестовом сайте books.toscrape.com.
