如何使一个SPAsearch引擎优化可抓取?
我一直在努力如何使谷歌根据谷歌的指示谷歌抓取。 尽pipe有很多一般性的解释,但是在实际的例子中,我找不到任何更深入的循序渐进的教程。 完成后,我想分享我的解决scheme,以便其他人也可以使用它,并可能进一步改善。
我正在使用Webapi
控制器的MVC
,服务器端的Phantomjs ,以及客户端的Durandal启用了push-state
; 我也使用Breezejs进行客户端 – 服务器数据交互,所有这些我都强烈build议,但是我会尽量给出一个足够的解释,以帮助人们使用其他平台。
在开始之前,请确保您了解谷歌需要什么,特别是使用漂亮和丑陋的 URL。 现在让我们看看实现:
客户端
在客户端,您只有一个通过AJAX调用dynamic地与服务器交互的html页面。 这就是SPA的内容。 客户端中的所有标签都是在我的应用程序中dynamic创build的,稍后我们将看到如何使这些链接在服务器中的Google bot中可见。 每个这样a
标签都需要能够在href
标签中有一个pretty URL
,这样Google的机器人才能抓取它。 当客户端点击时,你不希望使用href
部分(即使你希望服务器能够parsing它,我们稍后会看到),因为我们可能不想要一个新的页面加载,只是为了让AJAX调用获得一些数据在页面的一部分显示,并通过javascript(例如使用HTML5 pushstate
或Durandaljs
)更改URL。 所以,我们既有google的href
属性,也有onclick
,当用户点击链接的时候, onclick
可以完成这项工作。 现在,因为我使用push-state
,所以我不想在URL上有任何#
,所以一个典型a
标签可能看起来像这样:
<a href="http://www.xyz.com/#!/category/subCategory/product111" onClick="loadProduct('category','subCategory','product111')>see product111...</a>
“类别”和“子类别”可能是其他短语,例如电器商店的“沟通”和“电话”或“电脑”和“笔记本电脑”。 显然会有很多不同的类别和子类别。 正如您所看到的,链接直接指向类别,子类别和产品,而不是作为特定“商店”页面的额外参数,例如http://www.xyz.com/store/category/subCategory/product111
。 这是因为我喜欢更短,更简单的链接。 这意味着我不会有一个与我的“网页”同名的类别,即“约”。
我不会进入如何通过AJAX( onclick
部分)加载数据,在谷歌search,有很多很好的解释。 这里我唯一要提到的唯一重要的事情是,当用户点击这个链接时,我想让浏览器中的URL看起来像这样:
http://www.xyz.com/category/subCategory/product111
。 而这是URL不发送到服务器! 记住,这是一个SPA,客户端和服务器之间的所有交互都是通过AJAX完成的,根本没有链接! 所有的“页面”都在客户端实现,而不同的URL不会调用服务器(服务器确实需要知道如何处理这些URL,以防将其用作从另一个站点到您站点的外部链接,我们稍后会在服务器端看到)。 现在,这个由Durandal巧妙地处理了。 我强烈推荐它,但如果您更喜欢其他技术,也可以跳过这一部分。 如果你确实select了它,而且你也像我一样使用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 }); } }; });
有几件重要的事情要注意这里:
- 第一个路由(
route:''
)是没有额外数据的URL,即http://www.xyz.com
。 在这个页面中,您使用AJAX加载一般数据。 在这个页面中实际上可能没有标签。 你会想添加下面的标签,以便谷歌的机器人将知道该怎么做:
<meta name="fragment" content="!">
。 这个标签将使谷歌的机器人将URL转换为www.xyz.com?_escaped_fragment_=
,我们将在稍后看到。 - “about”路由仅仅是一个例子,指向您的Web应用程序中可能需要的其他“页面”的链接。
- 现在,棘手的部分是没有“类别”路线,并且可能有许多不同的类别 – 没有一个具有预定义的路线。 这是
mapUnknownRoutes
进来的地方。它将这些未知的路线映射到“商店”路线,并删除任何“!” 如果是由Google的search引擎生成的pretty URL
可以使用该pretty URL
。 “商店”路线获取“片段”属性中的信息,并进行AJAX调用以获取数据,显示数据并在本地更改URL。 在我的应用程序中,我不会为每个这样的调用加载一个不同的页面。 我只更改页面中与此数据相关的部分,并在本地更改URL。 - 注意
pushState:true
指示Durandal使用推送状态URL。
这是我们在客户端所需要的。 它也可以用哈希URL来实现(在Durandal中你可以简单的移除pushState:true
)。 更复杂的部分(至less对我来说)是服务器部分:
服务器端
我使用WebAPI
控制器在服务器端使用MVC 4.5
。 服务器实际上需要处理3种types的URL:由Google生成的那些 – 既pretty
又ugly
,同时也是一个与客户端浏览器中显示的格式相同的“简单”URL。 让我们看看如何做到这一点:
漂亮的URL和“简单”的URL首先被服务器解释,就像试图引用一个不存在的控制器一样。 服务器看到类似http://www.xyz.com/category/subCategory/product111
东西,并寻找名为“类别”的控制器。 所以在web.config
我添加下面一行来redirect到一个特定的error handling控制器:
<customErrors mode="On" defaultRedirect="Error"> <error statusCode="404" redirect="Error" /> </customErrors><br/>
现在,这将URL转换为: http://www.xyz.com/Error?aspxerrorpath=/category/subCategory/product111
: http://www.xyz.com/Error?aspxerrorpath=/category/subCategory/product111
category/subCategory/ http://www.xyz.com/Error?aspxerrorpath=/category/subCategory/product111
。 我想要的URL发送到客户端,将通过AJAX加载数据,所以这里的诀窍是调用默认的“索引”控制器,就好像没有引用任何控制器; 我通过在所有'category'和'subCategory'参数之前在URL中添加一个散列来实现这一点; 哈希URL不需要任何特殊的控制器,除了默认的“索引”控制器,数据发送到客户端,然后删除散列,并使用散列后的信息通过AJAX加载数据。 这里是error handling控制器代码:
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呢? 这些是由谷歌机器人创build的,并应返回纯HTML包含用户在浏览器中看到的所有数据。 为此我使用phantomjs 。 Phantom是一个浏览器在客户端执行浏览器的无头浏览器 – 但在服务器端。 换句话说,幽灵知道(除其他外)如何通过URL获取网页,parsing它,包括运行所有的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; } } }
这也是在'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 )
正如你可以在控制器中看到的,幻影在我创build的名为seo
的文件夹下加载一个名为createSnapshot.js
的JavaScript文件。 这是这个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()
页面直到checkLoaded()
函数返回true。 这是为什么? 这是因为我的特定的SPA使得几个AJAX调用来获取所有的数据并将其放在我的页面上的DOM中,并且在返回DOM的HTMLreflection之前,幻像无法知道所有调用何时完成。 我在这里做的是在最后的AJAX调用之后添加一个<span id='compositionComplete'></span>
,所以如果这个标记存在,我知道DOM已经完成了。 我这样做是为了回应Durandal的compositionComplete
事件, 在这里看到更多。 如果这种情况在10秒内没有发生,我就放弃了(最多只需要一秒钟的时间)。 返回的HTML包含用户在浏览器中看到的所有链接。 该脚本将无法正常工作,因为HTML快照中存在的<script>
标记没有引用正确的URL。 这也可以在JavaScript幻像文件中改变,但我不认为这是necassary,因为HTML snapshort只被谷歌用来获得a
链接,而不是运行JavaScript; 这些链接引用一个漂亮的URL,如果事实上,如果你试图在浏览器中看到HTML快照,你会得到JavaScript错误,但所有的链接将正常工作,并再次用一个漂亮的URL引导你到服务器这次获得完整的工作页面。
就是这个。 现在,服务器知道如何处理漂亮和丑陋的URL,在服务器和客户端都启用了推送状态。 所有丑陋的URL都使用幻像以相同的方式处理,所以不需要为每种types的呼叫创build单独的控制器。
你可能更喜欢改变的一件事情不是做一个普通的“类别/子类别/产品”调用,而是添加一个“商店”,以便链接看起来像这样: http://www.xyz.com/store/category/subCategory/product111
://www.xyz.com/store/category http://www.xyz.com/store/category/subCategory/product111
。 这将避免在我的解决scheme中,所有无效的URL被视为实际上是调用'index'控制器的问题,我想这些可以在'store'控制器内处理,而不需要添加到web.config
我上面显示。
Google现在可以呈现SPA页面: 弃用我们的AJAX抓取scheme
以下是我于8月14日在伦敦举办的Ember.js培训class的截屏录像的链接。 它概述了您的客户端应用程序和您的服务器端应用程序的策略,以及现场演示如何实现这些function将为JavaScript单页面应用程序提供优雅的降级,即使JavaScriptclosures的用户也是如此。
它使用PhantomJS来帮助抓取您的网站。
总之,所需的步骤是:
- 有一个托pipe版本的Web应用程序要抓取,该网站需要有生产中的所有数据
- 编写一个JavaScript应用程序(PhantomJS脚本)来加载您的网站
- 将index.html(或“/”)添加到要爬网的URL列表中
- popup添加到爬网列表中的第一个URL
- 加载页面并呈现其DOM
- find链接到您自己的网站(URL过滤)的加载页面上的任何链接
- 将此链接添加到“可抓取”url列表(如果其尚未抓取)
- 将渲染的DOM存储到文件系统上的文件中,但是首先将所有脚本标签剥离
- 最后,使用抓取的url创buildSitemap.xml文件
一旦完成了这一步,它会一直到您的后端为您的HTML的静态版本提供服务,作为该页面上noscript-tag的一部分。 这将允许Google和其他search引擎抓取您网站上的每个页面,即使您的应用最初是单页面应用。
链接到截屏video的全部细节:
您可以使用或创build自己的服务来预渲染SPA,并使用名为prerender的服务。 你可以在他的网站prerender.io和他的github项目 (它使用PhantomJS,它为你的网站renderize)检查出来。
从一开始就很容易。 您只需将抓取工具请求redirect到服务,他们将收到呈现的html。