Индексирование 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). Например по этому urlhttp://widjer.net/timeline/%23информационные_технологии. Статическую версиюhttp://widjer.net/timeline/%23информационные_технологии?_escaped_fragment_= лучше просматривать с отключенным javascript. Буду рад, если кому то пригодится мой опыт.