Développer un moteur de recherche avec Drupal

Récemment j’ai eu besoin de créer un moteur de recherche sous Drupal 9.

Besoin client

Le besoin est le suivant : L’utilisateur doit sélectionner des critères de recherche pour trouver des destinations de voyages qui correspondent à ses envies.

Le formulaire de recherche s’articule donc en plusieurs étapes successives :

  • Etape 1 : Type de voyageurs (solo, couple, famille ou amis)
  • Etape 2 : Centres d’intérêts (culuture, sport, fiesta, aventure)
  • Etape 3 : Envies (plage, montagne, ville ou nature)
  • Etape 4 : Budget (minimum et maximum)
  • Etape 5 : Temps de vol (en heures)
  • Etape 6 : Période (peut importe, week-end prochain, dans le mois, dans les 6 mois)

Les résultats doivent être triés par ordre de pertinence en fonction de ces critères de recherche avec un pourcentage de correspondance (% match).

Conception

Côté back-office on a des contenus de type « destination » qui possèdent des champs numérique (min : 0 | max : 5) pour chaque critère des 3 premières étapes :

  • field_step_1_solo
  • field_step_1_couple
  • field_step_1_friends
  • field_step_1_family
  • field_step_2_culture
  • field_step_2_sport
  • field_step_2_fiesta
  • field_step_2_adventure
  • field_step_3_beach
  • field_step_3_montain
  • field_step_3_nature
  • field_step_3_city

On a également un champ numérique qui stocke le temps de vol en minutes (min : 1 | max : 1200).

  • field_flight_time

Pour la récupération des prix on fait appel à une API d’un prestataire externe qui prend en paramètre :

  • Date de départ
  • Date de retour
  • Prix maximum
  • Codes IATA de la destination

Maintenant il faut mettre les ains dans le code !

Il va falloir que j’ai des données dans les champs des 3 premières étapes des contenus pour faire mes tests. La flemme de contribuer manuellement 12 valeurs pour 200 contenus. Alors un petit script et c’est parti :

$manager = \Drupal::entityTypeManager();
$nodes = $manager
  ->getStorage('node')
  ->loadByProperies(['type' => 'destination']);

foreach ($nodes as $nid => $node) {
  $node->set('field_step_1_solo', rand(0,5));
  $node->set('field_step_1_couple', rand(0,5));
  $node->set('field_step_1_friends', rand(0,5));
  $node->set('field_step_1_family', rand(0,5));
  $node->set('field_step_2_culture', rand(0,5));
  $node->set('field_step_2_sport', rand(0,5));
  $node->set('field_step_2_fiesta', rand(0,5));
  $node->set('field_step_2_adventure', rand(0,5));
  $node->set('field_step_3_beach', rand(0,5));
  $node->set('field_step_3_montain', rand(0,5));
  $node->set('field_step_3_nature', rand(0,5));
  $node->set('field_step_3_city', rand(0,5));
  $node->save();
}

Maintenant que j’ai des données je vais avoir besoin de l’architecture suivante :

  • Entité « Paragraph » : Moteur de recherche voyage
    • Cela permettra aux webmasters d’ajouter facilement ce composant dans les pages via le back-office de Drupal
  • Template Twig du composant
  • Fichier JavaScript lié au composant via le système de librairie de Drupal.
    • Cela permettra de récupérer les valeurs du formulaire et les passer en paramètre d’une requête AJAX pour récupérer les résultats
  • Route pour récupérer la requête AJAX
  • Controlleur pour cette route
  • Service qui gère la logique de recherche et renvoi une liste de résultat tout prêt à être afficher dans le template

Côté logique de recherche le déroulé va être le suivant :

  • Requête HTTP GET : /api/search depuis le JavaScript
    • ?step_1=solo
    • &step_2=fiesta
    • &step_3=city
    • &budget_min=10
    • &budget_max=1000
    • &flight_time=12
    • &period=mounth
const apiUrl = '/api/search' + this.getUrlFilters(this.data);

jQuery.ajax({
  method: 'GET',
  url: apiUrl,
  success: function (destinations) {
    this.updateDestinations(destinations);
  }
});
  • Route -> Controller
    • Retourne une JsonResponse() avec le résultat de lafonction query() de mon Service custom.
/**
 * Get search results destinations.
 *
 * @param Symfony\Component\HttpFoundation\Request $request
 *   Current request.
 *
 * @return \Symfony\Component\HttpFoundation\JsonResponse
 */
public function search(Request $request) {
  return new JsonResponse(
    $this->searchEngine->query($request)
  );
}
  • Service -> query(Request $request)
    • Récupération des filtres à partir de l’objet « Request« 
    • Construction d’une « Query » Drupal à parir des filtres
    • Exécution de la « Query » = Liste des identifiants des contenus
    • Chargement des « Nodes » à partir des identifiants des contenus
    • Retourne le résultat de la fonction getResults($nodes, $filters)
/**
 * Query the Drupal database to get results.
 *
 * @param \Symfony\Component\HttpFoundation\Request $request
 *   The current HTTP request.
 *
 * @return array
 */
public function query(Request $request) : array {
  $filters = $this->getFiltersFromRequest($request);
  $query = $this->buildQueryFromFilters($filters);
  $nids = $query->execute();
  $nodes = $this->entityManager
    ->getStorage('node')
    ->loadMultiple($nids);
  return $this->getResults($nodes, $filters);
}
  • Service -> getResults(array $nodes, array $filters)
    • Création d’un tableau temporaire
    • Boucle sur les contenus $nodes
      • Ajout dans le tableau temporaire du résultat de la fonction prepareResultFromNode($node, $filters) qui va calculer le score de matching et préparer le résultat pour l’affichage dans le template
    • Tri du tableau temporaire par score de matching
    • Troncage du tableau pour n’affciher que les 10 premiers résultats
    • Vérification que le score est plus élevé que le score minimum voulu (configurer en back-office)
    • Vérification de la présence d’une période autre que « Peut-importe »
    • Si c’est le cas on appelle l’API des prix pour chaque destination
      • Si l’API nous renvoi bien un prix on met à jour le prix du résultat de recherche. Par défaut on affiche un prix d’appel (meilleur prix sur l’année)
    • On renvoi les résultats
/**
 * Calculate matching score
 * and format result for the front template.
 *
 * @param array $nodes
 *   The query results nodes.
 * @param array $filters
 *   The current query filters.
 *
 * @return array
 */
public function getResults(array $nodes, array $filters) : array {
  $temp = [];
  // Prepare results with calculated score.
  foreach ($nodes as $nid => $node) {
    $temp[$nid] = $this->prepareResultFromNode(
      $node,
      $filters
    );
  }
  // Sort by score.
  uasort($temp, function ($a, $b) {
    return $b['score'] - $a['score'];
  });
  // Keeping the best scores.
  $results = array_slice($temp, 0, $this->maxResults, TRUE);
  // Check if period filter is set.
  $period = $filters['period'];
  if (is_null($period) === FALSE && $period != 'anytime') {
    $api_parameters = $this->getApiParameters($filters);
    foreach ($results as $nid => $result) {
      if ($result['score'] < $this->minScore) {
        unset($results[$nid]);
        continue;
      }
      $price = $this->getNodePriceData(
        $nodes[$nid],
        $api_parameters
      );
      if (
        is_null($price['fare']) === TRUE
        && is_null($price['price_url']) === TRUE
      ) {
        continue;
      }
      $results[$nid]['price_fare'] = $price['fare'];
      $results[$nid]['price_url'] = $price['url'];
    }
  }
  // Sort by score.
  usort($results, function ($a, $b) {
    return $b['score'] - $a['score'];
  });
  return $results;
}

L’avantage de pré-filtrer les résultats avant d’appeler l’API des prix est qu’on évite d’envoyer trop requêtes et donc on gagne en temps de chargement.

Pour le calcul du score la logique est la suivante : les 3 premières étapes possèdent des coefficients afin de définir un impact plus ou moins fort sur les réponses de certaines étapes :

  • Etape 1 : Coef 2
  • Etape 2 : Coef 3
  • Etape 3 : Coef 1

Ensuite on calcul le score maximum pour la recherche en cours :

Pour rappel chaque choix possède une note de 0 à 5.
Pour l’étape 1 un seul choix est possible et le coeficiant est « 2 » donc le maximum de point est 5 x 2
Pour l’étape 2 plusieurs choix sont possibles et le coeficiant est « 3 » donc il faut compter les nombre de choix sélectionnés par l’utilisateur (N) donc le maximum de point est 5 x 3 x N
Pour l’étape 3 plusieurs choix sont possibles et le coeficiant est « 1 » donc il faut compter les nombre de choix sélectionnés par l’utilisateur (N) donc le maximum de point est 5 x N

Ensuite on récupère les valeurs des choix pour chaque contenu de type « destination » :

$score += intval(
  $node->get('field_inspi_step_1_' . $val)->getString()
);

foreach ($step_2_values as $val) {
  $score += intval(
    $node->get('field_inspi_step_2_' . $val)->getString()
  );
}

foreach ($step_3_values as $val) {
  $score += intval(
    $node->get('field_inspi_step_3_' . $val)->getString()
  );
}

Maintenant qu’on a notre score maximal ($max) et le score du contenu ($score) on peut calculer le pourcentage de matching = round(($score / $max) * 100)

Plus qu’à faire un joli template avec de jolies animation en JavaScript pour rendre le tout dynamique et on a un beau moteur de recherche.


Publié

dans

, ,

par

Étiquettes :