Come rendere un crawler SEO SPA?


143

Ho lavorato su come rendere una SPA crawlabile da google in base alle istruzioni di google . Anche se ci sono alcune spiegazioni generali, non sono riuscito a trovare da nessuna parte un tutorial passo-passo più completo con esempi concreti. Dopo aver terminato questa operazione, vorrei condividere la mia soluzione in modo che anche altri possano utilizzarla e possibilmente migliorarla ulteriormente.
Sto usando MVCcon Webapicontroller e Phantomjs sul lato server e Durandal sul lato client con push-stateabilitato; Uso Breezejs anche per l'interazione dei dati client-server, che consiglio vivamente, ma cercherò di dare una spiegazione abbastanza generale che aiuterà anche le persone che usano altre piattaforme.


40
per quanto riguarda "off topic" - un programmatore di app web deve trovare un modo per rendere la sua app crawlabile per il SEO, questo è un requisito di base sul web. Fare ciò non riguarda la programmazione in sé, ma è rilevante per il tema dei "problemi pratici e di risposta che sono unici per la professione di programmazione" come descritto in stackoverflow.com/help/on-topic . È un problema per molti programmatori senza soluzioni chiare su tutto il Web. Speravo di aiutare gli altri e ho investito ore a descriverlo qui, ottenere punti negativi non mi motiva di nuovo ad aiutare.
raggiante

3
Se l'enfasi è sulla programmazione e non sull'olio di serpente / salsa segreta SEO voodoo / spam, allora può essere perfettamente attuale. Ci piacciono anche le risposte automatiche laddove hanno il potenziale per essere utili ai futuri lettori a lungo termine. Questa coppia di domande e risposte sembra superare entrambi questi test. (Alcuni dettagli di base potrebbero approfondire meglio la domanda piuttosto che essere introdotti nella risposta, ma questo è abbastanza secondario)
Flexo

6
+1 per mitigare i voti negativi. Indipendentemente se q / a sarebbe più adatto come post sul blog, la domanda è rilevante per Durandal e la risposta è ben studiata.
RainerAtSpirit,

2
Concordo sul fatto che la SEO è una parte importante al giorno d'oggi della vita quotidiana degli sviluppatori e dovrebbe sicuramente essere considerata un argomento nello stackoverflow!
Kim D.

Oltre a implementare l'intero processo da solo, puoi provare SnapSearch snapsearch.io che sostanzialmente affronta questo problema come servizio.
CMCDragonkai,

Risposte:


121

Prima di iniziare, assicurati di capire cosa richiede Google , in particolare l'uso di URL belli e brutti . Ora vediamo l'implementazione:

Dalla parte del cliente

Sul lato client hai solo una singola pagina html che interagisce dinamicamente con il server tramite chiamate AJAX. ecco di cosa parla SPA. Tutti i atag sul lato client vengono creati dinamicamente nella mia applicazione, vedremo in seguito come rendere questi collegamenti visibili al bot di Google nel server. Ognuno di tali aesigenze tag siano in grado di avere una pretty URLnel hreftag in modo che il bot di Google esegue la scansione di esso. Non vuoi che la hrefparte venga utilizzata quando il client fa clic su di essa (anche se desideri che il server sia in grado di analizzarla, lo vedremo più avanti), perché potremmo non voler caricare una nuova pagina, solo per effettuare una chiamata AJAX ottenendo alcuni dati da visualizzare in una parte della pagina e modificare l'URL tramite javascript (ad esempio utilizzando HTML5 pushstateo con Durandaljs). Quindi, abbiamo entrambi unhrefattributo per google e su onclickquale svolge il lavoro quando l'utente fa clic sul collegamento. Ora, poiché uso push-statenon ne voglio alcuno #sull'URL, quindi un tipico atag potrebbe apparire così:
<a href="http://www.xyz.com/#!/category/subCategory/product111" onClick="loadProduct('category','subCategory','product111')>see product111...</a>

'categoria' e 'sottoCategoria' probabilmente sarebbero altre frasi, come 'comunicazione' e 'telefoni' o 'computer' e "laptop" per un negozio di elettrodomestici. Ovviamente ci sarebbero molte diverse categorie e sottocategorie. Come puoi vedere, il link è direttamente alla categoria, alla sottocategoria e al prodotto, non come parametri extra a una specifica pagina 'store' come http://www.xyz.com/store/category/subCategory/product111. Questo perché preferisco collegamenti più brevi e più semplici. Implica che non ci sarà una categoria con lo stesso nome di una delle mie "pagine", cioè "
Non entrerò nel modo in cui caricare i dati tramite AJAX (la onclickparte), cercarli su google, ci sono molte buone spiegazioni. L'unica cosa importante qui che voglio menzionare è che quando l'utente fa clic su questo link, voglio che l'URL nel browser assomigli a questo:
http://www.xyz.com/category/subCategory/product111. E questo è l'URL non viene inviato al server! ricorda, questa è una SPA in cui tutta l'interazione tra client e server avviene tramite AJAX, nessun collegamento! tutte le "pagine" sono implementate sul lato client e il diverso URL non effettua una chiamata al server (il server deve sapere come gestire questi URL nel caso in cui vengano utilizzati come collegamenti esterni da un altro sito al tuo sito, lo vedremo più avanti nella parte lato server). Ora, questo è gestito meravigliosamente da Durandal. Lo consiglio vivamente, ma puoi anche saltare questa parte se preferisci altre tecnologie. Se lo scegli, e stai anche utilizzando MS Visual Studio Express 2012 per Web come me, puoi installare Durandal Starter Kit e lì, in shell.js, utilizzare qualcosa del genere:

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 });
        }
    };
});

Ci sono alcune cose importanti da notare qui:

  1. Il primo percorso (con route:'') è per l'URL che non contiene dati extra, ad es http://www.xyz.com. In questa pagina carichi i dati generali usando AJAX. In aquesta pagina potrebbero non esserci tag. Si vuole aggiungere il seguente tag in modo che il bot di Google saprà che cosa fare con esso:
    <meta name="fragment" content="!">. Questo tag consentirà al bot di Google di trasformare l'URL in www.xyz.com?_escaped_fragment_=cui vedremo più avanti.
  2. Il percorso 'about' è solo un esempio di un collegamento ad altre 'pagine' che potresti desiderare sulla tua applicazione web.
  3. Ora, la parte difficile è che non esiste un percorso 'categoria' e ci possono essere molte categorie diverse, nessuna delle quali ha un percorso predefinito. È qui che mapUnknownRoutesentra in gioco. Mappa questi percorsi sconosciuti sul percorso 'store' e rimuove anche qualsiasi '!' dall'URL nel caso sia pretty URLgenerato dal motore di ricerca di Google. Il percorso 'store' prende le informazioni nella proprietà 'fragment' ed effettua la chiamata AJAX per ottenere i dati, visualizzarli e modificare l'URL localmente. Nella mia applicazione, non carico una pagina diversa per ogni chiamata di questo tipo; Cambio solo la parte della pagina in cui questi dati sono rilevanti e cambio anche l'URL localmente.
  4. Si noti pushState:trueche indica a Durandal di utilizzare gli URL di stato push.

Questo è tutto ciò di cui abbiamo bisogno nel lato client. Può essere implementato anche con URL con hash (in Durandal è sufficiente rimuoverlo pushState:trueper quello). La parte più complessa (almeno per me ...) era la parte del server:

Lato server

Sto usando MVC 4.5sul lato server con i WebAPIcontroller. Il server è in realtà ha bisogno di gestire 3 tipi di URL: quelli generati da Google - sia prettyed uglye anche un URL 'semplice' con lo stesso formato di quello che appare nel browser del client. Vediamo come fare:

Gli URL graziosi e quelli "semplici" vengono prima interpretati dal server come se provassero a fare riferimento a un controller inesistente. Il server vede qualcosa di simile http://www.xyz.com/category/subCategory/product111e cerca un controller chiamato 'categoria'. Quindi web.configaggiungo la seguente riga per reindirizzarli a un controller di gestione degli errori specifico:

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

Ora, questo trasforma l'URL a qualcosa come: http://www.xyz.com/Error?aspxerrorpath=/category/subCategory/product111. Voglio che l'URL venga inviato al client che caricherà i dati tramite AJAX, quindi il trucco qui è chiamare il controller 'index' predefinito come se non si riferisse a nessun controller; Lo faccio aggiungendo un hash all'URL prima di tutti i parametri "categoria" e "sottocategoria"; l'URL con hash non richiede alcun controller speciale tranne il controller "indice" predefinito e i dati vengono inviati al client che quindi rimuove l'hash e utilizza le informazioni dopo l'hash per caricare i dati tramite AJAX. Ecco il codice del controller del gestore errori:

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;
        }
    }
}


Ma per quanto riguarda gli URL brutti ? Questi sono creati dal bot di Google e dovrebbero restituire un semplice HTML che contiene tutti i dati che l'utente vede nel browser. Per questo uso phantomjs . Phantom è un browser senza testa che fa ciò che fa il browser sul lato client, ma sul lato server. In altre parole, il fantasma sa (tra le altre cose) come ottenere una pagina Web tramite un URL, analizzarla includendo tutto il codice javascript in essa contenuto (nonché ottenere dati tramite chiamate AJAX) e restituirti l'HTML che riflette il DOM. Se si utilizza MS Visual Studio Express, molti desiderano installare Phantom tramite questo collegamento .
Ma prima, quando un brutto URL viene inviato al server, dobbiamo prenderlo; Per questo, ho aggiunto alla cartella 'App_start' il seguente file:

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;
        }
    }
}

Questo è chiamato da 'filterConfig.cs' anche in '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());
        }
    }
}

Come puoi vedere, "AjaxCrawlableAttribute" indirizza brutti URL a un controller chiamato "HtmlSnapshot", ed ecco questo controller:

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'associato viewè molto semplice, solo una riga di codice:
@Html.Raw( ViewBag.result )
come puoi vedere nel controller, phantom carica un file javascript chiamato createSnapshot.jssotto una cartella che ho creato chiamato seo. Ecco questo file 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);

Voglio prima ringraziare Thomas Davis per la pagina da cui ho ricevuto il codice di base da :-).
Noterai qualcosa di strano qui: phantom continua a ricaricare la pagina fino a quando la checkLoaded()funzione non ritorna vera. Perché? questo perché la mia SPA specifica effettua diverse chiamate AJAX per ottenere tutti i dati e inserirli nel DOM sulla mia pagina, e il fantasma non può sapere quando tutte le chiamate sono state completate prima di restituirmi il riflesso HTML del DOM. Quello che ho fatto qui è dopo l'ultima chiamata AJAX aggiungo un <span id='compositionComplete'></span>, in modo che se esiste questo tag so che il DOM è completato. Lo faccio in risposta compositionCompleteall'evento di Durandal , vedi quiper più. Se ciò non accade entro 10 secondi, mi arrendo (dovrebbe impiegare solo un secondo per farlo). L'HTML restituito contiene tutti i collegamenti che l'utente vede nel browser. Lo script non funzionerà correttamente perché i <script>tag presenti nell'istantanea HTML non fanno riferimento all'URL corretto. Questo può essere modificato anche nel file phantom javascript, ma non credo sia necessario perché lo snapshot HTML viene utilizzato solo da Google per ottenere i acollegamenti e non eseguire javascript; questi collegamenti fanno riferimento a un URL grazioso e, in realtà, se provi a vedere l'istantanea HTML in un browser, otterrai errori javascript ma tutti i collegamenti funzioneranno correttamente e ti indirizzeranno di nuovo al server con un URL grazioso questa volta ottenere la pagina completamente funzionante.
Questo è. Ora il server sa come gestire URL belli e brutti, con push-state abilitato sia sul server che sul client. Tutti gli URL brutti vengono trattati allo stesso modo utilizzando Phantom, quindi non è necessario creare un controller separato per ogni tipo di chiamata.
Una cosa che si potrebbe preferire di cambiamento non è di fare chiamate un generale 'categoria / sottocategoria / prodotto', ma di aggiungere un 'negozio' in modo che il link sarà simile: http://www.xyz.com/store/category/subCategory/product111. Ciò eviterà il problema nella mia soluzione che tutti gli URL non validi vengano trattati come se fossero effettivamente chiamate al controller "indice" e suppongo che questi possano essere gestiti quindi all'interno del controller "store" senza l'aggiunta a quella web.configmostrata sopra .


Ho una domanda veloce, penso che ora abbia funzionato, ma quando invio il mio sito a google e do collegamenti a google, mappe del sito, ecc. Devo dare google mysite.com/# ! o semplicemente mysite.com e google aggiungeranno l' escaped_fragment perché ce l'ho nel meta tag?
ccorrin,

ccorrin: per quanto ne so, non è necessario fornire a Google nulla; il bot di google troverà il tuo sito e cercherà in esso degli URL piuttosto carini (non dimenticare nella home page di aggiungere anche il meta tag, in quanto potrebbe non contenere alcun URL). l'URL brutto contenente escaped_fragment viene sempre aggiunto solo da google: non dovresti mai inserirlo tu stesso nei tuoi HTML. e grazie per il supporto :-)
raggiante

grazie Bjorn & Sandra :-) Sto lavorando a una versione migliore di questo documento, che includerà anche informazioni su come memorizzare nella cache le pagine in modo da rendere il processo più veloce e farlo nell'uso più comune in cui l'URL contiene il nome del responsabile del trattamento; Lo
pubblicherò

Questa è un'ottima spiegazione !! L'ho implementato e funziona come un incantesimo nel mio devbox localhost. Il problema è durante la distribuzione su siti Web di Azure perché il sito si blocca e dopo un po 'ottengo un errore 502. Hai qualche idea su come distribuire phantomjs in Azure ?? ... Grazie ( testypv.azurewebsite.net/?_escaped_fragment_=home/about )
yagopv

Non ho esperienza con i siti Web di Azure, ma ciò che mi viene in mente è che forse il processo di controllo per il caricamento completo della pagina non viene mai completato, quindi il server continua a provare a ricaricare la pagina più volte senza successo. forse è qui che si trova il problema (anche se c'è un limite di tempo per questi controlli, quindi potrebbe non esserci)? prova a mettere "return true;" come prima riga in 'checkLoaded ()' e vedere se fa la differenza.
beato


4

Ecco un link a una registrazione screencast dal mio corso di formazione Ember.js che ho ospitato a Londra il 14 agosto. Descrive una strategia sia per l'applicazione lato client sia per l'applicazione lato server, oltre a fornire una dimostrazione dal vivo di come l'implementazione di queste funzionalità fornirà alla tua App singola pagina JavaScript un gradevole degrado anche per gli utenti con JavaScript disattivato .

Utilizza PhantomJS per facilitare la scansione del tuo sito Web.

In breve, i passaggi richiesti sono:

  • Avere una versione ospitata dell'applicazione Web che si desidera sottoporre a scansione, questo sito deve contenere TUTTI i dati che si hanno in produzione
  • Scrivi un'applicazione JavaScript (PhantomJS Script) per caricare il tuo sito web
  • Aggiungi index.html (o “/“) all'elenco di URL da sottoporre a scansione
    • Pop il primo URL aggiunto all'elenco di ricerca per indicizzazione
    • Carica pagina e renderizza il suo DOM
    • Trova eventuali collegamenti sulla pagina caricata che si collegano al tuo sito (filtro URL)
    • Aggiungi questo link a un elenco di URL "scansionabili", se non è già sottoposto a scansione
    • Memorizza il DOM renderizzato in un file sul file system, ma togli prima TUTTI i tag di script
    • Alla fine, crea un file Sitemap.xml con gli URL sottoposti a scansione

Una volta fatto questo passaggio, spetta al tuo back-end servire la versione statica del tuo HTML come parte del tag noscript in quella pagina. Ciò consentirà a Google e ad altri motori di ricerca di eseguire la scansione di ogni singola pagina del tuo sito Web, anche se in origine l'app è a pagina singola.

Link allo screencast con tutti i dettagli:

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


0

È possibile utilizzare o creare il proprio servizio per il prerender della SPA con il servizio chiamato prerender. Puoi verificarlo sul suo sito Web prerender.io e sul suo progetto github (usa PhantomJS e rende il tuo sito web per te).

È molto facile iniziare. Devi solo reindirizzare le richieste dei crawler al servizio e loro riceveranno l'html renderizzato.


2
Sebbene questo collegamento possa rispondere alla domanda, è meglio includere qui le parti essenziali della risposta e fornire il collegamento come riferimento. Le risposte di solo collegamento possono diventare non valide se la pagina collegata cambia. - Dalla recensione
timgeb

2
Hai ragione. Ho aggiornato il mio commento ... spero ora sia più preciso.
gabrielperales,

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.