Ruby Web Scraping: Um Guia Prático
Advanced Data Extraction Specialist
TL;DR:
- A história de busca e análise do Ruby é composta por duas linhas de configuração.
Net::HTTPda 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ê analisaJSONprimeiro 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 parascrapeless.com. - Um patch de quinze linhas faz funcionar. Com SNI fornecido, o mesmo script retornou
title: Quotes to Scrapeequotes 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
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
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
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
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
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.
Escalada Dois: Um Navegador Real e Um Bug de Gem
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
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
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
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
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
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
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.



