Содержание
При разработке интерфейса одного веб приложения возникла задача сделать странички, формируемые 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. Буду рад, если кому то пригодится мой опыт.