Référencer une application web Single Page – AngularJS, NodeJS, PhantomJS et jsDOM

Le référencement c’est un peu le nerf de la guerre du web, et avec la venue de nouvelles pratiques de développement accompagnant l’explosion des frameworks JavaScript côté client (MVC, MVP, MVVM), de nouvelles problématiques apparaissent.
Les développeurs sont de plus en plus amenés à construire les contenus depuis le navigateur, l’application côté serveur se limitant à une page ‘template’ globale (SPA), et le plus souvent des ressources REST/json sollicitées à la demande en Ajax pour préserver l’UX.

Ce type d’architecture logique rend impuissants les moteurs de recherche qui ne sont pas (encore) propulsés par un moteur JavaScript, comme illustrés ici :

seospa1

seospa2


La simple indexation de fragments HTML ne pose pas véritablement de problème, il suffit de déclarer des hashbangs dans sitemap.xml et discriminer les requêtes de robots par _escaped_fragment_ (plutôt que user agent), comme suggéré dans le getting-started de Google sur ce sujet.
La vraie difficulté réside donc dans les contenus eux-mêmes.

Puisqu’on sait discriminer les requêtes de robots, une solution rapide consiste à leur servir des pages statiques, ce qui revient à avoir un deuxième site à gérer (!).

Une autre approche consiste à faire générer ces snapshots de pages par les visiteurs eux-même, mais il faut alors embarquer le code qui va poster les snapshots sur le serveur, pas vraiment idéal…

Peut-on trouver une solution plus efficace et industrielle ?

Prenons un exemple d’application web codée en NodeJS propulsée par AngularJS qui sert une page globale index.html et les fragments de contenus a.html et b.html correspondant aux URIs /a et /b (sources du projet).
Remarque : dans la terminologie AngularJS on parle de ‘partial’ pour faire référence à un fragment.

En browsant http://localhost:3000/a avec un navigateur on obtient :

Capture du 2013-09-15 13:36:18

Le conteneur central est constitué d’un fragment HTML, en fait un partial chargé par le routeur Angular, et contient des données dynamiques évaluées via le binding Angular.

En se faisant passer pour un moteur de recherche on obtient logiquement une page avec un conteneur central vide : #fail

Capture du 2013-09-15 13:49:22

Plusieurs approches sont possibles pour obtenir un contenu :

1. Chargement des partials côté serveur :

En ajoutant un middleware bien placé dans l’application côté serveur, on peut dérouter les requêtes provenant de robots et inclure dynamiquement le partial correspondant à l’URI dans la vue (sources du projet).

var fs = require('fs')
  , routes = require('../assets/js/routes') // Définition des routes Angular réutilisées par le serveur
  , config = require('../config');

module.exports = function (req, res, next) {
  var ua, template;
  res.locals.viewContent = null;
  ua = req.headers['user-agent'];
  if (!ua.match(/bot/i)) { // Si ce n'est pas un robot on passe au middleware suivant (le routeur Express)
    return next();
  }
  template = routes[req.path] || routes['']; // Chemin du template à charger en fonction de l'uri path
  fs.readFile(config.baseDir + template, 'utf-8', function (err, data) {
    if (err) {
      console.error(err.stack);
      res.send(500, 'Something broke!');
      return;
    }
    res.locals.viewContent = data; // Injection du contenu du fichier dans la variable de template viewContent
    next();
  });
};

Ajouter une expression expressjs dans la vue template pour injecter le contenu du partial :

<section id="main"><%- viewContent %></section>

Le serveur étant codé aussi en JS, on peut facilement mutualiser le composant ‘routes’ :

(function () {
  var routes;

  routes = { // Map des routes Angular (uri -> chemin du partial)
    '/a': '/assets/partials/a.html',
    '/b': '/assets/partials/b.html',
    '/': '/assets/partials/a.html',
    '': '/assets/partials/notFound.html'
  };
  // Technique pour mutualiser un module JS côté client et côté serveur
  if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
    module.exports = routes;
  } else {
    window.routes = routes;
  }
})(); // Fonction auto-appelante, permet de définir un scope limité pour les variables

Résultat avec un robot :

Capture du 2013-09-15 16:24:34

Les contenus des partials sont bien récupérés, mais les données restent non valorisées.
L’intérêt de ce type de solution est très limité, tout dépend de ce qu’on souhaite référencer.
Dans certains cas ces données bindées peuvent être liées au contexte client donc inutile au référencement, mais la plupart du temps cette limitation est rédhibitoire.

 

2. Navigateur sans tête avec PhantomJS :

C’est l’approche la plus répandue, il s’agit d’utiliser PhantomJS comme navigateur headless pour interpréter le code client et obtenir les contenus valorisés.
La difficulté concerne la récupération du contenu côté serveur, ici on triche en détournant l’API de log, voici donc le nouveau middleware avec phantomjs (sources du projet):

var phantom = require('phantom')
  , ph;
phantom.create(function (phantomPh) {
  ph = phantomPh;
});
module.exports = function (req, res, next) {
  var ua;
  res.locals.viewContent = null;
  ua = req.headers['user-agent'];
  if (!ua.match(/bot/i)) {
    return next();
  }
  ph.createPage(function (page) {
    page.set('onConsoleMessage', function (msg) { // Détection des événements console
      var data;
      try {
        data = JSON.parse(msg);
      } catch (err) {
      }
      if (data && data.mainContent) { // Récupération du contenu provenant de PhantomJS et injection dans le template
        res.locals.viewContent = data.mainContent;
        page.close();
        next();
      } else {
        console.log('log from phantom :', msg); // Trace normale
      }
    });
    page.open('http://localhost:3000' + req.path, function (status) {
      page.evaluate(function () { // Evaluation de la page par PhantomJS, ici on est dans un contexte client
        var mainElt, mainContent;
        mainElt = document.getElementById('main');
        mainContent = mainElt && mainElt.innerHTML; // On récupère le contenu qui nous intéresse
        mainContent = mainContent || '';
        console.log(JSON.stringify({ mainContent: mainContent })); // On envoie le contenu au serveur
      });
    });
  });
};

Résultat :

<section id="main">
<div class="ng-scope">
<h2 class="ng-binding">This is A</h2>
Donec eu tristique sapien. Pellentesque mattis nisl non gravida adipiscing. Sed non tristique nisi. In pharetra dolor venenatis eros volutpat. <em class="ng-binding"> 1 + 1 = 2 </em>
</div>
</section>

On obtient bien un contenu valorisé prêt à être référencé : objectif atteint
La solution nécessite d’installer PhantomJS, et d’ouvrir un port supplémentaire (par défaut 12300).
Chaque requête d’un robot déclenche sur le serveur une nouvelle requête vers le navigateur headless qui exécute tout le code client, en terme de scalabilité c’est moyen, mais cette limite ne concerne que les robots…
PhantomJS est assez sensible, si le code JS lui pose problème le process sortira avec un code retour pas toujours très explicite, il faut donc une gestion d’erreur rigoureuse.
Pour des raisons de commodités le serveur est codé en NodeJS, néanmoins le principe de mise en oeuvre est parfaitement transposable à n’importe quelle technologie serveur (Java par exemple).

3. Exécution de code client côté serveur avec jsDom :

Une autre approche consiste à utiliser une implémentation JavaScript de DOM qui joue un rôle similaire à celui de PhantomJS, mais sans nécessiter d’installation supplémentaire (binaire phantomjs) ni d’ouverture de port, tout se fait de manière programmatique.
Version modifiée du middleware avec jsDom (sources du projet) :

var path = require('path')
  , fs = require('fs')
  , jsdom = require('jsdom')
  , routes = require('../assets/js/routes')
  , config = require('../config')
  , template;

module.exports = function (req, res, next) {
  var ua, template;
  res.locals.viewContent = null;
  ua = req.headers['user-agent'];
  if (!ua.match(/bot/i)) {
    return next();
  }
  template = routes[req.path] || routes['']; // On charge le contenu du partial
  fs.readFile(config.baseDir + template, 'utf-8', function (err, data) {
    if (err) {
      console.error(err.stack);
      res.send(500, 'Something broke!');
      return;
    }
    res.locals.viewContent = data; // On stocke le contenu dans une variable de template
    res.render('index', function (err, content) { // Lancement du rendu de la page index.html
      var onLoad, jsDomConfig;
      onLoad = function (errors, window) {
        var document, angular, module, mainContent;
        angular = window.angular; // Dans jsDOM tout est porté par l'objet window, pas de variable globale
        document = window.document;
        document.getElementById('main').removeAttribute('ng-view'); // On retire l'attribut pour inhiber le routeur Angular
        window.scrollTo = function () { // API nécessaire à AngularJS mais manquante dans jsDOM
        };
        window.console = console;
        module = angular.module('seospa', []);
        module.config(['$locationProvider', function ($locationProvider) {
          $locationProvider.html5Mode(true);
        }]);
        angular.bootstrap(document); // Là Angular va valoriser les contenus
        mainContent = document.getElementById('main').innerHTML; // On récupère le fragment qui nous intéresse
        res.locals.viewContent = mainContent; // Et on le réinjecte dans notre page
        next();
      };
      jsDomConfig = {
        html: content, // Contenu de la page
        src: [ // Scripts à injecter
          fs.readFileSync(path.join(config.assetsDir, 'lib', 'angular', 'angular.min.js'), 'utf-8'),
          fs.readFileSync(path.join(config.assetsDir, 'js', 'controllers', 'HelloCtrl.js'), 'utf-8')
        ],
        done: onLoad // Fonction à appeler une fois la page chargée
      };
      jsdom.env(jsDomConfig); // Lancement du chargement dans jsDOM
    });
  });
};

Là encore on obtient bien les contenus valorisés, objectif atteint.
Chaque requête de robot déclenche sur le serveur le chargement dans jsDom de la page, on intercepte alors le chargement effectif pour exécuter un code JavaScript proche de notre application AngularJS originale, mais légèrement modifié pour inhiber le routeur.
Le routeur AngularJS est inutile car la page est déjà raffinée, de plus toute tentative de chargement d’une route fera planter jsDom (chargement externe).
Il reste à récupérer le contenu DOM qui nous intéresse (ici le sélecteur #main) et le réinjecter dans la vue côté serveur pour répondre au client.
Avec jsDOM le surcoût en performance est négligeable, pas de connexion avec un tier, pas d’impact dans le code une fois le middleware en place.
En revanche la solution est fortement liée à NodeJS; de plus le pseudo-navigateur se permet quelques interprétations qu’il va falloir hacker (absence de window.scrollTo par exemple).

Conclusion :

Référencer une application « Single Page » nécessite de combler l’absence de moteur JS dans les robots :

  • soit en reproduisant les comportements du client sur le serveur :
    Les + : simplicité, efficace si on ne s’intéresse qu’aux simples contenus des partials sans les données dynamiques
    Les – : limité, peu réaliste si l’on veut tout supporter, lourd quand le langage serveur n’est pas JavaScript
  • soit en utilisant un composant qui émule un navigateur : PhantomJSZombieJSSlimerJS voire HtmlUnit
    Les + : véritable exécution du JS (webkit pour PhantomJS, gecko pour SlimerJS), solution agnostique en terme de techno serveur
    Les – : un composant en plus à maintenir
  • soit en utilisant une implémentation DOM légère, avec NodeJS : jsDOM
    Les + : légèreté, efficacité, pas de connexion supplémentaire
    Les – : adhérence avec NodeJS, un navigateur en plus à gérer (hacks)

PhantomJS est souvent utilisé dans des usines logicielles via l’outil de build Grunt le plus souvent pour exécuter des tests, mais on peut facilement l’utiliser pour fabriquer une version statique du site dédiée aux robots si c’est l’objectif.
Dans la mesure où la génération des pages peut se limiter aux contenus, sans mise en page ni éléments d’interaction, ce compromis peut être avantageux car simple et pas si coûteux.

Compte tenu de l’essor du langage JavaScript, il est fort à parier qu’à terme les robots seront équipés de moteurs JS, en attendant, comme souvent dans le web, il faut vivre avec des astuces…

[ Intégralité des sources disponible sur GitHub ]

 

 

10 commentaires sur “Référencer une application web Single Page – AngularJS, NodeJS, PhantomJS et jsDOM”

  • Ce billet est une très bonne introduction au problème du référencement des sites utilisant beaucoup l’Ajax, en exposant les différentes approches possibles. Cependant, à propos des solutions basées sur l’utilisateur d’un navigateur, il y a d’autres points à prendre en compte qui me semblent important : - la scalabilité : il n’est pas possible de contrôler le rythme auquel les robots viennent chercher les captures. Il n’est pas rare qu’un robot vienne demander plusieurs pages à la seconde. - les temps de réponse : pour garder des temps de réponses corrects, il n’est pas raisonnable de générer les captures à la volée. En effet il faut en général quelques secondes pour générer une capture. Face à ces problèmes, il faut mettre en place une solution qui soit capable : - de générer et de mettre en cache les captures de pages, avant que les robots ne viennent les demander. - de mettre à jour les captures régulièrement. - de crawler le site dans le cas ou il est difficile de maintenir à jour un sitemap (contenus UGC par exemple). Cette architecture scalable est plus compliquée à mettre en place, mais elle assure un fonctionnement efficace de la solution. Nous avons developpé un SaaS, SEO4Ajax, qui résout ces problèmes. Nous sommes très interessés par des retours utilisateurs : la beta est donc gratuite.
  • Bonjour Yannick, Je m'intéresse au problématiques d'indexation des sites ajax et je suis donc allé voir votre service seo4ajax. Je suppose que Brombone (http://www.brombone.com/) est un concurrent direct. Quelles différence entre ces services ? Y a-t-il des comportements qui se prêtent plus à certains types de sites et pas d'autres ? De mon côté je suis curieux de connaître des solutions logicielles (et non services) qui pourraient réaliser les fonctionnalités exposées dans la solution 2 de l'article.
  • Bonjour Florent, En effet c’est un concurrent direct. Les deux service s’appuient sur PhantomJS pour générer les captures. La principale différence avec SEO4Ajax vient de la manière dont sont identifiées les urls à capturer. D’un coté le site à indexer doit fournir un sitemap et le mettre à jour régulièrement. C’est seulement à partir de celui-ci que les urls sont déterminées. De notre coté nous avons opté pour un crawler. Notre service est donc plus autonome et n’exige pas que le webmaster connaisse toutes les urls de son site. L’autre avantage du crawler est que notre service est aussi capable de générer un sitemap pour le fournir aux robots. Côté solution logicielle complète, je n’en connais pas qui soit complètement satisfaisante. C’est la raison pour laquelle nous avons bati notre propre solution pour SEO4Ajax.
  • Très bon article, comme toujours. J'utilise http://prerender.io/
  • @bruno Baia: Et ca marche bien ? Car utiliser un timer de 50ms pour laisser le temps aux requêtes Ajax de s’exécuter, ça me parait un peu primitif comme solution. L'absence de cache risque de s’avérer aussi très pénible lorsque les bots demanderont plusieurs pages à la seconde... De plus renvoyer une 404 lorsque phantom "plante", je ne suis pas sur que ça soit une bonne idée etc.
  • Pour compléter l'info sur le sujet : http://www.ng-newsletter.com/posts/serious-angular-seo.html
  • Bonjour, Excellent article ! Le conseil que je donne toujours si le référencement est capital est de ne surtout pas faire ça... Votre article le montre bien, c'est compliqué, pas robuste et ça le restera tant que les robots n’interpréteront pas le JS. Mon conseil : ayez un site éditorial classique (avec CMS ou non) mais un rendu coté serveur (php par exemple) pour tout ce qui doit être référencé. C'est fait pour et le web a été construit comme ça. Ensuite vous gardez une partie MV* pour les parties du site plus dynamique qui n'ont pas besoin d'être référencées. Evidemment c'est à réfléchir dès la conception du site. N'utilisez les fonctions et méthodes présentées ici que lorsque vraiment vous n'avez pas le choix. JM.
  • Bonjour, Je me permets de faire un petit UP de ce poste. :) Ca fait 3jours que je perds mes cheveux sur le SEO de mon nouveau site. C'EST UNE GROSSE CATASTROPHE :/ Je me suis aperçu que trop tard que AngularJS n'est pas du tout une bonne alternative pour le référencement naturel et que c'est encore pire quand on est sur un serveur Apache (PHP/MYSQL). Les PhantomJS on peut carrément mettre la pate dessus et l'oublier. Concernant les services prerender.io, le problème de ces trucs c'est (arrêtez moi si je me trompe) qu'ils ne semble pas gérer les requêtes $http exécutée une fois le body ready (ng-init). Donc pour les requête côté RUN d'Angular c'est tout bon (je pense aux traductions) mais pour ce qui touche aux variables c'est la grosse cata, le retour est donné avant la fin du chargement des requêtes. J'ai essayé de passer ces requêtes dans le resolve du routes mais rien à faire. Je me retrouve encore et toujours avec des {{ ma_var_caca }}. :( Auriez-vous une alternative, 2ans et demi après ? :) En vous remerciant. ;)
  • Bonjour, Google a annoncé il y a un an maintenant, que ses moteurs allaient interpréter le code JavaScript des pages indexées : http://googlewebmastercentral.blogspot.fr/2014/05/understanding-web-pages-better.html (l'article contient d'ailleurs quelques conseils sur comment configurer son serveur) Je n'ai personnellement pas d'expérience SEO avec AngularJS, mais voici un blog intéressant avec des retours d'expérience sur le JavaScript et le SEO, sur lequel vous trouverez certainement quelques éléments de réponse à vos questions : http://www.rimmkaufman.com/blog/ Bon courage
  • Voici un article bien détaillé de comment référencer une app AngularJS http://www.doz.com/search-engine/angularjs-index-seo
    1. Laisser un commentaire

      Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *


      Ce formulaire est protégé par Google Recaptcha