Индексирование 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<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. Буду рад, если кому то пригодится мой опыт.
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 5

      +2
      Обычно стараемся сделать что бы для поисковых систем существовали статические страницы, а при наличии JS и живого пользователя они уже становятся AJAX. Так и ссылки на страницы получаются вменяемые и вообще метод более универсальные… За информацию и способ тем не менее спасибо
        –1
        Ну описанный способ — это и есть просто другой способ иметь «статические страницы для поисковых систем», когда UX сильно интерактивен и заточен под AJAX и поддерживать frontend-app и генерируемые сервером HTML (с живыми данными) просто невыгодно.
          0
          Я с Вами полностью согласен. Как раз в старой версии было такое решение. Скажу я вам не очень удобно держать две версии в актуальном состоянии. Да и потом есть другая проблема. Пользователь, приходящий из поиска, попадает на статическую страницу и ему недоступно все, что мы хотели бы ему предложить.
        +3
        А ещё можно поднять на своей железке prerender.io (ну или заплатить им за сервис, кому что проще), на него простым конфигом nginx перенаправить поисковики с основного сайта — и не писать кода воообще ;).
          0
          Всегда есть множество вариантов решения. Конфиг — это же тоже код) Да и сам prerender.io кто то написал до этого и код для подключения ASP.net MVC тоже кто то написал. Я тоже написал:), чтобы кто то не писал тоже самое. prerender.io использует фантомджс, как я понимаю.

        Only users with full accounts can post comments. Log in, please.