Backbone, Node.Js et SEO

Les frameworks javascript du type Backbone.js ou Angular.js sont en plein essor et permettent de construire rapidement et proprement des applications dites Single page application. Ce type d’application permet principalement d’améliorer l’expérience de vos utilisateurs.

Lorsque l’utilisateur souhaite accéder à l’application, il envoie une requête sur un serveur web, qui lui répond par une page HTML classique. Cette page fait référence aux fichiers javascript de Backbone, underscore.js, jquery et le javascript contenant la logique de l’application basée sur Backbone.

Exemple :

<!DOCTYPE html>

<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="utf-8" />
    <meta name="fragment" content="!">

    <title></title>
</head>
<body>

</body>
</html>

<script src="http://underscorejs.org/underscore.js"></script>
<script src="http://code.jquery.com/jquery-2.1.3.js"></script>
<script src="http://backbonejs.org/backbone.js"></script>
<script>
    // Javascript correspondant à l'application Backbone
</script>

Le code javascript correspondant à l’application est ensuite exécuté par le navigateur. La page n’est plus rechargée, toutes les interactions avec le serveur se font via des appels AJAX. L’URL du navigateur peut changer via des ancres “#” ou via pushState disponible avec HTML5.

Problème

Même si Google indique dans ses best practices que les crawlers analysent le CSS et les fichiers javascript, il est fortement conseillé d’avoir le contenu du site web dans les balises de la page lorsqu’elle est téléchargée depuis le serveur web.

Exemple :

<!DOCTYPE html>

<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
    <!-- ... -->
</head>
<body>
    Le contenu du site web indexé par Google
</body>
</html>

Comme on peut le voir sur le 1er exemple, les balises sont vides. Le contenu arrivera dynamiquement lors de la construction par Backbone.

On peut donc en déduire que l’utilisation d’un framework type Backbone ou Angular n’est pas bonne pour l’indexation par Google.

Solution

Heureusement, Google a mis en mécanisme permettant d’indexer les applications basées sur des appels AJAX. Tout le fonctionnement est résumé sur la page développeur de Google.

En résumé, 2 cas se présentent :

<meta name="fragment" content="!">

Ainsi, si Google rencontre un “#!” ou la balise meta ci-dessus alors il appellera votre page avec un paramètre supplémentaire dans l’URL :

  • Exemple du “#!” :

www.example.com/index.html#key=value devient www.example.com/ajax.html?**_escaped_fragment_=**key=value

  • Exemple du pushState :

www.example.com/user/1 devient www.example.com/user/1**?_escaped_fragment_=**

Le paramètre “_escaped_fragment” est ajouté lors de l’appel au serveur. Ce paramètre permet de déclencher un comportement différent côté serveur pour rendre une page statique avec une contenu lisible par Google.

Mise en place de l’application

Côté serveur, nous allons utiliser Node.Js pour restituer la page HTML brute contenant le javascript pour les utilisateurs et une page HTML statique pour Google.

Le projet Node.Js se base sur la librairie ExpressJs permettant de mettre en place rapidement un serveur web. Le projet peut être téléchargé ici ou être généré via yeoman.

Dans notre exemple, l’application Backbone est minimaliste et présente un vêtement dérivé d’un animal de compagnie imaginaire au nom imprononçable (terme sans concurrence sur Google).

Une démonstration de l’application terminée est disponible ici.

L’architecture du projet est la suivante :

architecture project

Notre application Backbone se trouve dans le fichier /public/groingrek/index.html. Elle fait quelques appels au serveur en AJAX pour récupérer les informations sur notre produit :

<script>
    $(document).on('click', 'a[href^="/"]', function (event) {
        event.preventDefault();
        Backbone.history.navigate(this.href);
    });

    var eventAggregator = _.extend({}, Backbone.Events);
    
    var Cara = Backbone.Model.extend({});
    Caras = Backbone.Collection.extend({
        model: Cara,
        url: "http://aymeric.cloudapp.net/groingrek/caracteristiques"
    });
    
    var CarasView = Backbone.View.extend({
        tagName: "ul",
        render: function () {
            console.log(this);
            console.log(this.collection);
            _(this.collection.models).each(function (cara) {
                //console.log(person);
                this.$el.append(new CaraView({ model: cara }).render().el);
            }, this);
    
            return this;
        }
    });
    
    var CaraView = Backbone.View.extend({
        tagName: "li",
        render: function () {
            this.$el.html("Nom : " + this.model.get("name") + "<br />Valeur : " + this.model.get("value") + "<br />Description : " + this.model.get("description"));
            return this;
        }
    });
    
    var MainModel = Backbone.Model.extend({
        url: "http://aymeric.cloudapp.net/groingrek/description"
    });
    
    var MainView = Backbone.View.extend({
        tagName: "div",
        model: MainModel,
        render: function () {
            this.$el.html("Nom : " + this.model.get("nom") +
                    "<br />Description : " + this.model.get("description") + "br />" +
                    'twitter : <a href="' + this.model.get("twitter") + '" title="Groingrex Twitter">groingrek Twitter</a><br />' +
                    'informations : <a href="./details" title="Groingrex caractéristiques">groingrek caractéristiques</a>');
            return this;
        }
    });
    
    var CarasRouter = Backbone.Router.extend({
    
        routes: {
            "details": function () {
                var caras = new Caras();
                caras.fetch({
                    success: function () {
                        $('body').html(new CarasView({ collection: caras }).render().el);
                    }
                });
            },
            "index": function () {
                var main = new MainModel();
                main.fetch({
                    success: function () {
                        $('body').html(new MainView({ model: main }).render().el);
                    }
                });
            },
            "*path": function () {
                router.navigate("index", { trigger: true });
            }
        }
    });
    
    var router = new CarasRouter();
    Backbone.history.start({ pushState: true, "root": "/groingrek" });

</script>

Côté serveur, des routes sont définies pour répondre aux appels AJAX et pour délivrer le fichier index.html (/route/index.html) :

var express = require('express');
var router = express.Router();

router.get("/groingrek/caracteristiques", function (req, res, next) {
    res.json([
        {
            name: "taille",
            value: 'XL',
            description: "La taille du groingrek est XL, elle permet aux hommes corpulents de pouvoir arborer leur groingrek préféré"
        },
        {
            name: "couleur",
            value: "bleu",
            description: "La magnifique couleur de groingrek permet de se prendre pour un vertitable groingrek de par son éclatante qualité"
        },
        {
            name: "forme",
            value: "conique",
            description: "La forme conique de notre produit donne vraiment l'illusion que l'on se prend pour notre bestiole préférée, le groingrek"
        }
    ]);
});

router.get("/groingrek/description", function (req, res, next) {
    res.json({
        "nom": "groingrek le terrible",
        "photo": "http://aymeric.cloudapp.net/images/groingrek.png",
        "description": "Le groingrek est un animal de compagnie doté d'une puissance incomparable. Il peut engloutir des centaines de kilos de viandes en très peu de temps. En outre, le groingrek est un très sympathique est vous fera passer d'excellents moments. Le produit que nous proposons pour la modique somme de 5€ est un costume complet pour devenir vous même un groingrek et encourager vos amis dans votre groingrekitude.",
        "lien": "http://aymeric.cloudapp.net/groingrek/caracteristiques",
        "twitter": "https://twitter.com/groingrek"
    });
});

router.get("/groingrek*", function (req, res, next) {
    res.sendFile("./public/groingrek/index.html", { root: "../" });
});

module.exports = router;

Ici, l’application est pleinement fonctionnelle pour les utilisateurs mais est presque invisible pour Google.

Prerender

Pour gérer le cas du paramètre escaped_fragment= envoyé par Google, nous allons utiliser Prerender.

Prerender peut être utilisé en tant que service (gratuit jusqu’à un certain nombre de pages) mais il peut également être installé en standalone sur votre serveur. Il est basé sur PhantomJS, un navigateur sans interface graphique basé sur WebKit qui aura pour rôle de jouer le javascript de notre page HTML.

L’objectif ici est donc de déployer ce service et de l’utiliser depuis notre application web.

Pour installer prerender, rendez-vous sur la page GitHub dédiée et télécharger les sources.

Une fois téléchargé, déposez les sources sur votre serveur et lancez la commande suivante à la racine du répertoire :

npm install

Lancez ensuite le service Prerender avec la commande :

node server.js

Votre service Prerender est maintenant disponible à l’adresse : http://localhost:3000.

Pour tester que le service fonctionne correctement, tentez d’accéder à l’URL suivante : http://localhost:3000/http://aymeric.cloudapp.net/groingrek/index

On constate que le HTML renvoyé par le service contient des balises avec le contenu de ma page qui est normalement construit côté client avec Backbone.

resultat

Maintenant que le service est en place, il faut modifier notre application web pour utiliser ce service quand Google demandera la page.

On commence par installer le package Node.Js fourni par Prerender :

npm install prerender-node --save

On ouvre ensuite le fichier app.js et on ajoute la ligne suivante :

app.use(require('prerender-node').set('prerenderServiceUrl', 'http://localhost:3000'));

au dessus de la ligne :

app.use(logger('dev'));

Après avoir redémarré l’application web, les URLs qui utilisent escaped_fragment utiliseront Prerender.

La commande :

curl http://aymeric.cloudapp.net/groingrek/index

renvoie toujours notre application avec un body vide et tout le javascript utilisé pour construire l’application.

body

En revanche, la requête :

curl http://aymeric.cloudapp.net/groingrek/index?_escaped_fragment_=

renvoie une page HTML statique sans javascript et avec le contenu de l’application dans les balises .

body 2

Performance

Sur la requête qui appelle le service Prerender, on remarque que les temps de réponse sont mauvais. Ils viennent du temps de processing fait par PhrantomJS avant de renvoyer la page.

Temps :

Pour palier à ce problème, il est possible de mettre en cache le HTML généré par PhantomJS afin de le resservir très rapidement lors des prochains appels.

Cette gestion du cache est prévu dans le service Prerender grâce à Redis (installation de Redis ici). Lorsque Redis est installé sur votre machine, lancez la commande suivante à la racine du service prerender installé précédemment :

npm install prerender-redis-cache --save

Editez ensuite le fichier server.js de ce même service pour ajouter la ligne suivante :

server.use(prerender.httpHeaders());

au dessus de

server.start();

Relancez ensuite le service Prerender.

Si on relance des requêtes sur l’URL contenant escaped_fragment, la première requête prendra toujours environ 1s, en revanche, pour les suivantes, le temps se situe entre 10ms et 20ms.

Note : Sans configuration spécifique, prerender-redis-cache utilise l’adresse localhost et le port par défaut, le tout sans authentification pour se connecter au serveur Redis.

Cas pratique

Malheureusement, les outils proposés par Google dans les Wemasters tools ne gèrent pas les escaped_fragment. Le site apparait donc comme vide.

Afin de tester, j’ai mis quelques liens vers http://aymeric.cloudapp.net/groingrek/index sur mon blog, Twitter, etc. Après quelques jours, les 2 pages du sites sont indexées et disponibles dans Google.

google

Informations complémentaires

L’article présente ici l’utilisation de Prerender avec Node.Js et Backbone.Js mais le fonctionnement est identique avec des frameworks comme Ember.js ou AngularJs.

Du côté de Prerender, d’autres modules ou fichiers de configuration sont disponibles pour Nginx, Ruby, Apache, etc.