Article de synthèse sur l'écriture de scrapers en Ruby — du chargement d'une seule page à la collecte de données multi-thread via proxys et TOR. Tous les exemples sont fonctionnels et visent Ruby 3.x.
Sommaire
- Introduction : le web scraping et les questions à se poser avant de démarrer
- Comment récupérer la page
- Bibliothèques pour parser le contenu
- Résoudre les problèmes d'encodage du cyrillique
- Statut de la réponse et gestion des en-têtes
- Travailler en HTTPS / SSL
- Gérer les cookies
- Utiliser des proxys
- Scraper via TOR
- Multi / multithreading
- Pages JavaScript (navigateurs headless) (ajouté)
- Protection anti-bot, User-Agent, délais, retries (ajouté)
- Stockage des URL et files d'attente
- Frameworks de scraping prêts à l'emploi (ajouté)
- Principaux avantages et inconvénients de cette approche
- Conclusion et check-list
1. Introduction
Le web scraping — la collecte automatique de données sur des pages web. Le processus se compose presque toujours de deux tâches distinctes qu'il est important de ne pas mélanger :
- Le chargement — obtenir la réponse « brute » du serveur (client HTTP).
- L'extraction — tirer de la réponse les données recherchées (parseur HTML/JSON).
En Ruby, chaque tâche a ses propres outils, et un bon scraper combine en général un client HTTP avec un parseur.
À quoi penser avant d'écrire du code
Avant de scraper, mieux vaut prendre en compte quelques points — cela économise du temps et des nerfs :
robots.txt. Le fichierhttps://site.ru/robots.txtdécrit ce que le propriétaire autorise aux robots. Juridiquement, il n'est pas toujours contraignant, mais l'ignorer est une mauvaise pratique et un risque.- Conditions d'utilisation (ToS). Certains sites interdisent explicitement la collecte automatisée. C'est là une question juridique, et non technique.
- La charge. Un scraper se transforme facilement en attaque DoS. Prévoyez des délais et ne frappez pas le serveur avec des centaines de threads sans nécessité.
- Une API existe-t-elle ? Il est souvent plus simple et plus légal de passer par l'API officielle ou un endpoint JSON interne que de parser le HTML.
- Données personnelles. Leur collecte et leur stockage sont encadrés par la loi (RGPD, etc.).
Vérification simple du robots.txt avec les moyens du bord via le 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 "Scraping autorisé"
else
puts "robots.txt interdit ce chemin"
end
2. Comment récupérer la page
C'est le socle. Passons les clients en revue, du plus simple au plus flexible.
2.1. open-uri — le moyen le plus rapide
open-uri fait partie de la bibliothèque standard. Idéal pour « récupérer une page en une
ligne ».
require 'open-uri'
html = URI.open('https://example.com').read
puts html
Avec en-têtes et 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
).read
Avantages : rien à installer, un minimum de code.
Inconvénients : peu pratique pour le POST, les en-têtes de réponse, les redirections, les erreurs
(une 404/500 lève l'exception OpenURI::HTTPError).
2.2. Net::HTTP — bibliothèque standard, contrôle total
Net::HTTP est lui aussi intégré à Ruby. Verbeux, mais il donne accès à tout.
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 # corps de la réponse
puts response['Content-Type']
Requête POST :
uri = URI('https://example.com/login')
res = Net::HTTP.post_form(uri, 'user' => 'admin', 'pass' => 'secret')
puts res.body
Avantages : aucune dépendance, contrôle total sur la requête/réponse. Inconvénients : verbeux, gestion manuelle des redirections, API pas des plus agréables.
2.3. http.rb (gem http) — moderne et pratique
API propre et chaînable. L'un des meilleurs choix « par défaut ».
require 'http' # gem install http
response = HTTP
.headers('User-Agent' => 'MyBot/1.0')
.timeout(connect: 5, read: 10)
.follow # suivre automatiquement les redirections
.get('https://example.com')
puts response.status # 200
puts response.to_s # corps
puts response.headers['Content-Type']
POST avec JSON :
response = HTTP.post(
'https://api.example.com/items',
json: { name: 'Widget', qty: 3 }
)
data = response.parse # parse le JSON automatiquement
2.4. Faraday — le client à middlewares
Faraday est « un wrapper au-dessus des wrappers ». Son atout principal : la pile de
middlewares — on branche journalisation, retries, parsing JSON, gestion d'erreurs comme des couches.
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 # retries automatiques
f.response :raise_error # 4xx/5xx -> exception
f.options.timeout = 10
f.headers['User-Agent'] = 'MyBot/1.0'
end
response = conn.get('/data')
puts response.status
puts response.body
Faraday est un bon choix quand le scraper devient une application à part entière : un point de
configuration unique pour toutes les requêtes.
2.5. Typhoeus — quand il faut vitesse et parallélisme
Typhoeus est un wrapper de libcurl. Son avantage majeur : Hydra, qui exécute
de nombreuses requêtes en parallèle (voir la section sur le multithreading).
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']
Que choisir
| Client | Quand le prendre |
|---|---|
open-uri |
script ponctuel, « donne-moi cette page » |
Net::HTTP |
impossible d'installer des gems, contrôle total requis |
http (http.rb) |
le choix par défaut pour la plupart des scrapers |
Faraday |
application qui grandit, besoin de middlewares/retries |
Typhoeus |
collecte massive en parallèle |
Mechanize |
émulation de navigateur avec formulaires/cookies (section 14) |
3. Bibliothèques pour parser le contenu
Le HTML est récupéré — extrayons maintenant les données.
3.1. Nokogiri — le standard de facto
Nokogiri parse le HTML et le XML, prend en charge les sélecteurs CSS et XPath. C'est
l'outil principal pour 95 % des tâches.
require 'nokogiri'
require 'open-uri'
html = URI.open('https://example.com').read
doc = Nokogiri::HTML(html)
# Sélecteurs CSS
title = doc.css('h1.title').text.strip
links = doc.css('a').map { |a| a['href'] }
# un seul élément vs tous
first = doc.at_css('div.price') # premier élément trouvé (ou nil)
all = doc.css('div.item') # NodeSet de tous les éléments trouvés
# XPath (plus puissant pour les conditions complexes)
prices = doc.xpath('//div[@class="price"]/text()').map(&:to_s)
# Attributs et imbrication
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 — quand utiliser quoi :
- CSS — plus court et plus lisible pour les sélections simples :
div.item > a.link. - XPath — plus puissant : recherche par texte, par parent, par position :
# lien dont le texte contient "Скачать" (« Télécharger ») doc.xpath('//a[contains(text(), "Скачать")]') # élément dont l'ancêtre est un div avec id="main" doc.xpath('//div[@id="main"]//span[@class="price"]') # sélection par index doc.xpath('(//tr)[3]')
3.2. Parser du JSON — ne l'oubliez pas
Très souvent, les données ne sont pas dans le HTML mais dans du JSON (l'API interne du site, que la page charge en AJAX). Ouvrir l'onglet Network du navigateur et repérer l'endpoint JSON est souvent plus simple que de parser le 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]}"
end
3.3. Autres parseurs
Oga— alternative à Nokogiri en Ruby pur (sans extensions C). Plus lent, mais plus simple à installer. Utile là où Nokogiri est difficile à compiler.Loofah(au-dessus de Nokogiri) — pour le nettoyage/l'assainissement du HTML.- Expressions régulières — évitez de parser du HTML avec des regex. Elles cassent à la moindre modification du balisage. Une regex n'est pertinente que pour extraire un détail d'un texte déjà sélectionné (téléphone, prix dans une chaîne, etc.).
# OK : extraire un nombre d'un texte déjà sélectionné
price_text = doc.at_css('.price').text # "1 299 ₽"
price = price_text.gsub(/[^\d]/, '').to_i # 1299
4. Encodages et Unicode
La difficulté la plus fréquente quand on scrape des sites russophones : des « caractères illisibles » à la place du texte. La cause : une réponse HTTP est faite d'octets, et Ruby doit interpréter correctement leur encodage (UTF-8, Windows-1251, KOI8-R, etc.).
4.1. D'où vient le problème
Ruby stocke pour chaque chaîne son encoding. Si les octets sont en Windows-1251 alors que Ruby
pense qu'ils sont en UTF-8, on obtient du charabia.
str = response.body
puts str.encoding # par exemple ASCII-8BIT ou UTF-8
puts str.valid_encoding? # false -> quelque chose ne va pas
4.2. Détecter l'encodage et convertir
L'encodage peut se déduire de :
1. l'en-tête Content-Type: text/html; charset=windows-1251 ;
2. la balise meta <meta charset="..."> dans le HTML ;
3. une heuristique (bibliothèque rchardet/charlock_holmes).
Conversion manuelle (si l'encodage source est connu) :
# de Windows-1251 vers UTF-8
utf8 = body.force_encoding('Windows-1251').encode('UTF-8')
force_encoding ne change que l'« étiquette » d'encodage sans réencoder les octets, tandis que
encode réencode réellement les octets vers l'encodage cible. L'ordre compte :
dire d'abord à Ruby la vérité sur les octets d'origine, puis réencoder.
Conversion sûre avec remplacement des caractères invalides :
utf8 = body.encode(
'UTF-8',
'Windows-1251',
invalid: :replace,
undef: :replace,
replace: '?'
)
4.3. Nokogiri et les encodages — la bonne méthode
Le mieux est de passer l'encodage directement à Nokogiri — il réencode tout lui-même :
require 'nokogiri'
# si l'encodage est connu
doc = Nokogiri::HTML(body, nil, 'Windows-1251')
# Nokogiri sait lire <meta charset> tout seul si on ne le gêne pas :
doc = Nokogiri::HTML(body) # souvent suffisant
puts doc.css('h1').text # déjà en UTF-8
4.4. Détection automatique de l'encodage
Quand le site n'annonce pas honnêtement son charset, charlock_holmes (basé sur ICU) vient à la rescousse :
require 'charlock_holmes' # gem install charlock_holmes
detection = CharlockHolmes::EncodingDetector.detect(body)
puts detection[:encoding] # => "windows-1251"
puts detection[:confidence] # => 90
utf8 = body.encode('UTF-8', detection[:encoding],
invalid: :replace, undef: :replace)
4.5. Helper universel
def to_utf8(body, content_type = nil)
# 1. on essaie l'en-tête
if content_type && content_type =~ /charset=([\w-]+)/i
enc = $1
return body.encode('UTF-8', enc, invalid: :replace, undef: :replace)
end
# 2. déjà de l'UTF-8 valide ?
test = body.dup.force_encoding('UTF-8')
return test if test.valid_encoding?
# 3. détection automatique
require 'charlock_holmes'
det = CharlockHolmes::EncodingDetector.detect(body)
body.encode('UTF-8', det[:encoding] || 'UTF-8',
invalid: :replace, undef: :replace)
end
Règle : gardez tout votre pipeline interne en UTF-8. Convertissez à l'entrée, juste après le chargement, et n'y pensez plus.
5. Statut de la réponse et en-têtes
Avant de parser le corps, il faut s'assurer que la réponse est valide. Ignorer le statut HTTP est une erreur classique de débutant (on parse une page d'erreur en croyant traiter des données).
5.1. Lire le statut et les en-têtes
require 'http'
resp = HTTP.get('https://example.com')
puts resp.status # 200 (objet statut)
puts resp.status.to_i # 200 (nombre)
puts resp.status.success? # true pour les 2xx
puts resp.status.redirect? # true pour les 3xx
# en-têtes
puts resp.headers['Content-Type']
puts resp.headers['Content-Length']
puts resp.headers['Server']
puts resp.content_type.mime_type # "text/html"
Avec 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. Que faire selon le statut
case resp.code
when 200 then process(resp.to_s)
when 301, 302 then follow_redirect(resp.headers['Location'])
when 404 then log("page non trouvée")
when 403, 429 then back_off # ban/limite atteinte — ralentir
when 500..599 then retry_later # erreur serveur — réessayer plus tard
end
Particulièrement importants :
- 429 Too Many Requests — vous envoyez trop de requêtes. Regardez l'en-tête
Retry-After. - 403 Forbidden — souvent un anti-bot. Changez de User-Agent / de proxy.
- 3xx +
Location— redirection ; à vous de décider de la suivre ou non.
5.3. En-têtes utiles
Content-Type— type et encodage du contenu.Set-Cookie— les cookies (voir section 7).Location— cible de la redirection.Retry-After— délai avant de pouvoir réessayer.ETag/Last-Modified— pour le cache et les requêtes conditionnelles (If-None-Match/If-Modified-Since→ 304 Not Modified, économise du trafic).
6. HTTPS / SSL
La plupart des clients gèrent HTTPS « nativement » et vérifient les certificats — c'est correct et sûr.
# http.rb, Faraday, Typhoeus, open-uri — HTTPS fonctionne automatiquement
HTTP.get('https://example.com')
Avec Net::HTTP, il faut activer SSL explicitement :
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_PEER # par défaut : vérifier le certificat
Désactiver la vérification du certificat — prudence !
Il arrive qu'un site ait un certificat auto-signé ou « cassé ». Désactiver la vérification ouvre la porte aux attaques MITM, ne le faites donc qu'en toute connaissance de cause :
# Net::HTTP
http.verify_mode = OpenSSL::SSL::VERIFY_NONE # PAS pour la production
# 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)
Le problème « certificate verify failed »
Erreur fréquente sur une installation fraîche de Ruby/Windows : le jeu de certificats racines n'est pas à jour. Solutions :
- mettre à jour le gem
certifi/ les ca-certificates du système ; - indiquer le chemin du bundle :
http.ca_file = '/path/to/cacert.pem'; - sous macOS/Linux, mettre à jour OpenSSL suffit en général.
On peut aussi imposer une version précise de TLS si le serveur est capricieux :
http.min_version = OpenSSL::SSL::TLS1_2_VERSION
7. Cookies
Les cookies servent aux sessions : connexion, panier, comportement « humain ». Le serveur les envoie dans
Set-Cookie, et le client doit les renvoyer dans Cookie aux requêtes suivantes.
7.1. À la main
require 'http'
# on a reçu les cookies
resp = HTTP.get('https://example.com/login')
cookies = resp.cookies # HTTP::CookieJar
# on les renvoie dans la requête suivante
resp2 = HTTP.cookies(cookies).get('https://example.com/account')
7.2. Conserver la session entre les requêtes (http.rb)
require 'http'
require 'http-cookie'
jar = HTTP::CookieJar.new
# on se connecte
login = HTTP.post('https://example.com/login',
form: { user: 'me', pass: 'secret' })
login.cookies.each { |c| jar.add(c) }
# on utilise la session
page = HTTP.cookies(jar).get('https://example.com/dashboard')
7.3. Les cookies dans 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'] = cookie
7.4. Mechanize — des cookies « tout seuls »
Pour les sessions complexes, le plus simple est Mechanize (section 14) : il conserve
automatiquement le cookie-jar entre les requêtes, comme un navigateur.
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
# les cookies sont déjà stockés, agent garde la session en mémoire
dashboard = agent.get('https://example.com/dashboard')
7.5. Sauvegarder les cookies sur disque
Pour éviter de se reconnecter à chaque lancement :
agent.cookie_jar.save('cookies.yml') # sauvegarder
agent.cookie_jar.load('cookies.yml') # restaurer
8. Proxys
Les proxys servent à : contourner les blocages par IP, répartir la charge, masquer la source, scraper depuis la « bonne » géolocalisation. Pour le scraping massif, on utilise en général un pool de proxys avec rotation.
8.1. Les proxys dans les différents clients
# 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. Rotation de proxys
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. Types de proxys
- HTTP/HTTPS — les proxys web classiques.
- SOCKS5 — bas niveau, ils proxifient n'importe quel trafic (nécessaires pour TOR, voir plus bas).
- Datacenter vs Residential — les proxys datacenter sont moins chers mais plus vite bannis ; les résidentiels (via de vrais fournisseurs d'accès) coûtent plus cher mais « ressemblent à des humains ».
8.4. Gérer les proxys hors service
Les proxys tombent souvent en panne. Enveloppez la requête dans un retry avec changement 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 "Le proxy #{proxy} n'a pas fonctionné : #{e.message}"
next
end
end
nil
end
9. Scraper via TOR
TOR offre une anonymité gratuite et une rotation d'IP « infinie ». Techniquement, TOR est un
proxy SOCKS5 local (par défaut 127.0.0.1:9050).
9.1. Installation et lancement
# Linux
sudo apt install tor
sudo systemctl start tor
# macOS
brew install tor
brew services start tor
# vérification : TOR écoute sur 9050 (SOCKS) et en option sur 9051 (control)
9.2. Requêtes via TOR
TOR étant un proxy SOCKS5, il faut un client qui gère SOCKS. Le plus pratique est 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') ? 'Via TOR ✓' : 'Hors TOR ✗'
end
Avec http.rb via SOCKS :
require 'http'
require 'socksify/http'
# http.rb ne gère pas SOCKS nativement, mais Typhoeus oui :
require 'typhoeus'
resp = Typhoeus.get('https://check.torproject.org',
proxy: 'socks5://127.0.0.1:9050')
puts resp.code
9.3. Changer de circuit (nouvelle IP) via le port de contrôle
Pour obtenir une nouvelle IP, on envoie la commande NEWNYM au port de contrôle (9051). Il faut d'abord
le configurer dans /etc/tor/torrc :
ControlPort 9051
HashedControlPassword 16:... # à générer avec : tor --hash-password "yourpass"
Puis :
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
# on change d'identité toutes les N requêtes
tor_new_identity('yourpass')
sleep 5 # laisser à TOR le temps de construire un nouveau circuit
9.4. Limites de TOR
- C'est lent. Le trafic transite par 3 nœuds — les latences sont élevées.
- Beaucoup de sites bloquent les nœuds de sortie TOR (les listes d'exit nodes sont publiques).
- Pas pour la collecte massive — cela surcharge le réseau TOR, qui repose sur des bénévoles. Pour les gros volumes, prenez des proxys résidentiels commerciaux.
10. Multithreading et parallélisme
Le chargement de pages est une tâche I/O-bound : la majeure partie du temps, le programme attend le réseau. Le parallélisme apporte donc un gain énorme, et le GIL (Global VM Lock) de Ruby ne gêne pas ici : pendant l'attente réseau, le thread libère le GIL et les autres travaillent.
10.1. Threads 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}" }
Inconvénient : sans limite du nombre de threads, on ouvre vite 1 000 connexions d'un coup et on récolte un ban ou un crash. Il faut un pool.
10.2. Pool de threads avec limite (file d'attente)
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'approche industrielle
Le gem concurrent-ruby fournit pools et futures prêts à l'emploi — inutile d'écrire les vôtres.
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 bloque jusqu'au résultat
pool.shutdown
pool.wait_for_termination
10.4. Typhoeus::Hydra — parallélisme sur libcurl
Le plus efficace pour le parallélisme purement réseau : un seul thread, mais libcurl mène de nombreuses connexions à la fois (multiplexage).
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}"
# on parse response.body ici
end
hydra.queue(req)
req
end
hydra.run # exécute toutes les requêtes en parallèle
10.5. async (Fibers) — l'alternative moderne
Le gem async utilise les fibers pour tenir des milliers de connexions simultanées presque sans
surcoût.
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 # lire/fermer obligatoirement
end
end
tasks.each(&:wait)
ensure
internet&.close
end
Que choisir
- Jusqu'à quelques dizaines d'URL — de simples
Thread+Queue. - Code industriel —
concurrent-ruby. - Vitesse maximale, milliers de requêtes —
Typhoeus::Hydraouasync.
Important : le parsing avec Nokogiri est CPU-bound, et là le GIL gêne bel et bien. Si le goulot d'étranglement est l'analyse du HTML (et non le réseau), le vrai parallélisme CPU passe par des processus (gem
Parallel,fork) ou par JRuby/TruffleRuby, sans GIL.
require 'parallel' # gem install parallel
# 4 processus réellement parallèles (contournent le 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. Pages JavaScript
Beaucoup de sites modernes rendent leur contenu dans le navigateur via JS. Dans le HTML « brut » renvoyé par le serveur, les données recherchées sont absentes. Deux pistes s'offrent alors :
11.1. Trouver l'API (à privilégier)
Ouvrez DevTools → Network → XHR/Fetch. En général, le JS va chercher les données sur une API JSON. Interroger cette API directement est plus rapide et plus stable que de piloter un navigateur.
11.2. Navigateur headless
Si l'API reste introuvable, on lance un vrai navigateur sans UI et on récupère le DOM final.
Ferrum — pilotage de Chrome via CDP, Ruby pur, sans Selenium :
require 'ferrum' # gem install ferrum (Chrome/Chromium doit être installé)
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 # attendre la fin du chargement
html = page.body # le DOM déjà rendu
doc = Nokogiri::HTML(html)
puts doc.css('.dynamic-item').map(&:text)
browser.quit
Watir / Selenium — plus lourds, multi-navigateurs, avec une API riche pour les
clics/formulaires.
Playwright-ruby-client — l'alternative moderne à Selenium.
Les navigateurs headless sont nettement plus lents et plus gourmands en mémoire. Ne les utilisez que lorsque le rendu est incontournable.
12. Anti-bot, délais, retries
Pour fonctionner longtemps sans se faire bannir, un scraper doit se comporter « poliment » et comme un humain.
12.1. Délais (rate limiting)
urls.each do |url|
fetch(url)
sleep(rand(1.0..3.0)) # pause aléatoire — ça ressemble moins à un bot
end
12.2. Rotation du 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. Retries avec délai exponentiel
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 "Tentative #{attempt} échouée (#{e.message}), attente #{delay.round}s"
sleep delay
retry
else
raise
end
end
end
12.4. Autres techniques de discrétion
- envoyer des en-têtes réalistes (
Accept,Accept-Language,Referer) ; - maintenir une session à cookies (comme un navigateur) ;
- faire tourner les proxys (section 8) ;
- respecter
Retry-Afteren cas de 429 ; - éviter un rythme de requêtes trop régulier, trop « mécanique ».
Point éthique : contourner agressivement les protections peut enfreindre les ToS et la loi. Restez dans un cadre raisonnable et légal.
13. Stockage des URL et files d'attente
Dès que le scraper dépasse le stade « un script — une page », il faut gérer une multitude d'URL : ce qui est déjà téléchargé, ce qui attend en file, ce qui a échoué.
13.1. La variante la plus simple — un Set en mémoire
Convient aux tâches ponctuelles. L'essentiel est la déduplication, pour ne pas télécharger deux fois la même chose.
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)
# les nouveaux liens partent dans la file
doc.css('a').each do |a|
link = a['href']
queue << link if link&.start_with?('https://example.com') &&
!visited.include?(link)
end
end
Inconvénient : tout est perdu au redémarrage, ça ne passe pas à l'échelle, ça ne fonctionne pas entre processus.
13.2. File d'attente dans Redis — pour le scraping sérieux
Redis offre une file persistante, partagée entre plusieurs workers/machines, plus une
déduplication toute prête via les ensembles (SADD).
require 'redis' # gem install redis
redis = Redis.new
# ajouter une URL (si on ne l'a pas encore vue)
def enqueue(redis, url)
# SADD renvoie 1 si l'url est nouvelle
if redis.sadd('seen', url) == 1
redis.rpush('queue', url)
end
end
# le worker prend l'URL suivante (appel bloquant)
def dequeue(redis)
_list, url = redis.blpop('queue', timeout: 5)
url
end
enqueue(redis, 'https://example.com')
while (url = dequeue(redis))
process(url)
# les liens extraits -> retour dans la file
end
Avantages : plusieurs workers sur des machines différentes puisent dans la même file ; l'état survit au redémarrage ; facile d'ajouter des files « retry » et « failed ».
13.3. Systèmes de files de tâches prêts à l'emploi
Pour les scrapers en production, on prend en général un gestionnaire de tâches de fond, où « télécharger une page » est un job :
- Sidekiq (sur Redis) — le plus populaire, multithread.
- GoodJob / Solid Queue (sur PostgreSQL) — sans Redis séparé.
# Exemple de 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)
# on engendre de nouvelles tâches
doc.css('a').each { |a| ScrapeWorker.perform_async(a['href']) if internal?(a['href']) }
end
end
Cela apporte retries, priorités, monitoring et montée en charge horizontale « clés en main ».
13.4. Stockage des résultats
Les données elles-mêmes vont en base (PostgreSQL, SQLite, MongoDB) ou dans des fichiers (CSV/JSON/Parquet). Exemple minimal avec 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. À garder en tête pour les grands crawls
- Normalisation des URL (retirer les ancres
#, les paramètres superflus, unifier la forme), sinon les doublons « fuient » dans la file. - Profondeur de crawl et restriction au domaine, pour ne pas dériver vers tout l'internet.
- Filtre de Bloom pour dédupliquer des millions d'URL sans stocker toutes les chaînes.
- Priorités (les pages importantes d'abord).
- Checkpoints — pour pouvoir reprendre après un crash.
14. Frameworks prêts à l'emploi
Tout n'est pas à écrire à la main. Des outils couvrent déjà les besoins types.
14.1. Mechanize — « un navigateur sans UI »
Mechanize gère lui-même la session de cookies, navigue de lien en lien, remplit et soumet les formulaires,
suit les redirections. Idéal pour scraper derrière une page de connexion.
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 utilise Nokogiri en interne, on retrouve donc les mêmes sélecteurs (page.css(...)).
14.2. Kimurai — un vrai framework « spider »
Kimurai est l'équivalent Ruby du Scrapy de Python : routes, méthodes de parsing, prise en charge
intégrée des navigateurs headless (Selenium/Ferrum), pipelines, export.
require 'kimurai' # gem install kimurai
class NewsSpider < Kimurai::Base
@name = 'news_spider'
@engine = :mechanize # ou :selenium_chrome pour le 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']
}
# aller sur la page de l'article
request_to :parse_article, url: item[:link], data: item
end
# pagination
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!
À noter : dans ses dernières versions, le Kimurai d'origine a glissé vers un DSL assisté par IA. S'il vous faut le Kimurai « classique » avec des sélecteurs traditionnels, regardez le fork maintenu Tanakai — l'API est quasi identique.
14.3. Vessel / Wombat et autres
Vessel— spider léger au-dessus de Ferrum (rapide, sur Chrome).Wombat— description déclarative des champs à extraire via un DSL.Spidr— un explorateur de sites simple (crawler).
Quand prendre un framework
- Script ponctuel →
http.rb+Nokogirià la main. - Scraping derrière un login, formulaires →
Mechanize. - Grand crawl structuré avec pagination/pipelines →
Kimurai.
15. Avantages et inconvénients
Avantages du web scraping en Ruby
- Nokogiri — l'un des meilleurs parseurs HTML/XML tous langages confondus ; CSS + XPath inclus.
- Syntaxe expressive — le code d'un scraper se lit presque comme du pseudo-code, il s'écrit vite.
- Écosystème riche — Mechanize, Kimurai, Ferrum, Typhoeus, Sidekiq, etc. couvrent pratiquement tous les besoins.
- Excellente intégration avec Rails — si les données alimentent directement une application web.
- Le parallélisme I/O fonctionne bien — pour les tâches réseau, le GIL n'est pas un obstacle ; threads et fibers offrent une forte concurrence.
- Outils mûrs pour les files et les tâches de fond (Sidekiq) — facile de monter à l'échelle industrielle.
Inconvénients et pièges
- Le GIL limite le parallélisme CPU — si le goulot d'étranglement est l'analyse du HTML et non le réseau, il faut des processus/JRuby. Pour le parsing très gourmand en CPU « de base », Ruby cède face à Go/Rust.
- La vitesse de l'interpréteur reste inférieure à celle des langages compilés ; sur des volumes gigantesques, cela se sent.
- Les sites JS exigent un navigateur headless — lent et gourmand en ressources (mais c'est le problème de tous les langages, pas seulement de Ruby).
- Nokogiri est une extension C — parfois pénible à installer/compiler sur des systèmes atypiques (même si, aujourd'hui, l'installation se passe généralement sans accroc).
- La fragilité inhérente aux scrapers — tout scraper casse quand le balisage du site change ; ce n'est pas propre à Ruby, mais cela exige de la maintenance.
- Un écosystème plus petit que celui de Python — Python (Scrapy, BeautifulSoup, requests) offre plus de solutions toutes faites et de ressources d'apprentissage dédiées au scraping.
Quand Ruby est un bon choix
Quand vous êtes déjà dans la stack Ruby/Rails, qu'il vous faut un code lisible et maintenable, que les volumes sont moyens et que le goulot d'étranglement est le réseau (I/O), pas le processeur. Pour des volumes extrêmes et du parsing purement CPU, tournez-vous vers Go/Rust ou Python+Scrapy.
16. Conclusion
Un scraper Ruby se construit avec deux briques — un client HTTP et un parseur — et tout le reste (encodages, proxys, threads, files d'attente) vient s'y greffer à mesure que la tâche grandit.
Aide-mémoire pour le choix des outils
| Tâche | Outil |
|---|---|
| Charger une page (simple) | open-uri |
| Charger (flexible) | http (http.rb) |
| Chargement parallèle | Typhoeus::Hydra, async |
| Parser HTML/XML | Nokogiri |
| Parser JSON | JSON (stdlib) |
| Sessions/formulaires/cookies | Mechanize |
| Encodages | force_encoding/encode, charlock_holmes |
| Rendu JS | Ferrum, Watir, Playwright |
| Proxys/TOR | n'importe quel client + socksify pour SOCKS5 |
| Files/mise à l'échelle | Redis, Sidekiq |
| Framework complet | Kimurai |
Commencez simple (http + Nokogiri), n'ajoutez de la complexité que lorsqu'elle est
réellement nécessaire — c'est le principe premier d'un bon scraper.
Ressources officielles et documentation
Bibliothèque standard Ruby :
OpenURI— charger une page en une ligneNet::HTTP— client HTTP intégréURI— analyse et construction d'URLJSON— parsing de JSONThread/Queue— threads et file d'attente
Clients HTTP :
- http.rb — client moderne à l'API chaînable
- Faraday — client à middlewares ; faraday-retry — les retries
- Typhoeus — requêtes parallèles sur libcurl
- Mechanize — « navigateur sans UI » (formulaires, cookies, sessions)
Parseurs :
- Nokogiri — le parseur HTML/XML de référence (dépôt)
- Oga — Ruby pur, sans libxml
- Loofah — assainissement du HTML
Encodages :
- charlock_holmes — détection automatique d'encodage (ICU)
Proxys / TOR :
- socksify — SOCKS5 pour Ruby/Net::HTTP
- Tor Project — TOR lui-même
Parallélisme :
- concurrent-ruby — pools de threads, futures
- async — concurrence sur fibers (async-http)
- parallel — parallélisme par processus (contourne le GIL)
Navigateurs headless (JS) :
- Ferrum — pilotage de Chrome via CDP (site)
- Watir · Selenium · playwright-ruby-client
Files d'attente, tâches de fond, stockage :
- redis-rb — client Redis
- Sidekiq · GoodJob · Solid Queue — tâches de fond
- Sequel — accès aux bases de données (site)
Frameworks de scraping :
Divers :
- webrobots — analyse du
robots.txt