Как сделать SPA SEO сканируемым?

143

Я работаю над тем, как сделать Google Crawlab доступным на основе инструкций Google . Несмотря на то, что есть довольно много общих объяснений, я нигде не смог найти более подробного пошагового руководства с реальными примерами. После этого я хотел бы поделиться своим решением, чтобы другие тоже могли его использовать и, возможно, улучшить.
Я использую MVCс Webapiконтроллерами и Phantomjs на стороне сервера, и Durandal на стороне клиента с push-stateвключенным; Я также использую Breezejs для взаимодействия данных между клиентом и сервером, что я настоятельно рекомендую, но я постараюсь дать достаточно общее объяснение, которое также поможет людям, использующим другие платформы.

Бимиш
источник
40
Что касается "не по теме" - программист веб-приложения должен найти способ, как сделать его / ее приложение пригодным для поиска для SEO, это основное требование в Интернете. Это не относится к программированию как таковому, но оно имеет отношение к теме «практических, ответственных проблем, уникальных для профессии программиста», как описано в stackoverflow.com/help/on-topic . Это проблема для многих программистов, у которых нет четких решений во всей сети. Я надеялся помочь другим и потратил часы на то, чтобы просто описать это здесь, получение отрицательных баллов, безусловно, не побуждает меня снова помогать.
лучистый
3
Если акцент делается на программирование, а не на змеиный жир / секретный соус SEO вуду / спам, то это может быть совершенно актуально. Нам также нравятся самостоятельные ответы, где они могут быть полезными для будущих читателей в долгосрочной перспективе. Эта пара вопросов и ответов, кажется, прошла оба этих теста. (Некоторые исходные данные могли бы лучше прояснить вопрос, а не вводиться в ответ, но это довольно незначительно)
Flexo
6
+1, чтобы уменьшить количество голосов. Независимо от того, лучше ли использовать q / a в качестве поста в блоге, вопрос относится к Дюрандалю, и ответ хорошо изучен.
RainerAtSpirit
2
Я согласен с тем, что SEO является важной частью повседневной жизни разработчиков и определенно должна рассматриваться как тема в stackoverflow!
Ким Д.
Помимо самостоятельной реализации всего процесса, вы можете попробовать SnapSearch snapsearch.io, который в основном решает эту проблему как сервис.
CMCDragonkai

Ответы:

121

Перед началом, пожалуйста, убедитесь, что вы понимаете, что требует Google , в частности, использование красивых и некрасивых URL-адреса. Теперь давайте посмотрим на реализацию:

Сторона клиента

На стороне клиента у вас есть только одна HTML-страница, которая динамически взаимодействует с сервером через вызовы AJAX. вот что такое SPA. Все aтеги на стороне клиента создаются динамически в моем приложении, позже мы увидим, как сделать эти ссылки видимыми для бота Google на сервере. Каждый такой aтег должен иметь возможность иметь тег pretty URLв hrefтеге, чтобы бот Google сканировал его. Вы не хотите, чтобы эта hrefчасть использовалась, когда клиент нажимает на нее (даже если вы действительно хотите, чтобы сервер мог ее проанализировать, мы увидим это позже), потому что мы можем не захотеть загружать новую страницу, только для вызова AJAX с получением некоторых данных для отображения на части страницы и изменения URL-адреса с помощью JavaScript (например,pushstateDurandaljs ). Итак, у нас естьhrefАтрибут для Google, а также на onclickкотором выполняет свою работу, когда пользователь нажимает на ссылку. Теперь, поскольку я использую, push-stateя не хочу ничего #в URL, поэтому типичный aтег может выглядеть так:
<a href="http://www.xyz.com/#!/category/subCategory/product111" onClick="loadProduct('category','subCategory','product111')>see product111...</a>

«category» и «subCategory», вероятно, будут другими фразами, такими как «связь» и «телефоны» или «компьютеры» и «ноутбуки» для магазина электротоваров. Очевидно, будет много разных категорий и подкатегорий. Как видите, ссылка непосредственно на категорию, подкатегорию и продукт, а не в качестве дополнительных параметров для конкретной страницы «магазина», такой как http://www.xyz.com/store/category/subCategory/product111. Это потому, что я предпочитаю более короткие и простые ссылки. Это означает, что у меня не будет категории с тем же именем, что и на одной из моих «страниц», т.е.
Я не буду вдаваться в то, как загрузить данные через AJAX ( onclickчасть), искать их в Google, есть много хороших объяснений. Единственная важная вещь, о которой я хочу упомянуть, это то, что когда пользователь нажимает на эту ссылку, я хочу, чтобы URL в браузере выглядел так:
http://www.xyz.com/category/subCategory/product111, И это URL не отправляется на сервер! помните, что это SPA, где все взаимодействие между клиентом и сервером осуществляется через AJAX, никаких ссылок вообще! все «страницы» реализованы на стороне клиента, и другой URL не вызывает сервер (сервер должен знать, как обрабатывать эти URL, если они используются в качестве внешних ссылок с другого сайта на ваш сайт, мы увидим это позже на стороне сервера). Теперь, с этим прекрасно справляется Дюрандаль. Я настоятельно рекомендую это сделать, но вы также можете пропустить эту часть, если предпочитаете другие технологии. Если вы выберете его, и вы также используете MS Visual Studio Express 2012 для Web, как я, вы можете установить Durandal Starter Kit и там shell.jsиспользовать что-то вроде этого:

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

Здесь следует отметить несколько важных вещей:

  1. Первый маршрут (с route:'') является для URL , который не имеет никаких дополнительных данных в нем, то есть http://www.xyz.com. На этой странице вы загружаете общие данные, используя AJAX. На aэтой странице вообще не может быть никаких тегов. Вы хотите , чтобы добавить следующий тег так бот Google будет знать , что делать с ним:
    <meta name="fragment" content="!">. Этот тег заставит бота Google преобразовать URL, www.xyz.com?_escaped_fragment_=который мы увидим позже.
  2. Маршрут «about» - это просто пример ссылки на другие «страницы», которые вы можете захотеть использовать в своем веб-приложении.
  3. Сложность в том, что нет маршрута категории, и может быть много разных категорий, ни у одной из которых нет заранее определенного маршрута. Вот где mapUnknownRoutesприходит. Он сопоставляет эти неизвестные маршруты с маршрутом 'store', а также удаляет любые '!' с URL на случай, если он pretty URLсгенерирован поисковым движком Google. Маршрут store хранит информацию в свойстве фрагмента и вызывает AJAX для получения данных, их отображения и локального изменения URL. В моем приложении я не загружаю разные страницы для каждого такого вызова; Я изменяю только ту часть страницы, где эти данные имеют отношение, а также меняю местный URL.
  4. Обратите внимание на то, pushState:trueчто Durandal указывает использовать URL-адреса push-состояний.

Это все, что нам нужно на стороне клиента. Это может быть реализовано также с хэшированными URL-адресами (в Durandal вы просто удаляете pushState:trueдля этого). Более сложной частью (по крайней мере для меня ...) была серверная часть:

Сторона сервера

Я использую MVC 4.5на стороне сервера с WebAPIконтроллерами. Сервер фактически должен обрабатывать 3 типа URL: сгенерированные google - оба, prettyа uglyтакже «простой» URL того же формата, что и тот, который отображается в браузере клиента. Давайте посмотрим, как это сделать:

Красивые и простые URL-адреса сначала интерпретируются сервером, как будто они пытаются сослаться на несуществующий контроллер. Сервер видит что-то подобное http://www.xyz.com/category/subCategory/product111и ищет контроллер с именем 'category'. Поэтому web.configя добавляю следующую строку, чтобы перенаправить их на конкретный контроллер обработки ошибок:

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

Теперь это превращает URL в что - то вроде: http://www.xyz.com/Error?aspxerrorpath=/category/subCategory/product111. Я хочу, чтобы URL-адрес отправлялся клиенту, который будет загружать данные через AJAX, поэтому хитрость здесь заключается в том, чтобы вызвать контроллер индекса по умолчанию, как если бы он не ссылался на какой-либо контроллер; Я делаю это, добавляя хеш к URL-адресу перед всеми параметрами 'category' и 'subCategory'; Для хешированного URL-адреса не требуется никакого специального контроллера, кроме контроллера по умолчанию «index», и данные отправляются клиенту, который затем удаляет хеш и использует информацию после хеша для загрузки данных через AJAX. Вот код контроллера обработчика ошибок:

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


Но как насчет уродливых URL ? Они создаются ботом Google и должны возвращать простой HTML, который содержит все данные, которые пользователь видит в браузере. Для этого я использую фантомы . Phantom - это безголовый браузер, который делает то же, что и браузер на стороне клиента, но на стороне сервера. Другими словами, фантом знает (среди прочего), как получить веб-страницу через URL-адрес, проанализировать ее, включая выполнение всего кода javascript (а также получение данных с помощью вызовов AJAX), и вернуть вам HTML, который отражает ДОМ. Если вы используете MS Visual Studio Express, многие хотят установить фантом по этой ссылке .
Но сначала, когда на сервер отправляется некрасивый URL, мы должны его перехватить; Для этого я добавил в папку «App_start» следующий файл:

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

Это вызывается из 'filterConfig.cs' также в '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());
        }
    }
}

Как вы можете видеть, AjaxCrawlableAttribute направляет некрасивые URL-адреса на контроллер с именем «HtmlSnapshot», и вот этот контроллер:

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

    }
}

Это viewочень просто, всего одна строка кода:
@Html.Raw( ViewBag.result )
как вы можете видеть в контроллере, фантом загружает файл javascript с именем createSnapshot.jsв созданной мной папке с именем seo. Вот этот файл 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);

Сначала я хочу поблагодарить Томаса Дэвиса за страницу, с которой я получил основной код :-).
Здесь вы заметите нечто странное: фантом продолжает перезагружать страницу, пока checkLoaded()функция не вернет true. Это почему? это потому, что мой специальный SPA делает несколько AJAX-вызовов, чтобы получить все данные и поместить их в DOM на моей странице, и фантом не может знать, когда все вызовы завершены, прежде чем вернуть мне обратно HTML-отражение DOM. То, что я сделал здесь, после последнего вызова AJAX, я добавляю a <span id='compositionComplete'></span>, так что если этот тег существует, я знаю, что DOM завершен. Я делаю это в ответ на compositionCompleteсобытие Дюрандаля , см. здесьдля большего. Если этого не произойдет в течение 10 секунд, я сдаюсь (это займет всего одну секунду, чтобы максимально). Возвращенный HTML-код содержит все ссылки, которые пользователь видит в браузере. Сценарий не будет работать должным образом, поскольку <script>теги, которые существуют в снимке HTML, не ссылаются на правильный URL-адрес. Это также может быть изменено в фантомном файле javascript, но я не думаю, что это необходимо, потому что моментальный снимок HTML используется Google только для получения aссылок, а не для запуска javascript; эти ссылки делают ссылаться на довольно URL, и если самом деле, если вы пытаетесь увидеть HTML снимок в браузере, вы получите JavaScript ошибки , но все ссылки будут работать должным образом и направить вас на сервер еще раз с симпатичной URL на этот раз получить полностью рабочую страницу.
это все. Теперь сервер знает, как обрабатывать как красивые, так и некрасивые URL, с включенным push-состоянием как на сервере, так и на клиенте. Все уродливые URL обрабатываются одинаково с использованием фантома, поэтому нет необходимости создавать отдельный контроллер для каждого типа вызова.
Одна вещь , которую вы могли бы предпочесть изменения не сделать созвать общее «категорию / подкатегорию / продукт» , но , чтобы добавить «магазин» так , что ссылка будет выглядеть примерно так: http://www.xyz.com/store/category/subCategory/product111. Это позволит избежать проблемы в моем решении, заключающейся в том, что все недопустимые URL-адреса обрабатываются так, как будто они на самом деле являются вызовами контроллера «index», и я предполагаю, что они могут быть обработаны затем в контроллере «store» без добавления к тому, что web.configя показал выше. ,

Бимиш
источник
У меня есть быстрый вопрос, я думаю, что теперь у меня это работает, но когда я отправляю свой сайт в Google и даю ссылки на Google, карты сайта и т. Д., Мне нужно предоставить google mysite.com/# ! или просто mysite.com и гугл добавят в escaped_fragment, потому что он у меня есть в метатеге?
Коркорен
ccorrin - насколько мне известно, вам не нужно ничего давать Google; Бот Google найдет ваш сайт и поищет в нем красивые URL-адреса (не забудьте на домашней странице добавить метатег, так как он может не содержать никаких URL-адресов). уродливый URL, содержащий escaped_fragment, всегда добавляется только Google - вы никогда не должны помещать его сами в свои HTML-коды. и спасибо за поддержку :-)
балансирует
спасибо Bjorn & Sandra :-) Я работаю над улучшенной версией этого документа, которая также будет включать информацию о том, как кэшировать страницы, чтобы ускорить процесс и сделать это в более частом использовании, где URL содержит имя контролера; Я
опубликую
Это отличное объяснение !! Я реализовал это и работает как шарм в моем локальном devbox. Проблема заключается в развертывании на веб-сайтах Azure, потому что сайт зависает, и через некоторое время я получаю ошибку 502. Есть ли у вас какие-либо идеи о том, как развернуть phantomjs в Azure ?? ... Спасибо ( testypv.azurewebsites.net/?_escaped_fragment_=home/about )
yagopv
У меня нет опыта работы с веб-сайтами Azure, но мне приходит в голову то, что, возможно, процесс проверки загрузки страницы никогда не выполняется, поэтому сервер продолжает пытаться перезагрузить страницу снова и снова, но безуспешно. возможно, в этом проблема (даже если для этих проверок существует ограничение по времени, поэтому их может и не быть)? попробуйте поставить «вернуть истину»; как первая строка в 'checkLoaded ()' и посмотреть, если это имеет значение.
лучистый
4

Вот ссылка на скринкаст с моего учебного класса Ember.js, который я провел в Лондоне 14 августа. В нем изложена стратегия как для вашего клиентского приложения, так и для вашего серверного приложения, а также дается живая демонстрация того, как реализация этих функций обеспечит вашему одностраничному приложению JavaScript постепенную деградацию даже для пользователей с отключенным JavaScript ,

Он использует PhantomJS, чтобы помочь в сканировании вашего сайта.

Короче говоря, необходимые шаги:

  • Если у вас есть размещаемая версия веб-приложения, которое вы хотите сканировать, на этом сайте должны быть ВСЕ данные, имеющиеся у вас в работе.
  • Напишите приложение на JavaScript (PhantomJS Script) для загрузки вашего сайта
  • Добавьте index.html (или «/») в список URL для сканирования
    • Вставьте первый URL, добавленный в список сканирования
    • Загрузить страницу и отобразить ее DOM
    • Найдите любые ссылки на загруженной странице, которые ссылаются на ваш собственный сайт (фильтрация URL)
    • Добавьте эту ссылку в список «просматриваемых» URL, если она еще не просканирована
    • Сохраните обработанный DOM в файл в файловой системе, но сначала удалите ВСЕ скриптовые теги
    • В конце создайте файл Sitemap.xml с просканированными URL

После того, как этот шаг будет выполнен, ваш сервер сможет использовать статическую версию HTML как часть тега noscript на этой странице. Это позволит Google и другим поисковым системам сканировать каждую страницу на вашем сайте, даже если ваше приложение изначально представляет собой приложение на одну страницу.

Ссылка на скринкаст с полной информацией:

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

Йоахим Х. Скей
источник
0

Вы можете использовать или создать свой собственный сервис для предоплаты вашего SPA с помощью сервиса под названием prerender. Вы можете проверить это на его сайте prerender.io и в его проекте github. (он использует PhantomJS и обновляет ваш сайт для вас).

Это очень легко начать с. Вам нужно только перенаправить запросы сканерам в службу, и они получат визуализированный html.

gabrielperales
источник
2
Хотя эта ссылка может ответить на вопрос, лучше включить сюда основные части ответа и предоставить ссылку для справки. Ответы, содержащие только ссылки, могут стать недействительными, если связанная страница изменится. - Из Обзора
Timgeb
2
Ты прав. Я обновил свой комментарий ... Надеюсь, теперь он будет более точным.
gabrielperales
0

Вы можете использовать http://sparender.com/, что позволяет правильно сканировать одностраничные приложения.

ddtxra
источник