Scraping per linguaggio 30 min di lettura

Web scraping in Ruby: guida completa, dal semplice al complesso

Web scraping in Ruby: Nokogiri, HTTParty, Mechanize e Watir, dall’analisi delle pagine statiche all’automazione del browser.

TW
Team Web-Scraping.it
Raccolta dati per le esigenze del business
Pubblicato il 1 maggio 2025

Guida generale su come scrivere scraper in Ruby — dal download di una singola pagina alla raccolta dati multithread attraverso proxy e TOR. Tutti gli esempi sono funzionanti e pensati per Ruby 3.x.


Indice

  1. Introduzione: che cos’è il web scraping e cosa considerare prima di iniziare
  2. Come scarichiamo la pagina
  3. Librerie per il parsing del contenuto
  4. Come risolvere i problemi di codifica e caratteri accentati
  5. Stato della risposta e gestione degli header
  6. Lavorare con HTTPS / SSL
  7. Lavorare con i cookie
  8. Uso dei proxy
  9. Scraping attraverso TOR
  10. Concorrenza e parallelismo
  11. Pagine con JavaScript (browser headless) (aggiunto)
  12. Protezione anti-bot, User-Agent, pause, retry (aggiunto)
  13. Archiviazione degli URL e lavoro con le code
  14. Framework pronti per lo scraping (aggiunto)
  15. Principali vantaggi e svantaggi dell’implementazione
  16. Conclusione e checklist

1. Introduzione

Il web scraping è la raccolta automatica di dati dalle pagine web. Il processo consiste quasi sempre in due attività distinte, che è importante non mescolare:

  1. Download: ottenere la risposta «grezza» del server (client HTTP).
  2. Estrazione: tirare fuori dalla risposta i dati che servono (parser di HTML/JSON).

In Ruby ogni attività ha i suoi strumenti, e un buon scraper di solito combina un client HTTP con un parser.

Cosa considerare prima di scrivere codice

Prima di fare scraping conviene tenere presenti alcune cose — fanno risparmiare tempo e nervi:

  • robots.txt. Il file https://example.com/robots.txt descrive che cosa il proprietario consente di scansionare ai bot. Sul piano giuridico non è sempre vincolante, ma ignorarlo è cattivo stile e un rischio.
  • Condizioni d’uso (ToS). Alcuni siti vietano espressamente la raccolta automatica. Questa è ormai una questione legale, non tecnica.
  • Carico. Uno scraper si trasforma facilmente in un attacco DoS. Inserisci delle pause e non colpire il server con centinaia di thread senza necessità.
  • C’è un’API? Spesso è più semplice e più lecito usare l’API ufficiale o un endpoint JSON interno che fare scraping dell’HTML.
  • Dati personali. La raccolta e la conservazione dei dati personali sono regolate dalla legge (GDPR, normative locali sulla protezione dei dati, ecc.).

Una verifica semplice di robots.txt con il 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 "Si può fare scraping"
else
  puts "robots.txt vieta questo percorso"
end

2. Come scarichiamo la pagina

È la base. Vediamo i client dal più semplice al più flessibile.

2.1. open-uri: il modo più rapido

open-uri fa parte della libreria standard. Ideale per «ottenere una pagina in una sola riga».

ruby
require 'open-uri'

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

Con header e timeout:

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

Vantaggi: niente da installare, codice minimo. Svantaggi: è scomodo per lavorare con POST, header di risposta, redirect ed errori (su 404/500 solleva l’eccezione OpenURI::HTTPError).

2.2. Net::HTTP: libreria standard, controllo totale

Net::HTTP è anch’esso integrato in Ruby. Verboso, ma dà accesso a tutto.

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        # corpo della risposta
puts response['Content-Type']

Richiesta POST:

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

Vantaggi: senza dipendenze, controllo totale su richiesta e risposta. Svantaggi: verboso, gestione manuale dei redirect e un’API non proprio piacevole.

2.3. http.rb (gem http) — moderno e comodo

API pulita e concatenabile (chainable). Una delle migliori scelte «di default».

ruby
require 'http'   # gem install http

response = HTTP
  .headers('User-Agent' => 'MyBot/1.0')
  .timeout(connect: 5, read: 10)
  .follow                       # segue automaticamente i redirect
  .get('https://example.com')

puts response.status            # 200
puts response.to_s              # corpo
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           # esegue automaticamente il parsing del JSON

2.4. Faraday: client con middleware

Faraday è un «wrapper di wrapper». Il suo punto di forza è lo stack di middleware: puoi aggiungere logging, retry, parsing del JSON e gestione degli errori come livelli.

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   # retry automatici
  f.response :raise_error                    # 4xx/5xx -> eccezione
  f.options.timeout = 10
  f.headers['User-Agent'] = 'MyBot/1.0'
end

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

Faraday dà il meglio quando lo scraper cresce fino a diventare un’applicazione completa: un unico punto di configurazione per tutte le richieste.

2.5. Typhoeus: quando servono velocità e parallelismo

Typhoeus è un wrapper di libcurl. Il suo vantaggio principale è Hydra, che esegue molte richieste in parallelo (vedi la sezione sulla concorrenza).

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

Quale scegliere

Client Quando usarlo
open-uri script una tantum, «dammi questa pagina»
Net::HTTP non si possono installare gem, serve controllo totale
http (http.rb) scelta di default per la maggior parte degli scraper
Faraday applicazione in crescita, servono middleware/retry
Typhoeus raccolta massiva in parallelo
Mechanize serve emulare un browser con form/cookie (sezione 14)

3. Librerie per il parsing del contenuto

Abbiamo l’HTML — ora estraiamo i dati.

3.1. Nokogiri — lo standard de facto

Nokogiri esegue il parsing di HTML e XML e supporta selettori CSS e XPath. È lo strumento principale per il 95% delle attività.

ruby
require 'nokogiri'
require 'open-uri'

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

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

# un elemento vs tutti
first = doc.at_css('div.price')        # il primo che corrisponde (o nil)
all   = doc.css('div.item')            # NodeSet di tutte le corrispondenze

# XPath (più potente per condizioni complesse)
prices = doc.xpath('//div[@class="price"]/text()').map(&:to_s)

# Attributi e annidamento
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 — quando usare cosa:

  • CSS: più corto e leggibile per le selezioni semplici: div.item > a.link.

  • XPath: più potente: ricerca per testo, per genitore, per posizione:

    ruby
    # link il cui testo contiene "Scarica"
    doc.xpath('//a[contains(text(), "Scarica")]')
    # elemento il cui antenato è un div con id="main"
    doc.xpath('//div[@id="main"]//span[@class="price"]')
    # selezione per indice
    doc.xpath('(//tr)[3]')

3.2. Parsing di JSON — non dimenticarlo

Molto spesso i dati non stanno nell’HTML ma in JSON (l’API interna del sito, che la pagina carica via AJAX). Aprire la scheda Network del browser e individuare l’endpoint JSON è spesso più semplice che fare il parsing dell’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. Altri parser

  • Oga: un’alternativa a Nokogiri in Ruby puro (senza estensioni in C). Più lento, ma più facile da installare. Utile dove compilare Nokogiri è complicato.
  • Loofah (costruito su Nokogiri) — per la pulizia/sanitizzazione dell’HTML.
  • Espressioni regolari: evita di fare il parsing dell’HTML con le regex. Si rompono a ogni modifica del layout. Una regex ha senso solo per estrarre piccoli dettagli da un testo già selezionato (un telefono, un prezzo dentro una stringa, ecc.).
ruby
# OK: estrarre il numero da un testo già selezionato
price_text = doc.at_css('.price').text     # "1.299 €"
price = price_text.gsub(/[^\d]/, '').to_i   # 1299

4. Codifiche e caratteri accentati

Un classico grattacapo dello scraping sono i «caratteri illeggibili» (mojibake) al posto del testo: perché invece di perché, o più invece di più. Il motivo è che la risposta HTTP è fatta di byte, e Ruby deve interpretarne correttamente la codifica (UTF-8, Windows-1252, ISO-8859-1, ecc.).

4.1. Da dove nasce il problema

Ruby memorizza l’encoding di ogni stringa. Se i byte sono in Windows-1252 ma Ruby crede che siano UTF-8, otteniamo spazzatura.

ruby
str = response.body
puts str.encoding          # ad esempio, ASCII-8BIT o UTF-8
puts str.valid_encoding?   # false -> qualcosa non va

4.2. Rilevare la codifica e convertire

La codifica si può ricavare da: 1. l’header Content-Type: text/html; charset=windows-1252; 2. il meta tag <meta charset="..."> dentro l’HTML; 3. l’euristica (libreria rchardet/charlock_holmes).

Conversione manuale (se conosciamo la codifica di origine):

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

force_encoding cambia solo l’«etichetta» della codifica senza ricodificare i byte, mentre encode ricodifica davvero i byte nella codifica di destinazione. L’ordine conta: prima bisogna dire a Ruby la verità sui byte di origine, poi ricodificare.

Conversione sicura con sostituzione dei caratteri danneggiati:

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

4.3. Nokogiri e le codifiche — il modo corretto

La cosa migliore è passare la codifica direttamente a Nokogiri: penserà lui a ricodificare tutto:

ruby
require 'nokogiri'

# se conosciamo la codifica
doc = Nokogiri::HTML(body, nil, 'Windows-1252')

# Nokogiri sa leggere <meta charset> da solo, se non lo si ostacola:
doc = Nokogiri::HTML(body)   # spesso è sufficiente
puts doc.css('h1').text       # già in UTF-8

4.4. Rilevamento automatico della codifica

Quando il sito non dichiara onestamente il charset, aiuta charlock_holmes (basato su 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 universale

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

  # 2. è già UTF-8 valido?
  test = body.dup.force_encoding('UTF-8')
  return test if test.valid_encoding?

  # 3. rilevamento automatico
  require 'charlock_holmes'
  det = CharlockHolmes::EncodingDetector.detect(body)
  body.encode('UTF-8', det[:encoding] || 'UTF-8',
              invalid: :replace, undef: :replace)
end

Regola: tieni tutta la pipeline interna in UTF-8. Converti in ingresso, subito dopo il download, e non pensarci più.


5. Stato della risposta e header

Prima di fare il parsing del corpo bisogna assicurarsi che la risposta sia davvero valida. Ignorare lo stato HTTP è un classico errore da principiante (fai il parsing di una pagina di errore credendo che siano dati).

5.1. Leggere stato e header

ruby
require 'http'

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

puts resp.status              # 200 (oggetto di stato)
puts resp.status.to_i         # 200 (numero)
puts resp.status.success?     # true per 2xx
puts resp.status.redirect?    # true per 3xx

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

In 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. Cosa fare con i vari stati

ruby
case resp.code
when 200      then process(resp.to_s)
when 301, 302 then follow_redirect(resp.headers['Location'])
when 404      then log("pagina non trovata")
when 403, 429 then back_off    # ban/limite — rallentare
when 500..599 then retry_later # errore del server — riprovare più tardi
end

Particolarmente importanti:

  • 429 Too Many Requests: stai facendo troppe richieste. Guarda l’header Retry-After.
  • 403 Forbidden: spesso è un anti-bot. Cambia User-Agent / proxy.
  • 3xx + Location: redirect; decidi se seguirlo.

5.3. Header utili

  • Content-Type: tipo e codifica del contenuto.
  • Set-Cookie: i cookie (vedi la sezione 7).
  • Location: dove reindirizza.
  • Retry-After: tra quanto si può riprovare.
  • ETag / Last-Modified: per il caching e le richieste condizionali (If-None-Match / If-Modified-Since → 304 Not Modified, risparmia traffico).

6. HTTPS / SSL

La maggior parte dei client funziona con HTTPS «di serie» e verifica i certificati: ed è giusto e sicuro così.

ruby
# http.rb, Faraday, Typhoeus, open-uri — HTTPS funziona automaticamente
HTTP.get('https://example.com')

In Net::HTTP bisogna abilitare SSL esplicitamente:

ruby
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_PEER   # default, verifica il certificato

Disattivare la verifica del certificato — attenzione!

A volte un sito ha un certificato autofirmato o «rotto». Disattivare la verifica apre la porta ad attacchi MITM, quindi fallo solo in modo consapevole:

ruby
# Net::HTTP
http.verify_mode = OpenSSL::SSL::VERIFY_NONE   # NON per la produzione

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

Il problema «certificate verify failed»

Errore frequente su un’installazione fresca di Ruby/Windows — manca un set aggiornato di certificati radice. Soluzioni:

  • aggiornare il gem certifi / i ca-certificates di sistema;
  • indicare il percorso del bundle: http.ca_file = '/path/to/cacert.pem';
  • su macOS/Linux di solito basta aggiornare OpenSSL.

Si può anche fissare una versione specifica di TLS, se il server fa i capricci:

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

I cookie servono per le sessioni: login, carrello, comportamento «umano». Il server li invia in Set-Cookie e il client deve restituirli in Cookie nelle richieste successive.

7.1. A mano

ruby
require 'http'

# riceviamo i cookie
resp = HTTP.get('https://example.com/login')
cookies = resp.cookies   # HTTP::CookieJar

# li inviamo nella richiesta successiva
resp2 = HTTP.cookies(cookies).get('https://example.com/account')

7.2. Mantenere la sessione tra le richieste (http.rb)

ruby
require 'http'
require 'http-cookie'

jar = HTTP::CookieJar.new

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

# usiamo la sessione
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 — i cookie «da soli»

Per le sessioni complesse la via più semplice è Mechanize (sezione 14): mantiene automaticamente il cookie-jar tra le richieste, come un browser.

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
# i cookie sono già salvati, agent ricorda la sessione
dashboard = agent.get('https://example.com/dashboard')

Per non rifare il login a ogni esecuzione:

ruby
agent.cookie_jar.save('cookies.yml')   # salvare
agent.cookie_jar.load('cookies.yml')   # ripristinare

8. Proxy

I proxy servono per: aggirare i blocchi per IP, distribuire il carico, nascondere l’origine, fare scraping dalla geolocalizzazione «giusta». Nello scraping massivo si usa di solito un pool di proxy con rotazione.

8.1. I proxy nei vari client

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. Rotazione dei proxy

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. Tipi di proxy

  • HTTP/HTTPS: i normali proxy web.
  • SOCKS5: di basso livello, veicolano qualsiasi traffico (necessari per TOR, vedi sotto).
  • Datacenter vs Residential: i proxy datacenter costano meno, ma vengono bannati più facilmente; i residenziali (tramite provider reali) costano di più, ma «sembrano persone».

8.4. Gestire i proxy morti

I proxy cadono spesso. Avvolgi la richiesta in un retry con cambio di 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 "Il proxy #{proxy} non ha funzionato: #{e.message}"
      next
    end
  end
  nil
end

9. Scraping attraverso TOR

TOR offre anonimato gratuito e una rotazione di IP «infinita». Tecnicamente TOR è un proxy SOCKS5 locale (di default 127.0.0.1:9050).

9.1. Installazione e avvio

bash
# Linux
sudo apt install tor
sudo systemctl start tor

# macOS
brew install tor
brew services start tor

# verifica: TOR ascolta su 9050 (SOCKS) e, opzionalmente, su 9051 (control)

9.2. Richieste attraverso TOR

Poiché TOR è SOCKS5, serve un client che supporti SOCKS. Il più comodo è 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') ? 'Attraverso TOR ✓' : 'Non è TOR ✗'
end

Con http.rb via SOCKS:

ruby
require 'http'
require 'socksify/http'

# http.rb da solo non supporta SOCKS, ma Typhoeus sì:
require 'typhoeus'
resp = Typhoeus.get('https://check.torproject.org',
                    proxy: 'socks5://127.0.0.1:9050')
puts resp.code

9.3. Cambiare circuito (nuovo IP) tramite la porta di controllo

Per ottenere un nuovo IP inviamo il comando NEWNYM alla porta di controllo (9051). Prima bisogna configurarla in /etc/tor/torrc:

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

Poi:

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

# cambiamo identità ogni N richieste
tor_new_identity('yourpass')
sleep 5   # dare a TOR il tempo di costruire un nuovo circuito

9.4. Limiti di TOR

  • È lento. Il traffico passa per 3 nodi — le latenze sono alte.
  • Molti siti bloccano i nodi di uscita di TOR (le liste degli exit-node sono pubbliche).
  • Non è adatto alla raccolta massiva: sovraccarica la rete TOR, che si regge sui volontari. Per grandi volumi usa proxy residenziali commerciali.

10. Concorrenza e parallelismo

Il download delle pagine è un’attività I/O-bound: per la maggior parte del tempo il programma aspetta la rete. La concorrenza porta quindi un guadagno enorme, e il GIL (Global VM Lock) di Ruby qui non è d’intralcio: durante l’attesa di rete il thread rilascia il GIL e gli altri lavorano.

10.1. Thread semplici (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}" }

Svantaggio: senza limitare il numero di thread è facile aprire 1.000 connessioni in una volta e farsi bannare o andare in crash. Serve un pool.

10.2. Pool di thread con limite (coda)

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: l’approccio industriale

Il gem concurrent-ruby offre pool e future già pronti — non serve scriverseli da zero.

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 blocca finché non è pronto
pool.shutdown
pool.wait_for_termination

10.4. Typhoeus::Hydra: parallelismo su libcurl

Il più efficiente per il parallelismo puramente di rete: un solo thread, ma libcurl gestisce molte connessioni contemporaneamente (multiplexing).

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}"
    # qui facciamo il parsing di response.body
  end
  hydra.queue(req)
  req
end

hydra.run   # esegue tutte le richieste in parallelo

10.5. async (fiber) — l’alternativa moderna

Il gem async usa i fiber per gestire migliaia di connessioni simultanee quasi senza overhead.

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   # leggere/chiudere obbligatoriamente
    end
  end
  tasks.each(&:wait)
ensure
  internet&.close
end

Quale scegliere

  • Fino a qualche decina di URL: i normali Thread + Queue.
  • Codice industriale: concurrent-ruby.
  • Massima velocità, migliaia di richieste: Typhoeus::Hydra o async.

Importante: il parsing con Nokogiri è CPU-bound, e lì il GIL è invece d’intralcio. Se il collo di bottiglia è l’analisi dell’HTML (e non la rete), per un vero parallelismo di CPU servono processi (il gem Parallel, fork) oppure JRuby/TruffleRuby senza GIL.

ruby
require 'parallel'   # gem install parallel
# 4 processi realmente in parallelo (aggirano il 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. Pagine con JavaScript

Molti siti moderni renderizzano i contenuti nel browser via JS. Nell’HTML «grezzo» restituito dal server i dati che cerchi non ci sono. A quel punto le strade sono due:

11.1. Trovare l’API (preferibile)

Apri DevTools → Network → XHR/Fetch. Di solito il JS recupera i dati da un’API JSON. Interrogare quell’API direttamente è più veloce e più stabile che far girare un browser.

11.2. Browser headless

Se l’API non si trova — avviamo un browser vero senza UI e prendiamo il DOM già pronto.

Ferrum: controllo di Chrome via CDP, Ruby puro, senza Selenium:

ruby
require 'ferrum'   # gem install ferrum (richiede Chrome/Chromium installato)

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   # attendere il caricamento

html = page.body             # il DOM già renderizzato
doc  = Nokogiri::HTML(html)
puts doc.css('.dynamic-item').map(&:text)

browser.quit

Watir / Selenium: più pesanti, multi-browser, con un’API ricca per clic e form.

Playwright-ruby-client: un’alternativa moderna a Selenium.

I browser headless sono molto più lenti e voraci di memoria. Usali solo quando senza rendering non si può proprio fare.


12. Anti-bot, pause, retry

Perché uno scraper lavori a lungo senza farsi bannare, deve comportarsi in modo «educato» e umano.

12.1. Pause (rate limiting)

ruby
urls.each do |url|
  fetch(url)
  sleep(rand(1.0..3.0))   # pausa casuale — sembra meno un bot
end

12.2. Rotazione dello 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. Retry con backoff esponenziale

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 "Tentativo #{attempt} fallito (#{e.message}), attendo #{delay.round} s"
      sleep delay
      retry
    else
      raise
    end
  end
end

12.4. Altre tecniche di mimetizzazione

  • inviare header realistici (Accept, Accept-Language, Referer);
  • mantenere una sessione con i cookie (come un browser);
  • ruotare i proxy (sezione 8);
  • rispettare Retry-After in caso di 429;
  • evitare un ritmo di richieste troppo regolare e «meccanico».

Nota etica: aggirare le protezioni in modo aggressivo può violare i ToS e la legge. Muoviti entro i limiti del ragionevole e del legale.


13. Archiviazione degli URL e code

Non appena lo scraper supera lo schema «uno script — una pagina», nasce il problema di gestire molti URL: cosa è già stato scaricato, cosa è in coda, cosa è fallito.

13.1. La variante più semplice — un Set in memoria

Va bene per le attività una tantum. L’essenziale è la deduplicazione, per non scaricare due volte la stessa cosa.

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

Svantaggio: si perde al riavvio, non scala, non funziona tra processi.

13.2. Coda su Redis — per lo scraping serio

Redis offre una coda persistente, condivisa tra molti worker/macchine, più una deduplicazione già pronta tramite gli insiemi (SADD).

ruby
require 'redis'   # gem install redis

redis = Redis.new

# aggiungere un URL (se non l'abbiamo ancora visto)
def enqueue(redis, url)
  # SADD restituisce 1 se l'url è nuovo
  if redis.sadd('seen', url) == 1
    redis.rpush('queue', url)
  end
end

# il worker preleva l'URL successivo (in modo bloccante)
def dequeue(redis)
  _list, url = redis.blpop('queue', timeout: 5)
  url
end

enqueue(redis, 'https://example.com')

while (url = dequeue(redis))
  process(url)
  # i link estratti -> di nuovo in coda
end

Vantaggi: più worker su macchine diverse attingono alla stessa coda; lo stato sopravvive ai riavvii; è facile aggiungere le code «retry» e «failed».

13.3. Sistemi di code di job già pronti

Per gli scraper in produzione si usano di solito i processori di job in background, dove «scaricare una pagina» è un job:

ruby
# Esempio di worker 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)
    # generiamo nuovi job
    doc.css('a').each { |a| ScrapeWorker.perform_async(a['href']) if internal?(a['href']) }
  end
end

Questo dà retry, priorità, monitoraggio e scalabilità orizzontale «di serie».

13.4. Archiviazione dei risultati

I dati veri e propri si salvano in un database (PostgreSQL, SQLite, MongoDB) o in file (CSV/JSON/Parquet). Esempio minimo 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. Cos’altro tenere a mente nei crawl di grandi dimensioni

  • Normalizzazione degli URL (togliere le ancore # e i parametri superflui, uniformare il formato), altrimenti i duplicati «filtrano» nella coda.
  • Profondità di scansione e limite di dominio, per non finire a girare per tutto internet.
  • Filtro di Bloom per deduplicare milioni di URL senza conservare tutte le stringhe.
  • Priorità (le pagine importanti prima).
  • Checkpoint: per poter riprendere dopo un crash.

14. Framework già pronti

Non serve scrivere tutto a mano. Esistono strumenti che coprono le attività tipiche.

14.1. Mechanize: un «browser senza UI»

Mechanize gestisce da solo la sessione dei cookie, naviga tra i link, compila e invia i form, segue i redirect. Ideale per fare scraping dietro login.

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 al suo interno, quindi sono disponibili gli stessi selettori (page.css(...)).

14.2. Kimurai — un framework-«spider» completo

Kimurai è l’equivalente Ruby di Scrapy (Python): rotte, metodi di parsing, supporto integrato per i browser headless (Selenium/Ferrum), pipeline ed esportazione.

ruby
require 'kimurai'   # gem install kimurai

class NewsSpider < Kimurai::Base
  @name = 'news_spider'
  @engine = :mechanize           # oppure :selenium_chrome per 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']
      }
      # passare alla pagina dell'articolo
      request_to :parse_article, url: item[:link], data: item
    end

    # paginazione
    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!

Tieni presente: il Kimurai originale nelle ultime versioni si è spostato verso un DSL assistito dall’AI. Se ti serve il Kimurai «classico» con i normali selettori, guarda il fork mantenuto Tanakai: l’API è quasi identica.

14.3. Vessel / Wombat e altri

  • Vessel: uno spider leggero costruito su Ferrum (veloce, basato su Chrome).
  • Wombat: descrizione dichiarativa dei campi da estrarre tramite un DSL.
  • Spidr: un semplice crawler di siti.

Quando usare un framework

  • Script una tantum → http.rb + Nokogiri a mano.
  • Scraping dietro login, form → Mechanize.
  • Crawl grande e strutturato con paginazione/pipeline → Kimurai.

15. Vantaggi e svantaggi

Vantaggi dell’implementazione dello scraping in Ruby

  • Nokogiri: uno dei migliori parser HTML/XML in assoluto; CSS + XPath di serie.
  • Sintassi espressiva: il codice dello scraper si legge quasi come pseudocodice e si scrive in fretta.
  • Ecosistema ricco: Mechanize, Kimurai, Ferrum, Typhoeus, Sidekiq, ecc. coprono praticamente qualsiasi esigenza.
  • Ottima integrazione con Rails: se i dati confluiscono direttamente in un’applicazione web.
  • Il parallelismo I/O funziona bene: per i compiti di rete il GIL non è un ostacolo; thread e fiber danno un’alta concorrenza.
  • Strumenti maturi per code e job in background (Sidekiq) — è facile arrivare a una scala industriale.

Svantaggi e insidie

  • Il GIL limita il parallelismo di CPU: se il collo di bottiglia è l’analisi dell’HTML e non la rete, servono processi/JRuby. Per il parsing pesante lato CPU, Ruby «di serie» resta dietro Go/Rust.
  • La velocità dell’interprete è inferiore a quella dei linguaggi compilati; su volumi giganteschi si sente.
  • I siti JS richiedono un browser headless: lento e avido di risorse (ma è un problema di qualsiasi linguaggio, non solo di Ruby).
  • Nokogiri è un’estensione in C: a volte installarlo/compilarlo su sistemi non standard è un dolore (anche se oggi di solito si installa senza problemi).
  • La fragilità intrinseca degli scraper: qualsiasi scraper si rompe quando cambia il layout del sito; non è una specificità di Ruby, ma richiede manutenzione.
  • L’ecosistema è più piccolo di quello di Python: Python (Scrapy, BeautifulSoup, requests) ha più soluzioni pronte e più materiale didattico dedicato proprio allo scraping.

Quando Ruby è una buona scelta

Quando sei già in uno stack Ruby/Rails, ti serve codice leggibile e manutenibile, i volumi sono medi e il collo di bottiglia è la rete (I/O), non il processore. Per volumi estremi e parsing puramente CPU guarda a Go/Rust o a Python+Scrapy.


16. Conclusione

Lo scraping in Ruby si costruisce con due mattoni — un client HTTP e un parser — e tutto il resto (codifiche, proxy, thread, code) si aggiunge intorno man mano che il progetto cresce.

Riepilogo rapido per la scelta degli strumenti

Attività Strumento
Scaricare una pagina (semplice) open-uri
Scaricare (in modo flessibile) http (http.rb)
Download in parallelo Typhoeus::Hydra, async
Parsing di HTML/XML Nokogiri
Parsing di JSON JSON (stdlib)
Sessioni/form/cookie Mechanize
Codifiche force_encoding/encode, charlock_holmes
Rendering JS Ferrum, Watir, Playwright
Proxy/TOR qualsiasi client + socksify per SOCKS5
Code/scalabilità Redis, Sidekiq
Framework completo Kimurai

Inizia dal semplice (http + Nokogiri) e aggiungi complessità solo quando serve davvero — è il principio cardine di un buon scraper.


Risorse ufficiali e documentazione

Libreria standard di Ruby:

  • OpenURI — scaricare una pagina in una riga
  • Net::HTTP — client HTTP integrato
  • URI — analisi e costruzione di URL
  • JSON — parsing di JSON
  • Thread / Queue — thread e coda

Client HTTP:

  • http.rb — client moderno con API concatenabile
  • Faraday — client con middleware; faraday-retry — retry
  • Typhoeus — richieste in parallelo su libcurl
  • Mechanize — «browser senza UI» (form, cookie, sessioni)

Parser:

  • Nokogiri — il principale parser HTML/XML (repository)
  • Oga — Ruby puro, senza libxml
  • Loofah — sanitizzazione dell’HTML

Codifiche:

  • charlock_holmes — rilevamento automatico della codifica (ICU)

Proxy / TOR:

  • socksify — SOCKS5 per Ruby/Net::HTTP
  • Tor Project — TOR stesso

Parallelismo:

  • concurrent-ruby — pool di thread, future
  • async — concorrenza sui fiber (async-http)
  • parallel — parallelismo per processi (aggira il GIL)

Browser headless (JS):

Code, job in background, archiviazione:

Framework per lo scraping:

Altro: