Mot-clef « Recherche »

Moteur de recherche dans un site Jekyll

La problématique

Dernier des gros points noirs relevés lors de ma bascule sur Jekyll : l’absence de moteur de recherche.

Forcément quand le site est statique, c’est plus compliqué de mettre en place un moteur de recherche. Il y a plusieurs approches possibles :

  • faire une partie dynamique côté serveur pour la recherche : bof, le but c’est d’avoir maintenance côté serveur, donc non
  • faire appel à une API tierce qui ferait l’indexation puis la recherche : bof aussi, ça permet certes de rester statique côté serveur mais délocaliser la chose n’est pas mieux (en plus ça veut dire maintenance sur l’utilisation de l’API) donc non
  • déléguer complètement à un moteur externe type Google Search : ça existe sur certains sites (quoique ça fait un moment que je l’ai plus vu) sous la forme d’un formulaire de recherche redirigeant sur Google avec une recherche de type site:monsite.com ...… c’est mieux côté maintenance mais pire côté délocalisation puisqu’on quitte carrément le site, donc non

Reste la dernière : faire la recherche directement dans le navigateur en JS à partir d’un index fourni, solution finalement retenue.

Bon, c’est loin d’être parfait comme concept : ça ne passe évidemment pas à l’échelle notamment mais ce site reste un blog perso donc la volumétrie de devrait pas exploser…

L’index fait environ 500ko avec mes 156 articles actuels (représentant un peu plus de 10 ans) donc ça reste jouable, même si j’ai augmenté mon rythme ces derniers temps (notamment à cause des republications). Dans tous les cas on reste loin des pages d’accueil avec des gros carrousels d’images à 2Mo chacune qu’on trouve sur certains sites (notamment e-commerce) et ce n’est de toutes façons chargé que sur la page de recherche.

Donc c’est réaliste dans mon cas et vous pouvez le tester via le champ de recherche dans la colonne de droite.

Solution existante et adaptations

Je suis parti de cet article proposant une solution basée sur la bibliothèque Lunr mais comme souvent c’était loin d’être satisfaisant de base :

  • forcément c’est en anglais, donc fallait au moins traduire
  • ça ne permet pas de placer le champ de recherche sur toutes les pages (à moins d’inclure le code de recherche sur toutes les pages aussi, ce qui est exclus)
  • visuellement le rendu ne me convenait pas du tout
  • toutes les pages sont indexées, y compris les pages de catégories par exemple qui ne sont que des regroupements d’articles

Du coup j’ai réorganisé les choses différemment en :

  • séparant l’index dans un fichier JS à part
  • modifiant le formulaire pour qu’il fasse un simple GET vers une page dédiée au résultat de recherche
  • modifiant le JS pour que le terme à rechercher soit cherché dans l’URL
  • ajoutant dans l’en-tête des pages devant être indexées un paramètre searchable: true

La section suivante détaille la marche à suivre pour intégrer tout ça sur votre site.

Mise en œuvre

La bibliothèque Lunr

À récupérer ici et à ajouter dans le dossier assets/js.

Le code JS utilisant Lunr

Dans un fichier assets/js/search.js :

(function (window) {
	window.onload = function () {
		var idx = lunr(function () {
			this.ref('id');
			this.field('title');
			this.field('body');

			window.searchIndex.forEach(function (doc) {
				this.add(doc)
			}, this)
		});

		var resultsElement = document.getElementById('search-results');
		var HTML = '';
		var term = getQueryString('q');
		if (term) {
			document.getElementById('search-field').value = term;
			var results = idx.search(term);
			if (results.length > 0) {
				var plural = results.length > 1;
				HTML += '<p>' + results.length + ' résultat' + (plural ? 's' : '') + ' trouvé' + (plural ? 's' : '') + ' pour la recherche « ' + term + ' » :</p> <ul>';
				for (var i = 0; i < results.length; i++) {
					var result = window.searchIndex[results[i]['ref']];
					var url = result['url'];
					var title = result['title'];
					var date = result['date'];
					var body = result['body'].substring(0,160) + '...';
					var type = result.type === 'post' ? 'Article' : 'Page';

					HTML += '<li class="search-result"><p class="metadata"><a href="' + url + '" class="title">' + title + '</a>';
					HTML += ' <span class="type">' + type + '</span>';
					if (date) {
						HTML += ' <span class="date">publié le ' + date + '</span>';
					}
					HTML += '</p><p class="summary">' + body + '</p></li>';
				}
				HTML += '</ul>';
			}
			else {
				HTML += '<p>Aucun résultat trouvé poru la recherche « ' + term + ' ».</p>';
			}
		}
		else {
			HTML += '<p>Aucun terme à rechercher.</p>';
		}
		resultsElement.innerHTML = HTML;
	};

	/**
	 * Get the value of a query string.
	 * @param {string} field The field to get the value of
	 * @param {string=} url The URL to get the value from (optional)
	 * @return {string} The field value
	 */
	var getQueryString = function (field, url) {
		var href = url ? url : window.location.href;
		var reg = new RegExp( '[?&]' + field + '=([^&#]*)', 'i' );
		var string = reg.exec(href);
		return string ? string[1] : null;
	};
})(window);

Je n’ai pas cherché à extraire le rendu HTML du résultat de recherche, donc il faudra directement modifier ce fichier pour l’arranger.

Le fichier d’index : search-index.js

Dans un fichier search-index.html à la racine du projet :

---
layout: null
permalink: search-index.js
---

(function (window) {
  {%- assign counter = 0 %}
  window.searchIndex = [{% for page in site.pages %}{% if page.searchable %}{
    "id": {{ counter }},
    "url": "{{ site.url }}{{ page.url }}",
    "type": "page",
    "title": "{{ page.title | replace: '"', ' ' }}",
    "body": "{{ page.content | markdownify | replace: '.', '. ' | replace: '</h2>', ': ' | replace: '</h3>', ': ' | replace: '</h4>', ': ' | replace: '</p>', ' ' | strip_html | strip_newlines | replace: '  ', ' ' | replace: '"', ' ' }}"{% assign counter = counter | plus: 1 %}
  }, {% endif %}{% endfor %}{% for page in site.posts %}{
    "id": {{ counter }},
    "url": "{{ site.url }}{{ page.url }}",
    "type": "post",
    "date": "{{ page.date | date: '%d/%m/%Y à %R' }}",
    "title": "{{ page.title | replace: '"', ' ' }}",
    "body": "{{ page.content | markdownify | replace: '.', '. ' | replace: '</h2>', ': ' | replace: '</h3>', ': ' | replace: '</h4>', ': ' | replace: '</p>', ' ' | strip_html | strip_newlines | replace: '  ', ' ' | replace: '"', ' ' }}"{% assign counter = counter | plus: 1 %}
  }{% if forloop.last %}{% else %}, {% endif %}{% endfor %}];
})(window);

Il est possible de modifier ce fichier si vous voulez ajouter des informations à l’index (par exemple des données complémentaires pour l’affichage des résultats).

La page de résultat de recherche

Dans un fichier search.md à la racine du projet :

---
layout: page
title: Recherche
---

<script src="/assets/js/lunr.js?v={{ site.time | date: '%s' }}" type="application/javascript" async="async"></script>
<script src="/assets/js/search.js?v={{ site.time | date: '%s' }}" type="application/javascript" async="async"></script>
<script src="/search-index.js?v={{ site.time | date: '%s' }}" type="application/javascript" async="async"></script>

<div id="search-results">Recherche en cours...</div>

L’entrée layout est à adapter en fonction de ce que vous avez défini sur votre site.

Le formulaire de recherche

Il reste à intégrer le formulaire de recherche là où vous souhaitez de voir apparaitre :

<form action="/search.html" method="get" class="search-form">
  <p>
    <input id="search-field" type="text" name="q" maxlength="255" value="" />
    <button type="submit" title="Lancer la recherche"><img src="/assets/img/search.svg" alt="Lancer la recherche" /></button>
  </p>
</form>

Pages indexables

En l’état seuls les articles seront indexés. Il reste donc à ajouter searchable: true dans l’en-tête de chaque page que vous souhaitez indexer.


Le champ de recherche qui se vide au clic...

Certains développeurs (ou plus souvent encore, leurs clients) on des fois des idées saugrenues. De ces idées qu'une personne normalement constituée n'aurait jamais eu (et quand bien même l'aurait-elle eue qu'elle l'aurait écartée aussi sec). De ces idées qui leur vaudront d'être maudits par une bonne partie de leurs utilisateurs.

Des fois l'idée initiale peut se défendre mais l'implémentation finale la relègue au même rang que les précédentes.

L'une d'elle consiste vider par JavaScript le contenu d'un champ de recherche lorsqu'on clique dessus. Souvent c'est parce qu'on a voulu mettre dedans un descriptif initial qui doit partir lorsqu'on clique sur le champ. Il y a des solutions pour le faire bien, comme par exemple celle-ci.

Il y a aussi des solutions pour le faire mal, comme vider systématiquement le champ même si la valeur actuelle n'est pas le texte initial. Et c'est ce qui est fait sur le site BD Gest' qui comporte l'insanité suivante :

$('#cse-search-text').click(function() {
  $(this).attr('value','');
});
$('#cse-search-text').focusout(function() {
  if($(this).val()=='') {
    $(this).attr('value','Une chronique, une preview, un article ...');
  }
});

Donc moi quand je cherche une BD en copie/collant le titre depuis une boutique et qu'ensuite je dois retoucher ce même titre parce que la ponctuation n'est pas la même et qu'aucun résultat ne remonte, immanquablement je clique là où je veux faire la retouche... et ça me vide le champ :blase:

Bon, râler c'est bien mais au bout d'un moment, c'est plus constructif de trouver une solution.

Voilà donc un petit script pour Greasemonkey (j'imagine qu'elle doit exister sur d'autres navigateurs aussi) qui résout le problème :

// ==UserScript==
// @name        BDGest Pas vider le champ de recherche
// @namespace   http://darathor.com/vrac/UserScripts
// @description Désactive le vidage automarique du champ de recherche, ce qui permet de coller un texte puis de l'éditer.
// @include     http://www.bdgest.com/*
// @version     1
// ==/UserScript==
jQuery('#cse-search-text').click(function() {
	jQuery(this).unbind();
});

Avec ça, le champ se vide au premier clic (ce qui vire la phrase inutile qu'il contient) puis tous les listeners sont supprimés, du coup plus rien ne se passe aux clics suivants. Simple et efficace \o/

Bon, dans l'idéal il faudrait que la valeur saisie reste après la recherche pour qu'on n'ait pas besoin de la recoller dedans avant de la retoucher en cas d'absence de résultats... mais bon, on peut pas remédier de l'extérieur à tous les vices de conception non plus...


Petit bench sur la recherche dans un tableau PHP

Préambule...

Hier, j'avais à parcourir un tableau d'objets (pouvant avoir potentiellement des centaines voire exceptionnellement milliers d'entrée) pour rechercher si l'identifiant de l'un d'eux se trouvait dans un second tableau. J'avais commencé par utiliser pour ça la fonction in_array() à chaque itération pour voir si l'identifiant de l'objet était présent ou non dans le second tableau.

En voyant cela, un collègue m'a fait remarquer que ce serait peut-être plus performant de construire un tableau dont les clés sont les valeurs du second tableau (via array_flip()) pour pouvoir utiliser isset() au lieu de in_array() et voici les résultats obtenus :

Structure du bench

Le bench consiste à rechercher 100 000 fois la même valeur dans le tableau array('11345', '7437', '7329', '45494', '7894311', 'sdfsdg', 'qsqsdcirt', 'd787 sdfs df'), avec trois méthodes différentes :

  • in_array()
  • array_flip() suivi de isset()
  • array_flip() suivi de array_key_exists()

Le test est effectué avec deux valeurs différentes : d'abord avec la première valeur du tableau (cas théoriquement le plus favorable puisqu'on arrête la recherche une fois la valeur trouvée) puis avec une valeur qui n'est pas dans le tableau (cas théoriquement le plus défavorable puisqu'on est obligé de parcourir tout le tableau). Le résultat en conditions réelles sera donc compris dans cette fourchette.

Cas in_array()

Code exécuté :

for ($i = 0; $i < 100000; $i++)
{
	in_array($value, $values);
}

Cas favorable ($value = '11345') : ~0.33 secondes
Cas défavorable ($value = 'uottuyi') : ~0.52 secondes

Cas isset()

Code exécuté :

$keys = array_flip($values);
for ($i = 0; $i < 100000; $i++)
{
	isset($keys[$value]);
}

Cas favorable ($value = '11345') : ~0.12 secondes
Cas défavorable ($value = 'uottuyi') : ~0.09 secondes

Cas array_key_exists()

Code exécuté :

$keys = array_flip($values);
for ($i = 0; $i < 100000; $i++)
{
	array_key_exists($value, $keys);
}

Cas favorable ($value = '11345') : ~0.27secondes
Cas défavorable ($value = 'uottuyi') : ~0.24 secondes

Et pour de plus petites quantités ?

Les grands volumes c'est bien mais qu'est-ce que ça donne quand on a peu d'itérations ?

Un test à 5 itérations au lieu de 100 000 donne environ le même résultat pour les trois méthodes : avec ~6E-05 secondes pour les méthodes 1 et 3 et ~5E-05 pour la méthode 2.

Tandis qu'un test sur une unique itération donne la première méthode gagnante avec ~4E-05 secondes contre ~5E-05 pour les deux autres (à ce niveau c'est le array_flip pour transformer les valeurs en clés qui coute cher).

Conclusion

À moins d'avoir toujours très peu d'itérations (moins de 5), la méthode passant par array_flip() puis isset() est d'assez loin la meilleure (environ quatre fois plus rapide sur des grands nombres et pas plus lente sur des petits).

En passant, on remarque aussi qu'avec cette méthode, rechercher une valeur qui n'existe pas dans le tableau est plus rapide que de rechercher la première valeur du tableau, même si je ne vois pas forcément trop pourquoi :pense:


Enfin !

Ça y est, Tab Mix Plus est enfin compatible Firefox 3 ! C'était la dernière extension que j'attendais pour pouvoir mettre à jour, c'est donc chose faite :)

C'est l'occasion de relever un point noir sur le moteur de recherche d'extensions sur le site de Mozilla :

  1. en recherchant "tabmixplus" ou "tabmix", j'obtiens 5 résultats mais pas Tab mix plus (les 5 résultants faisant référence à l'extension dans leur description en tant que "TabMixPlus").
  2. en recherchant "tabmix plus", je n'obtiens aucun résultat.
  3. en recherchant "tab mix plus", je n'obtiens aucun résultat non plus est là c'est nettement moins compréhensible !
  4. ce n'est qu'en recherchant "tab mix" que j'obtiens enfin le résultat recherché.

En soi, ça peut se comprendre avec un algorithme simple de recherche (à part pour le point 3). J'ai d'ailleurs testé sur quelques sites pour voir et je n'en ai trouvé que parmi les moteurs de recherches (Google et Live Search) qui me trouvent les résultats en collant les mots.

C'est un point sur lequel les différentes implémentations de moteurs de recherches internes des CMS, forums, etc. devraient se pencher, parce que quand on cherche une marque ou un nom, on n'a pas forcément le découpage précis en tête...


Quelques trucs sur UNIX/Linux #1

Les habitués du mode console n'apprendront probablement rien de cet article mais pour ceux qui, comme moi, ne s'en servent qu'occasionnellement, ça peut s'avérer instructif.

Recherche dans l'historique

On a vu il y a quelques temps qu'on pouvait alléger l'historique des commandes saisies en ignorant les doublons. Si la commande que vous recherchez est malgré cela noyée dans la masse, vous pouvez utiliser ctrl+r et taper les premiers caractères de la commande recherchée pour la retrouver.

Blocage de la console

Le raccourci ctrl+s permet de suspendre le terminal (saisie et exécution). Pour le débloquer, le raccourci est ctrl+q donc si le terminal se bloquer mystérieusement (ce qui peut arriver vu que ctrl+s est le raccourci de sauvegarde dans la majorité des éditeurs de texte graphiques et qu'il n'est pas rare de le taper machinalement après une saisie), tentez ça avant de frapper sauvagement sur votre clavier ;)

Retrouver les plus gros fichier de votre système

Une partition est pleine, il faut faire de la place ! Oui, mais où sont les gros fichiers qui consomment tout l'espace ? Telle est la situation dans laquelle je me suis retrouvé pas plus tard qu'il y a 5 minutes...

Après une brève recherche sur le net ( que je en compte pas refaire à l'avenir, d'où l'intérêt d'en donner le résultat ici :) ), voici la réponse avec une combinaison de du et sort :

du -S <noeud-de-l'arborescence> | sort -n

Soit dans mon cas, pour lister tous les dossiers du système :

du -S / | sort -n

On retrouvera alors les dossiers contenant les plus gros fichiers en fin de liste. Par contre ça peut mettre un peu de temps à s'exécuter vu que ça fait le tour de tous les dossiers.