Обзорная статья о том, как писать парсеры (скраперы) на Ruby — от загрузки одной страницы до многопоточного сбора данных через прокси и TOR. Все примеры рабочие, ориентированы на Ruby 3.x.
Оглавление
- Введение: что такое парсинг и о чём подумать до старта
- Как забираем страницу
- Библиотеки для парсинга содержимого
- Решение проблем с парсингом кириллицы
- Статус ответа и работа с заголовками
- Работа с HTTPS / SSL
- Работа с cookie
- Использование прокси
- Парсинг через TOR
- Multi / многопоточность
- Страницы с JavaScript (headless-браузеры) (добавлено)
- Антибот-защита, User-Agent, задержки, ретраи (добавлено)
- Хранение URL и работа с очередями
- Готовые фреймворки для парсинга (добавлено)
- Основные плюсы и минусы реализации
- Заключение и чеклист
1. Введение
Парсинг (web scraping) — это автоматический сбор данных с веб-страниц. Процесс почти всегда состоит из двух разных задач, которые важно не смешивать:
- Загрузка — получить «сырой» ответ сервера (HTTP-клиент).
- Извлечение — вытащить из ответа нужные данные (парсер HTML/JSON).
В Ruby для каждой задачи есть свои инструменты, и хороший парсер обычно комбинирует один HTTP-клиент с одним парсером.
О чём подумать до того, как писать код
Прежде чем парсить, стоит учесть несколько вещей — это сэкономит время и нервы:
robots.txt. Файлhttps://site.ru/robots.txtописывает, что владелец разрешает обходить ботам. Юридически он не всегда обязателен, но игнорировать его — плохой тон и риск.- Условия использования (ToS). Некоторые сайты прямо запрещают автоматический сбор. Это уже правовой вопрос, а не технический.
- Нагрузка. Парсер легко превращается в DoS-атаку. Делайте задержки и не бейте по серверу в сотни потоков без необходимости.
- Есть ли API? Часто проще и легальнее взять официальный API или внутренний JSON-эндпоинт, чем парсить HTML.
- Персональные данные. Сбор и хранение ПД регулируется законами (GDPR, 152-ФЗ и т.д.).
Простая проверка robots.txt штатными средствами через gem
webrobots:
require 'open-uri'
require 'webrobots' # gem install webrobots
robots = WebRobots.new('MyParserBot/1.0')
url = 'https://example.com/some/page'
if robots.allowed?(url)
puts "Можно парсить"
else
puts "robots.txt запрещает этот путь"
end
2. Как забираем страницу
Это фундамент. Рассмотрим клиенты от самого простого к самому гибкому.
2.1. open-uri — самый быстрый способ
open-uri — часть стандартной библиотеки. Идеален для «получить страницу одной
строкой».
require 'open-uri'
html = URI.open('https://example.com').read
puts html
С заголовками и таймаутами:
require 'open-uri'
html = URI.open(
'https://example.com',
'User-Agent' => 'Mozilla/5.0 (compatible; MyBot/1.0)',
open_timeout: 5,
read_timeout: 10
).read
Плюсы: ничего не нужно ставить, минимум кода.
Минусы: неудобно работать с POST, заголовками ответа, редиректами, ошибками
(на 404/500 кидает исключение OpenURI::HTTPError).
2.2. Net::HTTP — стандартная библиотека, полный контроль
Net::HTTP тоже встроен в Ruby. Многословен, но даёт доступ ко всему.
require 'net/http'
require 'uri'
uri = URI('https://example.com/search?q=ruby')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == 'https')
http.open_timeout = 5
http.read_timeout = 10
request = Net::HTTP::Get.new(uri)
request['User-Agent'] = 'MyBot/1.0'
response = http.request(request)
puts response.code # "200"
puts response.body # тело ответа
puts response['Content-Type']
POST-запрос:
uri = URI('https://example.com/login')
res = Net::HTTP.post_form(uri, 'user' => 'admin', 'pass' => 'secret')
puts res.body
Плюсы: без зависимостей, полный контроль над запросом/ответом. Минусы: verbose, ручная обработка редиректов, не самый приятный API.
2.3. http.rb (gem http) — современный и удобный
Чистый, цепочечный (chainable) API. Один из лучших выборов «по умолчанию».
require 'http' # gem install http
response = HTTP
.headers('User-Agent' => 'MyBot/1.0')
.timeout(connect: 5, read: 10)
.follow # автоматически следовать редиректам
.get('https://example.com')
puts response.status # 200
puts response.to_s # тело
puts response.headers['Content-Type']
POST с JSON:
response = HTTP.post(
'https://api.example.com/items',
json: { name: 'Widget', qty: 3 }
)
data = response.parse # автоматически распарсит JSON
2.4. Faraday — клиент с middleware
Faraday — это «обёртка над обёртками». Главная фишка — middleware-стек: можно
подключать логирование, ретраи, парсинг JSON, обработку ошибок как слои.
require 'faraday' # gem install faraday
require 'faraday/retry' # gem install faraday-retry
conn = Faraday.new(url: 'https://example.com') do |f|
f.request :retry, max: 3, interval: 0.5 # авто-ретраи
f.response :raise_error # 4xx/5xx -> исключение
f.options.timeout = 10
f.headers['User-Agent'] = 'MyBot/1.0'
end
response = conn.get('/data')
puts response.status
puts response.body
Faraday хорош, когда парсер растёт в полноценное приложение: единая точка
конфигурации для всех запросов.
2.5. Typhoeus — когда нужна скорость и параллелизм
Typhoeus — обёртка над libcurl. Главное преимущество — Hydra, которая делает
много запросов параллельно (см. раздел о многопоточности).
require 'typhoeus' # gem install typhoeus
response = Typhoeus.get(
'https://example.com',
headers: { 'User-Agent' => 'MyBot/1.0' },
timeout: 10,
followlocation: true
)
puts response.code
puts response.body
puts response.headers['Content-Type']
Что выбрать
| Клиент | Когда брать |
|---|---|
open-uri |
разовый скрипт, «дай мне эту страницу» |
Net::HTTP |
нельзя ставить gem'ы, нужен полный контроль |
http (http.rb) |
дефолт для большинства парсеров |
Faraday |
растущее приложение, нужны middleware/ретраи |
Typhoeus |
массовый параллельный сбор |
Mechanize |
нужна эмуляция браузера с формами/cookie (раздел 14) |
3. Библиотеки для парсинга содержимого
Получили HTML — теперь извлекаем данные.
3.1. Nokogiri — стандарт де-факто
Nokogiri парсит HTML и XML, поддерживает CSS-селекторы и XPath. Это
основной инструмент для 95% задач.
require 'nokogiri'
require 'open-uri'
html = URI.open('https://example.com').read
doc = Nokogiri::HTML(html)
# CSS-селекторы
title = doc.css('h1.title').text.strip
links = doc.css('a').map { |a| a['href'] }
# один элемент vs все
first = doc.at_css('div.price') # первый совпавший (или nil)
all = doc.css('div.item') # NodeSet всех совпавших
# XPath (мощнее для сложных условий)
prices = doc.xpath('//div[@class="price"]/text()').map(&:to_s)
# Атрибуты и вложенность
doc.css('article.post').each do |post|
title = post.at_css('h2')&.text&.strip
date = post.at_css('time')&.[]('datetime')
body = post.at_css('.content')&.text&.strip
puts "#{date} — #{title}"
end
CSS vs XPath — когда что:
- CSS — короче и читабельнее для простых выборок:
div.item > a.link. - XPath — мощнее: поиск по тексту, по родителю, по позиции:
# ссылка, текст которой содержит "Скачать" doc.xpath('//a[contains(text(), "Скачать")]') # элемент, у которого предок — div с id="main" doc.xpath('//div[@id="main"]//span[@class="price"]') # выбор по индексу doc.xpath('(//tr)[3]')
3.2. Парсинг JSON — не забывайте про него
Очень часто данные лежат не в HTML, а в JSON (внутренний API сайта, который грузит страница через AJAX). Открыть вкладку Network в браузере и найти JSON-эндпоинт — часто проще, чем парсить HTML.
require 'json'
require 'http'
raw = HTTP.get('https://api.example.com/products?page=1').to_s
data = JSON.parse(raw, symbolize_names: true)
data[:products].each do |p|
puts "#{p[:name]}: #{p[:price]}"
end
3.3. Другие парсеры
Oga— альтернатива Nokogiri на чистом Ruby (без C-расширений). Медленнее, но проще ставится. Полезно там, где Nokogiri тяжело собрать.Loofah(поверх Nokogiri) — для очистки/санитизации HTML.- Регулярные выражения — избегайте парсинга HTML регэкспами. Они ломаются на любом изменении вёрстки. Регэксп уместен только для извлечения мелочи из уже выбранного текста (телефон, цена из строки и т.п.).
# ОК: достать число из уже выбранного текста
price_text = doc.at_css('.price').text # "1 299 ₽"
price = price_text.gsub(/[^\d]/, '').to_i # 1299
4. Кириллица и кодировки
Самая частая боль русскоязычного парсинга — «кракозябры» вместо текста. Причина в том, что HTTP-ответ — это байты, и Ruby должен правильно интерпретировать их кодировку (UTF-8, Windows-1251, KOI8-R и т.д.).
4.1. Откуда берётся проблема
Ruby у каждой строки хранит её encoding. Если байты в Windows-1251, а Ruby думает,
что это UTF-8, — получаем мусор.
str = response.body
puts str.encoding # например, ASCII-8BIT или UTF-8
puts str.valid_encoding? # false -> что-то не так
4.2. Определяем кодировку и конвертируем
Кодировку можно узнать из:
1. заголовка Content-Type: text/html; charset=windows-1251;
2. мета-тега <meta charset="..."> внутри HTML;
3. эвристики (библиотека rchardet/charlock_holmes).
Конвертация вручную (если знаем исходную кодировку):
# из Windows-1251 в UTF-8
utf8 = body.force_encoding('Windows-1251').encode('UTF-8')
force_encoding только меняет «ярлык» кодировки без перекодирования байтов, а
encode уже реально перекодирует байты в целевую кодировку. Порядок важен:
сначала сказать Ruby правду об исходных байтах, потом перекодировать.
Безопасная конвертация с заменой битых символов:
utf8 = body.encode(
'UTF-8',
'Windows-1251',
invalid: :replace,
undef: :replace,
replace: '?'
)
4.3. Nokogiri и кодировки — правильный способ
Лучше всего передать кодировку прямо в Nokogiri — он сам всё перекодирует:
require 'nokogiri'
# если знаем кодировку
doc = Nokogiri::HTML(body, nil, 'Windows-1251')
# Nokogiri умеет сам читать <meta charset>, если не мешать:
doc = Nokogiri::HTML(body) # часто достаточно
puts doc.css('h1').text # уже в UTF-8
4.4. Автоопределение кодировки
Когда сайт не указывает charset честно, помогает charlock_holmes (на базе ICU):
require 'charlock_holmes' # gem install charlock_holmes
detection = CharlockHolmes::EncodingDetector.detect(body)
puts detection[:encoding] # => "windows-1251"
puts detection[:confidence] # => 90
utf8 = body.encode('UTF-8', detection[:encoding],
invalid: :replace, undef: :replace)
4.5. Универсальный хелпер
def to_utf8(body, content_type = nil)
# 1. пробуем из заголовка
if content_type && content_type =~ /charset=([\w-]+)/i
enc = $1
return body.encode('UTF-8', enc, invalid: :replace, undef: :replace)
end
# 2. уже валидный UTF-8?
test = body.dup.force_encoding('UTF-8')
return test if test.valid_encoding?
# 3. автоопределение
require 'charlock_holmes'
det = CharlockHolmes::EncodingDetector.detect(body)
body.encode('UTF-8', det[:encoding] || 'UTF-8',
invalid: :replace, undef: :replace)
end
Правило: держите весь внутренний пайплайн в UTF-8. Конвертируйте на входе, сразу после загрузки, и больше об этом не думайте.
5. Статус ответа и заголовки
Прежде чем парсить тело, нужно убедиться, что ответ вообще валиден. Игнорировать HTTP-статус — частая ошибка новичка (парсишь страницу ошибки, думая, что это данные).
5.1. Чтение статуса и заголовков
require 'http'
resp = HTTP.get('https://example.com')
puts resp.status # 200 (объект статуса)
puts resp.status.to_i # 200 (число)
puts resp.status.success? # true для 2xx
puts resp.status.redirect? # true для 3xx
# заголовки
puts resp.headers['Content-Type']
puts resp.headers['Content-Length']
puts resp.headers['Server']
puts resp.content_type.mime_type # "text/html"
В Net::HTTP:
res = Net::HTTP.get_response(URI('https://example.com'))
puts res.code # "200"
puts res.message # "OK"
puts res['Set-Cookie']
res.each_header { |k, v| puts "#{k}: #{v}" }
5.2. Что делать с разными статусами
case resp.code
when 200 then process(resp.to_s)
when 301, 302 then follow_redirect(resp.headers['Location'])
when 404 then log("страница не найдена")
when 403, 429 then back_off # забанили/лимит — притормозить
when 500..599 then retry_later # ошибка сервера — повторить позже
end
Особенно важны:
- 429 Too Many Requests — вы слишком частите. Смотрите заголовок
Retry-After. - 403 Forbidden — часто это антибот. Меняйте User-Agent / прокси.
- 3xx +
Location— редирект; решите, следовать ли за ним.
5.3. Полезные заголовки
Content-Type— тип и кодировка контента.Set-Cookie— куки (см. раздел 7).Location— куда редиректит.Retry-After— через сколько можно повторить.ETag/Last-Modified— для кеширования и условных запросов (If-None-Match/If-Modified-Since→ 304 Not Modified, экономит трафик).
6. HTTPS / SSL
Большинство клиентов работают с HTTPS «из коробки» и проверяют сертификаты — это правильно и безопасно.
# http.rb, Faraday, Typhoeus, open-uri — HTTPS работает автоматически
HTTP.get('https://example.com')
В Net::HTTP нужно явно включить SSL:
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_PEER # по умолчанию, проверять серт
Отключение проверки сертификата — осторожно!
Иногда сайт с самоподписанным/«битым» сертификатом. Отключение проверки открывает дыру для MITM, поэтому делайте это только осознанно:
# Net::HTTP
http.verify_mode = OpenSSL::SSL::VERIFY_NONE # НЕ для продакшена
# http.rb
ctx = OpenSSL::SSL::SSLContext.new
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
HTTP.get('https://self-signed.example.com', ssl_context: ctx)
# Typhoeus
Typhoeus.get('https://example.com', ssl_verifypeer: false)
Проблема «certificate verify failed»
Частая ошибка на свежей установке Ruby/Windows — нет актуального набора корневых сертификатов. Решения:
- обновить gem
certifi/ системные ca-certificates; - указать путь к bundle:
http.ca_file = '/path/to/cacert.pem'; - на macOS/Linux обычно достаточно обновить OpenSSL.
Можно также включить конкретную версию TLS, если сервер капризный:
http.min_version = OpenSSL::SSL::TLS1_2_VERSION
7. Cookie
Cookie нужны для сессий: логин, корзина, «человеческое» поведение. Сервер шлёт их в
Set-Cookie, а клиент должен возвращать в Cookie при следующих запросах.
7.1. Вручную
require 'http'
# получили куки
resp = HTTP.get('https://example.com/login')
cookies = resp.cookies # HTTP::CookieJar
# отправляем их в следующем запросе
resp2 = HTTP.cookies(cookies).get('https://example.com/account')
7.2. Сохранение сессии между запросами (http.rb)
require 'http'
require 'http-cookie'
jar = HTTP::CookieJar.new
# логинимся
login = HTTP.post('https://example.com/login',
form: { user: 'me', pass: 'secret' })
login.cookies.each { |c| jar.add(c) }
# используем сессию
page = HTTP.cookies(jar).get('https://example.com/dashboard')
7.3. Cookie в Net::HTTP
res = Net::HTTP.get_response(URI('https://example.com'))
cookie = res['Set-Cookie']
req = Net::HTTP::Get.new(URI('https://example.com/next'))
req['Cookie'] = cookie
7.4. Mechanize — куки «сами собой»
Для сложных сессий проще всего Mechanize (раздел 14): он автоматически хранит
cookie-jar между запросами, как браузер.
require 'mechanize'
agent = Mechanize.new
agent.get('https://example.com/login') do |page|
form = page.forms.first
form.field_with(name: 'user').value = 'me'
form.field_with(name: 'pass').value = 'secret'
form.submit
end
# куки уже сохранены, agent помнит сессию
dashboard = agent.get('https://example.com/dashboard')
7.5. Сохранение cookie на диск
Чтобы не логиниться каждый запуск:
agent.cookie_jar.save('cookies.yml') # сохранить
agent.cookie_jar.load('cookies.yml') # восстановить
8. Прокси
Прокси нужны, чтобы: обходить блокировки по IP, распределять нагрузку, скрывать источник, парсить из «нужной» гео-локации. При массовом парсинге обычно используют пул прокси с ротацией.
8.1. Прокси в разных клиентах
# open-uri
URI.open('https://example.com',
proxy: 'http://user:pass@1.2.3.4:8080').read
# Net::HTTP
proxy = Net::HTTP::Proxy('1.2.3.4', 8080, 'user', 'pass')
proxy.start('example.com', 443, use_ssl: true) do |http|
http.get('/')
end
# http.rb
HTTP.via('1.2.3.4', 8080, 'user', 'pass').get('https://example.com')
# Faraday
Faraday.new('https://example.com',
proxy: 'http://user:pass@1.2.3.4:8080').get
# Typhoeus
Typhoeus.get('https://example.com',
proxy: 'http://1.2.3.4:8080',
proxyuserpwd: 'user:pass')
8.2. Ротация прокси
class ProxyPool
def initialize(proxies)
@proxies = proxies
@index = 0
@mutex = Mutex.new
end
def next_proxy
@mutex.synchronize do
proxy = @proxies[@index]
@index = (@index + 1) % @proxies.size
proxy
end
end
end
pool = ProxyPool.new([
'http://1.1.1.1:8080',
'http://2.2.2.2:8080',
'http://3.3.3.3:8080'
])
10.times do
host, port = pool.next_proxy.sub('http://', '').split(':')
resp = HTTP.via(host, port.to_i).get('https://example.com')
puts resp.status
end
8.3. Типы прокси
- HTTP/HTTPS — обычные веб-прокси.
- SOCKS5 — низкоуровневые, проксируют любой трафик (нужны для TOR, см. ниже).
- Datacenter vs Residential — серверные дешевле, но легче банятся; резидентные (через реальных провайдеров) дороже, но «выглядят как люди».
8.4. Обработка дохлых прокси
Прокси часто отваливаются. Оборачивайте запрос в ретрай со сменой прокси:
def fetch_with_proxy(url, pool, attempts: 3)
attempts.times do
proxy = pool.next_proxy
begin
host, port = proxy.sub(%r{^https?://}, '').split(':')
resp = HTTP.timeout(connect: 5, read: 10)
.via(host, port.to_i)
.get(url)
return resp if resp.status.success?
rescue HTTP::Error, Errno::ECONNREFUSED, IO::TimeoutError => e
warn "Прокси #{proxy} не сработал: #{e.message}"
next
end
end
nil
end
9. Парсинг через TOR
TOR даёт бесплатную анонимность и «бесконечную» ротацию IP. Технически TOR — это
локальный SOCKS5-прокси (по умолчанию 127.0.0.1:9050).
9.1. Установка и запуск
# Linux
sudo apt install tor
sudo systemctl start tor
# macOS
brew install tor
brew services start tor
# проверка: TOR слушает 9050 (SOCKS) и опционально 9051 (control)
9.2. Запросы через TOR
Поскольку TOR — это SOCKS5, нужен клиент с поддержкой SOCKS. Удобнее всего socksify:
require 'socksify' # gem install socksify
require 'socksify/http'
require 'net/http'
require 'uri'
uri = URI('https://check.torproject.org')
Net::HTTP.SOCKSProxy('127.0.0.1', 9050).start(uri.host, uri.port, use_ssl: true) do |http|
res = http.get(uri.path)
puts res.body.include?('Congratulations') ? 'Через TOR ✓' : 'Не TOR ✗'
end
С http.rb через SOCKS:
require 'http'
require 'socksify/http'
# http.rb сам не умеет SOCKS, но Typhoeus умеет:
require 'typhoeus'
resp = Typhoeus.get('https://check.torproject.org',
proxy: 'socks5://127.0.0.1:9050')
puts resp.code
9.3. Смена цепочки (нового IP) через control-порт
Чтобы получить новый IP, шлём команду NEWNYM на control-порт (9051). Сначала надо
настроить его в /etc/tor/torrc:
ControlPort 9051
HashedControlPassword 16:... # сгенерировать: tor --hash-password "yourpass"
Затем:
require 'socket'
def tor_new_identity(password, host: '127.0.0.1', port: 9051)
sock = TCPSocket.new(host, port)
sock.puts %(AUTHENTICATE "#{password}")
raise 'auth failed' unless sock.gets.start_with?('250')
sock.puts 'SIGNAL NEWNYM'
sock.gets
ensure
sock&.close
end
# меняем личность каждые N запросов
tor_new_identity('yourpass')
sleep 5 # дать TOR построить новую цепочку
9.4. Ограничения TOR
- Медленно. Трафик идёт через 3 узла — задержки большие.
- Многие сайты блокируют выходные узлы TOR (списки exit-node публичны).
- Не для массового сбора — это перегружает сеть TOR, которая держится на волонтёрах. Для больших объёмов берите коммерческие резидентные прокси.
10. Многопоточность и параллелизм
Загрузка страниц — I/O-bound задача: бóльшую часть времени программа ждёт сеть. Значит, параллелизм даёт огромный выигрыш, и GIL (Global VM Lock) Ruby тут не мешает: во время сетевого ожидания поток освобождает GIL, и другие работают.
10.1. Простые потоки (Thread)
require 'http'
urls = %w[https://example.com/1 https://example.com/2 https://example.com/3]
threads = urls.map do |url|
Thread.new do
resp = HTTP.get(url)
[url, resp.status.to_i]
end
end
results = threads.map(&:value)
results.each { |url, code| puts "#{code} #{url}" }
Минус: без ограничения числа потоков легко открыть 1000 соединений сразу и получить бан или упасть. Нужен пул.
10.2. Пул потоков с ограничением (очередь)
require 'thread'
require 'http'
def crawl(urls, pool_size: 10)
queue = Queue.new
results = Queue.new
urls.each { |u| queue << u }
workers = Array.new(pool_size) do
Thread.new do
until queue.empty?
url = queue.pop(true) rescue break
begin
resp = HTTP.timeout(10).get(url)
results << [url, resp.status.to_i, resp.to_s]
rescue => e
results << [url, :error, e.message]
end
end
end
end
workers.each(&:join)
Array.new(results.size) { results.pop }
end
crawl(urls, pool_size: 10).each { |url, code, _| puts "#{code} #{url}" }
10.3. concurrent-ruby — промышленный подход
Gem concurrent-ruby даёт готовые пулы и фьючи — не нужно писать свои.
require 'concurrent-ruby' # gem install concurrent-ruby
require 'http'
pool = Concurrent::FixedThreadPool.new(10)
futures = urls.map do |url|
Concurrent::Future.execute(executor: pool) do
HTTP.timeout(10).get(url).to_s
end
end
futures.each { |f| puts f.value&.length } # .value блокирует до готовности
pool.shutdown
pool.wait_for_termination
10.4. Typhoeus::Hydra — параллелизм на libcurl
Самый эффективный для чисто-сетевого параллелизма: один поток, но libcurl ведёт много соединений сразу (мультиплексирование).
require 'typhoeus'
hydra = Typhoeus::Hydra.new(max_concurrency: 20)
requests = urls.map do |url|
req = Typhoeus::Request.new(url, followlocation: true, timeout: 10)
req.on_complete do |response|
puts "#{response.code} #{url}"
# парсим response.body здесь
end
hydra.queue(req)
req
end
hydra.run # выполняет все запросы параллельно
10.5. async (Fibers) — современная альтернатива
Gem async использует файберы для тысяч одновременных соединений почти без
накладных расходов.
require 'async'
require 'async/http/internet'
Async do
internet = Async::HTTP::Internet.new
tasks = urls.map do |url|
Async do
response = internet.get(url)
puts "#{response.status} #{url}"
response.read # обязательно прочитать/закрыть
end
end
tasks.each(&:wait)
ensure
internet&.close
end
Что выбрать
- До нескольких десятков URL — обычные
Thread+Queue. - Промышленный код —
concurrent-ruby. - Максимальная скорость, тысячи запросов —
Typhoeus::Hydraилиasync.
Важно: Nokogiri-парсинг — CPU-bound, и тут GIL уже мешает. Если узкое место в разборе HTML (а не в сети), для настоящего параллелизма по CPU нужны процессы (
Parallelgem,fork) или JRuby/TruffleRuby без GIL.
require 'parallel' # gem install parallel
# 4 процесса реально параллельно (обходят GIL)
results = Parallel.map(urls, in_processes: 4) do |url|
doc = Nokogiri::HTML(HTTP.get(url).to_s)
doc.at_css('h1')&.text
end
11. JavaScript-страницы
Многие современные сайты рендерят контент в браузере через JS. В «сыром» HTML, что вернул сервер, нужных данных нет. Тогда есть два пути:
11.1. Найти API (предпочтительно)
Откройте DevTools → Network → XHR/Fetch. Обычно JS подтягивает данные с JSON-API. Парсить этот API напрямую — быстрее и стабильнее, чем гонять браузер.
11.2. Headless-браузер
Если API не найти — поднимаем настоящий браузер без UI и берём готовый DOM.
Ferrum — управление Chrome через CDP, чистый Ruby, без Selenium:
require 'ferrum' # gem install ferrum (нужен установленный Chrome/Chromium)
browser = Ferrum::Browser.new(headless: true, timeout: 20)
page = browser.create_page
page.go_to('https://spa-site.example.com')
page.network.wait_for_idle # дождаться загрузки
html = page.body # уже отрендеренный DOM
doc = Nokogiri::HTML(html)
puts doc.css('.dynamic-item').map(&:text)
browser.quit
Watir / Selenium — более тяжёлые, кроссбраузерные, с богатым API для
кликов/форм.
Playwright-ruby-client — современная альтернатива Selenium.
Headless-браузеры в разы медленнее и прожорливее по памяти. Используйте их только когда без рендеринга никак.
12. Антибот, задержки, ретраи
Чтобы парсер работал долго и не банился, он должен вести себя «вежливо» и по-человечески.
12.1. Задержки (rate limiting)
urls.each do |url|
fetch(url)
sleep(rand(1.0..3.0)) # случайная пауза — меньше похоже на бота
end
12.2. Ротация User-Agent
USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 ...',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ...'
]
HTTP.headers('User-Agent' => USER_AGENTS.sample).get(url)
12.3. Ретраи с экспоненциальной задержкой
def fetch_with_retry(url, max: 4)
attempt = 0
begin
attempt += 1
resp = HTTP.timeout(10).get(url)
raise "HTTP #{resp.code}" if resp.code >= 500 || resp.code == 429
resp
rescue => e
if attempt < max
delay = 2**attempt + rand # 2, 4, 8... + джиттер
warn "Попытка #{attempt} не удалась (#{e.message}), жду #{delay.round}с"
sleep delay
retry
else
raise
end
end
end
12.4. Прочие приёмы маскировки
- слать реалистичные заголовки (
Accept,Accept-Language,Referer); - держать cookie-сессию (как браузер);
- ротация прокси (раздел 8);
- уважать
Retry-Afterпри 429; - избегать слишком ровного «машинного» ритма запросов.
Этический момент: агрессивный обход защит может нарушать ToS и закон. Действуйте в рамках разумного и легального.
13. Хранение URL и очереди
Как только парсер выходит за рамки «один скрипт — одна страница», возникает задача управления множеством URL: что уже скачано, что в очереди, что упало.
13.1. Простейший вариант — Set в памяти
Подходит для разовых задач. Главное — дедупликация, чтобы не качать одно дважды.
require 'set'
visited = Set.new
queue = ['https://example.com']
until queue.empty?
url = queue.shift
next if visited.include?(url)
visited << url
doc = Nokogiri::HTML(HTTP.get(url).to_s)
# новые ссылки в очередь
doc.css('a').each do |a|
link = a['href']
queue << link if link&.start_with?('https://example.com') &&
!visited.include?(link)
end
end
Минус: теряется при перезапуске, не масштабируется, не работает между процессами.
13.2. Очередь в Redis — для серьёзного парсинга
Redis даёт персистентную очередь, общую для многих воркеров/машин, плюс готовую
дедупликацию через множества (SADD).
require 'redis' # gem install redis
redis = Redis.new
# добавить URL (если ещё не видели)
def enqueue(redis, url)
# SADD вернёт 1, если url новый
if redis.sadd('seen', url) == 1
redis.rpush('queue', url)
end
end
# воркер берёт следующий URL (блокирующе)
def dequeue(redis)
_list, url = redis.blpop('queue', timeout: 5)
url
end
enqueue(redis, 'https://example.com')
while (url = dequeue(redis))
process(url)
# извлечённые ссылки -> обратно в очередь
end
Преимущества: несколько воркеров на разных машинах берут из одной очереди; состояние переживает рестарт; легко добавить очереди «retry» и «failed».
13.3. Готовые системы очередей задач
Для production-парсеров обычно берут фоновые обработчики задач, где «скачать страницу» — это job:
- Sidekiq (на Redis) — самый популярный, многопоточный.
- GoodJob / Solid Queue (на PostgreSQL) — без отдельного Redis.
# Пример Sidekiq-воркера
class ScrapeWorker
include Sidekiq::Job
sidekiq_options retry: 3, queue: 'scraping'
def perform(url)
resp = HTTP.timeout(10).get(url)
return unless resp.status.success?
doc = Nokogiri::HTML(resp.to_s)
save(doc)
# порождаем новые задачи
doc.css('a').each { |a| ScrapeWorker.perform_async(a['href']) if internal?(a['href']) }
end
end
Это даёт ретраи, приоритеты, мониторинг и горизонтальное масштабирование «из коробки».
13.4. Хранение результатов
Сами данные кладут в БД (PostgreSQL, SQLite, MongoDB) или файлы (CSV/JSON/Parquet). Минимальный пример с SQLite:
require 'sequel' # gem install sequel sqlite3
DB = Sequel.sqlite('scraped.db')
DB.create_table?(:pages) do
primary_key :id
String :url, unique: true
String :title
Integer :status
DateTime :fetched_at
end
DB[:pages].insert_conflict(:replace).insert(
url: url, title: title, status: 200, fetched_at: Time.now
)
13.5. О чём ещё помнить при больших обходах
- Нормализация URL (убрать якоря
#, лишние параметры, привести к единому виду), иначе дубликаты «протекают» в очередь. - Глубина обхода и ограничение домена, чтобы не уползти на весь интернет.
- Bloom-фильтр для дедупликации миллионов URL без хранения всех строк.
- Приоритеты (важные страницы — раньше).
- Чекпойнты — чтобы можно было продолжить после падения.
14. Готовые фреймворки
Не всё нужно писать руками. Есть инструменты, которые закрывают типовые задачи.
14.1. Mechanize — «браузер без UI»
Mechanize сам ведёт cookie-сессию, ходит по ссылкам, заполняет и сабмитит формы,
следует редиректам. Идеален для парсинга за логином.
require 'mechanize' # gem install mechanize
agent = Mechanize.new
agent.user_agent_alias = 'Mac Safari'
page = agent.get('https://example.com')
search = page.form_with(id: 'search') do |f|
f.q = 'ruby parsing'
end.submit
search.links.each { |link| puts link.href }
Mechanize использует Nokogiri внутри, так что доступны те же селекторы (page.css(...)).
14.2. Kimurai — полноценный фреймворк-«паук»
Kimurai — это Ruby-аналог Python Scrapy: маршруты, парс-методы, встроенная
поддержка headless-браузеров (Selenium/Ferrum), пайплайны, экспорт.
require 'kimurai' # gem install kimurai
class NewsSpider < Kimurai::Base
@name = 'news_spider'
@engine = :mechanize # или :selenium_chrome для JS
@start_urls = ['https://example.com/news']
def parse(response, url:, data: {})
response.css('article.post').each do |post|
item = {
title: post.css('h2').text.strip,
link: post.css('a').first['href']
}
# перейти на страницу статьи
request_to :parse_article, url: item[:link], data: item
end
# пагинация
if (next_page = response.at_css('a.next'))
request_to :parse, url: absolute_url(next_page['href'], base: url)
end
end
def parse_article(response, url:, data: {})
data[:body] = response.css('.content').text.strip
save_to 'results.json', data, format: :json
end
end
NewsSpider.crawl!
Учтите: оригинальный Kimurai в последних версиях сместился в сторону AI-ассистируемого DSL. Если нужен «классический» Kimurai с обычными селекторами, присмотритесь к поддерживаемому форку Tanakai — API почти идентичен.
14.3. Vessel / Wombat и др.
Vessel— лёгкий паук поверх Ferrum (быстрый, на Chrome).Wombat— декларативное описание извлекаемых полей через DSL.Spidr— простой обходчик сайтов (краулер).
Когда брать фреймворк
- Разовый скрипт →
http.rb+Nokogiriвручную. - Парсинг за логином, формы →
Mechanize. - Большой структурированный обход с пагинацией/пайплайнами →
Kimurai.
15. Плюсы и минусы
Плюсы реализации парсинга на Ruby
- Nokogiri — один из лучших HTML/XML-парсеров вообще; CSS + XPath из коробки.
- Выразительный синтаксис — код парсера читается почти как псевдокод, быстро писать.
- Богатая экосистема — Mechanize, Kimurai, Ferrum, Typhoeus, Sidekiq и т.д. закрывают практически любую задачу.
- Отличная интеграция с Rails — если данные сразу льются в веб-приложение.
- I/O-параллелизм работает хорошо — для сетевых задач GIL не помеха, потоки/файберы дают высокую конкурентность.
- Зрелые инструменты для очередей и фоновых задач (Sidekiq) — легко довести до промышленного масштаба.
Минусы и подводные камни
- GIL ограничивает CPU-параллелизм — если узкое место в разборе HTML, а не в сети, нужны процессы/JRuby. Для тяжёлого CPU-парсинга «из коробки» Ruby уступает Go/Rust.
- Скорость интерпретатора ниже компилируемых языков; на гигантских объёмах чувствуется.
- JS-сайты требуют headless-браузера — это медленно и ресурсоёмко (но это проблема любого языка, не только Ruby).
- Nokogiri — C-расширение — иногда боль при установке/сборке на нестандартных системах (хотя сейчас обычно ставится без проблем).
- Хрупкость парсеров в принципе — любой парсер ломается при изменении вёрстки сайта; это не специфично для Ruby, но требует поддержки.
- Экосистема меньше, чем у Python — у Python (Scrapy, BeautifulSoup, requests) больше готовых решений и обучающих материалов именно под скрапинг.
Когда Ruby — хороший выбор
Когда вы уже в Ruby/Rails-стеке, нужен читаемый поддерживаемый код, объёмы средние, а узкое место — сеть (I/O), а не процессор. Для экстремальных объёмов и чистого CPU-парсинга присмотритесь к Go/Rust или Python+Scrapy.
16. Заключение
Парсинг на Ruby строится из двух кирпичей — HTTP-клиента и парсера — а всё остальное (кодировки, прокси, потоки, очереди) наращивается вокруг них по мере роста задачи.
Краткая шпаргалка по выбору инструментов
| Задача | Инструмент |
|---|---|
| Загрузить страницу (просто) | open-uri |
| Загрузить (гибко) | http (http.rb) |
| Параллельная загрузка | Typhoeus::Hydra, async |
| Разбор HTML/XML | Nokogiri |
| Разбор JSON | JSON (stdlib) |
| Сессии/формы/cookie | Mechanize |
| Кодировки | force_encoding/encode, charlock_holmes |
| JS-рендеринг | Ferrum, Watir, Playwright |
| Прокси/TOR | любой клиент + socksify для SOCKS5 |
| Очереди/масштаб | Redis, Sidekiq |
| Полный фреймворк | Kimurai |
Начинайте с простого (http + Nokogiri), добавляйте сложность только тогда, когда
она реально нужна — это главный принцип хорошего парсера.
Официальные ресурсы и документация
Стандартная библиотека Ruby:
OpenURI— загрузка страницы одной строкойNet::HTTP— встроенный HTTP-клиентURI— разбор и сборка URLJSON— разбор JSONThread/Queue— потоки и очередь
HTTP-клиенты:
- http.rb — современный клиент с цепочечным API
- Faraday — клиент с middleware; faraday-retry — ретраи
- Typhoeus — параллельные запросы на libcurl
- Mechanize — «браузер без UI» (формы, cookie, сессии)
Парсеры:
- Nokogiri — основной HTML/XML-парсер (репозиторий)
- Oga — чистый Ruby, без libxml
- Loofah — санитизация HTML
Кодировки:
- charlock_holmes — автоопределение кодировки (ICU)
Прокси / TOR:
- socksify — SOCKS5 для Ruby/Net::HTTP
- Tor Project — сам TOR
Параллелизм:
- concurrent-ruby — пулы потоков, фьючи
- async — конкурентность на файберах (async-http)
- parallel — параллелизм по процессам (обход GIL)
Headless-браузеры (JS):
- Ferrum — управление Chrome через CDP (сайт)
- Watir · Selenium · playwright-ruby-client
Очереди, фоновые задачи, хранение:
- redis-rb — клиент Redis
- Sidekiq · GoodJob · Solid Queue — фоновые задачи
- Sequel — работа с БД (сайт)
Фреймворки для парсинга:
Прочее:
- webrobots — разбор
robots.txt