【问题标题】:How to make a SPA SEO crawlable?如何使 SPA SEO 可抓取?
【发布时间】:2013-09-03 00:38:44
【问题描述】:

我一直在研究如何根据谷歌的instructions 让谷歌可以抓取 SPA。尽管有相当多的一般性解释,但我在任何地方都找不到更详尽的分步教程和实际示例。完成此操作后,我想分享我的解决方案,以便其他人也可以使用它并可能进一步改进它。
我在服务器端使用MVCWebapi 控制器,在服务器端使用Phantomjs,在客户端使用Durandal 并启用push-state;我还使用Breezejs 进行客户端-服务器数据交互,我强烈推荐所有这些,但我会尽量给出一个足够笼统的解释,这也将有助于使用其他平台的人们。

【问题讨论】:

  • 关于“离题” - 网络应用程序程序员必须找到一种方法如何使他/她的应用程序可用于搜索引擎优化,这是网络上的基本要求。这样做与编程本身无关,而是与stackoverflow.com/help/on-topic 中描述的“编程专业所特有的实际、可回答的问题”主题相关。对于整个网络上没有明确解决方案的许多程序员来说,这是一个问题。我希望能帮助其他人,并在这里花费了数小时来描述它,得到负分肯定不会激励我再次提供帮助。
  • 如果重点是编程而不是蛇油/秘方 SEO 巫毒/垃圾邮件,那么它可以是完美的话题。我们也喜欢自我回答,因为它们有可能对未来的读者长期有用。这个问答对似乎通过了这两个测试。 (一些背景细节可以更好地充实问题,而不是在答案中介绍,但这是相当次要的)
  • +1 以减少反对票。不管 q/a 是否更适合作为博客文章,这个问题都与 Durandal 相关,并且答案已经过充分研究。
  • 我同意 SEO 是当今开发者日常生活的重要组成部分,绝对应该被视为 stackoverflow 中的一个主题!
  • 除了自己实现整个过程,你可以试试SnapSearchsnapsearch.io,它基本上以服务的形式解决了这个问题。

标签: ajax seo phantomjs single-page-application durandal


【解决方案1】:

2021 年更新

  • SPA 应使用History API 以便对 SEO 友好。

    SPA 页面之间的转换通常通过history.pushState(path) 调用来实现。接下来发生的事情取决于框架。如果使用 React,名为 React Router 的组件会监视 history 并显示/呈现为使用的 path 配置的 React 组件。

  • 实现简单 SPA 的 SEO 是 straightforward

  • article 所示,为更高级的 SPA(使用选择性预呈现以获得更好的性能)实现 SEO 涉及更多。我是作者。

【讨论】:

    【解决方案2】:

    我使用Rendertron解决ASP.net core中的SEO问题和客户端的Angular,它是一个基于爬虫或客户端区分请求的中间件,所以当请求来自爬虫端时,会生成响应简短而快速地进行。

    • 为普通客户呈现的网站:

    • 为爬虫渲染的网站:

    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 corePython (Django)Express.jsFirebase等服务端框架中实现。

    查看源代码及更多详情:https://github.com/GoogleChrome/rendertron

    【讨论】:

      【解决方案3】:

      您可以使用http://sparender.com/ 来正确抓取单页应用程序。

      【讨论】:

      • 网站不可用
      【解决方案4】:

      Google 现在可以呈现 SPA 页面: Deprecating our AJAX crawling scheme

      【讨论】:

        【解决方案5】:

        您可以使用或创建自己的服务来使用称为 prerender 的服务来预渲染您的 SPA。你可以在他的网站prerender.io 和他的github project 上查看它(它使用 PhantomJS 并为你渲染你的网站)。

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

        【讨论】:

        • 虽然此链接可能会回答问题,但最好在此处包含答案的基本部分并提供链接以供参考。如果链接页面发生更改,仅链接答案可能会失效。 - From Review
        • 你是对的。我已经更新了我的评论...我希望现在更准确。
        【解决方案6】:

        这是我 8 月 14 日在伦敦主持的 Ember.js 培训课程的截屏视频链接。它为您的客户端应用程序和服务器端应用程序概述了一种策略,并现场演示了如何实现这些功能将为您的 JavaScript 单页应用程序提供优雅的降级,即使对于关闭 JavaScript 的用户也是如此.

        它使用 PhantomJS 来帮助抓取您的网站。

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

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

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

        包含完整详细信息的截屏视频链接:

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

        【讨论】:

          【解决方案7】:

          在开始之前,请确保您了解 google requires 的含义,尤其是 prettyugly 网址的使用。现在让我们看看实现:

          客户端

          在客户端,您只有一个 html 页面,它通过 AJAX 调用与服务器动态交互。这就是SPA的意义所在。客户端中的所有a 标签都是在我的应用程序中动态创建的,稍后我们将了解如何使这些链接对服务器中的 google 机器人可见。每个这样的a 标签都需要能够在href 标签中有一个pretty URL,这样google 的机器人才能抓取它。您不希望在客户端单击时使用 href 部分(即使您确实希望服务器能够解析它,我们稍后会看到),因为我们可能不想要一个新页面加载,只是为了进行 AJAX 调用,获取一些数据以显示在页面的一部分并通过 javascript 更改 URL(例如使用 HTML5 pushstateDurandaljs)。因此,我们既有 google 的 href 属性,也有 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部分)加载数据我就不说了,在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 });
                  }
              };
          });
          

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

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

          这就是我们在客户端所需要的。它也可以通过散列 URL 实现(在 Durandal 中,您只需删除 pushState:true 即可)。更复杂的部分(至少对我来说......)是服务器部分:

          服务器端

          我在服务器端使用MVC 4.5WebAPI 控制器。服务器实际上需要处理 3 种类型的 URL:由 google 生成的 URL - prettyugly 以及与客户端浏览器中显示的格式相同的“简单”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;
                  }
              }
          }
          


          但是 Ugly URLs 呢?这些是由谷歌的机器人创建的,应该返回包含用户在浏览器中看到的所有数据的纯 HTML。为此,我使用phantomjs。 Phantom 是一个无头浏览器,做浏览器在客户端所做的事情——但在服务器端。换句话说,Phantom 知道(除其他外)如何通过 URL 获取网页,解析它,包括运行其中的所有 javascript 代码(以及通过 AJAX 调用获取数据),并将反映的 HTML 返回给您DOM。如果您使用的是 MS Visual Studio Express,您可能希望通过此link 安装幻像。
          但首先,当一个丑陋的 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 )
          正如您在控制器中看到的,幻象在我创建的名为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() 函数返回 true。这是为什么?这是因为我的特定 SPA 进行了多次 AJAX 调用以获取所有数据并将其放置在我页面上的 DOM 中,并且在将 DOM 的 HTML 反射返回给我之前,幻影无法知道所有调用何时完成。我在这里所做的是在最后的 AJAX 调用之后添加一个&lt;span id='compositionComplete'&gt;&lt;/span&gt;,这样如果这个标签存在,我就知道 DOM 已经完成。我这样做是为了响应 Durandal 的 compositionComplete 事件,请参阅 here 了解更多信息。如果这在 10 秒内没有发生,我放弃(最多只需要一秒钟)。返回的 HTML 包含用户在浏览器中看到的所有链接。该脚本将无法正常工作,因为 HTML 快照中确实存在的 &lt;script&gt; 标记未引用正确的 URL。这也可以在 javascript 幻像文件中进行更改,但我认为这不是必需的,因为 HTML snapshort 仅由 google 用于获取 a 链接而不是运行 javascript;这些链接 do 引用了一个漂亮的 URL,如果事实上,如果您尝试在浏览器中查看 HTML 快照,您将收到 javascript 错误,但所有链接都将正常工作并将您定向到服务器一次这次再次使用漂亮的 URL 获取完整的工作页面。
          就是这个。现在服务器知道如何处理漂亮和丑陋的 URL,在服务器和客户端都启用了推送状态。所有丑陋的 URL 都使用 phantom 以相同的方式处理,因此无需为每种类型的调用创建单独的控制器。
          您可能希望更改的一件事是不要进行一般的“类别/子类别/产品”调用,而是添加一个“商店”,以便链接看起来像:http://www.xyz.com/store/category/subCategory/product111。这将避免我的解决方案中的问题,即所有无效的 URL 都被视为实际上是对“索引”控制器的调用,我认为这些可以在“存储”控制器中处理,而无需添加到 web.config我在上面展示了。

          【讨论】:

          • 我有一个简单的问题,我想我现在已经完成了这项工作,但是当我将我的网站提交给 google,并提供指向 google、站点地图等的链接时,我需要给 google mysite.com/#!或者只是mysite.com 和谷歌将添加 escaped_fragment 因为我在元标记中有它?
          • ccorrin - 据我所知,你不需要给谷歌任何东西; google 的机器人会找到您的网站并在其中查找漂亮的 URL(不要忘记在主页中添加元标记,因为它可能不包含任何 URL)。包含 escaped_fragment 的丑陋 URL 始终只能由 google 添加 - 你永远不应该将它自己放在你的 HTML 中。并感谢您的支持:-)
          • 感谢 Bjorn 和 Sandra :-) 我正在编写本文档的更好版本,其中还将包括有关如何缓存页面的信息,以便加快处理速度并在更多url 包含控制器名称的常见用途;准备好后我会尽快发布
          • 这是一个很好的解释!!。我实现了它,并且在我的本地主机 devbox 中像一个魅力一样工作。问题是在部署到 Azure 网站时,因为站点冻结并且一段时间后我收到 502 错误。您对如何将 phantomjs 部署到 Azure 有任何想法吗?...谢谢 (testypv.azurewebsites.net/?_escaped_fragment_=home/about)
          • 我没有使用 Azure 网站的经验,但我想到的可能是页面完全加载的检查过程从未完成,因此服务器不断尝试重新加载页面没有成功。也许这就是问题所在(即使这些检查有时间限制,所以它可能不存在)?试着把'return true;'作为“checkLoaded()”中的第一行,看看它是否有所作为。
          猜你喜欢
          • 2014-01-05
          • 2012-12-12
          • 1970-01-01
          • 2016-12-27
          • 2015-11-07
          • 1970-01-01
          • 2022-01-20
          • 2015-11-19
          • 1970-01-01
          相关资源
          最近更新 更多