Scraping por lenguaje 30 min de lectura

Web scraping en Ruby: guía completa de lo simple a lo complejo

Web scraping en Ruby: Nokogiri, HTTParty, Mechanize y Watir, del análisis de páginas estáticas a la automatización del navegador.

EW
Equipo Web-Scraping.es
Recopilación de datos para las necesidades del negocio
Publicado: 1 mayo 2025

Guía general sobre cómo escribir scrapers en Ruby — desde descargar una sola página hasta la recolección multihilo de datos a través de proxies y TOR. Todos los ejemplos son funcionales y están pensados para Ruby 3.x.


Índice

  1. Introducción: qué es el web scraping y qué considerar antes de empezar
  2. Cómo descargamos la página
  3. Bibliotecas para el parseo del contenido
  4. Cómo resolver los problemas de codificación y acentos
  5. Estado de la respuesta y trabajo con cabeceras
  6. Trabajar con HTTPS / SSL
  7. Trabajar con cookies
  8. Uso de proxies
  9. Scraping a través de TOR
  10. Concurrencia y paralelismo
  11. Páginas con JavaScript (navegadores headless) (añadido)
  12. Protección anti-bot, User-Agent, pausas, reintentos (añadido)
  13. Almacenamiento de URL y trabajo con colas
  14. Frameworks listos para scraping (añadido)
  15. Principales ventajas e inconvenientes de la implementación
  16. Conclusión y checklist

1. Introducción

El web scraping es la recolección automática de datos de páginas web. El proceso casi siempre consta de dos tareas distintas que conviene no mezclar:

  1. Descarga — obtener la respuesta «en bruto» del servidor (cliente HTTP).
  2. Extracción — sacar de la respuesta los datos que interesan (parser de HTML/JSON).

En Ruby cada tarea tiene sus propias herramientas, y un buen scraper suele combinar un cliente HTTP con un parser.

Qué considerar antes de escribir código

Antes de scrapear conviene tener en cuenta varias cosas — ahorran tiempo y disgustos:

  • robots.txt. El archivo https://example.com/robots.txt describe qué permite el propietario que rastreen los bots. Jurídicamente no siempre es obligatorio, pero ignorarlo es de mala educación y un riesgo.
  • Condiciones de uso (ToS). Algunos sitios prohíben expresamente la recolección automática. Eso ya es una cuestión legal, no técnica.
  • Carga. Un scraper se convierte con facilidad en un ataque DoS. Introduzca pausas y no golpee el servidor con cientos de hilos sin necesidad.
  • ¿Hay API? A menudo es más sencillo y más legal usar una API oficial o un endpoint JSON interno que parsear el HTML.
  • Datos personales. La recolección y el almacenamiento de datos personales están regulados por leyes (RGPD/GDPR, normativas locales de protección de datos, etc.).

Una comprobación sencilla de robots.txt con el 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 "Se puede scrapear"
else
  puts "robots.txt prohíbe esta ruta"
end

2. Cómo descargamos la página

Es la base. Veamos los clientes del más simple al más flexible.

2.1. open-uri — la vía más rápida

open-uri forma parte de la biblioteca estándar. Ideal para «obtener una página en una sola línea».

ruby
require 'open-uri'

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

Con cabeceras y 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

Ventajas: no hay que instalar nada, mínimo código. Inconvenientes: es incómodo para trabajar con POST, cabeceras de respuesta, redirecciones y errores (ante un 404/500 lanza la excepción OpenURI::HTTPError).

2.2. Net::HTTP — biblioteca estándar, control total

Net::HTTP también viene integrado en Ruby. Es verboso, pero da acceso a todo.

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        # cuerpo de la respuesta
puts response['Content-Type']

Petición POST:

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

Ventajas: sin dependencias, control total sobre la petición y la respuesta. Inconvenientes: verboso, gestión manual de las redirecciones y una API poco agradable.

2.3. http.rb (gem http) — moderno y cómodo

API limpia y encadenable (chainable). Una de las mejores opciones «por defecto».

ruby
require 'http'   # gem install http

response = HTTP
  .headers('User-Agent' => 'MyBot/1.0')
  .timeout(connect: 5, read: 10)
  .follow                       # seguir las redirecciones automáticamente
  .get('https://example.com')

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

POST con JSON:

ruby
response = HTTP.post(
  'https://api.example.com/items',
  json: { name: 'Widget', qty: 3 }
)
data = response.parse           # parsea el JSON automáticamente

2.4. Faraday — cliente con middleware

Faraday es una «envoltura sobre envolturas». Su gran baza es la pila de middleware: se pueden añadir el registro (logging), los reintentos, el parseo de JSON y el manejo de errores como capas.

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   # reintentos automáticos
  f.response :raise_error                    # 4xx/5xx -> excepción
  f.options.timeout = 10
  f.headers['User-Agent'] = 'MyBot/1.0'
end

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

Faraday va bien cuando el scraper crece hasta convertirse en una aplicación completa: un único punto de configuración para todas las peticiones.

2.5. Typhoeus — cuando hacen falta velocidad y paralelismo

Typhoeus es una envoltura sobre libcurl. Su principal ventaja es Hydra, que lanza muchas peticiones en paralelo (véase la sección sobre concurrencia).

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

Cuál elegir

Cliente Cuándo usarlo
open-uri script puntual, «dame esta página»
Net::HTTP no se pueden instalar gems, se necesita control total
http (http.rb) opción por defecto para la mayoría de scrapers
Faraday aplicación en crecimiento, con middleware/reintentos
Typhoeus recolección masiva en paralelo
Mechanize emulación de navegador con formularios/cookies (sección 14)

3. Bibliotecas para el parseo del contenido

Ya tenemos el HTML — ahora extraemos los datos.

3.1. Nokogiri — el estándar de facto

Nokogiri parsea HTML y XML, y admite selectores CSS y XPath. Es la herramienta principal para el 95 % de las tareas.

ruby
require 'nokogiri'
require 'open-uri'

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

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

# un elemento vs todos
first = doc.at_css('div.price')        # el primero que coincide (o nil)
all   = doc.css('div.item')            # NodeSet con todas las coincidencias

# XPath (más potente para condiciones complejas)
prices = doc.xpath('//div[@class="price"]/text()').map(&:to_s)

# Atributos y anidamiento
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 — cuándo cada uno:

  • CSS — más corto y legible para selecciones simples: div.item > a.link.

  • XPath — más potente: búsqueda por texto, por elemento padre, por posición:

    ruby
    # enlace cuyo texto contiene "Descargar"
    doc.xpath('//a[contains(text(), "Descargar")]')
    # elemento cuyo ancestro es un div con id="main"
    doc.xpath('//div[@id="main"]//span[@class="price"]')
    # selección por índice
    doc.xpath('(//tr)[3]')

3.2. Parseo de JSON — no lo olvide

Muy a menudo los datos no están en el HTML, sino en JSON (una API interna del sitio que la página carga por AJAX). Abrir la pestaña Network del navegador y localizar el endpoint JSON suele ser más fácil que parsear el 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. Otros parsers

  • Oga — una alternativa a Nokogiri en Ruby puro (sin extensiones en C). Más lento, pero más fácil de instalar. Útil allí donde compilar Nokogiri resulta complicado.
  • Loofah (sobre Nokogiri) — para la limpieza/saneamiento (sanitizing) de HTML.
  • Expresiones regularesevite parsear HTML con regex. Se rompen ante cualquier cambio en la maquetación. Una regex solo tiene sentido para extraer pequeños detalles de un texto ya seleccionado (un teléfono, un precio dentro de una cadena, etc.).
ruby
# OK: extraer el número de un texto ya seleccionado
price_text = doc.at_css('.price').text     # "1.299 €"
price = price_text.gsub(/[^\d]/, '').to_i   # 1299

4. Codificaciones y acentos

Una molestia clásica del scraping son los «caracteres ilegibles» (mojibake) en lugar del texto: España en vez de España, o corazón en vez de corazón. El motivo es que la respuesta HTTP son bytes, y Ruby debe interpretar correctamente su codificación (UTF-8, Windows-1252, ISO-8859-1, etc.).

4.1. De dónde surge el problema

Ruby guarda el encoding de cada cadena. Si los bytes están en Windows-1252 pero Ruby cree que son UTF-8, obtenemos basura.

ruby
str = response.body
puts str.encoding          # por ejemplo, ASCII-8BIT o UTF-8
puts str.valid_encoding?   # false -> algo va mal

4.2. Detectar la codificación y convertir

La codificación puede averiguarse a partir de: 1. la cabecera Content-Type: text/html; charset=windows-1252; 2. la metaetiqueta <meta charset="..."> dentro del HTML; 3. la heurística (biblioteca rchardet/charlock_holmes).

Conversión manual (si conocemos la codificación de origen):

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

force_encoding solo cambia la «etiqueta» de la codificación sin recodificar los bytes, mientras que encode sí recodifica realmente los bytes a la codificación de destino. El orden importa: primero hay que decirle a Ruby la verdad sobre los bytes de origen y después recodificar.

Conversión segura con reemplazo de los caracteres dañados:

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

4.3. Nokogiri y las codificaciones — la forma correcta

Lo mejor es pasar la codificación directamente a Nokogiri — él se encarga de recodificar todo:

ruby
require 'nokogiri'

# si conocemos la codificación
doc = Nokogiri::HTML(body, nil, 'Windows-1252')

# Nokogiri sabe leer <meta charset> por sí solo si no se le estorba:
doc = Nokogiri::HTML(body)   # a menudo es suficiente
puts doc.css('h1').text       # ya en UTF-8

4.4. Detección automática de la codificación

Cuando el sitio no declara el charset con honestidad, ayuda charlock_holmes (basado en ICU):

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. Un helper universal

ruby
def to_utf8(body, content_type = nil)
  # 1. probamos desde la cabecera
  if content_type && content_type =~ /charset=([\w-]+)/i
    enc = $1
    return body.encode('UTF-8', enc, invalid: :replace, undef: :replace)
  end

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

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

Regla: mantenga todo el pipeline interno en UTF-8. Convierta en la entrada, justo después de la descarga, y olvídese del tema.


5. Estado de la respuesta y cabeceras

Antes de parsear el cuerpo hay que asegurarse de que la respuesta es válida. Ignorar el estado HTTP es un error habitual de principiante (parsea una página de error creyendo que son datos).

5.1. Leer el estado y las cabeceras

ruby
require 'http'

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

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

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

En 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. Qué hacer con los distintos estados

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 no encontrada")
when 403, 429 then back_off    # baneo/límite — reducir el ritmo
when 500..599 then retry_later # error del servidor — reintentar más tarde
end

Especialmente importantes:

  • 429 Too Many Requests — va demasiado rápido. Mire la cabecera Retry-After.
  • 403 Forbidden — a menudo es un anti-bot. Cambie el User-Agent / proxy.
  • 3xx + Location — redirección; decida si seguirla.

5.3. Cabeceras útiles

  • Content-Type — tipo y codificación del contenido.
  • Set-Cookie — cookies (véase la sección 7).
  • Location — adónde redirige.
  • Retry-After — cuánto hay que esperar para reintentar.
  • ETag / Last-Modified — para el cacheo y las peticiones condicionales (If-None-Match / If-Modified-Since → 304 Not Modified, ahorra tráfico).

6. HTTPS / SSL

La mayoría de los clientes trabajan con HTTPS «de fábrica» y verifican los certificados — lo cual es correcto y seguro.

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

En Net::HTTP hay que activar 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 defecto, verificar el certificado

Desactivar la verificación del certificado — ¡con cuidado!

A veces un sitio tiene un certificado autofirmado o «roto». Desactivar la verificación abre un agujero para ataques MITM, así que hágalo solo de forma consciente:

ruby
# Net::HTTP
http.verify_mode = OpenSSL::SSL::VERIFY_NONE   # NO para producción

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

El error «certificate verify failed»

Un error habitual en una instalación reciente de Ruby/Windows — no hay un conjunto actualizado de certificados raíz. Soluciones:

  • actualizar el gem certifi / los ca-certificates del sistema;
  • indicar la ruta al bundle: http.ca_file = '/path/to/cacert.pem';
  • en macOS/Linux suele bastar con actualizar OpenSSL.

También se puede fijar una versión concreta de TLS si el servidor es caprichoso:

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

Las cookies sirven para las sesiones: inicio de sesión, carrito, comportamiento «humano». El servidor las envía en Set-Cookie y el cliente debe devolverlas en Cookie en las peticiones siguientes.

7.1. A mano

ruby
require 'http'

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

# las enviamos en la siguiente petición
resp2 = HTTP.cookies(cookies).get('https://example.com/account')

7.2. Mantener la sesión entre peticiones (http.rb)

ruby
require 'http'
require 'http-cookie'

jar = HTTP::CookieJar.new

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

# usamos la sesión
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 — las cookies «solas»

Para las sesiones complejas, lo más fácil es Mechanize (sección 14): guarda automáticamente el cookie-jar entre peticiones, como un 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
# las cookies ya están guardadas, agent recuerda la sesión
dashboard = agent.get('https://example.com/dashboard')

Para no iniciar sesión en cada ejecución:

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

8. Proxies

Los proxies sirven para: sortear los bloqueos por IP, repartir la carga, ocultar el origen y scrapear desde la geolocalización deseada. En el scraping masivo se suele usar un pool de proxies con rotación.

8.1. Proxies en los distintos 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. Rotación 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 habituales.
  • SOCKS5 — de bajo nivel, canalizan cualquier tráfico (necesarios para TOR, véase más abajo).
  • Datacenter vs Residential — los de centro de datos son más baratos, pero se banean con más facilidad; los residenciales (a través de proveedores reales) son más caros, pero «parecen personas».

8.4. Gestión de los proxies caídos

Los proxies se caen a menudo. Envuelva la petición en un reintento con cambio 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 "El proxy #{proxy} no funcionó: #{e.message}"
      next
    end
  end
  nil
end

9. Scraping a través de TOR

TOR ofrece anonimato gratuito y una rotación de IP «infinita». Técnicamente, TOR es un proxy SOCKS5 local (por defecto 127.0.0.1:9050).

9.1. Instalación y arranque

bash
# Linux
sudo apt install tor
sudo systemctl start tor

# macOS
brew install tor
brew services start tor

# comprobación: TOR escucha en 9050 (SOCKS) y, opcionalmente, en 9051 (control)

9.2. Peticiones a través de TOR

Como TOR es SOCKS5, hace falta un cliente compatible con SOCKS. Lo más cómodo es 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') ? 'A través de TOR ✓' : 'No es TOR ✗'
end

Con http.rb a través de SOCKS:

ruby
require 'http'
require 'socksify/http'

# http.rb no admite SOCKS por sí mismo, pero Typhoeus sí:
require 'typhoeus'
resp = Typhoeus.get('https://check.torproject.org',
                    proxy: 'socks5://127.0.0.1:9050')
puts resp.code

9.3. Cambiar de circuito (nueva IP) por el puerto de control

Para obtener una IP nueva, enviamos el comando NEWNYM al puerto de control (9051). Primero hay que configurarlo en /etc/tor/torrc:

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

Después:

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

# cambiamos de identidad cada N peticiones
tor_new_identity('yourpass')
sleep 5   # dar tiempo a TOR para construir un nuevo circuito

9.4. Limitaciones de TOR

  • Es lento. El tráfico pasa por 3 nodos — las latencias son altas.
  • Muchos sitios bloquean los nodos de salida de TOR (las listas de exit-node son públicas).
  • No sirve para la recolección masiva — sobrecarga la red TOR, que se sostiene con voluntarios. Para grandes volúmenes, use proxies residenciales comerciales.

10. Concurrencia y paralelismo

La descarga de páginas es una tarea limitada por E/S (I/O-bound): la mayor parte del tiempo el programa espera a la red. Por eso la concurrencia da una enorme ganancia, y el GIL (Global VM Lock) de Ruby aquí no estorba: durante la espera de red el hilo libera el GIL y los demás trabajan.

10.1. Hilos 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}" }

Inconveniente: sin limitar el número de hilos es fácil abrir 1000 conexiones a la vez y acabar baneado o cayéndose. Hace falta un pool.

10.2. Pool de hilos con límite (cola)

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 — el enfoque industrial

El gem concurrent-ruby ofrece pools y futures ya hechos — no hay que escribir los propios.

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 bloquea hasta que esté listo
pool.shutdown
pool.wait_for_termination

10.4. Typhoeus::Hydra — paralelismo sobre libcurl

El más eficiente para el paralelismo puramente de red: un solo hilo, pero libcurl gestiona muchas conexiones a la vez (multiplexación).

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}"
    # parseamos response.body aquí
  end
  hydra.queue(req)
  req
end

hydra.run   # ejecuta todas las peticiones en paralelo

10.5. async (fibers) — la alternativa moderna

El gem async usa fibers para miles de conexiones simultáneas casi sin 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   # hay que leer/cerrar obligatoriamente
    end
  end
  tasks.each(&:wait)
ensure
  internet&.close
end

Cuál elegir

  • Hasta unas pocas decenas de URLThread + Queue normales.
  • Código industrialconcurrent-ruby.
  • Máxima velocidad, miles de peticionesTyphoeus::Hydra o async.

Importante: el parseo con Nokogiri es CPU-bound, y ahí el GIL sí estorba. Si el cuello de botella está en el análisis del HTML (y no en la red), para un verdadero paralelismo de CPU hacen falta procesos (el gem Parallel, fork) o JRuby/TruffleRuby sin GIL.

ruby
require 'parallel'   # gem install parallel
# 4 procesos realmente en paralelo (sortean el 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 con JavaScript

Muchos sitios modernos renderizan el contenido en el navegador mediante JS. En el HTML «en bruto» que devuelve el servidor no están los datos que buscamos. Entonces hay dos caminos:

11.1. Localizar la API (opción preferible)

Abra DevTools → Network → XHR/Fetch. Normalmente el JS trae los datos de una API JSON. Consultar esa API directamente es más rápido y más estable que hacer funcionar un navegador.

11.2. Navegador headless

Si no se encuentra la API — levantamos un navegador real sin interfaz y tomamos el DOM ya renderizado.

Ferrum — control de Chrome mediante CDP, en Ruby puro, sin Selenium:

ruby
require 'ferrum'   # gem install ferrum (requiere 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 a que cargue

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

browser.quit

Watir / Selenium — más pesados, multinavegador, con una API rica para clics/formularios.

Playwright-ruby-client — una alternativa moderna a Selenium.

Los navegadores headless son varias veces más lentos y voraces en memoria. Úselos solo cuando no haya forma de evitar el renderizado.


12. Anti-bot, pausas, reintentos

Para que un scraper funcione mucho tiempo y no acabe baneado, debe comportarse de forma «educada» y humana.

12.1. Pausas (rate limiting)

ruby
urls.each do |url|
  fetch(url)
  sleep(rand(1.0..3.0))   # pausa aleatoria — se parece menos a un bot
end

12.2. Rotación 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. Reintentos con retroceso 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 "Intento #{attempt} fallido (#{e.message}), espero #{delay.round} s"
      sleep delay
      retry
    else
      raise
    end
  end
end

12.4. Otras técnicas de camuflaje

  • enviar cabeceras realistas (Accept, Accept-Language, Referer);
  • mantener una sesión con cookies (como un navegador);
  • rotación de proxies (sección 8);
  • respetar Retry-After ante un 429;
  • evitar un ritmo de peticiones demasiado regular y «mecánico».

Apunte ético: sortear las protecciones de forma agresiva puede infringir los ToS y la ley. Actúe dentro de lo razonable y lo legal.


13. Almacenamiento de URL y colas

En cuanto un scraper supera el esquema «un script — una página», surge la tarea de gestionar muchas URL: qué se ha descargado ya, qué está en cola y qué ha fallado.

13.1. La opción más simple — un Set en memoria

Sirve para tareas puntuales. Lo esencial es la deduplicación, para no descargar lo mismo dos veces.

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

Inconveniente: se pierde al reiniciar, no escala y no funciona entre procesos.

13.2. Cola en Redis — para scraping serio

Redis ofrece una cola persistente, compartida por muchos workers/máquinas, además de una deduplicación lista para usar mediante conjuntos (SADD).

ruby
require 'redis'   # gem install redis

redis = Redis.new

# añadir una URL (si no la hemos visto aún)
def enqueue(redis, url)
  # SADD devuelve 1 si la url es nueva
  if redis.sadd('seen', url) == 1
    redis.rpush('queue', url)
  end
end

# el worker toma la siguiente 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)
  # los enlaces extraídos -> de vuelta a la cola
end

Ventajas: varios workers en distintas máquinas toman de una misma cola; el estado sobrevive a los reinicios; es fácil añadir colas «retry» y «failed».

13.3. Sistemas de colas de tareas ya hechos

Para los scrapers de producción se suelen usar procesadores de tareas en segundo plano, donde «descargar una página» es un job:

ruby
# Ejemplo de un worker de 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)
    # generamos nuevas tareas
    doc.css('a').each { |a| ScrapeWorker.perform_async(a['href']) if internal?(a['href']) }
  end
end

Esto aporta reintentos, prioridades, monitorización y escalado horizontal «de fábrica».

13.4. Almacenamiento de los resultados

Los datos en sí se guardan en una BD (PostgreSQL, SQLite, MongoDB) o en archivos (CSV/JSON/Parquet). Ejemplo mínimo con 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. Qué más tener en cuenta en los recorridos grandes

  • Normalización de URL (quitar los anclajes #, los parámetros sobrantes, unificar el formato), o los duplicados se «cuelan» en la cola.
  • Profundidad de rastreo y limitación de dominio, para no acabar recorriendo todo internet.
  • Filtro de Bloom para deduplicar millones de URL sin almacenar todas las cadenas.
  • Prioridades (las páginas importantes, primero).
  • Checkpoints — para poder continuar tras una caída.

14. Frameworks ya hechos

No hay que escribirlo todo a mano. Existen herramientas que resuelven las tareas típicas.

14.1. Mechanize — un «navegador sin interfaz»

Mechanize gestiona por sí solo la sesión de cookies, navega por los enlaces, rellena y envía formularios y sigue las redirecciones. Ideal para scrapear tras un inicio de sesión.

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 usa Nokogiri por dentro, así que están disponibles los mismos selectores (page.css(...)).

14.2. Kimurai — un framework «araña» completo

Kimurai es el equivalente en Ruby de Scrapy (Python): rutas, métodos de parseo, soporte integrado de navegadores headless (Selenium/Ferrum), pipelines y exportación.

ruby
require 'kimurai'   # gem install kimurai

class NewsSpider < Kimurai::Base
  @name = 'news_spider'
  @engine = :mechanize           # o :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 a la página del artículo
      request_to :parse_article, url: item[:link], data: item
    end

    # paginación
    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!

Tenga en cuenta que el Kimurai original, en sus últimas versiones, se ha orientado hacia un DSL asistido por IA. Si necesita el Kimurai «clásico» con selectores normales, fíjese en el fork mantenido Tanakai — la API es casi idéntica.

14.3. Vessel / Wombat y otros

  • Vessel — una araña ligera sobre Ferrum (rápida, basada en Chrome).
  • Wombat — descripción declarativa de los campos a extraer mediante un DSL.
  • Spidr — un recorredor de sitios (crawler) sencillo.

Cuándo usar un framework

  • Script puntual → http.rb + Nokogiri a mano.
  • Scraping tras un login, con formularios → Mechanize.
  • Recorrido grande y estructurado con paginación/pipelines → Kimurai.

15. Ventajas e inconvenientes

Ventajas de implementar scraping en Ruby

  • Nokogiri — uno de los mejores parsers de HTML/XML que existen; CSS + XPath de fábrica.
  • Sintaxis expresiva — el código del scraper se lee casi como pseudocódigo y se escribe rápido.
  • Ecosistema rico — Mechanize, Kimurai, Ferrum, Typhoeus, Sidekiq, etc., cubren prácticamente cualquier tarea.
  • Excelente integración con Rails — si los datos van directos a una aplicación web.
  • El paralelismo de E/S funciona bien — para las tareas de red el GIL no es un obstáculo; hilos y fibers dan una alta concurrencia.
  • Herramientas maduras para colas y tareas en segundo plano (Sidekiq) — fáciles de llevar a escala industrial.

Inconvenientes y escollos

  • El GIL limita el paralelismo de CPU — si el cuello de botella está en el análisis del HTML y no en la red, hacen falta procesos/JRuby. Para el parseo intensivo de CPU «de fábrica», Ruby va por detrás de Go/Rust.
  • La velocidad del intérprete es menor que la de los lenguajes compilados; a volúmenes gigantescos se nota.
  • Los sitios con JS requieren un navegador headless — es lento y consume muchos recursos (pero es un problema de cualquier lenguaje, no solo de Ruby).
  • Nokogiri es una extensión en C — a veces resulta doloroso instalarlo/compilarlo en sistemas poco habituales (aunque hoy suele instalarse sin problemas).
  • La fragilidad inherente de los scrapers — cualquier scraper se rompe cuando cambia la maquetación del sitio; no es algo específico de Ruby, pero exige mantenimiento.
  • El ecosistema es menor que el de Python — Python (Scrapy, BeautifulSoup, requests) tiene más soluciones ya hechas y material didáctico enfocado precisamente en el scraping.

Cuándo Ruby es una buena elección

Cuando ya está en un stack Ruby/Rails, necesita un código legible y mantenible, los volúmenes son medios y el cuello de botella es la red (E/S), no el procesador. Para volúmenes extremos y parseo puro de CPU, fíjese en Go/Rust o en Python+Scrapy.


16. Conclusión

El scraping en Ruby se construye con dos ladrillos — un cliente HTTP y un parser — y todo lo demás (codificaciones, proxies, hilos, colas) se va añadiendo a su alrededor a medida que crece la tarea.

Chuleta rápida para elegir herramientas

Tarea Herramienta
Descargar una página (simple) open-uri
Descargar (con flexibilidad) http (http.rb)
Descarga en paralelo Typhoeus::Hydra, async
Análisis de HTML/XML Nokogiri
Parseo de JSON JSON (stdlib)
Sesiones/formularios/cookies Mechanize
Codificaciones force_encoding/encode, charlock_holmes
Renderizado JS Ferrum, Watir, Playwright
Proxies/TOR cualquier cliente + socksify para SOCKS5
Colas/escalado Redis, Sidekiq
Framework completo Kimurai

Empiece por lo simple (http + Nokogiri) y añada complejidad solo cuando de verdad haga falta — ese es el principio fundamental de un buen scraper.


Recursos oficiales y documentación

Biblioteca estándar de Ruby:

  • OpenURI — descarga de una página en una línea
  • Net::HTTP — cliente HTTP integrado
  • URI — análisis y construcción de URL
  • JSON — parseo de JSON
  • Thread / Queue — hilos y cola

Clientes HTTP:

  • http.rb — cliente moderno con API encadenable
  • Faraday — cliente con middleware; faraday-retry — reintentos
  • Typhoeus — peticiones en paralelo sobre libcurl
  • Mechanize — «navegador sin interfaz» (formularios, cookies, sesiones)

Parsers:

  • Nokogiri — el parser de HTML/XML principal (repositorio)
  • Oga — Ruby puro, sin libxml
  • Loofah — saneamiento de HTML

Codificaciones:

  • charlock_holmes — detección automática de la codificación (ICU)

Proxies / TOR:

  • socksify — SOCKS5 para Ruby/Net::HTTP
  • Tor Project — el propio TOR

Paralelismo:

  • concurrent-ruby — pools de hilos, futures
  • async — concurrencia con fibers (async-http)
  • parallel — paralelismo por procesos (sortea el GIL)

Navegadores headless (JS):

Colas, tareas en segundo plano, almacenamiento:

Frameworks para scraping:

Otros: