Comment rendre un SPA SEO crawlable?

143

J'ai travaillé sur la façon de rendre un SPA crawlable par Google en fonction des instructions de Google . Même s'il existe de nombreuses explications générales, je n'ai trouvé nulle part un didacticiel pas à pas plus complet avec des exemples réels. Après avoir terminé cela, j'aimerais partager ma solution afin que d'autres puissent également l'utiliser et éventuellement l'améliorer davantage.
J'utilise MVCavec les Webapicontrôleurs et Phantomjs côté serveur et Durandal côté client avec push-stateactivé; J'utilise également Breezejs pour l'interaction de données client-serveur, ce que je recommande vivement, mais je vais essayer de donner une explication suffisamment générale qui aidera également les personnes utilisant d'autres plates-formes.

radieux
la source
40
concernant le "hors sujet" - un programmeur d'application Web doit trouver un moyen de rendre son application explorable pour le référencement, c'est une exigence de base sur le Web. Faire cela n'est pas une question de programmation en soi, mais cela est pertinent pour le sujet des «problèmes pratiques et résolus qui sont propres à la profession de programmeur», comme décrit dans stackoverflow.com/help/on-topic . C'est un problème pour de nombreux programmeurs sans solutions claires sur l'ensemble du Web. J'espérais aider les autres et j'ai passé des heures à le décrire ici, obtenir des points négatifs ne me motive certainement pas à aider à nouveau.
beamish
3
Si l'accent est mis sur la programmation et non sur l'huile de serpent / sauce secrète SEO vaudou / spam alors cela peut être parfaitement d'actualité. Nous aimons également les auto-réponses où elles ont le potentiel d'être utiles aux futurs lecteurs à long terme. Cette paire de questions et réponses semble réussir ces deux tests. (Certains détails de fond pourraient mieux étoffer la question plutôt que d'être introduits dans la réponse, mais c'est assez mineur)
Flexo
6
+1 pour atténuer les votes. Peu importe si q / a serait mieux adapté comme article de blog, la question est pertinente pour Durandal et la réponse est bien documentée.
RainerAtSpirit
2
Je suis d'accord que le référencement est une partie importante de la vie quotidienne des développeurs et devrait certainement être considéré comme un sujet dans stackoverflow!
Kim D.
Outre la mise en œuvre de l'ensemble du processus vous-même, vous pouvez essayer SnapSearch snapsearch.io qui résout ce problème en tant que service.
CMCDragonkai

Réponses:

121

Avant de commencer, assurez-vous de comprendre ce que google exige , en particulier l'utilisation d' URL jolies et laides . Voyons maintenant l'implémentation:

Côté client

Du côté client, vous n'avez qu'une seule page html qui interagit dynamiquement avec le serveur via des appels AJAX. c'est ça le SPA. Toutes les abalises côté client sont créées dynamiquement dans mon application, nous verrons plus tard comment rendre ces liens visibles au bot de google sur le serveur. Chacun de ces abesoins d'étiquette pour être en mesure d'avoir un pretty URLdans la hrefbalise si le bot de Google va explorer. Vous ne voulez pas que la hrefpartie soit utilisée lorsque le client clique dessus (même si vous voulez que le serveur puisse l'analyser, nous verrons cela plus tard), car nous ne voulons peut-être pas qu'une nouvelle page se charge, uniquement pour faire un appel AJAX pour obtenir des données à afficher dans une partie de la page et changer l'URL via javascript (par exemple en utilisant HTML5 pushstateou avec Durandaljs). Donc, nous avons à la fois unhrefattribut pour google ainsi que sur onclicklequel fait le travail lorsque l'utilisateur clique sur le lien. Maintenant, puisque j'utilise push-stateje n'en veux pas #sur l'URL, donc une abalise typique peut ressembler à ceci:
<a href="http://www.xyz.com/#!/category/subCategory/product111" onClick="loadProduct('category','subCategory','product111')>see product111...</a>

«catégorie» et «sous-catégorie» seraient probablement d'autres expressions, telles que «communication» et «téléphones» ou «ordinateurs» et des «ordinateurs portables» pour un magasin d'appareils électriques. De toute évidence, il y aurait de nombreuses catégories et sous-catégories différentes. Comme vous pouvez le voir, le lien est directement vers la catégorie, la sous-catégorie et le produit, et non comme des paramètres supplémentaires vers une page de «magasin» spécifique telle que http://www.xyz.com/store/category/subCategory/product111. C'est parce que je préfère des liens plus courts et plus simples. Cela implique qu'il n'y aura pas de catégorie avec le même nom qu'une de mes 'pages', c'est à dire '
Je n'entrerai pas dans la façon de charger les données via AJAX (la onclickpartie), recherchez-les sur google, il y a beaucoup de bonnes explications. La seule chose importante que je veux mentionner ici est que lorsque l'utilisateur clique sur ce lien, je veux que l'URL du navigateur ressemble à ceci:
http://www.xyz.com/category/subCategory/product111. Et c'est l'URL n'est pas envoyée au serveur! rappelez-vous, c'est un SPA où toute l'interaction entre le client et le serveur se fait via AJAX, pas de liens du tout! toutes les `` pages '' sont implémentées côté client et les différentes URL n'appellent pas le serveur (le serveur a besoin de savoir comment gérer ces URL au cas où elles seraient utilisées comme liens externes d'un autre site vers votre site, nous verrons cela plus tard sur la partie côté serveur). Maintenant, cela est géré à merveille par Durandal. Je le recommande fortement, mais vous pouvez également sauter cette partie si vous préférez d'autres technologies. Si vous le choisissez et que vous utilisez également MS Visual Studio Express 2012 pour le Web comme moi, vous pouvez installer le kit de démarrage Durandal , et là, dans shell.js, utilisez quelque chose comme ceci:

define(['plugins/router', 'durandal/app'], function (router, app) {
    return {
        router: router,
        activate: function () {
            router.map([
                { route: '', title: 'Store', moduleId: 'viewmodels/store', nav: true },
                { route: 'about', moduleId: 'viewmodels/about', nav: true }
            ])
                .buildNavigationModel()
                .mapUnknownRoutes(function (instruction) {
                    instruction.config.moduleId = 'viewmodels/store';
                    instruction.fragment = instruction.fragment.replace("!/", ""); // for pretty-URLs, '#' already removed because of push-state, only ! remains
                    return instruction;
                });
            return router.activate({ pushState: true });
        }
    };
});

Il y a quelques points importants à noter ici:

  1. La première route (avec route:'') est pour l'URL qui ne contient pas de données supplémentaires, c'est-à-dire http://www.xyz.com. Dans cette page, vous chargez des données générales à l'aide d'AJAX. Il se peut qu'il n'y ait en fait aucune abalise sur cette page. Vous voulez ajouter la balise suivante si le bot de Google saura quoi faire avec elle:
    <meta name="fragment" content="!">. Cette balise permettra au bot de Google de transformer l'URL vers www.xyz.com?_escaped_fragment_=laquelle nous verrons plus tard.
  2. La route «à propos» n'est qu'un exemple d'un lien vers d'autres «pages» que vous voudrez peut-être sur votre application Web.
  3. Maintenant, la partie délicate est qu'il n'y a pas de route de «catégorie», et il peut y avoir de nombreuses catégories différentes - dont aucune n'a une route prédéfinie. C'est là mapUnknownRoutesqu'intervient. Il mappe ces routes inconnues à la route «magasin» et supprime également tout «! à partir de l'URL au cas où elle pretty URLserait générée par le moteur de recherche de Google. La route 'store' prend les informations dans la propriété 'fragment' et effectue l'appel AJAX pour obtenir les données, les afficher et modifier l'URL localement. Dans mon application, je ne charge pas une page différente pour chaque appel de ce type; Je ne change que la partie de la page où ces données sont pertinentes et change également l'URL localement.
  4. Notez le pushState:truequi indique à Durandal d'utiliser les URL d'état push.

C'est tout ce dont nous avons besoin du côté client. Il peut également être implémenté avec des URL hachées (dans Durandal, vous supprimez simplement le pushState:truepour cela). La partie la plus complexe (du moins pour moi ...) était la partie serveur:

Du côté serveur

J'utilise MVC 4.5côté serveur avec des WebAPIcontrôleurs. Le serveur doit en fait gérer 3 types d'URL: celles générées par Google - à la fois prettyet uglyaussi une URL «simple» avec le même format que celle qui apparaît dans le navigateur du client. Voyons comment procéder:

Les jolies URL et les «simples» sont d'abord interprétées par le serveur comme s'il essayait de référencer un contrôleur inexistant. Le serveur voit quelque chose comme http://www.xyz.com/category/subCategory/product111et recherche un contrôleur nommé «catégorie». Donc, dans web.configj'ajoute la ligne suivante pour les rediriger vers un contrôleur de gestion des erreurs spécifique:

<customErrors mode="On" defaultRedirect="Error">
    <error statusCode="404" redirect="Error" />
</customErrors><br/>

Maintenant, cela transforme l'URL à quelque chose comme: http://www.xyz.com/Error?aspxerrorpath=/category/subCategory/product111. Je veux que l'URL soit envoyée au client qui chargera les données via AJAX, donc l'astuce ici est d'appeler le contrôleur «index» par défaut comme s'il ne faisait référence à aucun contrôleur; Je fais cela en ajoutant un hachage à l'URL avant tous les paramètres «category» et «subCategory»; l'URL hachée ne nécessite aucun contrôleur spécial à l'exception du contrôleur «index» par défaut et les données sont envoyées au client qui supprime ensuite le hachage et utilise les informations après le hachage pour charger les données via AJAX. Voici le code du contrôleur du gestionnaire d'erreurs:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Web.Http;

using System.Web.Routing;

namespace eShop.Controllers
{
    public class ErrorController : ApiController
    {
        [HttpGet, HttpPost, HttpPut, HttpDelete, HttpHead, HttpOptions, AcceptVerbs("PATCH"), AllowAnonymous]
        public HttpResponseMessage Handle404()
        {
            string [] parts = Request.RequestUri.OriginalString.Split(new[] { '?' }, StringSplitOptions.RemoveEmptyEntries);
            string parameters = parts[ 1 ].Replace("aspxerrorpath=","");
            var response = Request.CreateResponse(HttpStatusCode.Redirect);
            response.Headers.Location = new Uri(parts[0].Replace("Error","") + string.Format("#{0}", parameters));
            return response;
        }
    }
}


Mais qu'en est-il des URL laides ? Ceux-ci sont créés par le bot de Google et doivent renvoyer du HTML brut contenant toutes les données que l'utilisateur voit dans le navigateur. Pour cela, j'utilise phantomjs . Phantom est un navigateur sans tête faisant ce que le navigateur fait du côté client - mais du côté serveur. En d'autres termes, phantom sait (entre autres) comment obtenir une page Web via une URL, l'analyser, y compris exécuter tout le code javascript qu'elle contient (ainsi que récupérer des données via des appels AJAX), et vous rendre le HTML qui reflète le DOM. Si vous utilisez MS Visual Studio Express, vous souhaitez souvent installer phantom via ce lien .
Mais d'abord, lorsqu'une URL laide est envoyée au serveur, nous devons l'attraper; Pour cela, j'ai ajouté au dossier 'App_start' le fichier suivant:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;

namespace eShop.App_Start
{
    public class AjaxCrawlableAttribute : ActionFilterAttribute
    {
        private const string Fragment = "_escaped_fragment_";

        public override void OnActionExecuting(ActionExecutingContext filterContext)
        {
            var request = filterContext.RequestContext.HttpContext.Request;

            if (request.QueryString[Fragment] != null)
            {

                var url = request.Url.ToString().Replace("?_escaped_fragment_=", "#");

                filterContext.Result = new RedirectToRouteResult(
                    new RouteValueDictionary { { "controller", "HtmlSnapshot" }, { "action", "returnHTML" }, { "url", url } });
            }
            return;
        }
    }
}

Ceci est appelé depuis 'filterConfig.cs' également dans 'App_start':

using System.Web.Mvc;
using eShop.App_Start;

namespace eShop
{
    public class FilterConfig
    {
        public static void RegisterGlobalFilters(GlobalFilterCollection filters)
        {
            filters.Add(new HandleErrorAttribute());
            filters.Add(new AjaxCrawlableAttribute());
        }
    }
}

Comme vous pouvez le voir, 'AjaxCrawlableAttribute' achemine les URL laides vers un contrôleur nommé 'HtmlSnapshot', et voici ce contrôleur:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Web;
using System.Web.Mvc;

namespace eShop.Controllers
{
    public class HtmlSnapshotController : Controller
    {
        public ActionResult returnHTML(string url)
        {
            string appRoot = Path.GetDirectoryName(AppDomain.CurrentDomain.BaseDirectory);

            var startInfo = new ProcessStartInfo
            {
                Arguments = String.Format("{0} {1}", Path.Combine(appRoot, "seo\\createSnapshot.js"), url),
                FileName = Path.Combine(appRoot, "bin\\phantomjs.exe"),
                UseShellExecute = false,
                CreateNoWindow = true,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                RedirectStandardInput = true,
                StandardOutputEncoding = System.Text.Encoding.UTF8
            };
            var p = new Process();
            p.StartInfo = startInfo;
            p.Start();
            string output = p.StandardOutput.ReadToEnd();
            p.WaitForExit();
            ViewData["result"] = output;
            return View();
        }

    }
}

L'associé viewest très simple, juste une ligne de code:
@Html.Raw( ViewBag.result )
Comme vous pouvez le voir dans le contrôleur, phantom charge un fichier javascript nommé createSnapshot.jssous un dossier que j'ai créé appelé seo. Voici ce fichier javascript:

var page = require('webpage').create();
var system = require('system');

var lastReceived = new Date().getTime();
var requestCount = 0;
var responseCount = 0;
var requestIds = [];
var startTime = new Date().getTime();

page.onResourceReceived = function (response) {
    if (requestIds.indexOf(response.id) !== -1) {
        lastReceived = new Date().getTime();
        responseCount++;
        requestIds[requestIds.indexOf(response.id)] = null;
    }
};
page.onResourceRequested = function (request) {
    if (requestIds.indexOf(request.id) === -1) {
        requestIds.push(request.id);
        requestCount++;
    }
};

function checkLoaded() {
    return page.evaluate(function () {
        return document.all["compositionComplete"];
    }) != null;
}
// Open the page
page.open(system.args[1], function () { });

var checkComplete = function () {
    // We don't allow it to take longer than 5 seconds but
    // don't return until all requests are finished
    if ((new Date().getTime() - lastReceived > 300 && requestCount === responseCount) || new Date().getTime() - startTime > 10000 || checkLoaded()) {
        clearInterval(checkCompleteInterval);
        var result = page.content;
        //result = result.substring(0, 10000);
        console.log(result);
        //console.log(results);
        phantom.exit();
    }
}
// Let us check to see if the page is finished rendering
var checkCompleteInterval = setInterval(checkComplete, 300);

Je tiens d'abord à remercier Thomas Davis pour la page où j'ai obtenu le code de base :-).
Vous remarquerez quelque chose d'étrange ici: phantom continue de recharger la page jusqu'à ce que la checkLoaded()fonction retourne true. Pourquoi donc? c'est parce que mon SPA spécifique fait plusieurs appels AJAX pour obtenir toutes les données et les placer dans le DOM sur ma page, et phantom ne peut pas savoir quand tous les appels sont terminés avant de me renvoyer le reflet HTML du DOM. Ce que j'ai fait ici, c'est après le dernier appel AJAX, j'ajoute un <span id='compositionComplete'></span>, de sorte que si cette balise existe, je sache que le DOM est terminé. Je fais cela en réponse à l' compositionCompleteévénement de Durandal , voir icipour plus. Si cela ne se produit pas dans les 10 secondes, j'abandonne (cela ne devrait prendre qu'une seconde au maximum). Le HTML renvoyé contient tous les liens que l'utilisateur voit dans le navigateur. Le script ne fonctionnera pas correctement car les <script>balises qui existent dans l'instantané HTML ne référencent pas la bonne URL. Cela peut également être modifié dans le fichier fantôme javascript, mais je ne pense pas que ce soit nécessaire car le snapshort HTML est uniquement utilisé par Google pour obtenir les aliens et non pour exécuter javascript; ces liens font référence à une jolie URL, et si en fait, si vous essayez de voir l'instantané HTML dans un navigateur, vous obtiendrez des erreurs javascript mais tous les liens fonctionneront correctement et vous dirigeront à nouveau vers le serveur avec une jolie URL cette fois obtenir la page entièrement fonctionnelle.
Ça y est. Maintenant, le serveur sait comment gérer à la fois les URL jolies et laides, avec l'état push activé sur le serveur et le client. Toutes les URL laides sont traitées de la même manière en utilisant un fantôme, il n'est donc pas nécessaire de créer un contrôleur distinct pour chaque type d'appel.
Une chose que vous pourriez préférer le changement est de ne pas faire une « catégorie / sous / produit » appel général , mais d'ajouter un « magasin » de sorte que le lien ressemblera à quelque chose comme: http://www.xyz.com/store/category/subCategory/product111. Cela évitera le problème dans ma solution que toutes les URL invalides sont traitées comme si elles étaient en fait des appels au contrôleur `` index '', et je suppose que celles-ci peuvent être traitées ensuite dans le contrôleur `` magasin '' sans l'ajout de celui que web.configj'ai montré ci-dessus .

radieux
la source
J'ai une question rapide, je pense que cela fonctionne maintenant, mais lorsque je soumets mon site à google et que je donne des liens vers google, des plans de site, etc., dois-je donner google mysite.com/# ! ou juste mysite.com et google ajoutera le fragment escaped_fragment parce que je l'ai dans la balise meta?
ccorrin
ccorrin - à ma connaissance, vous n'avez rien à donner à Google; Le bot de google trouvera votre site et y recherchera de jolies URL (n'oubliez pas dans la page d'accueil d'ajouter également la balise meta, car elle ne peut contenir aucune URL). l'URL laide contenant le fragment escaped_fragment est toujours ajoutée uniquement par Google - vous ne devriez jamais la mettre vous-même dans vos HTML. et merci pour le soutien :-)
beamish
merci Bjorn & Sandra :-) Je travaille sur une meilleure version de ce document, qui comprendra également des informations sur la façon de mettre en cache les pages afin de rendre le processus plus rapide et de le faire dans l'utilisation plus courante où l'url contient le le nom du contrôleur; Je le
posterai
C'est une excellente explication !!. Je l'ai implémenté et fonctionne à merveille dans ma devbox localhost. Le problème est lors du déploiement sur les sites Web Azure, car le site se fige et après un certain temps, j'obtiens une erreur 502. Avez-vous une idée sur la façon de déployer phantomjs sur Azure ?? ... Merci ( testypv.azurewebsites.net/?_escaped_fragment_=home/about )
yagopv
Je n'ai aucune expérience avec les sites Web Azure, mais ce qui me vient à l'esprit est que peut-être que le processus de vérification du chargement complet de la page n'est jamais terminé, de sorte que le serveur continue d'essayer de recharger la page encore et encore sans succès. c'est peut-être là que réside le problème (même s'il y a une limite de temps pour ces vérifications, donc il se peut que ce ne soit pas là)? essayez de mettre «retour vrai»; comme première ligne dans 'checkLoaded ()' et voyez si cela fait une différence.
beamish
4

Voici un lien vers un enregistrement screencast de mon cours de formation Ember.js que j'ai organisé à Londres le 14 août. Il décrit une stratégie pour votre application côté client et pour votre application côté serveur, ainsi qu'une démonstration en direct de la façon dont la mise en œuvre de ces fonctionnalités fournira à votre application JavaScript à page unique une dégradation gracieuse même pour les utilisateurs avec JavaScript désactivé. .

Il utilise PhantomJS pour faciliter l'exploration de votre site Web.

En bref, les étapes requises sont:

  • Avoir une version hébergée de l'application Web que vous souhaitez explorer, ce site doit avoir TOUTES les données que vous avez en production
  • Ecrire une application JavaScript (PhantomJS Script) pour charger votre site web
  • Ajoutez index.html (ou "/") à la liste des URL à explorer
    • Pop la première URL ajoutée à la liste d'exploration
    • Charger la page et rendre son DOM
    • Recherchez des liens sur la page chargée qui renvoient à votre propre site (filtrage d'URL)
    • Ajoutez ce lien à une liste d'URL "explorables", si elles ne sont pas déjà explorées
    • Stockez le DOM rendu dans un fichier du système de fichiers, mais supprimez d'abord TOUTES les balises de script
    • À la fin, créez un fichier Sitemap.xml avec les URL explorées

Une fois cette étape terminée, il appartient à votre backend de diffuser la version statique de votre HTML dans le cadre de la balise noscript sur cette page. Cela permettra à Google et aux autres moteurs de recherche d'explorer chaque page de votre site Web, même si votre application est à l'origine une application à page unique.

Lien vers le screencast avec tous les détails:

http://www.devcasts.io/p/spas-phantomjs-and-seo/#

Joachim H. Skeie
la source
0

Vous pouvez utiliser ou créer votre propre service pour prerender votre SPA avec le service appelé prerender. Vous pouvez le vérifier sur son site Web prerender.io et sur son projet github (il utilise PhantomJS et il rend votre site Web pour vous).

C'est très facile de commencer. Il vous suffit de rediriger les requêtes des robots d'exploration vers le service et ils recevront le HTML rendu.

gabrielperales
la source
2
Bien que ce lien puisse répondre à la question, il est préférable d'inclure les parties essentielles de la réponse ici et de fournir le lien pour référence. Les réponses aux liens uniquement peuvent devenir invalides si la page liée change. - De l'avis
timgeb
2
Vous avez raison. J'ai mis à jour mon commentaire ... J'espère maintenant qu'il sera plus précis.
gabrielperales