Petit moteur de recherche dynamique

03/10/2004

Objectif

L'idée est ici de créer un moteur de recherche qui conviendrait aux sites de petite taille avec des pages bien formatées. Pour simplifier l'administration, il procède à une recherche à la vollée ce qui fait qu'il ne nécessite ni indexation préalable, ni bases de données. Pour la discussion théorique de cette solution, reportez-vous à cet autre document. Voyons ici un exemple de mise en oeuvre.

La manoeuvre

Le programe qui va suivre est simple mais assez efficace. Il commence à naviguer à l'intérieur de l'arborescence du serveur web (dans les parties qui lui sont désignées), examine les fichiers HTML qu'il rencontre, regarde s'ils ont un rapport avec la requête puis les affiche par ordre de pertinence présumée. Cela suppose que les pages HTML sont bien formées, qu'elles disposent d'un titre et d'une balise meta de description renseignée. Les résultats affichés sous forme de liste essayent même de mettre en évidence le contexte dans lequel ont étés retrouvés les termes de la recherche.

L'implémentation

Le prologue de ce programme contient la configuration des répertoires à scanner pour la recherche ainsi que l'appel aux modules nécessaires.

#!/usr/bin/perl
# All by HAbeTT
# Raw search engine v0.22

# liste des repertoires à examiner
@reps = (papers,perl,misc);
foreach (@reps) {
  push (@repstodo,$ENV{'DOCUMENT_ROOT'}."/$_/");
}
$perco = length($ENV{'DOCUMENT_ROOT'});

use CGI::Carp qw(fatalsToBrowser);
use CGI;
use Benchmark;
use File::Find;
use locale;

On initialise le benchmark du script pour en mesurer les performances.

# init benchmark
$timea = new Benchmark;

Comme le script est appellé en mode CGI, on récupère les paramètres de la recherche, on les modifie pour les mettre en basse case avec test supplémentaire par expression régulière en cas de use locale; défectueux ce qui peux arriver sur certains serveurs, et enfin, on sépare les différents termes de la recherche.

# recuperation des params de la recherche
$query = new CGI;
$kw = lc $query->param("kw");
$kw =~ tr/ÉÈÊÀÔÎÇ/éèêàôîç/;
$kw =~ s/[^a-zéèêàîôç0-9 ]//g;
$kw =~ s/ +/ /g;
@kws = split (/ /,$kw);

Pour la mise en forme de la sortie, on retourne le code HTML du début de la page avec les headers qui conviennent.

# headers http
print >>Fragment;
Content-type: text/html

<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01 Transitional//EN\"
  \"http://www.w3.org/TR/html4/loose.dtd\">
<html>
<head>
<title>Textra search</title>
<link rel=\"stylesheet\" href=\"/style.css\" type=\"text/css\">
</head>
<body>
<p><a href=\"/index.html\" title=\"Main menu\"><img src=\"/grafz/logo.png\"
 width=500 height=150 alt=\"logo\" border=\"0\"></a>
</p>
<p>Search for <b>$kw</b>.</p>
<div align=\"left\"><ul>
Fragment

Après avoir fait les initialisations de rigueur, on lance le processus principal de recherche. Notez que la recherche sera récursive dans les répertoires désignés. Le mode File::Find nous simplifie ici grandement la tâche.

# processus de recherche
$hits = 0;
$fetched = 0;
$files = 0;
find(\&processor, @repstodo);

Nous verons la routine de recherche plus loin mais pour le moment il faut savoir qu'elle va s'occuper de peupler deux tables de hashage qui contiendront respectivement le texte à afficher et le score de chacun des résultats identifiés. On effectue un simple tri pour que les impacts avec le plus grand nombre de points (donc présumés plus significatifs) apparaîssent en premier.

# trier les résultats et les sortir
@order = reverse sort byval keys %sco;
foreach (@order) {
  print $rez{$_};
}

Le programme principal se termine par la fin du benchmark et du code HTML qui contient entres autres un résumé des performances et un nouveau formulaires pour une nouvelle recherche.

# end list
print "</ul>\n</div>\n";

# fin benchmark
$timez = new Benchmark;
# aff benchmark
$td = timediff ($timez,$timea);
$totime = timestr($td);
$oripar = lc $query->param("kw");
print <<Block;
<p>
It took $totime to find those $hits hits out of $fetched documents ($files files).
<form action="http://habett.com/cgi-bin/textracom.cgi">
<input type="text" name="kw" value="$oripar">
<input type="submit">
</form>
</p>
</body>
</html>

Block

exit();

Voyons rapidement la routine de tri des résultats qui est sans surprise.

sub byval {
  $sco{$a} <=> $sco{$b}
}

Passons maintenant au coeur du script, la routine de recherche elle-même. On commence par récupérer le résultat du handle de File::Find puis on élimine les résultats qui correpondent à des répertoires ou à des fichiers qui ne sont pas apparement du HTML.

# sub main search
sub processor {
  # get filename
  $fille = $File::Find::name;
  # account
  $files++;
  # eliminate directories
  return if (-d $fille);
  # eliminate non html files
  return unless (substr($fille,$perco) =~ /\.htm/io);

On commence par lire l'intégralité du fichiers par blocs (en espérant avoir choisi une taille suffisante pour qu'un bloc suffise à chaque fichier) et on stocke tout le code HTML dans une variable.

  # begin parse file
  open (DAFILE, $fille);
  $fetched++;
  # lecture du fichier
  $html = "";
  while ($p = read(DAFILE,$donnees,8192)) {
    # agregation
    $html .= $donnees ;
  }
  # fermeture fichier
  close(DAFILE);

Avant d'aller plus loin, on isole les deux méta données sur le document qui nous concernent, son titre et sa balise de description.

  # parse meta data
  $title = "";
  ($title) = ($html =~ /.*<title>(.*)<\/title>.*/io);
  $description = "";
  ($description) = ($html =~ /meta.*?description.*?content.*?=.*?"(.*?)"/io);

De même que nous avions mis en minuscules les termes de la requête, nous allons faire de même avec le code HTML. Ensuite, pour chaque terme recherché, nous allons attribuer un score au fichier éxaminé en fonction du nombre d'occurences, du nombre d'occurences en tant que mot isolé et de la présence dans les meta données. Le calcul est simple mais il permet de hiérarchiser les résultats préalablement à leur sortie.

  # minusculer
  $html = lc $html;
  # score calculation
  $score = 0;
  foreach $target (@kws) {
    $score += ($html =~ /$target/);
    $score += ($html =~ /\W$target\W/) * 2;
    $score += 5 if ($description =~ /$target/);
    $score += 10 if ($title =~ /$target/);
  }

Si le score n'est pas nul, nous commencons à préparer les données en retraitant le code HTML pour l'expurger de ses balises et des autres choses qui pourraient gêner un résultat propre.

  # store scores in an hash
  if ($score != 0) {
    $hits++;
    # extraction file location
    $location = substr($fille,$perco);
    # remove tags
    $html =~ s/<.*?>//go;
    # treat CR LF
    $html =~ s/(?:\012\015|\012|\015)/ /go;

Nous mettons en valeur les termes de la requête dans les meta données.

    # highlight in title
    foreach $target (@kws) {
      $title =~ s/$target/<span class="high">$target<\/span>/g;
      $description =~ s/$target/<span class="high">$targetlt\/span>/g;
    }

Enfin, nous générons la sortie HTML future, sous forme d'éléments de listes, avec des liens, les meta données et le contexte des occurences.

    # data
    $data = "<li><a href=\"$location\" title=\"$location\"><b>$title</b></a><br><i>$description</i><br><pre>\n";
    foreach $target (@kws) {
      $poss = undef;
      $poss = index ($html, $target);
      if ($poss) {
        $extra = substr($html,$poss-30,60);
        $extra =~ s/$target/<span class="high">$target<\/span>/g;
        $data .= "$extra\n" if ($extra =~ /span/);
      }
    }
    $data .= "</pre></li>\n";

Stockage dans les tables de hashage et le tour est joué.

    $rez{"$location"} = $data;
    $sco{"$location"} = $score;
  }
  
}

Comme on peut en faire la démonstration, faire un moteur de recherche n'est pas une opération compliquée et donc ne doît pas être considéré comme une fin en soi. La vraie plus value que l'on apporte pour la redonner aux autres se trouve dans le tri par pertinence que l'on peut apporter. L'idée ici était de donner la priorité au texte de titre et de méta mais on est loin de l'objectif. Nous y reviendrons.

menu principal