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

При разработке интерфейса одного веб приложения возникла задача сделать странички, формируемые AJAX запросом, индексируемыми поисковиками. У Яндекса и Google есть механизм для индексации таких страниц (https://developers.google.com/webmasters/ajax-crawling/ http://help.yandex.ru/webmaster/robot-workings/ajax-indexing.xml). Суть довольно проста, чтобы сообщить роботу о HTML версии страницы, в тело нужно включить тег . Этот тег можно использовать на всех 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 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 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 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 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 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 { { «json», jsonData } })); var o = result.Content.ReadAsStringAsync ().Result; //как говорил описания классов запросов можно взять на сайте var response = JsonConvert.DeserializeObject(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 = »1200×800», 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. Буду рад, если кому то пригодится мой опыт.

© Habrahabr.ru