Rails & Angular - рендерим сайт для поисковиков

Как то в очередную бурную ночь пришла мне в голову идея вывести сайт напианный на AngularJS в поиск, с чего началась вполне себе занимательная история.

Задача на первый взляд представилсь не очень тяжелой, особенно с учетом того, что ранее я натыкался в сети на такой сервис как https://prerender.io. Внимательно поизучав вопрос с данным сервисом выяснилось что он платный. В следствие полной бесплатности проекта - это мне полностью не подходило и встал вопрос - как это все таки делается.

30 минут поиска наделили меня следующим набором ссылок:

Собственно последняя ссылка дала самый большой толчок к тому, что бы побыстрому накропать свое.

Итого план был такой:

  • Пишем middleware для Rails в котором дергаем PhantomJS;
  • Отдаем полученное поисковику;
  • Все :)

Middleware

Вариант Карима мне показался тежелым, поэтому я на всякий случай глянул сорцы Hashbang. Получилось примерно следующее:

# lib/prerenderer.rb

class Prerenderer
  def initialize(application)
    @application = application
  end

  def call(env)
    if env['QUERY_STRING'].include? "_escaped_fragment_"
      output = `phantomjs #{Rails.root.join('bin', 'prerenderer.js')} #{urlFromRack(env)}`
      [200, {'Content-Type' => 'text/html; charset=utf-8'}, [output]]
    else
      @application.call(env)
    end
  end

  def urlFromRack(environment)
    url = []
    url << environment['rack.url_scheme'] + '://'
    url << environment['HTTP_HOST']
    url << environment['REQUEST_PATH']
    url << '?' unless environment['QUERY_STRING'].starts_with? '_escaped_fragment_'
    url << environment['QUERY_STRING'].gsub(/(\&)?_escaped_fragment_=/, '')[1..-1]
    url.join ''
  end
end

Далее регестрируем это счастье:

# config/initializers/prerenderer.rb

require 'prerenderer'
Rails.application.config.middleware.use Prerenderer

Чуть не забыли скрипт для самого PhantomJS:

# bin/prerenderer.js

var page = require('webpage').create(), system = require('system'), output, address;

page.settings.loadImages = false;
// 2 строчки ниже скорее всего не нужны :)
page.settings.localToRemoteUrlAccessEnabled = true;
page.settings.userAgent = 'Phantom';

if (system.args.length === 1) {
  console.log('Usage: prerenderer.js <URL>');
  phantom.exit();
}

address = system.args[1];

// Очень! Очень важная штука. 
// Если надо раздебажить что у вас происходит - это для вас. 
// Перенаправляет вывод console.log со консоли страницы в системную консоль
page.onConsoleMessage = function(msg, lineNum, sourceId) {
  //console.log('CONSOLE: ' + msg + ' (from line #' + lineNum + ' in "' + sourceId + '")');
};

// В статье Карима можно посмотреть как использвать этот callback
page.onResourceRequested = function(requestData, request) {
  //console.log(requestData['url'])
};

// Совершенно бесполезный callback за счет того, что нельзя получить тело ресурса
page.onResourceReceived = function(response, data) {
};

page.open(address, function(status) {
  if (status !== 'success') {
    console.log('FAIL to load the address: ' + address);
    phantom.exit();
  } else {
    window.setTimeout(function() {
      console.log(page.content);
      phantom.exit();
    }, 100);
  }
});

Есть в этом решении одна проблема - локально оно НЕ заработает сразу. По умолчанию Rails приложение обрабатывает только по 1 запросу. При работе данного middleware выходит следующая цепочка:

  • Вызов Rails (1) получение страницы по адресу http://localhost:3000/?escapedfragment_=/catalog/307
  • Вызывается наш класс Prerenderer, который совершает вызов PhantomJS
  • PhantomJS загружается и спрашивает страницу http://localhost:3000/catalog/307
  • А вот теперь самая грусть - вызов Rails (2) который еще не вышел с обработки первого. Естественно все это замирает.... навсегда.

От этого спасает только одно - ставим для рельсы Unicorn и настраиваем. Ну или небольшой изврат - стратуем 2 сервера не забыв поправить в классе, что бы запрос был по другому порту.

Магия Angular

Дальше у меня началось веселье, которое надеюсь никому не светит. Суть проблемы - все работало почти нормально, кроме одного ньюанса - все AJAX запросы от Angulara не выполнялись. JS вывод о проблеме оказался как всегда очень информативен - Error: SYNTAX_ERR: DOM Exception 12.

Потыкав настройки и кэлбеки PhantomJS удалось получить инфу о проблеме примерно в таком виде: "line":7887,"sourceId":4521835328,"sourceURL":"http://localhost:3000/assets/angular.js?body=1"

В этих строках оказалось следующее:

if (responseType) {
  xhr.responseType = responseType;
}

Констатирую: мне потребовалось не менее часа дебага адской связки что бы понять в чем дело... Есть такая штука в декларации экшенов ресурса Angular как responseType, которая обычно помогает (кроме тех случаев когда у пользователя случайно Safari). Для примера:

$resource "/maps/:id", {},
  index:
    method: 'GET'
    isArray: true
    responseType: 'json'
    transformResponse: (data, headersGetter) ->
      if (typeof data == 'string')
        data = JSON.parse(data)
      if data
        Map.paginationInfo = data.meta.pagination
        data.maps

Так вот... Достаточно ее убрать и все начинает магическим образом работать. Как я приметил потом такой проблемой грешит не только PhantomJS... GoogleBot при загрузке страницы точно так же спотыкается на этой проблеме.

И еще немного неприятного

При использовании сторонних JS-скриптов на сайте нужно хорошо проверять что и куда они пихают... У меня на странице( может конечно не повезло) оказалось сразу 2 скрипта, которые модифицировали head страницы вставляя туда еще скрипты. Грозит это тем, что из-за этого когда поисковики будут грузить страницу вначале окажутся скрипты, которые загружались последними и естественно использовали уже созданные объекты. 2 словами - здравствуй, undefined :(