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
- Introducción: qué es el web scraping y qué considerar antes de empezar
- Cómo descargamos la página
- Bibliotecas para el parseo del contenido
- Cómo resolver los problemas de codificación y acentos
- Estado de la respuesta y trabajo con cabeceras
- Trabajar con HTTPS / SSL
- Trabajar con cookies
- Uso de proxies
- Scraping a través de TOR
- Concurrencia y paralelismo
- Páginas con JavaScript (navegadores headless) (añadido)
- Protección anti-bot, User-Agent, pausas, reintentos (añadido)
- Almacenamiento de URL y trabajo con colas
- Frameworks listos para scraping (añadido)
- Principales ventajas e inconvenientes de la implementación
- 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:
- Descarga — obtener la respuesta «en bruto» del servidor (cliente HTTP).
- 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 archivohttps://example.com/robots.txtdescribe 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:
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"
end2. 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».
require 'open-uri'
html = URI.open('https://example.com').read
puts htmlCon cabeceras y timeouts:
require 'open-uri'
html = URI.open(
'https://example.com',
'User-Agent' => 'Mozilla/5.0 (compatible; MyBot/1.0)',
open_timeout: 5,
read_timeout: 10
).readVentajas: 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.
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:
uri = URI('https://example.com/login')
res = Net::HTTP.post_form(uri, 'user' => 'admin', 'pass' => 'secret')
puts res.bodyVentajas: 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».
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:
response = HTTP.post(
'https://api.example.com/items',
json: { name: 'Widget', qty: 3 }
)
data = response.parse # parsea el JSON automáticamente2.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.
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.bodyFaraday 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).
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.
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}"
endCSS 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:
# 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.
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]}"
end3.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 regulares — evite 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.).
# 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 # 12994. 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.
str = response.body
puts str.encoding # por ejemplo, ASCII-8BIT o UTF-8
puts str.valid_encoding? # false -> algo va mal4.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):
# 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:
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:
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-84.4. Detección automática de la codificación
Cuando el sitio no declara el charset con honestidad, ayuda charlock_holmes (basado en ICU):
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
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)
endRegla: 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
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:
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
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
endEspecialmente 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.
# 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:
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_PEER # por defecto, verificar el certificadoDesactivar 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:
# 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/ losca-certificatesdel 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:
http.min_version = OpenSSL::SSL::TLS1_2_VERSION7. Cookies
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
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)
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')7.3. Cookies en Net::HTTP
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'] = cookie7.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.
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')7.5. Guardar las cookies en disco
Para no iniciar sesión en cada ejecución:
agent.cookie_jar.save('cookies.yml') # guardar
agent.cookie_jar.load('cookies.yml') # restaurar8. 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
# 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
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
end8.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:
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
end9. 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
# 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:
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 ✗'
endCon http.rb a través de SOCKS:
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.code9.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:
ControlPort 9051
HashedControlPassword 16:... # generar: tor --hash-password "yourpass"Después:
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 circuito9.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)
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)
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.
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_termination10.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).
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 paralelo10.5. async (fibers) — la alternativa moderna
El gem async usa fibers para miles de conexiones simultáneas casi sin sobrecarga.
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
endCuál elegir
- Hasta unas pocas decenas de URL —
Thread+Queuenormales. - Código industrial —
concurrent-ruby. - Máxima velocidad, miles de peticiones —
Typhoeus::Hydraoasync.
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.
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
end11. 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:
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.quitWatir / 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)
urls.each do |url|
fetch(url)
sleep(rand(1.0..3.0)) # pausa aleatoria — se parece menos a un bot
end12.2. Rotación de User-Agent
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
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
end12.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-Afterante 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.
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
endInconveniente: 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).
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
endVentajas: 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:
- Sidekiq (sobre Redis) — el más popular, multihilo.
- GoodJob / Solid Queue (sobre PostgreSQL) — sin un Redis aparte.
# 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
endEsto 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:
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.
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.
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+Nokogiria 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íneaNet::HTTP— cliente HTTP integradoURI— análisis y construcción de URLJSON— parseo de JSONThread/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):
- Ferrum — control de Chrome mediante CDP (sitio)
- Watir · Selenium · playwright-ruby-client
Colas, tareas en segundo plano, almacenamiento:
- redis-rb — cliente de Redis
- Sidekiq · GoodJob · Solid Queue — tareas en segundo plano
- Sequel — trabajo con bases de datos (sitio)
Frameworks para scraping:
Otros:
- webrobots — análisis de
robots.txt