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