如何使SPA SEO可抓取?


143

我一直在研究如何根据Google的说明使SPA可被Google抓取。即使有很多一般性的解释,我也找不到任何包含实际示例的更详尽的分步教程。完成此操作后,我想分享我的解决方案,以便其他人也可以使用它,并可能进一步改进它。
我使用MVCWebapi控制器和Phantomjs在服务器端,并迪朗达尔与客户端push-state启用; 我还使用Breezejs进行客户端-服务器数据交互,我强烈建议所有这些操作,但是我将尝试给出足够概括的解释,这也将有助于使用其他平台的人们。


40
有关“主题外”的问题-Web应用程序程序员必须找到一种方法来使他/她的应用程序可抓取以进行SEO,这是Web上的基本要求。这样做本身与编程无关,而是与stackoverflow.com/help/on-topic中所述的“编程专业人员特有的实用,可解答的问题”相关。对于许多在整个网络上都没有明确解决方案的程序员来说,这是一个问题。我希望能帮助别人,并花了几个小时在这里进行描述,得到负面的肯定不会激励我再次提供帮助。
2013年

3
如果重点是编程,而不是蛇油/秘制酱SEO伏都教/垃圾邮件,那么它绝对是热门话题。我们也喜欢自我回答,因为它们有可能长期对未来的读者有用。这对问答似乎同时通过了这两个测试。(某些背景细节可能会比在答案中介绍的内容更好地充实问题,但这是次要的)
Flexo

6
+1以减少不赞成票。无论q / a是否更适合作为博客文章,该问题都与Durandal有关,并且答案得到了很好的研究。
RainerAtSpirit

2
我同意SEO是当今开发人员日常生活的重要组成部分,绝对应该将其视为Stackoverflow中的主题!
金D.

除了自己实现整个过程之外,您还可以尝试SnapSearch snapsearch.io,该服务基本上可以解决此问题。
CMCDragonkai 2014年

Answers:


121

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

客户端

在客户端,您只有一个HTML页面,该页面通过AJAX调用与服务器动态交互。这就是SPA的目的。a客户端中的所有标记都是在我的应用程序中动态创建的,稍后我们将看到如何使这些链接对服务器中的Google bot可见。每个此类a标签都必须能够pretty URLhref标签中包含,以便Google的漫游器抓取它。您不希望该href部分在客户端单击时使用(即使您确实希望服务器能够对其进行解析,但我们稍后会看到),因为我们可能不希望加载新页面,只是进行AJAX调用,以使某些数据显示在页面的一部分中,并通过javascript(例如,使用HTML5 pushstateDurandaljs)更改URL 。因此,我们都有hrefgoogle的属性,以及onclick当用户单击链接时执行的工作。现在,由于使用了URL,因此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部分)加载数据,在Google上搜索它,这里有很多很好的解释。我要在此提及的唯一重要的事情是,当用户单击此链接时,我希望浏览器中的URL如下所示:
http://www.xyz.com/category/subCategory/product111。而且这是URL未发送到服务器!请记住,这是一个SPA,其中客户端和服务器之间的所有交互都是通过AJAX完成的,根本没有链接!所有“页面”都是在客户端实现的,并且不同的URL不会调用服务器(服务器确实需要知道如何处理这些URL,以防它们用作从另一个站点到您站点的外部链接,我们稍后会在服务器端看到)。现在,杜兰达尔(Durandal)很好地处理了这一问题。我强烈建议您这样做,但是如果您喜欢其他技术,也可以跳过此部分。如果您选择了它,并且还像我一样使用MS Visual Studio Express 2012 for 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_=以后将看到的URL 。
  2. “关于”路线只是指向您在Web应用程序上可能需要的其他“页面”链接的示例。
  3. 现在,棘手的部分是没有“类别”路线,并且可能有很多不同的类别-没有一个具有预定义的路线。这就是其中的mapUnknownRoutes来源。它将这些未知路由映射到“商店”路由,并删除所有“!” 如果pretty URL是Google的搜索引擎生成的,则从URL中获取。“存储”路由在“ fragment”属性中获取信息,并进行AJAX调用以获取数据,显示数据并在本地更改URL。在我的应用程序中,我不会为每个此类调用加载不同的页面。我只更改页面中与该数据相关的部分,并在本地更改URL。
  4. 请注意,pushState:true该指令指示Durandal使用推送状态URL。

这就是我们在客户端需要的。它也可以使用哈希URL来实现(在Durandal中,您可以简单地删除pushState:true它)。更复杂的部分(至少对我来说...)是服务器部分:

服务器端

MVC 4.5在服务器端使用WebAPI控制器。服务器实际上需要处理3种类型的URL:由google生成的URL — pretty以及由URL生成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/>

现在,这会将URL转换为:http://www.xyz.com/Error?aspxerrorpath=/category/subCategory/product111。我希望将URL发送到将通过AJAX加载数据的客户端,因此这里的技巧是调用默认的“索引”控制器,就好像未引用任何控制器一样。我通过在所有“类别”和“ subCategory”参数之前在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;
        }
    }
}


但是丑陋的网址呢?这些是由Google的漫游器创建的,应返回纯HTML,其中包含用户在浏览器中看到的所有数据。为此,我使用phantomjs。Phantom是一种无头浏览器,它在客户端-但在服务器端执行浏览器的工作。换句话说,phantom知道(其中包括)如何通过URL获取网页,解析包括运行其中的所有javascript代码(以及通过AJAX调用获取数据)以及如何返回反映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;
        }
    }
}

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

如您所见,“ 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 )
正如您在控制器中看到的那样,幻影会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);

首先,我要感谢Thomas Davis提供的页面:-)。
您会在这里注意到一些奇怪的现象:phantom不断重新加载页面,直到checkLoaded()函数返回true。这是为什么?这是因为我的特定SPA进行了几次AJAX调用以获取所有数据并将其放置在页面上的DOM中,而phantom在返回DOM的HTML反射之前无法知道所有调用何时完成。我在这里做的是在最后一个AJAX调用之后添加了<span id='compositionComplete'></span>,这样,如果存在此标记,我就知道DOM已完成。我这样做是为了响应杜兰达尔的compositionComplete活动,请参见此处更多。如果在10秒钟内仍没有发生这种情况,我会放弃(最多应该花一秒钟)。返回的HTML包含用户在浏览器中看到的所有链接。该脚本将无法正常运行,因为<script>HTML快照中确实存在的标记未引用正确的URL。这也可以在javascript幻象文件中更改,但是我认为这不是必需的,因为HTML snapshort仅由Google用于获取a链接而不是运行javascript;这些链接确实引用了漂亮的URL,如果实际上,如果您尝试在浏览器中查看HTML快照,则会收到JavaScript错误,但所有链接都将正常工作,并再次使用漂亮的URL将您定向到服务器获取完整的工作页面。
就是这个。现在,服务器知道如何处理漂亮的URL和丑陋的URL,并且在服务器和客户端上都启用了推状态。使用幻像对所有丑陋的URL进行相同的处理,因此无需为每种呼叫类型创建单独的控制器。
您可能希望更改的一件事不是发出常规的“ category / subCategory / product”调用,而是添加“ store”,以便链接看起来像:http://www.xyz.com/store/category/subCategory/product111。这样可以避免我的解决方案出现的问题,即所有无效的URL都将被视为实际上是对“索引”控制器的调用,并且我认为这些URL可以在“存储”控制器中进行处理,而无需添加web.config上面显示的内容。 。


我有一个快速的问题,我想我可以立即执行此操作,但是当我将网站提交给Google并提供指向Google的链接,站点地图等时,我需要给google mysite.com/#!还是仅mysite.com和google会在metad标签中添加escaped_fragment
ccorrin

ccorrin-据我所知,您不需要给Google任何东西;google的漫游器会找到您的网站,并在其中查找漂亮的URL(不要忘记在首页中也添加meta标签,因为它可能不包含任何URL)。包含escaped_fragment的丑陋网址始终仅由google添加-您永远不要将其自己放在HTML中。并感谢您的支持:-)
喜怒无常

感谢Bjorn&Sandra :-)我正在研究该文档的一个更好的版本,该文档还将包含有关如何缓存页面的信息,以使过程更快,并在URL包含以下内容的更常用的情况下进行:控制器名称;准备好后,我会立即发布
偏偏

这是一个很好的解释!我实现了它,并且在我的本地主机devbox中像一个魅力一样工作。问题是部署到Azure网站时,因为该站点冻结,并且一段时间后出现502错误。你有关于如何部署phantomjs天青?? ...感谢任何想法(testypv.azurewebsites.net/?_escaped_fragment_=home/about
yagopv

我没有使用Azure网站的经验,但是我想到的是,可能无法完全完成页面完全加载的检查过程,因此服务器不断尝试一次又一次地重新加载页面,但没有成功。也许就是问题所在(即使这些检查有时间限制,所以可能不存在)?尝试使“返回真实”;作为“ checkLoaded()”中的第一行,看看是否有所不同。
2013年


4

这是我8月14日在伦敦举办的Ember.js培训班的截屏录像的链接。它概述了客户端应用程序和服务器端应用程序的策略,并现场演示了如何实现这些功能,即使关闭了JavaScript的用户,也能为您的JavaScript单页应用程序带来优美的降级效果。

它使用PhantomJS辅助爬网您的网站。

简而言之,所需的步骤是:

  • 拥有要爬网的Web应用程序的托管版本,此站点需要拥有生产中拥有的所有数据
  • 编写一个JavaScript应用程序(PhantomJS脚本)以加载您的网站
  • 将index.html(或“ /”)添加到要爬网的URL列表中
    • 弹出添加到爬网列表的第一个URL
    • 加载页面并呈现其DOM
    • 在加载的页面上找到链接到您自己的网站的任何链接(URL过滤)
    • 将此链接添加到“可爬网” URL列表(如果尚未被爬网)
    • 将渲染的DOM存储到文件系统上的文件中,但先剥离所有脚本标签
    • 最后,使用爬网的URL创建一个Sitemap.xml文件

完成此步骤后,由您的后端决定是否将HTML的静态版本作为该页面上noscript-tag的一部分提供。即使您的应用程序最初是单页应用程序,这也将允许Google和其他搜索引擎抓取您网站上的每个页面。

链接到截屏,具有完整的详细信息:

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


0

您可以使用或创建自己的服务来通过称为prerender的服务来预渲染SPA。您可以在他的网站prerender.io和他的github项目(使用PhantomJS并为您呈现网站)中进行检查。

开始很容易。您只需要将搜寻器请求重定向到服务,它们就会收到呈现的html。


2
尽管此链接可以回答问题,但最好在此处包括答案的基本部分,并提供链接以供参考。如果链接的页面发生更改,仅链接的答案可能会失效。- 评论
-timgeb

2
你是对的。我已经更新了我的评论...我希望现在更加准确。
gabrielperales's

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.