如何使SPA SEO可抓取?

bea*_*ish 142 ajax seo phantomjs single-page-application durandal

我一直在研究如何根据谷歌的指示谷歌可以抓取SPA .尽管有很多一般性的解释,但我找不到更详尽的逐步教程和实际示例.完成此操作后,我想分享我的解决方案,以便其他人也可以使用它,并可能进一步改进它.
我使用MVCWebapi控制器和Phantomjs在服务器端,并迪朗达尔与客户端push-state启用; 我还使用Breezejs进行客户端 - 服务器数据交互,我强烈推荐所有这些,但我会尝试给出一个足够的解释,也可以帮助人们使用其他平台.

bea*_*ish 121

在开始之前,请确保您了解谷歌需要什么,特别是使用漂亮丑陋的 URL.现在让我们看一下实现:

客户端

在客户端,您只有一个html页面,它通过AJAX调用动态地与服务器交互.这就是SPA的意义所在.a客户端中的所有标签都是在我的应用程序中动态创建的,稍后我们将看到如何在服务器中将这些链接显示为google的bot.每个此类a标记都需要能够pretty URLhref标记中包含一个标记,以便google的bot会抓取它.您不希望在href客户端点击它时使用该部件(即使您确实希望服务器能够解析它,我们稍后会看到),因为我们可能不希望加载新页面,只做一个AJAX调用,让一些数据显示在页面的一部分,并通过javascript更改URL(例如使用HTML5 pushstate或with Durandaljs).因此,我们既有href谷歌的属性,也有onclick用户点击链接时的工作.现在,因为我使用push-state我不想#在URL上,所以典型的a标签可能看起来像这样:
<a href="http://www.xyz.com/#!/category/subCategory/product111" onClick="loadProduct('category','subCategory','product111')>see product111...</a>

'category'和'subCategory'可能是其他短语,例如'communication'和'phones'或'computers'和'笔记本电脑'的电器商店.显然会有许多不同的类别和子类别.如您所见,链接直接指向类别,子类别和产品,而不是特定"商店"页面的额外参数,例如http://www.xyz.com/store/category/subCategory/product111.这是因为我更喜欢更短更简单的链接.这意味着我不会有一个与我的"页面"名称相同的类别,即"关于".
我不会介绍如何通过AJAX(onclick部分)加载数据,在谷歌上搜索,有很多很好的解释.我想要提到的唯一重要的事情是,当用户点击此链接时,我希望浏览器中的URL看起来像这样:
http://www.xyz.com/category/subCategory/product111.这是URL不发送到服务器!记住,这是一个SPA,客户端和服务器之间的所有交互都是通过AJAX完成的,根本没有链接!所有"页面"都在客户端实现,不同的URL不会调用服务器(服务器确实需要知道如何处理这些URL,以防它们被用作从另一个站点到您站点的外部链接,我们稍后会在服务器端部分看到).现在,Durandal精彩地处理了这个问题.我强烈推荐它,但如果您更喜欢其他技术,也可以跳过此部分.如果您选择它,并且您也使用像我这样的MS Visual Studio Express 2012 for Web,您可以安装Durandal入门套件,在那里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 });
        }
    };
});
Run Code Online (Sandbox Code Playgroud)

这里有一些重要的事情需要注意:

  1. 第一条路线(with route:'')用于没有额外数据的URL,即http://www.xyz.com.在此页面中,您可以使用AJAX加载常规数据.a此页面中实际上可能根本没有标签.您将需要添加以下标记,以便谷歌的机器人知道如何处理它:
    <meta name="fragment" content="!">.此标记会让谷歌的机器人转换为www.xyz.com?_escaped_fragment_=我们稍后会看到的网址.
  2. "关于"路线只是您在Web应用程序中可能需要的其他"页面"链接的示例.
  3. 现在,棘手的部分是没有"类别"路线,并且可能有许多不同的类别 - 其中没有一个具有预定义的路线.这就是它的mapUnknownRoutes用武之地.它将这些未知路线映射到"商店"路线,并删除任何"!" 来自URL,以防它是pretty URL谷歌的搜索引擎生成的.'store'路径获取'fragment'属性中的信息,并进行AJAX调用以获取数据,显示数据并在本地更改URL.在我的应用程序中,我没有为每个这样的调用加载不同的页面; 我只更改了与此数据相关的页面部分,并在本地更改了URL.
  4. 请注意,pushState:true它指示Durandal使用推送状态URL.

这就是我们在客户端所需要的一切.它也可以用散列URL实现(在Durandal中你可以简单地删除pushState:true它).更复杂的部分(至少对我而言......)是服务器部分:

服务器端

MVC 4.5在服务器端使用WebAPI控制器.服务器实际上需要处理3种类型的URL:谷歌生成的URL - 两者pretty以及ugly"简单"URL,其格式与客户端浏览器中显示的URL相同.让我们看看如何做到这一点:

漂亮的URL和"简单的"URL首先被服务器解释为好像试图引用一个不存在的控制器.服务器看到类似的内容http://www.xyz.com/category/subCategory/product111并查找名为"category"的控制器.因此,在web.config我添加以下行以将这些行重定向到特定的错误处理控制器:

<customErrors mode="On" defaultRedirect="Error">
    <error statusCode="404" redirect="Error" />
</customErrors><br/>
Run Code Online (Sandbox Code Playgroud)

现在,这会将URL转换为:http://www.xyz.com/Error?aspxerrorpath=/category/subCategory/product111.我希望将URL发送到将通过AJAX加载数据的客户端,因此这里的技巧是调用默认的"索引"控制器,就像没有引用任何控制器一样; 我通过在所有'category'和'subCategory'参数之前向URL 添加哈希来做到这一点; 散列URL不需要任何特殊控制器,除了默认的"索引"控制器,数据被发送到客户端,然后客户端删除散列并使用散列后的信息通过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;
        }
    }
}
Run Code Online (Sandbox Code Playgroud)


丑陋的URL怎么样?这些是由google的bot创建的,应该返回包含用户在浏览器中看到的所有数据的纯HTML.为此我使用phantomjs.Phantom是一个无头浏览器,可以在客户端执行浏览器操作 - 但在服务器端.换句话说,幻影知道(除其他事项外)如何通过URL获取网页,解析它包括运行其中的所有javascript代码(以及通过AJAX调用获取数据),并返回反映的HTML DOM.如果您使用的是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;
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

这也是从'app_start'中的'filterConfig.cs'调用的:

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());
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

如您所见,'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();
        }

    }
}
Run Code Online (Sandbox Code Playgroud)

关联view非常简单,只需要一行代码:
@Html.Raw( ViewBag.result )
正如您在控制器中看到的,幻像加载了一个名为createSnapshot.js我创建的文件夹下的javascript文件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);
Run Code Online (Sandbox Code Playgroud)

我首先要感谢Thomas Davis,我从中获得了基本代码的页面:-).
你会发现一些奇怪的事情:幻像不断重新加载页面,直到checkLoaded()函数返回true.这是为什么?这是因为我的特定SPA进行了几次AJAX调用以获取所有数据并将其放在我页面上的DOM中,并且幻像无法知道所有调用何时完成,然后再返回DOM的HTML反射.我在这里做的是在我添加a的最终AJAX调用之后<span id='compositionComplete'></span>,如果这个标签存在,我知道DOM已经完成.我这样做是为了回应Durandal的compositionComplete活动,请点击此处了解更多信息.如果在10秒钟内没有发生这种情况,我会放弃(最多只需要一秒钟).返回的HTML包含用户在浏览器中看到的所有链接.该脚本无法正常运行,因为<script>HTML快照中存在的标记不会引用正确的URL.这也可以在javascript幻像文件中更改,但我不认为这是必要的,因为HTML snapshort只用于谷歌获取a链接而不是运行javascript; 这些链接确实引用了一个漂亮的URL,如果你试图在浏览器中看到HTML快照,你会得到javascript错误,但是所有链接都能正常工作,并且这次使用漂亮的URL再次引导你到服务器获得完整工作的页面.
就是这个.现在,服务器知道如何处理漂亮和丑陋的URL,并在服务器和客户端上启用了推送状态.所有丑陋的URL都使用幻像以相同的方式处理,因此不需要为每种类型的调用创建单独的控制器.
您可能更喜欢改变的一件事是不要进行一般的'category/subCategory/product'调用,而是添加一个'store',以便链接看起来像:http://www.xyz.com/store/category/subCategory/product111.这将避免我的解决方案中的问题,即所有无效的URL都被视为实际上是对"索引"控制器的调用,并且我认为这些可以在"存储"控制器中处理,而无需添加到web.config上面显示的I .


Edw*_*san 32

Google现在能够呈现SPA页面: 弃用我们的AJAX抓取方案