Парсинг на Ruby: полное руководство от простого к сложному

Парсинг на Ruby: Nokogiri, HTTParty, Mechanize и Watir — от разбора статических страниц до автоматизации браузера.

КP
Команда Parsing.agency
Сбор данных под задачи бизнеса
Опубликовано: 1 мая 2025

Обзорная статья о том, как писать парсеры (скраперы) на Ruby — от загрузки одной страницы до многопоточного сбора данных через прокси и TOR. Все примеры рабочие, ориентированы на Ruby 3.x.


Оглавление

  1. Введение: что такое парсинг и о чём подумать до старта
  2. Как забираем страницу
  3. Библиотеки для парсинга содержимого
  4. Решение проблем с парсингом кириллицы
  5. Статус ответа и работа с заголовками
  6. Работа с HTTPS / SSL
  7. Работа с cookie
  8. Использование прокси
  9. Парсинг через TOR
  10. Multi / многопоточность
  11. Страницы с JavaScript (headless-браузеры) (добавлено)
  12. Антибот-защита, User-Agent, задержки, ретраи (добавлено)
  13. Хранение URL и работа с очередями
  14. Готовые фреймворки для парсинга (добавлено)
  15. Основные плюсы и минусы реализации
  16. Заключение и чеклист

1. Введение

Парсинг (web scraping) — это автоматический сбор данных с веб-страниц. Процесс почти всегда состоит из двух разных задач, которые важно не смешивать:

  1. Загрузка — получить «сырой» ответ сервера (HTTP-клиент).
  2. Извлечение — вытащить из ответа нужные данные (парсер HTML/JSON).

В Ruby для каждой задачи есть свои инструменты, и хороший парсер обычно комбинирует один HTTP-клиент с одним парсером.

О чём подумать до того, как писать код

Прежде чем парсить, стоит учесть несколько вещей — это сэкономит время и нервы:

  • robots.txt. Файл https://site.ru/robots.txt описывает, что владелец разрешает обходить ботам. Юридически он не всегда обязателен, но игнорировать его — плохой тон и риск.
  • Условия использования (ToS). Некоторые сайты прямо запрещают автоматический сбор. Это уже правовой вопрос, а не технический.
  • Нагрузка. Парсер легко превращается в DoS-атаку. Делайте задержки и не бейте по серверу в сотни потоков без необходимости.
  • Есть ли API? Часто проще и легальнее взять официальный API или внутренний JSON-эндпоинт, чем парсить HTML.
  • Персональные данные. Сбор и хранение ПД регулируется законами (GDPR, 152-ФЗ и т.д.).

Простая проверка robots.txt штатными средствами через gem webrobots:

ruby
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 — часть стандартной библиотеки. Идеален для «получить страницу одной строкой».

ruby
require 'open-uri'

html = URI.open('https://example.com').read
puts html

С заголовками и таймаутами:

ruby
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. Многословен, но даёт доступ ко всему.

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-запрос:

ruby
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. Один из лучших выборов «по умолчанию».

ruby
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:

ruby
response = HTTP.post(
  'https://api.example.com/items',
  json: { name: 'Widget', qty: 3 }
)
data = response.parse           # автоматически распарсит JSON

2.4. Faraday — клиент с middleware

Faraday — это «обёртка над обёртками». Главная фишка — middleware-стек: можно подключать логирование, ретраи, парсинг JSON, обработку ошибок как слои.

ruby
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, которая делает много запросов параллельно (см. раздел о многопоточности).

ruby
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% задач.

ruby
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 — мощнее: поиск по тексту, по родителю, по позиции:
    ruby
    # ссылка, текст которой содержит "Скачать"
    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.

ruby
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 регэкспами. Они ломаются на любом изменении вёрстки. Регэксп уместен только для извлечения мелочи из уже выбранного текста (телефон, цена из строки и т.п.).
ruby
# ОК: достать число из уже выбранного текста
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, — получаем мусор.

ruby
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).

Конвертация вручную (если знаем исходную кодировку):

ruby
# из Windows-1251 в UTF-8
utf8 = body.force_encoding('Windows-1251').encode('UTF-8')

force_encoding только меняет «ярлык» кодировки без перекодирования байтов, а encode уже реально перекодирует байты в целевую кодировку. Порядок важен: сначала сказать Ruby правду об исходных байтах, потом перекодировать.

Безопасная конвертация с заменой битых символов:

ruby
utf8 = body.encode(
  'UTF-8',
  'Windows-1251',
  invalid: :replace,
  undef:   :replace,
  replace: '?'
)

4.3. Nokogiri и кодировки — правильный способ

Лучше всего передать кодировку прямо в Nokogiri — он сам всё перекодирует:

ruby
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):

ruby
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. Универсальный хелпер

ruby
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. Чтение статуса и заголовков

ruby
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:

ruby
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. Что делать с разными статусами

ruby
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 «из коробки» и проверяют сертификаты — это правильно и безопасно.

ruby
# http.rb, Faraday, Typhoeus, open-uri — HTTPS работает автоматически
HTTP.get('https://example.com')

В Net::HTTP нужно явно включить SSL:

ruby
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_PEER   # по умолчанию, проверять серт

Отключение проверки сертификата — осторожно!

Иногда сайт с самоподписанным/«битым» сертификатом. Отключение проверки открывает дыру для MITM, поэтому делайте это только осознанно:

ruby
# 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, если сервер капризный:

ruby
http.min_version = OpenSSL::SSL::TLS1_2_VERSION

Cookie нужны для сессий: логин, корзина, «человеческое» поведение. Сервер шлёт их в Set-Cookie, а клиент должен возвращать в Cookie при следующих запросах.

7.1. Вручную

ruby
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)

ruby
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')
ruby
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 между запросами, как браузер.

ruby
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')

Чтобы не логиниться каждый запуск:

ruby
agent.cookie_jar.save('cookies.yml')   # сохранить
agent.cookie_jar.load('cookies.yml')   # восстановить

8. Прокси

Прокси нужны, чтобы: обходить блокировки по IP, распределять нагрузку, скрывать источник, парсить из «нужной» гео-локации. При массовом парсинге обычно используют пул прокси с ротацией.

8.1. Прокси в разных клиентах

ruby
# 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. Ротация прокси

ruby
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. Обработка дохлых прокси

Прокси часто отваливаются. Оборачивайте запрос в ретрай со сменой прокси:

ruby
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. Установка и запуск

bash
# 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:

ruby
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:

ruby
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:

code
ControlPort 9051
HashedControlPassword 16:...   # сгенерировать: tor --hash-password "yourpass"

Затем:

ruby
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)

ruby
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. Пул потоков с ограничением (очередь)

ruby
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 даёт готовые пулы и фьючи — не нужно писать свои.

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 ведёт много соединений сразу (мультиплексирование).

ruby
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 использует файберы для тысяч одновременных соединений почти без накладных расходов.

ruby
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 нужны процессы (Parallel gem, fork) или JRuby/TruffleRuby без GIL.

ruby
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:

ruby
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)

ruby
urls.each do |url|
  fetch(url)
  sleep(rand(1.0..3.0))   # случайная пауза — меньше похоже на бота
end

12.2. Ротация User-Agent

ruby
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. Ретраи с экспоненциальной задержкой

ruby
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 в памяти

Подходит для разовых задач. Главное — дедупликация, чтобы не качать одно дважды.

ruby
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).

ruby
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.
ruby
# Пример 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:

ruby
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-сессию, ходит по ссылкам, заполняет и сабмитит формы, следует редиректам. Идеален для парсинга за логином.

ruby
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), пайплайны, экспорт.

ruby
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 — разбор и сборка URL
  • JSON — разбор JSON
  • Thread / 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):

Очереди, фоновые задачи, хранение:

Фреймворки для парсинга:

Прочее: