我一直在研究如何根据谷歌的说明让谷歌可以抓取 SPA 。尽管有相当多的一般性解释,但我在任何地方都找不到更详尽的分步教程和实际示例。完成此操作后,我想分享我的解决方案,以便其他人也可以使用它并可能进一步改进它。
我使用MVC
控制器Webapi
,服务器端使用Phantomjs ,客户端启用Durandal ;push-state
我还使用Breezejs进行客户端-服务器数据交互,我强烈推荐所有这些,但我会尝试给出一个足够通用的解释,这也将有助于使用其他平台的人们。
7 回答
在开始之前,请确保您了解 google的要求,尤其是漂亮和丑陋的URL 的使用。现在让我们看看实现:
客户端
在客户端,您只有一个 html 页面,它通过 AJAX 调用与服务器动态交互。这就是SPA的意义所在。客户端中的所有a
标签都是在我的应用程序中动态创建的,稍后我们将看到如何使这些链接对服务器中的 google 机器人可见。每个这样的a
标签都需要能够pretty URL
在href
标签中有一个,以便谷歌的机器人可以抓取它。您不希望在href
客户端单击时使用该部分(即使您确实希望服务器能够解析它,我们稍后会看到),因为我们可能不希望加载新页面,只是为了进行 AJAX 调用,获取一些要在页面的一部分中显示的数据并通过 javascript 更改 URL(例如使用 HTML5pushstate
或 with Durandaljs
)。所以,我们都有一个href
google 的属性以及onclick
当用户点击链接时哪个工作。现在,由于我在 URL 上使用了push-state
I don't want any #
,所以典型的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,以防它们被用作从另一个站点到您的站点的外部链接,我们稍后会在服务器端部分看到这一点)。现在,这被杜兰达尔处理得很好。我强烈推荐它,但如果您更喜欢其他技术,也可以跳过这部分。如果您确实选择了它,并且您也像我一样使用 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 加载一般数据。这个页面实际上可能根本没有a
标签。您需要添加以下标签,以便 google 的机器人知道如何处理它:
<meta name="fragment" content="!">
. 这个标签将使 google 的 bot 转换www.xyz.com?_escaped_fragment_=
我们稍后会看到的 URL。 - 'about' 路由只是一个链接到您可能希望在您的 Web 应用程序上使用的其他“页面”的示例。
- 现在,棘手的部分是没有“类别”路线,并且可能有许多不同的类别 - 没有一个具有预定义的路线。这就是
mapUnknownRoutes
进来的地方。它将这些未知路线映射到“商店”路线,并删除任何“!”pretty URL
如果它是由谷歌的搜索引擎生成的,则来自 URL 。'store' 路由获取 'fragment' 属性中的信息并进行 AJAX 调用以获取数据、显示数据并在本地更改 URL。在我的应用程序中,我不会为每个此类调用加载不同的页面;我只更改与此数据相关的页面部分,并在本地更改 URL。 - 注意
pushState:true
指示 Durandal 使用推送状态 URL。
这就是我们在客户端所需要的。它也可以使用散列 URL 实现(在 Durandal 中,您只需删除pushState:true
它)。更复杂的部分(至少对我来说......)是服务器部分:
服务器端
我MVC 4.5
在服务器端使用WebAPI
控制器。服务器实际上需要处理 3 种类型的 URL:由 google 生成的 URL - 两者pretty
以及ugly
与客户端浏览器中显示的格式相同的“简单”URL。让我们看看如何做到这一点:
漂亮的 URL 和“简单”的 URL 首先被服务器解释为试图引用一个不存在的控制器。服务器看到类似的东西http://www.xyz.com/category/subCategory/product111
并寻找一个名为“类别”的控制器。因此,在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 加载数据的客户端,所以这里的技巧是调用默认的“索引”控制器,就好像不引用任何控制器一样;我通过在所有 '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;
}
}
}
但是丑陋的 URLs呢?这些是由谷歌的机器人创建的,应该返回包含用户在浏览器中看到的所有数据的纯 HTML。为此,我使用phantomjs。Phantom 是一个无头浏览器,做浏览器在客户端所做的事情——但在服务器端。换句话说,Phantom 知道(除其他外)如何通过 URL 获取网页,解析它,包括运行其中的所有 javascript 代码(以及通过 AJAX 调用获取数据),并将反映的 HTML 返回给您DOM。如果您使用的是 MS Visual Studio Express,您可能希望通过此链接安装 phantom 。
但首先,当一个丑陋的 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 )
正如你在控制器中看到的那样,phantom 加载了一个名为 .js的文件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 中,并且在将 DOM 的 HTML 反射返回给我之前,幻影无法知道所有调用何时完成。我在这里所做的是在最后的 AJAX 调用之后添加一个<span id='compositionComplete'></span>
,这样如果这个标签存在,我就知道 DOM 已经完成。我这样做是为了响应 Durandal 的compositionComplete
事件,请参见此处更多。如果这在 10 秒内没有发生,我放弃(最多只需要一秒钟)。返回的 HTML 包含用户在浏览器中看到的所有链接。该脚本将无法正常工作,因为<script>
HTML 快照中确实存在的标签未引用正确的 URL。这也可以在 javascript 幻像文件中进行更改,但我不认为这是必要的,因为 HTML snapshort 仅由 google 用于获取a
链接而不是运行 javascript;这些链接确实引用了一个漂亮的 URL,如果事实上,如果您尝试在浏览器中查看 HTML 快照,您将收到 javascript 错误,但所有链接都将正常工作,并再次使用漂亮的 URL 将您定向到服务器获得完整的工作页面。
就是这个。现在服务器知道如何处理漂亮和丑陋的 URL,在服务器和客户端都启用了推送状态。所有丑陋的 URL 都使用 phantom 以相同的方式处理,因此无需为每种类型的调用创建单独的控制器。
您可能希望更改的一件事是不要进行一般的“类别/子类别/产品”调用,而是添加一个“商店”,以便链接看起来像:http://www.xyz.com/store/category/subCategory/product111
. 这将避免我的解决方案中的问题,即所有无效的 URL 都被视为实际上是对“索引”控制器的调用,我想这些可以在“商店”控制器中处理,而不需要添加web.config
上面显示的内容.
Google 现在能够呈现 SPA 页面: 弃用我们的 AJAX 抓取方案
这是我 8 月 14 日在伦敦主持的 Ember.js 培训课程的截屏视频链接。它为您的客户端应用程序和服务器端应用程序概述了一种策略,并现场演示了如何实现这些功能将为您的 JavaScript 单页应用程序提供优雅的降级,即使对于关闭 JavaScript 的用户也是如此.
它使用 PhantomJS 来帮助抓取您的网站。
简而言之,所需的步骤是:
- 拥有您要抓取的 Web 应用程序的托管版本,该站点需要拥有您在生产中拥有的所有数据
- 编写一个 JavaScript 应用程序(PhantomJS 脚本)来加载您的网站
- 将 index.html(或“/”)添加到要抓取的 URL 列表中
- 弹出添加到爬虫列表的第一个 URL
- 加载页面并渲染其 DOM
- 在加载的页面上查找链接到您自己网站的任何链接(URL 过滤)
- 将此链接添加到“可抓取”网址列表(如果尚未抓取)
- 将渲染的 DOM 存储到文件系统上的文件中,但首先剥离所有脚本标签
- 最后,使用抓取的 URL 创建一个 Sitemap.xml 文件
完成此步骤后,由您的后端将 HTML 的静态版本作为该页面上 noscript-tag 的一部分提供。这将允许 Google 和其他搜索引擎抓取您网站上的每个页面,即使您的应用最初是单页应用。
链接到包含完整详细信息的截屏视频:
我曾经在客户端和 AngularRendertron
中解决 SEO 问题ASP.net core
,它是一个中间件,可以根据爬虫或客户端来区分请求,因此当请求来自爬虫端时,会快速快速地生成响应。
在Startup.cs
配置 rendertron 服务:
public void ConfigureServices(IServiceCollection services)
{
// Add rendertron services
services.AddRendertron(options =>
{
// rendertron service url
options.RendertronUrl = "http://rendertron:3000/render/";
// proxy url for application
options.AppProxyUrl = "http://webapplication";
// prerender for firefox
//options.UserAgents.Add("firefox");
// inject shady dom
options.InjectShadyDom = true;
// use http compression
options.AcceptCompression = true;
});
}
确实,这种方法有点不同,需要很短的代码来生成特定于爬虫的内容,但它对于 CMS 或门户网站等小型项目很有用。
这种方法可以在大多数编程语言或服务器端框架中完成,例如ASP.net core
, Python (Django)
, Express.js
, Firebase
.
要查看源代码和更多详细信息:https ://github.com/GoogleChrome/rendertron
2021 年更新
SPA 应该使用History API以便对 SEO 友好。
SPA 页面之间的转换通常通过
history.pushState(path)
调用来实现。接下来发生的事情取决于框架。在使用 React 的情况下,一个名为 React Router 的组件会监视history
并显示/呈现为所path
使用的配置的 React 组件。为一个简单的 SPA 实现 SEO 很简单。
如文章所示,为更高级的 SPA(使用选择性预渲染以获得更好的性能)实现 SEO 涉及更多。我是作者。
您可以使用或创建自己的服务来使用名为 prerender 的服务来预渲染您的 SPA。你可以在他的网站prerender.io和他的github 项目上查看它(它使用 PhantomJS 并为你渲染你的网站)。
这很容易开始。您只需要将爬虫请求重定向到服务,它们就会收到呈现的 html。
您可以使用http://sparender.com/来正确抓取单页应用程序。