De volta ao blog

Ruby Web Scraping: Um Guia Prático

Emily Chen
Emily Chen

Advanced Data Extraction Specialist

11-Aug-2026

TL;DR:

  • A história de busca e análise do Ruby é composta por duas linhas de configuração. Net::HTTP da biblioteca padrão mais Nokogiri cobre páginas renderizadas no servidor completamente.
  • Nokogiri aceita seletores CSS, então a maioria dos exemplos se adapta diretamente. doc.css("div.quote") se comporta da mesma maneira que o mesmo seletor se comporta em um console de navegador.
  • A Scrapeless Universal Scraping API responde com um envelope JSON. A marcação chega dentro de data, então você analisa JSON primeiro e HTML segundo.
  • Ferrum não consegue acessar um endpoint de navegador TLS fora da caixa, e a razão é uma linha ausente. Ele constrói seu socket CDP sem definir hostname, portanto nenhum SNI é enviado e um host dependente de SNI recusa o handshake. Confirmado em isolamento: socket idêntico, sem SNI falha, SNI tem sucesso contra um certificado para scrapeless.com.
  • Um patch de quinze linhas faz funcionar. Com SNI fornecido, o mesmo script retornou title: Quotes to Scrape e quotes on page: 10.
  • Comece grátis: o painel Scrapeless emite uma chave que funciona com todos os exemplos abaixo.

O Que Você Precisa

Tudo abaixo foi executado no Ruby 3.2.3 contra quotes.toscrape.com, um site publicado para prática de raspagem.

bash Copy
gem install nokogiri ferrum --no-document
# nokogiri 1.19.4 · ferrum 0.17.2

Net::HTTP, URI e JSON são da biblioteca padrão, então as únicas dependências reais são o parser e, mais tarde, o driver do navegador.

Uma peculiaridade de nomenclatura que vale a pena saber antes de escrever um banner de versão: Nokogiri expõe Nokogiri::VERSION, mas Ferrum não define nenhuma constante Ferrum::VERSION. Referenciá-la gera NameError: uninitialized constant Ferrum::VERSION. Leia a versão a partir de Gem.loaded_specs["ferrum"].version em vez disso.

Buscando e Analisando

ruby Copy
require "net/http"
require "uri"
require "nokogiri"

uri = URI("https://quotes.toscrape.com/")
res = Net::HTTP.get_response(uri)
puts "http=#{res.code} bytes=#{res.body.bytesize}"

doc    = Nokogiri::HTML(res.body)
quotes = doc.css("div.quote")
puts "quotes=#{quotes.size}"
puts "first_author=#{quotes.first.at_css('small.author').text}"
puts "first_text=#{quotes.first.at_css('span.text').text[0, 40]}"
text Copy
http=200 bytes=11064
quotes=10
first_author=Albert Einstein
first_text=“The world as we have created it is a pr

Dois hábitos compensam imediatamente. css retorna um conjunto de nós e at_css retorna a primeira correspondência ou nil, então busque at_css sempre que você quiser um elemento — é a diferença entre nil e um conjunto vazio quando um campo está ausente, e nil falha barulhentamente no ponto do erro.

Limite as consultas filhas à linha. quotes.first.at_css('span.text') pesquisa dentro desse nó, enquanto chamar doc.at_css em um loop retorna o texto da primeira citação em cada iteração. Isso produz um conjunto de dados com aparência plausível, onde cada linha carrega o mesmo valor, o que é muito pior do que uma falha.

A documentação do Nokogiri cobre os equivalentes XPath se você preferir; doc.xpath("//div[@class='quote']") é a mesma consulta no outro dialeto.

Onde o Ruby Puro Para

O script funciona porque o alvo renderiza do lado do servidor e não filtra seus chamadores. Duas situações encerram isso: conteúdo que só existe após a execução do JavaScript e sites que tratam um cliente HTTP nu como um bot. Nenhuma é uma limitação do Ruby — nenhum cliente HTTP em nenhuma linguagem resolve qualquer um dos dois.

A partir daqui, as escaladas são ferramentas diferentes em vez de versões melhores uma da outra, e o caminho puro permanece o mais rápido e barato onde funciona. Se você precisar de um contexto sobre o que um navegador adiciona, o explicativo de automação de navegador cobre a forma geral.

Escalada Um: A API Universal de Raspagem

Isso mantém seu código como um cliente HTTP comum e move a dificuldade para o lado do servidor.

ruby Copy
require "net/http"
require "uri"
require "json"
require "nokogiri"

key = ENV.fetch("SCRAPELESS_API_KEY")
api = URI("https://api.scrapeless.com/api/v2/unlocker/request")

req = Net::HTTP::Post.new(api, "Content-Type" => "application/json", "x-api-token" => key)
req.body = JSON.dump(
  actor: "unlocker.webunlocker",
  input: { url: "https://quotes.toscrape.com/", js_render: false }
)

resp = Net::HTTP.start(api.hostname, api.port, use_ssl: true, read_timeout: 90) { |h| h.request(req) }
body = JSON.parse(resp.body)

puts "http=#{resp.code} envelope_keys=#{body.keys.join(',')}"
puts "quotes=#{Nokogiri::HTML(body['data']).css('div.quote').size}"
text Copy
http=200 envelope_keys=code,data
quotes=10

O envelope é a parte a ser internalizada. resp.body é JSON; a marcação vive em body["data"]. Passar resp.body diretamente para Nokogiri::HTML produz um documento sem nós correspondentes e não gera nada — seletores silenciosamente retornam zero linhas. Analise o JSON, pegue data, depois analise o HTML e mantenha essa desembrulha em um método em vez de espalhar JSON.parse(...)["data"] pela base de código.

Note Net::HTTP.start(..., use_ssl: true) em vez do mais curto Net::HTTP.post. Omissão de use_ssl contra uma URI https é um tropeço comum do Ruby que se apresenta como uma redefinição de conexão confusa em vez de um erro claro.

Ferrum é o driver do Protocolo Chrome DevTools do Ruby, e aceita um ws_url: para se conectar a um navegador que você não lançou. Apontado para um endpoint TLS, ele falha:

text Copy
OpenSSL::SSL::SSLError: SSL_connect returned=1 errno=0 peeraddr=172.67.74.62:443
state=error: sslv3 alert handshake failure

Essa mensagem nomeia a camada errada. Não é um problema de certificado, um problema de cifra ou um problema de proxy.

A Causa Real

Ferrum 0.17.2 constrói seu socket CDP em lib/ferrum/client/web_socket.rb assim:

ruby Copy
tcp = TCPSocket.new(uri.host, port)
ssl_context = OpenSSL::SSL::SSLContext.new
@sock = OpenSSL::SSL::SSLSocket.new(tcp, ssl_context)
@sock.sync_close = true
@sock.connect

OpenSSL::SSL::SSLSocket#hostname nunca é atribuído, então Ruby não envia nenhuma Indicação de Nome do Servidor extensão. Um host que serve muitos certificados de um único endereço não pode escolher um, e responde com uma falha de handshake.
Vinte linhas de Ruby simples isolam isso — sockets idênticos, uma linha diferente:

ruby Copy
require "socket"
require "openssl"

HOST = "browser.scrapeless.com"

def attempt(sni)
  tcp  = TCPSocket.new(HOST, 443)
  sock = OpenSSL::SSL::SSLSocket.new(tcp, OpenSSL::SSL::SSLContext.new)
  sock.hostname = HOST if sni      # the line ferrum omits
  sock.sync_close = true
  sock.connect
  cn = sock.peer_cert.subject.to_a.find { |a| a[0] == "CN" }
  result = "OK  cert_cn=#{cn && cn[1]}"
  sock.close
  result
rescue => e
  "FAIL #{e.class}: #{e.message.to_s[0, 70]}"
end

puts "without SNI: #{attempt(false)}"
puts "with SNI:    #{attempt(true)}"
text Copy
without SNI: FAIL OpenSSL::SSL::SSLError: SSL_connect returned=1 errno=0 peeraddr=172.67.74.62:443
with SNI:    OK  cert_cn=scrapeless.com

Esse é o bug inteiro. Qualquer cliente Ruby que omita hostname= atinge qualquer endpoint dependente de SNI, que hoje é a maioria deles.

Uma Armadilha Vermelha que Vale Nomear

Há um segundo comportamento suspeito no Ferrum, e não é a causa. Ferrum::Browser::Process.parse_json_version executa URI.join(url, "/json/version") contra seu ws_url, que substitui o caminho e descarta a string de consulta. Uma URL wss:// cujo caminho é /api/v2/browser e cuja consulta contém seu token é reescrita para a raiz do host mais /json/version, descartando completamente a credencial. Esse endereço reescrito responde 404 page not found aqui.

Parece fatal e não é: o método captura JSON::ParserError, e um corpo 404 não é JSON válido, então a falha é engolida. Corrigir apenas o SNI é suficiente — a execução corrigida abaixo ainda retorna 404 nesse teste e funciona de qualquer maneira. Vale a pena saber para que você não passe uma tarde nisso, como foi o diagnóstico inicial deste artigo.

Fazendo Funcionar

O socket CDP é tipicamente o único socket TLS que um scraper desse tipo abre, então fornecer o nome na construção é suficiente:

ruby Copy
require "ferrum"
require "openssl"

module SNIPatch
  def connect
    self.hostname = @ferrum_sni if @ferrum_sni && respond_to?(:hostname=)
    super
  end
end

class OpenSSL::SSL::SSLSocket
  prepend SNIPatch

  def ferrum_sni=(host)
    @ferrum_sni = host
  end
end

module SSLSocketFactoryPatch
  def new(io, ctx = nil)
    sock = super
    sock.ferrum_sni = Thread.current[:ferrum_sni_host]
    sock
  end
end

class << OpenSSL::SSL::SSLSocket
  prepend SSLSocketFactoryPatch
end

key = ENV.fetch("SCRAPELESS_API_KEY")
Thread.current[:ferrum_sni_host] = "browser.scrapeless.com"

browser = Ferrum::Browser.new(
  ws_url: "wss://browser.scrapeless.com/api/v2/browser?token=#{key}",
  timeout: 60,
  process_timeout: 60
)
browser.go_to("https://quotes.toscrape.com/")
puts "title: #{browser.evaluate('document.title')}"
puts "quotes on page: #{browser.evaluate('document.querySelectorAll(".quote").length')}"
browser.quit
text Copy
title: Quotes to Scrape
quotes on page: 10

Duas observações sobre o escopo. O patch é global para OpenSSL::SSL::SSLSocket, o que é aceitável em um scraper de único propósito e não algo para carregar em uma aplicação Rails que abre outros sockets TLS — lá, restrinja-o ao processo que aciona o navegador. E a correção upstream é uma única linha no gem, então verifique se uma versão após 0.17.2 a incorporou antes de levar um patch próprio.

Escolhendo Entre os Três

abordagem usar quando custo
Net::HTTP + Nokogiri HTML renderizado pelo servidor, alvo permissivo o mais baixo; um gem
API de Raspeamento Universal bloqueado ou desafiado, sem JS necessário uma chamada HTTP, envelope para desembrulhar
Ferrum + um navegador remoto o conteúdo requer execução de JavaScript o mais alto; uma sessão por trabalho, além do patch de SNI

Trabalhe para baixo na lista em vez de para cima. Uma página que parece precisar de um navegador frequentemente incorpora seus dados em uma tag <script>, e doc.at_css("script#__NEXT_DATA__") com JSON.parse supera uma sessão de navegador em todos os eixos.

Conclusão

Ruby lida bem com raspagem, e a biblioteca padrão contém mais disso do que as pessoas esperam. Net::HTTP e Nokogiri cobrem páginas renderizadas pelo servidor, com at_css versus css e consultas filhas com escopo de linha como os dois detalhes que separam a extração correta de um conjunto de dados que parece bom e não é.

O caminho do navegador é onde Ruby atualmente custa mais do que seus vizinhos, e a razão é estreita em vez de fundamental: um atributo não definido em um gem, produzindo uma mensagem de erro que aponta para TLS em vez de SNI. O script de isolamento acima responde a isso em vinte linhas, e o patch é pequeno o suficiente para carregar até que a upstream envie a correção de uma linha.

Pronto para Raspar Com Ruby?

Crie uma chave no painel do Scrapeless e execute o exemplo Net::HTTP contra uma página que você já coleta. Se retornar o que você espera, você está terminado — um gem, sem navegador. A API de Raspeamento Universal cobre as páginas onde não cobre, e as taxas atuais estão na página de preços.

FAQ

Q: Nokogiri ou um parser alternativo?

Nokogiri continua sendo o padrão para HTML. Ele aceita tanto seletores CSS quanto XPath, é bem documentado e lida com marcação malformada. Existem parsers leves em Ruby puro que existem e valem a pena considerar apenas se extensões nativas forem um problema em sua implantação.

Q: Por que minha conexão Ferrum falha com um erro de handshake SSL?

Porque Ferrum 0.17.2 não define hostname em seu OpenSSL::SSL::SSLSocket, então nenhum SNI é enviado e um endpoint dependente de SNI não pode selecionar um certificado. Reproduza isso em isolamento com o script de vinte linhas acima, depois aplique o patch ou espere pela correção upstream.

Q: Por que minha análise não retorna nada quando a chamada da API claramente teve sucesso?

Você está analisando o envelope. A resposta é JSON com a marcação dentro de data, então Nokogiri recebe uma string JSON, não corresponde a nada e não lança nada. Analise o JSON primeiro e passe body["data"] para Nokogiri.

Q: Ferrum::VERSION existe?

Não. Ele gera NameError: uninitialized constant Ferrum::VERSION. Use Gem.loaded_specs["ferrum"].version quando quiser registrar a versão.

Q: Ruby é uma escolha razoável para grandes trabalhos de raspagem?
Para trabalho de busca e análise, sim — Nokogiri é um analisador nativo rápido e as threads lidam bem com a concorrência limitada de IO. A área mais fraca é a automação do navegador, onde as ferramentas em torno são mais escassas do que as do Python ou Node, como ilustra o problema do SNI acima. Uma divisão comum é usar Ruby para o caminho HTTP e um navegador hospedado para as páginas que precisam de um.

Na Scorretless, acessamos apenas dados disponíveis ao público, enquanto cumprem estritamente as leis, regulamentos e políticas de privacidade do site aplicáveis. O conteúdo deste blog é apenas para fins de demonstração e não envolve atividades ilegais ou infratoras. Não temos garantias e negamos toda a responsabilidade pelo uso de informações deste blog ou links de terceiros. Antes de se envolver em qualquer atividade de raspagem, consulte seu consultor jurídico e revise os termos de serviço do site de destino ou obtenha as permissões necessárias.

Artigos mais populares

Catálogo