Le scraping par langage 30 min de lecture

Web scraping en Ruby : le guide complet, du simple au complexe

Le web scraping en Ruby : Nokogiri, HTTParty, Mechanize et Watir — du parsing de pages statiques à l'automatisation du navigateur.

ÉW
Équipe Web-Scraping.fr
Collecte de données pour votre activité
Publié le: 1 mai 2025

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

  1. Introduction : le web scraping et les questions à se poser avant de démarrer
  2. Comment récupérer la page
  3. Bibliothèques pour parser le contenu
  4. Résoudre les problèmes d'encodage du cyrillique
  5. Statut de la réponse et gestion des en-têtes
  6. Travailler en HTTPS / SSL
  7. Gérer les cookies
  8. Utiliser des proxys
  9. Scraper via TOR
  10. Multi / multithreading
  11. Pages JavaScript (navigateurs headless) (ajouté)
  12. Protection anti-bot, User-Agent, délais, retries (ajouté)
  13. Stockage des URL et files d'attente
  14. Frameworks de scraping prêts à l'emploi (ajouté)
  15. Principaux avantages et inconvénients de cette approche
  16. 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 :

  1. Le chargement — obtenir la réponse « brute » du serveur (client HTTP).
  2. 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 fichier https://site.ru/robots.txt dé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 :

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 "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 ».

ruby
require 'open-uri'

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

Avec en-têtes et timeouts :

ruby
require 'open-uri'

html = URI.open(
  'https://example.com',
  'User-Agent' => 'Mozilla/5.0 (compatible; MyBot/1.0)',
  open_timeout: 5,
  read_timeout: 10
).read

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.

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        # corps de la réponse
puts response['Content-Type']

Requête POST :

ruby
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 ».

ruby
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 :

ruby
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.

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

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

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.

ruby
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 :
    ruby
    # 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.

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

ruby
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) :

ruby
# 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 :

ruby
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 :

ruby
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 :

ruby
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

ruby
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

ruby
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 :

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. Que faire selon le statut

ruby
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.

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

Avec Net::HTTP, il faut activer SSL explicitement :

ruby
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 :

ruby
# 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 :

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

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

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

ruby
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')
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 — 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.

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
# les cookies sont déjà stockés, agent garde la session en mémoire
dashboard = agent.get('https://example.com/dashboard')

Pour éviter de se reconnecter à chaque lancement :

ruby
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

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. Rotation de proxys

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. 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 :

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 "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

bash
# 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 :

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') ? 'Via TOR ✓' : 'Hors TOR ✗'
end

Avec http.rb via SOCKS :

ruby
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 :

code
ControlPort 9051
HashedControlPassword 16:...   # à générer avec : tor --hash-password "yourpass"

Puis :

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

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

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}" }

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)

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'approche industrielle

Le gem concurrent-ruby fournit pools et futures prêts à l'emploi — inutile d'écrire les vôtres.

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

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}"
    # 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.

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   # lire/fermer obligatoirement
    end
  end
  tasks.each(&:wait)
ensure
  internet&.close
end

Que choisir

  • Jusqu'à quelques dizaines d'URL — de simples Thread + Queue.
  • Code industrielconcurrent-ruby.
  • Vitesse maximale, milliers de requêtesTyphoeus::Hydra ou async.

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.

ruby
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 :

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

ruby
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

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. Retries avec délai exponentiel

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 "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-After en 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.

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

ruby
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 :

ruby
# 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 :

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. À 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.

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 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.

ruby
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 ligne
  • Net::HTTP — client HTTP intégré
  • URI — analyse et construction d'URL
  • JSON — parsing de JSON
  • Thread / 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) :

Files d'attente, tâches de fond, stockage :

Frameworks de scraping :

Divers :