Индексирование AJAX-сайтов

от автора

При разработке интерфейса одного веб приложения возникла задача сделать странички, формируемые AJAX запросом, индексируемыми поисковиками. У Яндекса и Google есть механизм для индексации таких страниц (https://developers.google.com/webmasters/ajax-crawling/ http://help.yandex.ru/webmaster/robot-workings/ajax-indexing.xml). Суть довольно проста, чтобы сообщить роботу о HTML версии страницы, в тело нужно включить тег <meta name="fragment" content="!">. Этот тег можно использовать на всех AJAX страницах. HTML версия должна быть доступна по адресу www.example.com/чтотоеще?_escaped_fragment_=. То есть, если у нас есть страница http://widjer.net/posts/posts-430033, то статическая версия должна иметь адрес http://widjer.net/posts/posts-430033?_escaped_fragment_=.
Чтобы не быть обвиненным в клоакинге, динамическая и статическая версии не должны отличаться, поэтому возникает необходимость создания слепков ajax страниц, о чем и хотелось бы рассказать.

Поиск решения

Приложение написано на ASP MVC с использованием durandaljs (http://durandaljs.com/). На сайте durandal есть пример возможной реализации (http://durandaljs.com/documentation/Making-Durandal-Apps-SEO-Crawlable.html). В частности, там предлагалось использовать сервис Blitline (http://www.blitline.com/docs/seo_optimizer). После непродолжительных поисков аналогов, я решил согласиться с их рекомендацией. Для получения слепка страницы необходимо отправить запрос определенного вида, а результат будет размещен в указанном Amazon S3 bucket. Данный подход мне понравился, так как некоторые страницы почти не меняются и их можно спокойно кешировать и не тратить время на повторную обработку.

Реализация

Для начала необходимо зарегистрироваться на http://aws.amazon.com/s3/ и произвести некоторые настройки. Опишу основные шаги не вдаваясь в подробности, так как есть документация и куча статей на данную тему. Сам, до данного момента, дела с этим продуктом не имел и нашел всю необходимую информацию довольно быстро.

Настройка S3

На странице управления S3 создаем три buckets: day, month, weak. Это нужно для того, чтобы была возможность хранить кеш страниц различное время. Для каждого bucket настраиваем Lifecycle. Как можно понять из названий, настраиваем время жизни один день, 7 дней и 30 дней для ранее созданных bucket.

Для того чтобы Blitline мог разместить результат у нас в хранилище настраиваем права доступа. Для этого добавляем следующий код для каждого bucket в их политики безопасности.

 {      "Version": "2008-10-17",      "Statement": [         {             "Sid": "AddCannedAcl",             "Effect": "Allow",             "Principal": { "CanonicalUser": "dd81f2e5f9fd34f0fca01d29c62e6ae6cafd33079d99d14ad22fbbea41f36d9a"},             "Action": [                 "s3:PutObjectAcl",                 "s3:PutObject"             ],             "Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/*"         }     ]  } 

YOUR_BUCKET_NAME заменяем на название нужного bucket.
С S3 закончили, переходим к реализации.

Серверная часть, MVC Controller

Так как у нас SPA, то все запросы идут в HomeController, а уже дальше разруливаются durandal на стороне клиента. Метод Index в Home контроллере будет выглядеть примерно следующим образом.

 if (Request.QueryString["_escaped_fragment_"] == null) {             бизнес логика             return View(); }  try  {     //We´ll crawl the normal url without _escaped_fragment_     var result = await _crawler.SnaphotUrl(                     Request.Url.AbsoluteUri.Replace("?_escaped_fragment_=", "") );     return Content(result); } catch (Exception ex) {     Trace.TraceError("CrawlError: {0}", ex.Message);     return View("FailedCrawl"); } 
Основная логика

_crawler реализует следующий интерфейс

 public interface ICrawl {         Task<string> SnaphotUrl(string url); } 

На вход мы получаем url, с которого необходимо сделать снимок, а возвращаем html код статической страницы. Реализация данного интерфейса

 public class Crawl: ICrawl     {         private IUrlStorage _sorage; //работа с хранилищем S3         private ISpaSnapshot _snapshot; //сервис создания статических снимков         public Crawl(IUrlStorage st, ISpaSnapshot ss)         {             Debug.Assert(st != null);             Debug.Assert(ss != null);             _sorage = st;             _snapshot = ss;         }             public async Task<string> SnaphotUrl(string url)         {             //есть ли данные в кеше (S3 хранилище)             string res = await _sorage.Get(url);             //Данные есть, возвращаем             if (!string.IsNullOrWhiteSpace(res))                 return res;             //данных нет, создаем снимок             await _snapshot.TakeSnapshot(url, _sorage);             //тупо ждем результата             var i = 0;             do {                 res = await _sorage.Get(url);                 if(!string.IsNullOrWhiteSpace(res))                     return res;                 Thread.Sleep(5000);             } while(i < 3);             //не получилось             throw new CrawlException("данные так и не появились");         }     } 

Данный кусок тривиален, идем дальше.

Работа с S3

Рассмотрим реализацию IUrlStorage

 public interface IUrlStorage     {         Task<string> Get(string url); //получить данные из кеша         Task Put(string url, string body); //положить данные в кеш         //чуть ниже опишем         IUrlToBucketNameStrategy BuckName { get; } //преобразование url в bucketname         IUrlToKeyStrategy KeyName { get; } //преобразование url в ключ по которому будут доступны данные     } 

Так как с S3 раньше не сталкивался, делал все по наитию.

 public class S3Storage: IUrlStorage     {         private IUrlToBucketNameStrategy _buckName; //преобразование url в имя bucket         public IUrlToBucketNameStrategy BuckName { get { return _buckName;} }                   private IUrlToKeyStrategy _keyName; //преобразование url в ключ         public IUrlToKeyStrategy KeyName { get { return _keyName; } }         //данные для подключения к хранилищу, берем из консоли управления на сайте amazon         private readonly string _amazonS3AccessKeyID;          private readonly string _amazonS3secretAccessKeyID;          private readonly AmazonS3Config _amazonConfig;          public S3Storage(string S3Key = null,              string S3SecretKey = null,              IUrlToBucketNameStrategy bns = null,             IUrlToKeyStrategy kn = null)         {             _amazonS3AccessKeyID = S3Key;             _amazonS3secretAccessKeyID = S3SecretKey;             _buckName = bns ?? new UrlToBucketNameStrategy(); //если не задана стратегия берем по умолчанию, описана ниже             _keyName = kn ?? new UrlToKeyStrategy(); //если не задана стратегия берем по умолчанию, описана ниже             _amazonConfig = new AmazonS3Config              {                 RegionEndpoint = Amazon.RegionEndpoint.USEast1 //если при создании bucket было выбрано US Default, в противном случае другое значение             };         }          public async Task<string> Get(string url)         {             //преобразуем url в имя bucket и ключ             string bucket = _buckName.Get(url),                  key = _keyName.Get(url),                 res = string.Empty;             //инициализируем клиента             var client = CreateClient();             //инициализируем запрос             GetObjectRequest request = new GetObjectRequest             {                 BucketName = bucket,                 Key = key,             };              try             {                 //читаем данные из хранилища                 var S3response = await client.GetObjectAsync(request);                 using (var reader = new StreamReader(S3response.ResponseStream))                 {                     res = reader.ReadToEnd();                 }             }             catch (AmazonS3Exception ex)             {                 if (ex.ErrorCode != "NoSuchKey")                     throw ex;             }              return res;         }          private IAmazonS3 CreateClient()         {             //создаем клиента             var client = string.IsNullOrWhiteSpace(_amazonS3AccessKeyID) //были ли указаны ключи в коде или их брать из файла настроек                 ? Amazon.AWSClientFactory.CreateAmazonS3Client(_amazonConfig) //from appSettings                 : Amazon.AWSClientFactory.CreateAmazonS3Client(_amazonS3AccessKeyID, _amazonS3secretAccessKeyID, _amazonConfig);             return client;         }          public async Task Put(string url, string body)         {             string bucket = _buckName.Get(url),                 key = _keyName.Get(url);              var client = CreateClient();              PutObjectRequest request = new PutObjectRequest             {                 BucketName = bucket,                 Key = key,                 ContentType = "text/html",                 ContentBody = body             };              await client.PutObjectAsync(request);         }     } 

Подключаться мы умеем, теперь быстренько напишем стратегии перевода url в адрес в S3 хранилище. Bucket у нас определяет время хранения кеша страницы. У каждого приложения будет своя реализация, вот как примерно выглядит моя.

 public interface IUrlToBucketNameStrategy     {         string Get(string url); //получаем url, отдаем имя ранее созданного bucket     }  public class UrlToBucketNameStrategy : IUrlToBucketNameStrategy     {         private static readonly char[] Sep = new[] { '/' };         public string Get(string url)         {             Debug.Assert(url != null);              var bucketName = "day"; //по умолчанию храним день             var parts = url.Split(Sep, StringSplitOptions.RemoveEmptyEntries);              if(parts.Length > 1)             {                 //если есть параметры                 switch(parts[1])                 {                     case "posts": //это страница поста, она не меняется долго, кладем на месяц                         bucketName = "month";                         break;                     case "users": //это станица пользователя, храним неделю                         bucketName = "weak";                         break;                 }             }             return bucketName;         }     } 

Имя bucket получили, теперь необходимо сгенерировать уникальный ключ для каждой страницы. За это у нас отвечает IUrlToKeyStrategy.

 public interface IUrlToKeyStrategy     {         string Get(string url);     }  public class UrlToKeyStrategy: IUrlToKeyStrategy     {         private static readonly char[] Sep = new[] { '/' };         public string Get(string url)         {             Debug.Assert(url != null);              string key = "mainpage";             //разбиваем на части             var parts = url.Split(Sep, StringSplitOptions.RemoveEmptyEntries);             //если длинный путь             if(parts.Length > 0)             {                    //соединяем все через точки и преобразуем в "читаемый" вид                 key = string.Join(".", parts.Select(x => HttpUtility.UrlEncode(x)));             }              return key;         }     } 

С хранилищем закончили, переходим к последней части Марлезонского балета.

Создание статических копий AJAX страниц

За это у нас отвечает ISpaSnapshot

 public interface ISpaSnapshot     {         Task TakeSnapshot(string url, IUrlStorage storage);     } 

Вот его реализация работающая с сервисом Blitline. Кода получается слишком много, поэтому привожу основные моменты, а описание классов для сериализации данных можно взять на их сайте.

 public class BlitlineSpaSnapshot : ISpaSnapshot     {         private string _appId; //id выдаваемый нам при регистрации         private IUrlStorage _storage; //уже знакомый нам интерфейс         private int _regTimeout = 30000; //30s //сколько ждать будем          public BlitlineSpaSnapshot(string appId, IUrlStorage st)         {             _appId = appId;             _storage = st;         }          public async Task TakeSnapshot(string url, IUrlStorage storage)         {             //формируем строку запроса к их сервису             string jsonData = FormatCrawlRequest(url);             //отправляем запрос              var resp = await Crawl(url, jsonData);             //в ответ получаем ошибку, если ошибка генерим исключение             if (!string.IsNullOrWhiteSpace(resp))                 throw new CrawlException(resp);         }          private async Task<string> Crawl(string url, string jsonData)         {             //тут стандартно отправка запроса             string crawlResponse = string.Empty;              using (var client = new HttpClient())             {                 var result = await client.PostAsync("http://api.blitline.com/job",                      new FormUrlEncodedContent(new Dictionary<string, string> { { "json", jsonData } }));                                  var o = result.Content.ReadAsStringAsync().Result;                 //как говорил описания классов запросов можно взять на сайте                 var response = JsonConvert.DeserializeObject<BlitlineBatchResponse>(o);                 //есть ошибки                 if(response.Failed)                     crawlResponse = string.Join("; ", response.Results.Select(x => x.Error));             }              return crawlResponse;         }          private string FormatCrawlRequest(string url)         {             //здесь формируем запрос к серверу, заполняем поля классов и сериализуем в JSON             var reqData = new BlitlineRequest             {                 ApplicationId = _appId,                 Src = url,                 SrcType = "screen_shot_url",                 SrcData = new SrcDataDto                 {                     ViewPort = "1200x800",                     SaveHtml = new SaveDest                     {                         S3Des = new StorageDestination                         {                             Bucket = _storage.BuckName.Get(url),                             Key = _storage.KeyName.Get(url)                         }                     }                 },                 Functions = new[] {                      new FunctionData { Name = "no_op" }                 }             };              return JsonConvert.SerializeObject(new[] { reqData });         }     } 

Делаем велосипед

К сожалению количество страниц сайта было слишком велико, а платить за сервис не хотелось. Вот реализация на своей стороне. Это простейший пример, не всегда корректно работающий. Для создания снимков самостоятельно нам понадобиться PhantomJS

 public class PhantomJsSnapShot : ISpaSnapshot     {         private readonly string _exePath; //путь к PhantomJS         private readonly string _jsPath; //путь к скрипту, приведен ниже          public PhantomJsSnapShot(string exePath, string jsPath)         {             _exePath = exePath;             _jsPath = jsPath;         }          public Task TakeSnapshot(string url, IUrlStorage storage)         {            //стартуем процесс создания сника             var startInfo = new ProcessStartInfo {                 Arguments = String.Format("{0} {1}", _jsPath, url),                 FileName = _exePath,                 UseShellExecute = false,                 CreateNoWindow = true,                 RedirectStandardOutput = true,                 RedirectStandardError = true,                 RedirectStandardInput = true,                 StandardOutputEncoding = System.Text.Encoding.UTF8             };              Process p = new Process { StartInfo = startInfo };             p.Start();             //читаем данные             string output = p.StandardOutput.ReadToEnd();             p.WaitForExit();             //кладем данные в хранилище             return storage.Put(url, output);         }     } 

Скрипт создания снимка _jsPath

 var resourceWait = 13000,     maxRenderWait = 13000;  var page = require('webpage').create(),     system = require('system'),     count = 0,     forcedRenderTimeout,     renderTimeout;  page.viewportSize = { width: 1280, height: 1024 };  function doRender() {     console.log(page.content);     phantom.exit(); }  page.onResourceRequested = function (req) {     count += 1;     clearTimeout(renderTimeout); };  page.onResourceReceived = function (res) {     if (!res.stage || res.stage === 'end') {         count -= 1;         if (count === 0) {             renderTimeout = setTimeout(doRender, resourceWait);         }     } };  page.open(system.args[1], function (status) {     if (status !== "success") {         phantom.exit();     } else {         forcedRenderTimeout = setTimeout(function () {             doRender();         }, maxRenderWait);     } }); 

Заключение

В результате у нас есть реализация позволяющая индексировать наши AJAX страницы, код написан на скорую руку и в нем есть огрехи. Демо можно проверить на сайте widjer.net (ключевое слово DEMO). Например по этому url http://widjer.net/timeline/%23информационные_технологии. Статическую версию http://widjer.net/timeline/%23информационные_технологии?_escaped_fragment_= лучше просматривать с отключенным javascript. Буду рад, если кому то пригодится мой опыт.

ссылка на оригинал статьи http://habrahabr.ru/post/219875/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *