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
- Introduzione: che cos’è il web scraping e cosa considerare prima di iniziare
- Come scarichiamo la pagina
- Librerie per il parsing del contenuto
- Come risolvere i problemi di codifica e caratteri accentati
- Stato della risposta e gestione degli header
- Lavorare con HTTPS / SSL
- Lavorare con i cookie
- Uso dei proxy
- Scraping attraverso TOR
- Concorrenza e parallelismo
- Pagine con JavaScript (browser headless) (aggiunto)
- Protezione anti-bot, User-Agent, pause, retry (aggiunto)
- Archiviazione degli URL e lavoro con le code
- Framework pronti per lo scraping (aggiunto)
- Principali vantaggi e svantaggi dell’implementazione
- 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:
- Download: ottenere la risposta «grezza» del server (client HTTP).
- 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 filehttps://example.com/robots.txtdescrive 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:
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"
end2. 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».
require 'open-uri'
html = URI.open('https://example.com').read
puts htmlCon header e timeout:
require 'open-uri'
html = URI.open(
'https://example.com',
'User-Agent' => 'Mozilla/5.0 (compatible; MyBot/1.0)',
open_timeout: 5,
read_timeout: 10
).readVantaggi: 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.
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:
uri = URI('https://example.com/login')
res = Net::HTTP.post_form(uri, 'user' => 'admin', 'pass' => 'secret')
puts res.bodyVantaggi: 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».
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:
response = HTTP.post(
'https://api.example.com/items',
json: { name: 'Widget', qty: 3 }
)
data = response.parse # esegue automaticamente il parsing del JSON2.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.
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.bodyFaraday 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).
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à.
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}"
endCSS 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:
# 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.
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. 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.).
# 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 # 12994. 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.
str = response.body
puts str.encoding # ad esempio, ASCII-8BIT o UTF-8
puts str.valid_encoding? # false -> qualcosa non va4.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):
# 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:
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:
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-84.4. Rilevamento automatico della codifica
Quando il sito non dichiara onestamente il charset, aiuta charlock_holmes (basato su 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 universale
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)
endRegola: 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
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:
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
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
endParticolarmente 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ì.
# http.rb, Faraday, Typhoeus, open-uri — HTTPS funziona automaticamente
HTTP.get('https://example.com')In Net::HTTP bisogna abilitare SSL esplicitamente:
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_PEER # default, verifica il certificatoDisattivare 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:
# 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/ ica-certificatesdi 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:
http.min_version = OpenSSL::SSL::TLS1_2_VERSION7. Cookie
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
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)
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')7.3. Cookie in 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 — 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.
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')7.5. Salvare i cookie su disco
Per non rifare il login a ogni esecuzione:
agent.cookie_jar.save('cookies.yml') # salvare
agent.cookie_jar.load('cookies.yml') # ripristinare8. 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
# 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
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. 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:
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
end9. 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
# 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:
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 ✗'
endCon http.rb via SOCKS:
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.code9.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:
ControlPort 9051
HashedControlPassword 16:... # generare con: tor --hash-password "yourpass"Poi:
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 circuito9.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)
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)
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.
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_termination10.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).
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 parallelo10.5. async (fiber) — l’alternativa moderna
Il gem async usa i fiber per gestire migliaia di connessioni simultanee quasi senza overhead.
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
endQuale scegliere
- Fino a qualche decina di URL: i normali
Thread+Queue. - Codice industriale:
concurrent-ruby. - Massima velocità, migliaia di richieste:
Typhoeus::Hydraoasync.
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.
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
end11. 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:
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.quitWatir / 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)
urls.each do |url|
fetch(url)
sleep(rand(1.0..3.0)) # pausa casuale — sembra meno un bot
end12.2. Rotazione dello 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. Retry con backoff esponenziale
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
end12.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-Afterin 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.
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
endSvantaggio: 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).
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
endVantaggi: 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:
- Sidekiq (su Redis) — il più popolare, multithread.
- GoodJob / Solid Queue (su PostgreSQL) — senza un Redis a parte.
# 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
endQuesto 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:
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.
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.
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+Nokogiria 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 rigaNet::HTTP— client HTTP integratoURI— analisi e costruzione di URLJSON— parsing di JSONThread/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):
- Ferrum — controllo di Chrome via CDP (sito)
- Watir · Selenium · playwright-ruby-client
Code, job in background, archiviazione:
- redis-rb — client Redis
- Sidekiq · GoodJob · Solid Queue — job in background
- Sequel — lavorare con i database (sito)
Framework per lo scraping:
Altro:
- webrobots — parsing di
robots.txt