Volver al blog

Ruby Web Scraping: Una Guía Práctica

Emily Chen
Emily Chen

Advanced Data Extraction Specialist

11-Aug-2026

TL;DR:

  • La historia de búsqueda y análisis de Ruby consiste en dos líneas de configuración. Net::HTTP de la biblioteca estándar más Nokogiri cubre completamente las páginas renderizadas por el servidor.
  • Nokogiri toma selectores CSS, así que la mayoría de ejemplos portan directamente. doc.css("div.quote") se comporta de la misma manera que el mismo selector se comporta en la consola del navegador.
  • La API Universal de Scraping Scrapeless responde con un sobrescrito JSON. El marcado llega dentro de data, así que debes analizar JSON primero y HTML segundo.
  • Ferrum no puede alcanzar un punto final de navegador TLS fuera de la caja, y la razón es una línea faltante. Construye su socket CDP sin configurar hostname, así que no se envía SNI y un host dependiente de SNI rechaza el apretón de manos. Confirmado en aislamiento: socket idéntico, fallos sin SNI, SNI exitoso contra un certificado para scrapeless.com.
  • Un parche de quince líneas lo hace funcionar. Con SNI proporcionado, el mismo script devolvió title: Quotes to Scrape y quotes on page: 10.
  • Comienza gratis: el tablero de Scrapeless emite una clave que funciona con cada ejemplo a continuación.

Lo Que Necesitas

Todo lo que aparece a continuación se ejecutó en Ruby 3.2.3 contra quotes.toscrape.com, un sitio publicado para práctica de scraping.

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

Net::HTTP, URI y JSON son de la biblioteca estándar, así que las únicas dependencias reales son el analizador y, más adelante, el controlador del navegador.

Una peculiaridad en la nomenclatura que vale la pena conocer antes de escribir un banner de versión: Nokogiri expone Nokogiri::VERSION, pero Ferrum no define ninguna constante Ferrum::VERSION. Referenciarla provoca NameError: uninitialized constant Ferrum::VERSION. Lee la versión de Gem.loaded_specs["ferrum"].version en su lugar.

Búsqueda y Análisis

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

Dos hábitos dan resultados de inmediato. css devuelve un conjunto de nodos y at_css devuelve la primera coincidencia o nil, así que busca at_css cada vez que quieras un elemento — es la diferencia entre nil y un conjunto vacío cuando falta un campo, y nil falla ruidosamente en el punto del error.

Limita las consultas secundarias a la fila. quotes.first.at_css('span.text') busca dentro de ese nodo, mientras que llamar a doc.at_css en un bucle devuelve el texto de la primera cita en cada iteración. Eso produce un conjunto de datos que parece plausible donde cada fila lleva el mismo valor, lo cual es mucho peor que un bloqueo.

La documentación de Nokogiri cubre los equivalentes XPath si los prefieres; doc.xpath("//div[@class='quote']") es la misma consulta en el otro dialecto.

Donde el Ruby Simple Se Detiene

El script funciona porque el objetivo se renderiza del lado del servidor y no filtra a sus llamadores. Dos situaciones terminan eso: contenido que solo existe después de que se ejecute JavaScript, y sitios que tratan a un cliente HTTP básico como un bot. Ninguna es una limitación de Ruby: ningún cliente HTTP en ningún lenguaje soluciona ninguno de los dos.

A partir de aquí, las escalaciones son herramientas diferentes en lugar de mejores versiones entre sí, y el camino simple sigue siendo el más rápido y económico donde funciona. Si necesitas información sobre lo que un navegador agrega, el explicador de automatización del navegador cubre la forma general.

Escalación Uno: La API Universal de Scraping

Esto mantiene tu código como un cliente HTTP ordinario y mueve la dificultad al lado del 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

El sobre es la parte que debes internalizar. resp.body es JSON; el marcado vive en body["data"]. Pasando resp.body directamente a Nokogiri::HTML produce un documento sin nodos coincidentes y no genera errores; los selectores devuelven silenciosamente cero filas. Analiza el JSON, toma data, luego analiza el HTML, y mantén ese desempaquetado en un solo método en lugar de esparcir JSON.parse(...)["data"] por toda la base de código.

Nota Net::HTTP.start(..., use_ssl: true) en lugar de la más corta Net::HTTP.post. Omitir use_ssl contra un URI https es un tropiezo común en Ruby que se manifiesta como un restablecimiento de conexión confuso en lugar de un error claro.

Ferrum es el controlador del Protocolo de Herramientas de Desarrollo de Chrome de Ruby, y acepta un ws_url: para adjuntarse a un navegador que no iniciaste. Apuntado a un punto final TLS, falla:

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

Ese mensaje nombra la capa equivocada. No es un problema de certificado, un problema de cifrado, o un problema de proxy.

La Causa Real

Ferrum 0.17.2 construye su socket CDP en lib/ferrum/client/web_socket.rb así:

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 se asigna, así que Ruby no envía ninguna Indicación de Nombre de Servidor extensión. Un host que sirve muchos certificados desde una dirección no puede elegir uno, y responde con una falla en el apretón de manos.
Veinte líneas de Ruby puro lo aíslan: sockets idénticos, una línea 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

Ese es todo el error. Cualquier cliente Ruby que omita hostname= lo encuentra contra cualquier endpoint dependiente de SNI, que hoy en día son la mayoría.

Una pista falsa que vale la pena nombrar

Hay un segundo comportamiento sospechoso en Ferrum, y no es la causa. Ferrum::Browser::Process.parse_json_version ejecuta URI.join(url, "/json/version") contra tu ws_url, lo que reemplaza la ruta y descarta la cadena de consulta. Una URL wss:// cuya ruta es /api/v2/browser y cuya consulta lleva tu token se reescribe a la raíz del host más /json/version, dejando totalmente fuera las credenciales. Esa dirección reescrita responde 404 page not found aquí.

Parece fatal y no lo es: el método captura JSON::ParserError, y un cuerpo 404 no es JSON válido, por lo que la falla se ignora. Arreglar solo SNI es suficiente: la ejecución parcheada a continuación aún devuelve 404 en esa prueba, y de todos modos funciona. Vale la pena saberlo para que no pierdas una tarde en ello, como lo hizo el primer diagnóstico de este artículo.

Haciendo que funcione

El socket CDP es típicamente el único socket TLS que abre un raspador de esta forma, por lo que proporcionar el nombre en la construcción es 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

Dos notas sobre el alcance. El parche es global a OpenSSL::SSL::SSLSocket, lo cual es aceptable en un raspador de un solo propósito y no algo que cargar en una aplicación Rails que abra otros sockets TLS; allí, confínalo al proceso que impulsa el navegador. Y la corrección en upstream es una sola línea en la gema, así que verifica si una liberación posterior a 0.17.2 la ha incluido antes de llevar un parche propio.

Elegir entre los tres

enfoque usar cuando costo
Net::HTTP + Nokogiri HTML renderizado por el servidor, objetivo permisivo el más bajo; una gema
API de Raspado Universal bloqueado o desafiado, no se necesita JS una llamada HTTP, sobre envoltorio por deshacer
Ferrum + un navegador remoto el contenido requiere ejecución de JavaScript el más alto; una sesión por trabajo, más el parche SNI

Trabaja hacia abajo en la lista en lugar de hacia arriba. Una página que parece necesitar un navegador a menudo incrusta sus datos en una etiqueta <script>, y doc.at_css("script#__NEXT_DATA__") con JSON.parse supera una sesión de navegador en todos los aspectos.

Conclusión

Ruby maneja el raspado bien, y la biblioteca estándar contiene más de ello de lo que la gente espera. Net::HTTP y Nokogiri cubren páginas renderizadas por el servidor, con at_css frente a css y consultas hijas con alcance por filas como los dos detalles que separan la extracción correcta de un conjunto de datos que parece bien y no lo es.

El camino del navegador es donde Ruby actualmente cuesta más que sus vecinos, y la razón es estrecha más que fundamental: un atributo no establecido en una gema, produciendo un mensaje de error que apunta a TLS en lugar de a SNI. El script de aislamiento anterior lo responde en veinte líneas, y el parche es lo suficientemente pequeño como para llevarlo hasta que la versión en upstream envíe la corrección de una línea.

¿Listo para raspar con Ruby?

Crea una clave en el tablero de Scrapeless y ejecuta el ejemplo Net::HTTP contra una página que ya recolectas. Si devuelve lo que esperas, has terminado: una gema, sin navegador. La API de Raspado Universal cubre las páginas donde no lo hace, y las tarifas actuales están en la página de precios.

FAQ

P: ¿Nokogiri o un analizador alternativo?

Nokogiri sigue siendo el predeterminado para HTML. Acepta tanto selectores CSS como XPath, está bien documentado y maneja el marcado mal formado. Existen analizadores puros de Ruby más ligeros que valen la pena considerar solo si las extensiones nativas son un problema en tu implementación.

P: ¿Por qué falla mi conexión Ferrum con un error de apretón de mano SSL?

Porque Ferrum 0.17.2 no establece hostname en su OpenSSL::SSL::SSLSocket, por lo que no se envía SNI y un endpoint dependiente de SNI no puede seleccionar un certificado. Reproduce esto en aislamiento con el script de veinte líneas anterior, luego aplica el parche o espera la corrección en upstream.

P: ¿Por qué mi análisis no devuelve nada cuando la llamada a la API claramente tuvo éxito?

Estás analizando el sobre. La respuesta es JSON con el marcado dentro data, así que Nokogiri recibe una cadena JSON, no coincide con nada y no lanza nada. Analiza primero el JSON y entrega body["data"] a Nokogiri.

P: ¿Existe Ferrum::VERSION?

No. Lanza NameError: uninitialized constant Ferrum::VERSION. Usa Gem.loaded_specs["ferrum"].version cuando quieras registrar la versión.

P: ¿Es Ruby una opción razonable para trabajos grandes de raspado?
Para trabajos de obtención y análisis, sí: Nokogiri es un analizador nativo rápido y los hilos manejan bien la concurrencia limitada por IO. El área más débil es la automatización del navegador, donde las herramientas circundantes son más escasas que las de Python o Node, como ilustra el problema de SNI mencionado anteriormente. Una división común es Ruby para la ruta HTTP y un navegador alojado para las páginas que necesitan uno.

En Scrapeless, solo accedemos a datos disponibles públicamente y cumplimos estrictamente con las leyes, regulaciones y políticas de privacidad del sitio web aplicables. El contenido de este blog es sólo para fines de demostración y no implica ninguna actividad ilegal o infractora. No ofrecemos garantías y renunciamos a toda responsabilidad por el uso de la información de este blog o enlaces de terceros. Antes de realizar cualquier actividad de scraping, consulte a su asesor legal y revise los términos de servicio del sitio web de destino u obtenga los permisos necesarios.

Artículos más populares

Catalogar