Retrouvez cet article dans : Linux Magazine 105
Lors du hors-série n°33 consacré à Ruby (pardon, j’ai dû hiberner depuis...), nous avons vu les fondamentaux du langage et je vous ai montré comment développer des applications Web avec différents frameworks. Dans cet article, je vous propose de voir un cas pratique d’utilisation. Nous allons créer notre propre moteur de recherche. Ne rêvez pas, nous n’allons pas essayer de rivaliser avec Google ou Yahoo! L’idée est simplement de vous présenter différentes bibliothèques Ruby et de voir qu’avec très peu de code nous pouvons facilement ébaucher un petit moteur simple, mais efficace.
1. Moteur de recherche
Nous avons besoin de trois éléments pour créer un moteur de recherche. Le plus évident est bien entendu l’interface de recherche elle-même. Sans entrer dans les considérations techniques pour le moment, nous savons déjà qu’il s’offre à nous un grand nombre de solutions pour ce travail. Certains prôneront Rails, d’autre Camping, Nitro ou eRuby... Pour ma part, je vous proposerai une solution encore plus simple consistant à utiliser Mongrel sans autre framework. Avant la recherche, il nous faut un crawler qui va se charger, à partir d’une source donnée, de suivre les liens hypertextes de façon récursive. Ruby a tout ce qu’il faut dans sa bibliothèque standard pour nous aider à développer ce crawler. Une fois les données récupérées, il faudra les indexer. Ceci se fait en extrayant les mots de chaque page et en les stockant de façon à pouvoir les utiliser comme des entrées d’index pointant vers les différentes pages. Sans en dire plus pour le moment, sachez que nous n’utiliserons pas de base de données au profit d’une solution basée sur Ferret. Décomposons le travail en trois parties. Tout d’abord, nous fabriquerons le crawler. Ensuite, nous implémenterons l’indexeur. Puis, nous mettrons en place une interface Web afin de permettre à nos utilisateurs de faire des recherches.2. Le crawler
Un crawler ou " robot " est par définition quelque chose d’assez simple. Et si vous avez déjà utilisé wget ou curl, vous avez déjà utilisé un crawler. Dans le principe, vous lui passez une adresse de page web (une URL) et il va se charger de récupérer le contenu de cette page, d’y rechercher les liens hypertextes, puis de traiter récursivement chacun de ces liens. Bien entendu, le contenu de chaque page sera envoyé à l’indexeur qui se charge de traiter et stocker les données. Pour créer notre crawler, nous allons utiliserrequire ‘net/http’ response = Net::HTTP.get_response(‘rubyfrance.org’, ‘/’)
pageContent = response.body( )Si vous affichez le contenu de pageContent, vous verrez que nous avons bien le code HTML de la page demandée. Avant de parser le résultat, attardons-nous deux secondes sur un point de détail. Vous l’avez vu,
require ‘uri’ uri = URI.parse( "http://monserveur.com/chemin/page.html" )Nous pouvons alors récupérer le nom du serveur, le chemin d’accès à la ressource, le port et le contenu de la requête s’il y en a une :
puts uri.host # => monserveur.com puts uri.path # => /chemin/page.html puts uri.post # => 80 puts uri.scheme # => httpComble du bonheur,
require ‘net/http’ require ‘uri’ response = Net::HTTP.get_response( URI.parse( ‘http://rubyfrance.org/’ ) ) pageContent = response.body( )Passons maintenant à la récupération des liens dans le corps de la réponse. Certes, nous pouvons là aussi envisager d’utiliser des expressions rationnelles. Heureusement, il y a plus simple. Si nous partons du principe que le HTML est " parsable " comme XML, nous pourrions explorer de ce côté-là. Il y a encore plus simple : Why The Lucky Stiff (encore lui) nous propose un parseur HTML de toute beauté, au doux nom de
require ‘rubygems’ require ‘hpricot’ pageDocument = Hpricot( pageContent ) links = pageDocument / “a” # => #<Hpricot::Elements[...]>
links.each do |element| # faire quelque chose avec element endChaque
require ‘net/http’
require ‘uri’
def crawler( uri )
response = Net::HTTP.get_response( URI.parse( uri ) )
pageContent = response.body( )
pageDocument = Hpricot( pageContent )
links = pageDocument / “a”
links.each do |element|
link = element[‘href’]
crawler( link )
end
end
Surtout, ne vous amusez pas à essayer ce bout de code. En effet, vous risqueriez de parcourir tout internet sans jamais vous arrêter ! De plus, avec ce simple code, vous récupérerez tout, même les éléments " non HTML ".
Il faut donc ajouter trois choses à notre crawler. En premier, il faut éliminer tout ce qui n’est pas de type HTML ou texte. Ensuite, il serait bon de borner l’étendue du parcours des liens. Enfin, il faudrait éviter de passer deux fois par la même page. Voyons cela dans l’ordre.
Si nous voulons éviter de traiter autre chose que du HTML ou du texte, il suffit simplement d’ignorer les ressources dont le case response.content_type when "text/html" # Traiter le contenu comme du HTML when "text/plain" # traiter le contenu comme du texte else # ignorer endNous distinguons ce qui est de type HTML de ce qui est de type texte. En effet, il est inutile de rechercher des liens hypertextes dans un document texte. Pour borner la recherche et éviter d’indexer tout internet, je vous propose que notre crawler n’agisse que pour un domaine donné. Pour cela, on ne traitera que les liens dont le hostname correspond à celui de la page courante :
(pageDocument / "a").each do |element|
link = element[‘href’]
if /#{url.host}/.match( link )
# traiter le lien
else
# ignorer le lien
end
end
Enfin, pour éviter d’indexer deux fois la même page, je vous propose de calculer et stocker, pour chaque page, une chaîne MD5 calculée selon son contenu, et de vérifier, pour chaque nouvelle page, que la chaîne MD5 correspondante n’a pas déjà été calculée :
require ‘digest/md5’ ... @pagesMD5 = Array::new( ) ... pageDigest = Digest::MD5.hexdigest( pageContent ) if @pagesMD5.include?( pageDigest ) # la page a déjà été indexée, l’ignorer else @pageMD5 << pageDigest # indexer la page endVoilà, nous pouvons maintenant mettre en place la nouvelle version de notre crawler. Et tant qu’à faire, allons-y pour de l’objet :
require ‘digest/md5’
require ‘net/http’
require ‘uri’
require ‘rubygems’
require ‘hpricot’
class WebIndexer
def initialize
@pagesMD5 = Array::new( )
end
def indexer( uri )
puts “Indexation de #{uri}”
end
def crawler( uri )
# Récupération de la ressource
url = URI.parse( uri )
response = Net::HTTP.get_response( url )
# Récupération du contenu de la page
pageContent = response.body
# Calcul du MD5 de la page
pageDigest = Digest::MD5.hexdigest( pageContent )
if @pagesMD5.include?( pageDigest )
return
end
@pagesMD5 << pageDigest
# Indexation
case response.content_type
when "text/html"
# Indexation
indexer( uri )
# Parcour des liens
(pageDocument/"a").each do |element|
href = element[‘href’]
if /#{url.host}/.match( href )
href += "/" if URI.parse( href ).path == ""
crawler( href )
else
puts "\t[INFO] - Ignore #{href} : Host not match!"
end
end
when "text/plain"
# Indexation
indexer( uri )
else
puts "\t[IGNORE] - #{uri} : Not Text or HTML!"
end
end
end
site = WebIndexer.new( )
site.crawler( ARGV[0] )
Nous pouvons faire un test :
$ ruby WebIndex.rb http://greg.rubyfr.net Indexation de http://greg.rubyfr.net Indexation de http://greg.rubyfr.net/pub [INFO] - Ignore http://forum.rubyfr.net : Host not match! [INFO] - Ignore http://planet.rubyfr.net : Host not match! [INFO] - Ignore http://www.rubyfrance.org : Host not match! [INFO] - Ignore http://www.railsfrance.org/ : Host not match! Indexation de http://greg.rubyfr.net/pub/?page_id=22 [INFO] - Ignore http://forum.rubyfr.net : Host not match! [INFO] - Ignore http://planet.rubyfr.net : Host not match! [INFO] - Ignore http://www.rubyfrance.org : Host not match! [INFO] - Ignore http://www.railsfrance.org/ : Host not match! Indexation de http://greg.rubyfr.net/pub/?page_id=14 [INFO] - Ignore http://forum.rubyfr.net : Host not match! [INFO] - Ignore http://planet.rubyfr.net : Host not match! [INFO] - Ignore http://www.rubyfrance.org : Host not match! [INFO] - Ignore http://www.railsfrance.org/ : Host not match! Indexation de http://greg.rubyfr.net/pub/?page_id=15 ...Ça fonctionne...
3. L’indexeur
Contrairement à ce que l’on pourrait croire, l’indexation sera beaucoup plus facile à mettre en œuvre. En effet, grâce à la bibliothèque Ferret [4], vous allez voir que ce travail est un jeu d’enfant. Ferret est un portage en Ruby du projet Lucen [5] de Dave Balmain. Il s’agit d’un moteur de recherche permettant d’indexer et de rechercher du texte. Pour installer Ferret, un petitrequire ‘ferret’ index_en_memoire = Ferret::Index::Index.new( ) index_persistant = Ferret::Index::Index.new( :path => "/path/to/index" )L’option
index << "Contenu du document à indexer..." index << "Contenu d’un autre document à indexer..."Nous ajoutons à l’index directement le contenu du document. Le plus souvent, nous préférerons ajouter des champs tels que le titre, l’auteur,... Bref tout ce qui pourrait être utile par la suite pour identifier un contenu :
index << { :titre => "Mon super livre", :auteur => "Moah", :contenu => "bla bla bla..." }
index << { :titre => "La cuisine pour les nuls", :auteur => "Bryan Miller", :contenu => "En premier, aller dans la cuisine..." }
Nous venons d’ajouter dans notre index deux entrées, l’une dont le titre est " Mon super livre ", l’auteur est " Moah " et le contenu est " bla bla bla... " et l’autre dont le titre est " La cuisine pour les nuls ", l’auteur est " Bryan Miller " et le contenu est " En premier, aller dans la cuisine... ". Si nous voulions comparer avec une base de données (chose non comparable, mais bon), c’est comme si nous avions eu une table avec trois colonnes : titre, auteur et contenu... Je pense que vous avez compris le reste, je n’en dirai pas plus, car cela reviendrait à comparer des sushis avec des quenelles !
Il ne reste plus qu’à faire des recherches. Pour cela, nous utilisons la méthode Ferret::Index::Index#search_each prenant en paramètre la requête de recherche et un bloc auquel est passé, pour chaque élément trouvé, son identifiant et son score (compris entre 0.0 et 1.0) :
index.search_each(‘content:"cuisine"’) do |id, score|
puts “Document ID ##{id} trouvé avec le score #{score}"
end
Si maintenant, pour un id de document donné, vous voulez récupérer le titre ou l’auteur par exemple, rien de plus simple :
titre = index[id][:titre] auteur = index[id][:auteur]Bien entendu, nous pouvons faire des recherches bien plus complexes que cela. Tout d’abord, en passant des options à la méthode
- Le contenu de la page bien entendu, dans :content ;
- Le titre de la page, que nous récupérerons grâce à
Hpricot, et que nous mettrons dans :title ; - L’URL, dans :
url; - La valeur du MD5, ce qui permettra d’éviter, quand nous relancerons l’indexation sur un site, d’indexer à nouveau des pages déjà présentes dans l’index. Nous mettrons cette information dans :
digest.
require ‘digest/md5’
require ‘net/http’
require ‘uri’
require ‘rubygems’
require ‘hpricot’
require ‘ferret’
include Ferret
class WebIndexer
def initialize
@index = Index::Index.new(:path => ‘./index’)
end
def indexer( uri, title, content, digest )
print "[INFO] - Index #{uri} / #{title} : "
@index << {:title => title, :content => content, :url => uri, :digest => digest}
puts "ok!"
end
def crawler( uri )
# Récupération de la ressource
url = URI.parse( uri )
response = Net::HTTP.get_response( url )
# Récupération du contenu de la page
pageContent = response.body
# Calcul du MD5 de la page
pageDigest = Digest::MD5.hexdigest( pageContent )
@index.search_each(‘digest:"’ + pageDigest + ‘"’) do |id, score|
if @index[id][:digest] == pageDigest
puts "\t[INFO] - Ignore #{uri} : Already parsed as #{@index[id][:url]}!"
return
end
end
# Indexation
case response.content_type
when "text/html"
pageDocument = Hpricot( pageContent )
pageTitle = (pageDocument/”title”)[0]
pageTitle = ((pageTitle.nil?)?””:pageTitle.inner_html) # “
indexer( uri, pageTitle, pageContent, pageDigest )
# Parcour des liens
(pageDocument/”a”).each do |element|
href = element[‘href’]
if /#{url.host}/.match( href )
href += "/" if URI.parse( href ).path == ""
crawler( href )
else
puts "\t[INFO] - Ignore #{href} : Host not match!"
end
end
when "text/plain"
pageDocument = pageContent
pageTitle = uri
indexer( uri, pageTitle, pageContent, pageDigest )
else
puts "\t[INFO] - Ignore #{uri} : Not Text or HTML!"
end
end
end
site = WebIndexer.new( )
site.crawler( ARGV[0] )
4. L’interface de recherche
Nous avons maintenant un index, nous pouvons l’utiliser pour créer une interface de recherche. Comme je vous l’ai proposé, nous allons créer cette interface Web en utilisant Mongrel [9]. Mongrel n’est pas qu’un serveur HTTP, c’est également un ensemble de bibliothèques. Dans le principe, un serveur HTTP doit permettre de servir des pages statiques et dynamiques. Si vous avez déjà développé avec PHP par exemple, vous avez certainement mêlé ces deux types de pages. Une page statique est délivrée au client " telle quelle ". Une page dynamique a un contenu qui change en fonction d’un contexte. Partons du principe que vous connaissez déjà cela. Dans le cas qui nous intéresse, nous avons besoin de servir des pages dynamiques. En effet, l’utilisateur de notre moteur de recherche va saisir un terme et, en fonction de ce terme, nous lui renverrons une liste de pointeurs correspondant à des entrées de notre index matchant avec le terme demandé. Bien entendu, la première page, celle contenant le formulaire de recherche, peut être statique. Pour mettre en place un serveur avec la bibliothèque Mongrel, nous avons besoin de créer une nouvelle instance de type#!/usr/bin/env ruby require ‚rubygems‘ require ‚mongrel‘ s = Mongrel::HttpServer.new( „0.0.0.0“, „3000“ ) Il ne reste plus qu’à démarrer le serveur : s.run.joinSi vous exécutez ce script et que vous vous connectez sur l’URL
#!/usr/bin/env ruby require ‚rubygems‘ require ‚mongrel‘ h = Mongrel::HttpServer.new(„0.0.0.0“, „3000“) h.register(„/“, Mongrel::DirHandler.new(„./static“)) h.run.joinLa ligne
h.register("/", Mongrel::DirHandler.new("./static"))
est celle par laquelle nous " enregistrons " le handler. Le premier paramètre (#!/usr/bin/env ruby
require ‚rubygems‘
require ‚mongrel‘
class TestHandler < Mongrel::HttpHandler
def process( request, response )
response.start(200) do |head, body|
head[„Content-Type“] = „text/plain“
body.write( „Hello World!“ )
end
end
end
h = Mongrel::HttpServer.new( „0.0.0.0“, „3000“ )
h.register( „/“, Mongrel::DirHandler.new(„./static/“) )
h.register( „/test“, TestHandler.new( ) )
h.run.join
L’enregistrement du handler est conforme à ce à quoi l’on pouvait s’attendre. Attardons-nous plutôt sur l’écriture de la classe d = Mongrel::HttpRequest.query_parse( request.params[‘QUERY_STRING’] ) v1 = d[‘p1’] v2 = d[‘p2’]Ceci étant dit, nous pouvons maintenant développer notre moteur de recherche. Pour cela, nous allons utiliser deux handlers, l’un permettant de servir des données statiques, que nous utiliserons pour renvoyer la page d’index, mais également les CSS et images de notre site. Le second handler servira à afficher le résultat de la recherche. Nous avons donc le squelette suivant :
WebSearch.rb
#!/usr/bin/env ruby
require ‚rubygems‘
require ‚mongrel‘
class ResultHandler < Mongrel::HttpHandler
def process( request, response )
response.start(200) do |head, out|
head[„Content-Type“] = „text/html“
# ... Affichage de la liste de résultats
end
end
end
h = Mongrel::HttpServer.new( "0.0.0.0", "3000" )
h.register( "/", Mongrel::DirHandler.new("./static/") )
h.register( "/r", ResultHandler.new )
h.run.join
Le handler pour l’affichage des résultats est défini via la classe <html>
<head>
<meta http-equiv=content-type content="text/html; charset=UTF-8">
<title>Mooteur</title>
<link rel="stylesheet" href="/style.css" type="text/css" media="screen" />
</head>
<body>
<div id="center">
<img src=”/mooteur.gif” alt=”Mooteur...”>
<form action=”/r”>
<input type=”text” name=”s” size=”55” /><br />
<input type=”submit” value=”Recherche Mooteur”/>
</form>
©2007 Mooteur
</div>
</body>
</html>
Comme vous pouvez le voir, cette page utilise une CSS (+- WebSearch.rb | ±- static | +- index.html | +- style.css | +- mooteur.gifIl ne reste plus qu’à compléter le handler de recherche. Dans la page index.html, nous avons créé un formulaire avec un champ texte
search_term = begin Mongrel::HttpRequest.query_parse( request.params[‘QUERY_STRING’] )[‘s’] rescue nil endNous utilisons ici une méthode qui nous permet de mettre à
index = Index::Index.new(:path => ‘./index’)
index.search_each(‘content:"’ + search_term + ‘"’) do |id, score|
out.write( "<p>" )
out.write( "<a href=’#{index[id][:url]}’>#{index[id][:title]}</a> <small>- [score of #{score}]</small><br />" )
out.write( "<small><a href=’#{index[id][:url]}’>#{index[id][:url]}</a></small>")
out.write( "</p>\n" )
end
Nous commençons par ouvrir l’index, puis, pour chaque entrée dont le contenu (

Pour être propre, nous proposons, en début de page, un formulaire permettant de refaire une recherche sans retourner à la page d’index. Voici alors ce que donnerait le code complet de notre handler :
class ResultHandler < Mongrel::HttpHandler
def process( request, response )
response.start(200) do |head, out|
head["Content-Type"] = "text/html"
search_term = begin
Mongrel::HttpRequest.query_parse( request.params[‘QUERY_STRING’] )[‘s’]
rescue
nil
end
out.write( "<html>
<head>
<meta http-equiv=content-type content=’text/html; charset=UTF-8’>
<title>Mooteur</title>
<link rel=’stylesheet’ href=’/style.css’ type=’text/css’ media=’screen’/>
</head>
<body>
<div id=’left’>
<table><tr>
<td><a href=’/’><img src=’/mooteur.gif’ alt=’Mooteur...’ width=’200’ border=’0’></a></td>
<td><form action=’/r’>
<input type=’text’ name=’s’ size=’41’ value=’#{search_term}’ />
<input type=’submit’ value=’Recherche Mooteur’/>
</form></td>
</tr></table>
</div>")
if search_term
out.write( "<div id=’results’>
<div id=’head’>
Résultats pour <b>#{search_term}</b>
</div>
<div id=’list’>")
index = Index::Index.new(:path => ‘./index’)
index.search_each(‘content:"’ + search_term + ‘"’) do |id, score|
out.write( "<p>" )
out.write( "<a href=’#{index[id][:url]}’>#{index[id][:title]}</a> <small>- [score of #{score}]</small><br />" )
out.write( "<small><a href=’#{index[id][:url]}’>#{index[id][:url]}</a></small>")
out.write( "</p>\n" )
end
out.write( "</div>
</div>
</body>
</html>" )
end
end
end
end
Voilà, vous avez maintenant un petit moteur de recherche 100% fonctionnel.

Figure 1 : Inclusions multiples
Conclusion
Cet article n’a fait qu’effleurer les possibilités offertes par Ferret et Mongrel. Si vous savez en plus qu’il existe un plugin Ferret pour Rails, je suis sûr que certains d’entre vous ont les yeux qui brillent... Liens- [1] http://www.ruby-doc.org/stdlib/libdoc/net/http/rdoc/index.html
- [2] http://www.ruby-doc.org/stdlib/libdoc/uri/rdoc/
- [3] http://code.whytheluckystiff.net/hpricot/
- [4] http://ferret.davebalmain.com/trac/
- [5] http://lucene.apache.org/
- [6] http://www.oreilly.com/catalog/9780596527853/index.html
- [7] http://www.oreilly.fr/catalogue/2354020694
- [8] http://ferret.davebalmain.com/api/classes/Ferret/QueryParser.html
- [9] http://mongrel.rubyforge.org/
Retrouvez cet article dans : Linux Magazine 105





Laissez une réponse
Vous devez avoir ouvert une session pour écrire un commentaire.