Scraping por linguagem 30 min de leitura

Web scraping em Ruby: guia completo do simples ao complexo

Web scraping em Ruby: Nokogiri, HTTParty, Mechanize e Watir, da análise de páginas estáticas à automação do navegador.

EW
Equipe Web-Scraping.biz
Coleta de dados para as demandas do negócio
Publicado: 1 maio 2025

Guia abrangente sobre como escrever scrapers em Ruby — desde baixar uma única página até a coleta de dados em múltiplas threads por meio de proxies e TOR. Todos os exemplos são funcionais e foram pensados para o Ruby 3.x.


Sumário

  1. Introdução: o que é web scraping e o que considerar antes de começar
  2. Como baixamos a página
  3. Bibliotecas para o parsing do conteúdo
  4. Como resolver os problemas de codificação e acentuação
  5. Status da resposta e trabalho com cabeçalhos
  6. Trabalhar com HTTPS / SSL
  7. Trabalhar com cookies
  8. Uso de proxies
  9. Scraping através do TOR
  10. Concorrência e paralelismo
  11. Páginas com JavaScript (navegadores headless) (acrescentado)
  12. Proteção anti-bot, User-Agent, pausas, novas tentativas (acrescentado)
  13. Armazenamento de URLs e trabalho com filas
  14. Frameworks prontos para scraping (acrescentado)
  15. Principais vantagens e desvantagens da implementação
  16. Conclusão e checklist

1. Introdução

O web scraping é a coleta automática de dados de páginas web. O processo quase sempre consiste em duas tarefas distintas que convém não misturar:

  1. Download — obter a resposta «bruta» do servidor (cliente HTTP).
  2. Extração — retirar da resposta os dados que interessam (parser de HTML/JSON).

Em Ruby, cada tarefa tem suas próprias ferramentas, e um bom scraper costuma combinar um cliente HTTP com um parser.

O que considerar antes de escrever código

Antes de fazer scraping, convém levar em conta algumas coisas — elas poupam tempo e dores de cabeça:

  • robots.txt. O arquivo https://example.com/robots.txt descreve o que o proprietário permite que os bots rastreiem. Do ponto de vista jurídico, nem sempre é obrigatório, mas ignorá-lo é falta de educação e um risco.
  • Termos de uso (ToS). Alguns sites proíbem expressamente a coleta automática. Isso já é uma questão jurídica, não técnica.
  • Carga. Um scraper se transforma com facilidade em um ataque DoS. Insira pausas e não bombardeie o servidor com centenas de threads sem necessidade.
  • Existe API? Muitas vezes é mais simples e mais legal usar uma API oficial ou um endpoint JSON interno do que fazer o parsing do HTML.
  • Dados pessoais. A coleta e o armazenamento de dados pessoais são regulados por leis (LGPD, GDPR, normas locais de proteção de dados, etc.).

Uma verificação simples do robots.txt com a 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 "Pode fazer scraping"
else
  puts "O robots.txt proíbe esta rota"
end

2. Como baixamos a página

É a base. Vamos ver os clientes, do mais simples ao mais flexível.

2.1. open-uri — o caminho mais rápido

O open-uri faz parte da biblioteca padrão. Ideal para «obter uma página em uma única linha».

ruby
require 'open-uri'

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

Com cabeçalhos e timeouts:

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

Vantagens: não é preciso instalar nada, código mínimo. Desvantagens: é incômodo trabalhar com POST, cabeçalhos de resposta, redirecionamentos e erros (diante de um 404/500, lança a exceção OpenURI::HTTPError).

2.2. Net::HTTP — biblioteca padrão, controle total

O Net::HTTP também vem integrado ao Ruby. É verboso, mas dá acesso a tudo.

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        # corpo da resposta
puts response['Content-Type']

Requisição POST:

ruby
uri = URI('https://example.com/login')
res = Net::HTTP.post_form(uri, 'user' => 'admin', 'pass' => 'secret')
puts res.body

Vantagens: sem dependências, controle total sobre a requisição e a resposta. Desvantagens: verboso, gerenciamento manual dos redirecionamentos e uma API pouco agradável.

2.3. http.rb (gem http) — moderno e prático

API limpa e encadeável (chainable). Uma das melhores opções «padrão».

ruby
require 'http'   # gem install http

response = HTTP
  .headers('User-Agent' => 'MyBot/1.0')
  .timeout(connect: 5, read: 10)
  .follow                       # seguir os redirecionamentos automaticamente
  .get('https://example.com')

puts response.status            # 200
puts response.to_s              # corpo
puts response.headers['Content-Type']

POST com JSON:

ruby
response = HTTP.post(
  'https://api.example.com/items',
  json: { name: 'Widget', qty: 3 }
)
data = response.parse           # faz o parsing do JSON automaticamente

2.4. Faraday — cliente com middleware

O Faraday é um «invólucro sobre invólucros». Seu grande trunfo é a pilha de middleware: dá para adicionar o registro (logging), as novas tentativas, o parsing de JSON e o tratamento de erros como camadas.

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   # novas tentativas automáticas
  f.response :raise_error                    # 4xx/5xx -> exceção
  f.options.timeout = 10
  f.headers['User-Agent'] = 'MyBot/1.0'
end

response = conn.get('/data')
puts response.status
puts response.body

O Faraday funciona bem quando o scraper cresce até virar uma aplicação completa: um único ponto de configuração para todas as requisições.

2.5. Typhoeus — quando são necessárias velocidade e paralelismo

O Typhoeus é um invólucro sobre a libcurl. Sua principal vantagem é o Hydra, que dispara muitas requisições em paralelo (veja a seção sobre concorrência).

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']

Qual escolher

Cliente Quando usar
open-uri script pontual, «me dê esta página»
Net::HTTP não dá para instalar gems, é preciso controle total
http (http.rb) opção padrão para a maioria dos scrapers
Faraday aplicação em crescimento, com middleware/novas tentativas
Typhoeus coleta massiva em paralelo
Mechanize emulação de navegador com formulários/cookies (seção 14)

3. Bibliotecas para o parsing do conteúdo

Já temos o HTML — agora extraímos os dados.

3.1. Nokogiri — o padrão de facto

O Nokogiri faz o parsing de HTML e XML e aceita seletores CSS e XPath. É a ferramenta principal para 95 % das tarefas.

ruby
require 'nokogiri'
require 'open-uri'

html = URI.open('https://example.com').read
doc = Nokogiri::HTML(html)

# seletores CSS
title = doc.css('h1.title').text.strip
links = doc.css('a').map { |a| a['href'] }

# um elemento vs todos
first = doc.at_css('div.price')        # o primeiro que corresponde (ou nil)
all   = doc.css('div.item')            # NodeSet com todas as correspondências

# XPath (mais poderoso para condições complexas)
prices = doc.xpath('//div[@class="price"]/text()').map(&:to_s)

# Atributos e aninhamento
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 — quando usar cada um:

  • CSS — mais curto e legível para seleções simples: div.item > a.link.

  • XPath — mais poderoso: busca por texto, por elemento pai, por posição:

    ruby
    # link cujo texto contém "Baixar"
    doc.xpath('//a[contains(text(), "Baixar")]')
    # elemento cujo ancestral é uma div com id="main"
    doc.xpath('//div[@id="main"]//span[@class="price"]')
    # seleção por índice
    doc.xpath('(//tr)[3]')

3.2. Parsing de JSON — não se esqueça

Muitas vezes os dados não estão no HTML, e sim em JSON (uma API interna do site que a página carrega por AJAX). Abrir a aba Network do navegador e localizar o endpoint JSON costuma ser mais fácil do que fazer o parsing do 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. Outros parsers

  • Oga — uma alternativa ao Nokogiri em Ruby puro (sem extensões em C). Mais lento, mas mais fácil de instalar. Útil onde compilar o Nokogiri se mostra complicado.
  • Loofah (baseado no Nokogiri) — para a limpeza/sanitização (sanitizing) de HTML.
  • Expressões regularesevite fazer o parsing de HTML com regex. Elas quebram diante de qualquer mudança na marcação. Uma regex só faz sentido para extrair pequenos detalhes de um texto já selecionado (um telefone, um preço dentro de uma string, etc.).
ruby
# OK: extrair o número de um texto já selecionado
price_text = doc.at_css('.price').text     # "R$ 1.299"
price = price_text.gsub(/[^\d]/, '').to_i   # 1299

4. Codificações e acentuação

Um aborrecimento clássico do scraping são os «caracteres ilegíveis» (mojibake) no lugar do texto: São Paulo em vez de São Paulo, ou coração em vez de coração. O motivo é que a resposta HTTP são bytes, e o Ruby precisa interpretar corretamente sua codificação (UTF-8, Windows-1252, ISO-8859-1, etc.).

4.1. De onde surge o problema

O Ruby guarda o encoding de cada string. Se os bytes estão em Windows-1252 mas o Ruby acha que são UTF-8, obtemos lixo.

ruby
str = response.body
puts str.encoding          # por exemplo, ASCII-8BIT ou UTF-8
puts str.valid_encoding?   # false -> algo está errado

4.2. Detectar a codificação e converter

A codificação pode ser descoberta a partir de: 1. do cabeçalho Content-Type: text/html; charset=windows-1252; 2. da metatag <meta charset="..."> dentro do HTML; 3. da heurística (biblioteca rchardet/charlock_holmes).

Conversão manual (se conhecemos a codificação de origem):

ruby
# de Windows-1252 para UTF-8
utf8 = body.force_encoding('Windows-1252').encode('UTF-8')

O force_encoding apenas muda o «rótulo» da codificação sem recodificar os bytes, enquanto o encode de fato recodifica os bytes para a codificação de destino. A ordem importa: primeiro é preciso dizer ao Ruby a verdade sobre os bytes de origem e só depois recodificar.

Conversão segura com substituição dos caracteres danificados:

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

4.3. Nokogiri e as codificações — a forma correta

O melhor é passar a codificação diretamente para o Nokogiri — ele se encarrega de recodificar tudo:

ruby
require 'nokogiri'

# se conhecemos a codificação
doc = Nokogiri::HTML(body, nil, 'Windows-1252')

# o Nokogiri sabe ler <meta charset> sozinho se não for atrapalhado:
doc = Nokogiri::HTML(body)   # muitas vezes é suficiente
puts doc.css('h1').text       # já em UTF-8

4.4. Detecção automática da codificação

Quando o site não declara o charset com honestidade, o charlock_holmes (baseado em ICU) ajuda:

ruby
require 'charlock_holmes'   # gem install charlock_holmes

detection = CharlockHolmes::EncodingDetector.detect(body)
puts detection[:encoding]    # => "windows-1252"
puts detection[:confidence]  # => 90

utf8 = body.encode('UTF-8', detection[:encoding],
                   invalid: :replace, undef: :replace)

4.5. Um helper universal

ruby
def to_utf8(body, content_type = nil)
  # 1. tentamos a partir do cabeçalho
  if content_type && content_type =~ /charset=([\w-]+)/i
    enc = $1
    return body.encode('UTF-8', enc, invalid: :replace, undef: :replace)
  end

  # 2. já é UTF-8 válido?
  test = body.dup.force_encoding('UTF-8')
  return test if test.valid_encoding?

  # 3. detecção automática
  require 'charlock_holmes'
  det = CharlockHolmes::EncodingDetector.detect(body)
  body.encode('UTF-8', det[:encoding] || 'UTF-8',
              invalid: :replace, undef: :replace)
end

Regra: mantenha todo o pipeline interno em UTF-8. Converta na entrada, logo após o download, e esqueça o assunto.


5. Status da resposta e cabeçalhos

Antes de fazer o parsing do corpo, é preciso garantir que a resposta é válida. Ignorar o status HTTP é um erro comum de iniciante (faz o parsing de uma página de erro achando que são dados).

5.1. Ler o status e os cabeçalhos

ruby
require 'http'

resp = HTTP.get('https://example.com')

puts resp.status              # 200 (objeto de status)
puts resp.status.to_i         # 200 (número)
puts resp.status.success?     # true para 2xx
puts resp.status.redirect?    # true para 3xx

# cabeçalhos
puts resp.headers['Content-Type']
puts resp.headers['Content-Length']
puts resp.headers['Server']
puts resp.content_type.mime_type   # "text/html"

Em 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. O que fazer com os diferentes status

ruby
case resp.code
when 200      then process(resp.to_s)
when 301, 302 then follow_redirect(resp.headers['Location'])
when 404      then log("página não encontrada")
when 403, 429 then back_off    # ban/limite — reduzir o ritmo
when 500..599 then retry_later # erro do servidor — tentar novamente mais tarde
end

Especialmente importantes:

  • 429 Too Many Requests — está indo rápido demais. Verifique o cabeçalho Retry-After.
  • 403 Forbidden — muitas vezes é um anti-bot. Troque o User-Agent / proxy.
  • 3xx + Location — redirecionamento; decida se vai segui-lo.

5.3. Cabeçalhos úteis

  • Content-Type — tipo e codificação do conteúdo.
  • Set-Cookie — cookies (veja a seção 7).
  • Location — para onde redireciona.
  • Retry-After — quanto tempo esperar para tentar novamente.
  • ETag / Last-Modified — para o cache e as requisições condicionais (If-None-Match / If-Modified-Since → 304 Not Modified, economiza tráfego).

6. HTTPS / SSL

A maioria dos clientes trabalha com HTTPS «de fábrica» e verifica os certificados — o que é correto e seguro.

ruby
# http.rb, Faraday, Typhoeus, open-uri — HTTPS funciona automaticamente
HTTP.get('https://example.com')

Em Net::HTTP, é preciso ativar o SSL de forma explícita:

ruby
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_PEER   # por padrão, verificar o certificado

Desativar a verificação do certificado — com cuidado!

Às vezes um site tem um certificado autoassinado ou «quebrado». Desativar a verificação abre uma brecha para ataques MITM, então faça isso apenas de forma consciente:

ruby
# Net::HTTP
http.verify_mode = OpenSSL::SSL::VERIFY_NONE   # NÃO usar em produção

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

O erro «certificate verify failed»

Um erro comum em uma instalação recente de Ruby/Windows — não há um conjunto atualizado de certificados raiz. Soluções:

  • atualizar a gem certifi / os ca-certificates do sistema;
  • indicar o caminho para o bundle: http.ca_file = '/path/to/cacert.pem';
  • em macOS/Linux costuma bastar atualizar o OpenSSL.

Também dá para fixar uma versão específica de TLS se o servidor for exigente:

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

Os cookies servem para as sessões: login, carrinho, comportamento «humano». O servidor os envia em Set-Cookie e o cliente precisa devolvê-los em Cookie nas requisições seguintes.

7.1. Na mão

ruby
require 'http'

# recebemos os cookies
resp = HTTP.get('https://example.com/login')
cookies = resp.cookies   # HTTP::CookieJar

# os enviamos na requisição seguinte
resp2 = HTTP.cookies(cookies).get('https://example.com/account')

7.2. Manter a sessão entre requisições (http.rb)

ruby
require 'http'
require 'http-cookie'

jar = HTTP::CookieJar.new

# fazemos login
login = HTTP.post('https://example.com/login',
                  form: { user: 'me', pass: 'secret' })
login.cookies.each { |c| jar.add(c) }

# usamos a sessão
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 — os cookies «sozinhos»

Para as sessões complexas, o mais fácil é o Mechanize (seção 14): ele guarda automaticamente o cookie-jar entre requisições, como um navegador.

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
# os cookies já estão salvos, o agent lembra da sessão
dashboard = agent.get('https://example.com/dashboard')

Para não fazer login a cada execução:

ruby
agent.cookie_jar.save('cookies.yml')   # salvar
agent.cookie_jar.load('cookies.yml')   # restaurar

8. Proxies

Os proxies servem para: contornar os bloqueios por IP, distribuir a carga, ocultar a origem e fazer scraping a partir da geolocalização desejada. No scraping massivo costuma-se usar um pool de proxies com rotação.

8.1. Proxies nos diferentes clientes

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. Rotação de proxies

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. Tipos de proxy

  • HTTP/HTTPS — proxies web comuns.
  • SOCKS5 — de baixo nível, encaminham qualquer tráfego (necessários para o TOR, veja mais abaixo).
  • Datacenter vs Residential — os de data center são mais baratos, mas são banidos com mais facilidade; os residenciais (por meio de provedores reais) são mais caros, mas «parecem pessoas».

8.4. Tratamento dos proxies fora do ar

Os proxies caem com frequência. Envolva a requisição em uma nova tentativa com troca de proxy:

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 "O proxy #{proxy} não funcionou: #{e.message}"
      next
    end
  end
  nil
end

9. Scraping através do TOR

O TOR oferece anonimato gratuito e uma rotação de IP «infinita». Tecnicamente, o TOR é um proxy SOCKS5 local (por padrão 127.0.0.1:9050).

9.1. Instalação e inicialização

bash
# Linux
sudo apt install tor
sudo systemctl start tor

# macOS
brew install tor
brew services start tor

# verificação: o TOR escuta na 9050 (SOCKS) e, opcionalmente, na 9051 (controle)

9.2. Requisições através do TOR

Como o TOR é SOCKS5, é preciso um cliente compatível com SOCKS. O mais prático é o 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') ? 'Via TOR ✓' : 'Não é TOR ✗'
end

Com http.rb via SOCKS:

ruby
require 'http'
require 'socksify/http'

# o http.rb não suporta SOCKS sozinho, mas o Typhoeus sim:
require 'typhoeus'
resp = Typhoeus.get('https://check.torproject.org',
                    proxy: 'socks5://127.0.0.1:9050')
puts resp.code

9.3. Trocar de circuito (novo IP) pela porta de controle

Para obter um IP novo, enviamos o comando NEWNYM para a porta de controle (9051). Primeiro é preciso configurá-la em /etc/tor/torrc:

code
ControlPort 9051
HashedControlPassword 16:...   # gerar: tor --hash-password "yourpass"

Depois:

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

# trocamos de identidade a cada N requisições
tor_new_identity('yourpass')
sleep 5   # dar tempo ao TOR para construir um novo circuito

9.4. Limitações do TOR

  • É lento. O tráfego passa por 3 nós — as latências são altas.
  • Muitos sites bloqueiam os nós de saída do TOR (as listas de exit-node são públicas).
  • Não serve para a coleta massiva — sobrecarrega a rede TOR, que se sustenta com voluntários. Para grandes volumes, use proxies residenciais comerciais.

10. Concorrência e paralelismo

O download de páginas é uma tarefa limitada por E/S (I/O-bound): na maior parte do tempo o programa espera pela rede. Por isso a concorrência traz um ganho enorme, e o GIL (Global VM Lock) do Ruby aqui não atrapalha: durante a espera pela rede, a thread libera o GIL e as demais trabalham.

10.1. Threads simples (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}" }

Desvantagem: sem limitar o número de threads, é fácil abrir 1000 conexões de uma vez e acabar banido ou fora do ar. É preciso um pool.

10.2. Pool de threads com limite (fila)

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 — a abordagem industrial

A gem concurrent-ruby oferece pools e futures prontos — não é preciso escrever os seus próprios.

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 bloqueia até estar pronto
pool.shutdown
pool.wait_for_termination

10.4. Typhoeus::Hydra — paralelismo sobre a libcurl

O mais eficiente para o paralelismo puramente de rede: uma única thread, mas a libcurl gerencia muitas conexões ao mesmo tempo (multiplexação).

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}"
    # fazemos o parsing de response.body aqui
  end
  hydra.queue(req)
  req
end

hydra.run   # executa todas as requisições em paralelo

10.5. async (fibers) — a alternativa moderna

A gem async usa fibers para milhares de conexões simultâneas quase sem sobrecarga.

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   # é obrigatório ler/fechar
    end
  end
  tasks.each(&:wait)
ensure
  internet&.close
end

Qual escolher

  • Até algumas dezenas de URLsThread + Queue comuns.
  • Código industrialconcurrent-ruby.
  • Máxima velocidade, milhares de requisiçõesTyphoeus::Hydra ou async.

Importante: o parsing com o Nokogiri é CPU-bound, e aí o GIL de fato atrapalha. Se o gargalo está na análise do HTML (e não na rede), para um verdadeiro paralelismo de CPU são necessários processos (a gem Parallel, fork) ou JRuby/TruffleRuby sem GIL.

ruby
require 'parallel'   # gem install parallel
# 4 processos realmente em paralelo (driblam o 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. Páginas com JavaScript

Muitos sites modernos renderizam o conteúdo no navegador via JS. No HTML «bruto» que o servidor devolve não estão os dados que procuramos. Então há dois caminhos:

11.1. Localizar a API (opção preferível)

Abra o DevTools → Network → XHR/Fetch. Normalmente o JS busca os dados de uma API JSON. Consultar essa API diretamente é mais rápido e mais estável do que fazer um navegador funcionar.

11.2. Navegador headless

Se a API não for encontrada — subimos um navegador real sem interface e pegamos o DOM já renderizado.

Ferrum — controle do Chrome via CDP, em Ruby puro, sem Selenium:

ruby
require 'ferrum'   # gem install ferrum (requer Chrome/Chromium instalado)

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   # esperar o carregamento

html = page.body             # o DOM já renderizado
doc  = Nokogiri::HTML(html)
puts doc.css('.dynamic-item').map(&:text)

browser.quit

Watir / Selenium — mais pesados, multinavegador, com uma API rica para cliques/formulários.

Playwright-ruby-client — uma alternativa moderna ao Selenium.

Os navegadores headless são várias vezes mais lentos e vorazes em memória. Use-os apenas quando não houver como evitar a renderização.


12. Anti-bot, pausas, novas tentativas

Para que um scraper funcione por muito tempo e não acabe banido, ele precisa se comportar de forma «educada» e humana.

12.1. Pausas (rate limiting)

ruby
urls.each do |url|
  fetch(url)
  sleep(rand(1.0..3.0))   # pausa aleatória — parece menos com um bot
end

12.2. Rotação de 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. Novas tentativas com recuo exponencial

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... + jitter
      warn "Tentativa #{attempt} falhou (#{e.message}), aguardo #{delay.round} s"
      sleep delay
      retry
    else
      raise
    end
  end
end

12.4. Outras técnicas de camuflagem

  • enviar cabeçalhos realistas (Accept, Accept-Language, Referer);
  • manter uma sessão com cookies (como um navegador);
  • rotação de proxies (seção 8);
  • respeitar o Retry-After diante de um 429;
  • evitar um ritmo de requisições regular e «mecânico» demais.

Nota ética: contornar as proteções de forma agressiva pode violar os ToS e a lei. Aja dentro do razoável e do legal.


13. Armazenamento de URLs e filas

Assim que um scraper ultrapassa o esquema «um script — uma página», surge a tarefa de gerenciar muitas URLs: o que já foi baixado, o que está na fila e o que falhou.

13.1. A opção mais simples — um Set em memória

Serve para tarefas pontuais. O essencial é a deduplicação, para não baixar a mesma coisa duas vezes.

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)
  # novos links para a fila
  doc.css('a').each do |a|
    link = a['href']
    queue << link if link&.start_with?('https://example.com') &&
                     !visited.include?(link)
  end
end

Desvantagem: se perde ao reiniciar, não escala e não funciona entre processos.

13.2. Fila no Redis — para scraping sério

O Redis oferece uma fila persistente, compartilhada por muitos workers/máquinas, além de uma deduplicação pronta para usar por meio de conjuntos (SADD).

ruby
require 'redis'   # gem install redis

redis = Redis.new

# adicionar uma URL (se ainda não a vimos)
def enqueue(redis, url)
  # SADD retorna 1 se a url for nova
  if redis.sadd('seen', url) == 1
    redis.rpush('queue', url)
  end
end

# o worker pega a próxima URL (de forma bloqueante)
def dequeue(redis)
  _list, url = redis.blpop('queue', timeout: 5)
  url
end

enqueue(redis, 'https://example.com')

while (url = dequeue(redis))
  process(url)
  # os links extraídos -> de volta para a fila
end

Vantagens: vários workers em máquinas diferentes pegam de uma mesma fila; o estado sobrevive aos reinícios; é fácil adicionar filas «retry» e «failed».

13.3. Sistemas de filas de tarefas prontos

Para os scrapers de produção costuma-se usar processadores de tarefas em segundo plano, onde «baixar uma página» é um job:

ruby
# Exemplo de um worker do 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)
    # geramos novas tarefas
    doc.css('a').each { |a| ScrapeWorker.perform_async(a['href']) if internal?(a['href']) }
  end
end

Isso traz novas tentativas, prioridades, monitoramento e escalabilidade horizontal «de fábrica».

13.4. Armazenamento dos resultados

Os dados em si são salvos em um BD (PostgreSQL, SQLite, MongoDB) ou em arquivos (CSV/JSON/Parquet). Exemplo mínimo com 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. O que mais levar em conta nos rastreamentos grandes

  • Normalização de URL (remover as âncoras #, os parâmetros supérfluos, unificar o formato), ou os duplicados se «infiltram» na fila.
  • Profundidade de rastreamento e limitação de domínio, para não acabar percorrendo a internet inteira.
  • Filtro de Bloom para deduplicar milhões de URLs sem armazenar todas as strings.
  • Prioridades (as páginas importantes primeiro).
  • Checkpoints — para poder continuar após uma queda.

14. Frameworks prontos

Não é preciso escrever tudo na mão. Existem ferramentas que resolvem as tarefas típicas.

14.1. Mechanize — um «navegador sem interface»

O Mechanize gerencia sozinho a sessão de cookies, navega pelos links, preenche e envia formulários e segue os redirecionamentos. Ideal para fazer scraping após um login.

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 }

O Mechanize usa o Nokogiri por dentro, então os mesmos seletores estão disponíveis (page.css(...)).

14.2. Kimurai — um framework «aranha» completo

O Kimurai é o equivalente em Ruby do Scrapy (Python): rotas, métodos de parsing, suporte integrado a navegadores headless (Selenium/Ferrum), pipelines e exportação.

ruby
require 'kimurai'   # gem install kimurai

class NewsSpider < Kimurai::Base
  @name = 'news_spider'
  @engine = :mechanize           # ou :selenium_chrome para 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']
      }
      # ir para a página do artigo
      request_to :parse_article, url: item[:link], data: item
    end

    # paginação
    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!

Observe que o Kimurai original, nas suas últimas versões, se voltou para um DSL assistido por IA. Se você precisar do Kimurai «clássico» com seletores normais, veja o fork mantido Tanakai — a API é quase idêntica.

14.3. Vessel / Wombat e outros

  • Vessel — uma aranha leve sobre o Ferrum (rápida, baseada no Chrome).
  • Wombat — descrição declarativa dos campos a extrair por meio de um DSL.
  • Spidr — um rastreador de sites (crawler) simples.

Quando usar um framework

  • Script pontual → http.rb + Nokogiri na mão.
  • Scraping após um login, com formulários → Mechanize.
  • Rastreamento grande e estruturado com paginação/pipelines → Kimurai.

15. Vantagens e desvantagens

Vantagens de implementar scraping em Ruby

  • Nokogiri — um dos melhores parsers de HTML/XML que existem; CSS + XPath de fábrica.
  • Sintaxe expressiva — o código do scraper se lê quase como pseudocódigo e se escreve rápido.
  • Ecossistema rico — Mechanize, Kimurai, Ferrum, Typhoeus, Sidekiq, etc., cobrem praticamente qualquer tarefa.
  • Excelente integração com o Rails — se os dados vão direto para uma aplicação web.
  • O paralelismo de E/S funciona bem — para as tarefas de rede o GIL não é um obstáculo; threads e fibers proporcionam uma alta concorrência.
  • Ferramentas maduras para filas e tarefas em segundo plano (Sidekiq) — fáceis de levar à escala industrial.

Desvantagens e armadilhas

  • O GIL limita o paralelismo de CPU — se o gargalo está na análise do HTML e não na rede, são necessários processos/JRuby. Para o parsing intensivo de CPU «de fábrica», o Ruby fica atrás de Go/Rust.
  • A velocidade do interpretador é menor que a das linguagens compiladas; em volumes gigantescos, isso se nota.
  • Os sites com JS exigem um navegador headless — é lento e consome muitos recursos (mas é um problema de qualquer linguagem, não só do Ruby).
  • O Nokogiri é uma extensão em C — às vezes é trabalhoso instalá-lo/compilá-lo em sistemas incomuns (embora hoje costume instalar sem problemas).
  • A fragilidade inerente aos scrapers — qualquer scraper quebra quando a marcação do site muda; não é algo específico do Ruby, mas exige manutenção.
  • O ecossistema é menor que o do Python — o Python (Scrapy, BeautifulSoup, requests) tem mais soluções prontas e material didático focado justamente no scraping.

Quando o Ruby é uma boa escolha

Quando você já está em um stack Ruby/Rails, precisa de um código legível e de fácil manutenção, os volumes são médios e o gargalo é a rede (E/S), não o processador. Para volumes extremos e parsing puro de CPU, veja Go/Rust ou Python+Scrapy.


16. Conclusão

O scraping em Ruby se constrói com dois tijolos — um cliente HTTP e um parser — e todo o resto (codificações, proxies, threads, filas) vai sendo acrescentado ao redor à medida que a tarefa cresce.

Guia rápido para escolher ferramentas

Tarefa Ferramenta
Baixar uma página (simples) open-uri
Baixar (com flexibilidade) http (http.rb)
Download em paralelo Typhoeus::Hydra, async
Análise de HTML/XML Nokogiri
Parsing de JSON JSON (stdlib)
Sessões/formulários/cookies Mechanize
Codificações force_encoding/encode, charlock_holmes
Renderização JS Ferrum, Watir, Playwright
Proxies/TOR qualquer cliente + socksify para SOCKS5
Filas/escalabilidade Redis, Sidekiq
Framework completo Kimurai

Comece pelo simples (http + Nokogiri) e acrescente complexidade só quando for realmente necessário — esse é o princípio fundamental de um bom scraper.


Recursos oficiais e documentação

Biblioteca padrão de Ruby:

  • OpenURI — download de uma página em uma linha
  • Net::HTTP — cliente HTTP integrado
  • URI — análise e construção de URL
  • JSON — parsing de JSON
  • Thread / Queue — threads e fila

Clientes HTTP:

  • http.rb — cliente moderno com API encadeável
  • Faraday — cliente com middleware; faraday-retry — novas tentativas
  • Typhoeus — requisições em paralelo sobre a libcurl
  • Mechanize — «navegador sem interface» (formulários, cookies, sessões)

Parsers:

  • Nokogiri — o parser de HTML/XML principal (repositório)
  • Oga — Ruby puro, sem libxml
  • Loofah — sanitização de HTML

Codificações:

  • charlock_holmes — detecção automática da codificação (ICU)

Proxies / TOR:

  • socksify — SOCKS5 para Ruby/Net::HTTP
  • Tor Project — o próprio TOR

Paralelismo:

  • concurrent-ruby — pools de threads, futures
  • async — concorrência com fibers (async-http)
  • parallel — paralelismo por processos (dribla o GIL)

Navegadores headless (JS):

Filas, tarefas em segundo plano, armazenamento:

  • redis-rb — cliente do Redis
  • Sidekiq · GoodJob · Solid Queue — tarefas em segundo plano
  • Sequel — trabalho com bancos de dados (site)

Frameworks para scraping:

Outros: