AngularJSのSEO対策について

[Scotch.io](https://scotch.io/tutorials/angularjs-seo-with-prerender-io)で取り上げられていた[prerender-node#1.2.1](https://github.com/prerender/prerender-node.git)では、[angular-ui-router#0.2.13](https://github.com/angular-ui/ui-router)のレンダリング結果をうまく取得できなかったので、DIYで解決する手順を紹介します。
忙しい人は[express-turnout#0.0.4-alpha](https://github.com/59naga/express-turnout)を参照ください。

## Phantomjs 2.0.0 のインストール
`$ phantomjs -v`で`2.0.0`が返る環境を用意します。

### Win ([cmder](http://gooseberrycreative.com/cmder/))
```
λ curl -OL https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.0.0-windows.zip
λ unzip phantomjs-2.0.0-windows.zip
λ mv %cd%\phantomjs-2.0.0-windows\bin\phantomjs.exe %CMDER_ROOT%\bin\phantomjs.exe
λ phantomjs -v
2.0.0
```

### Mac (Terminal/Homebrew)
```bash
$ brew install phantomjs
$ phantomjs -v
2.0.0
```

### Linux
こちらの記事を参考に、CentOS 5.9でビルドに成功しました（終了まで２時間超掛かります）。
> [Compiling phantomJS 2.0 for AWS EC2 linux image](http://blog.rampinteractive.co.uk/compiling-phantomjs-2-0-for-aws-ec2-linux-image/)

## nodejsからphantomjsを実行する
同様に、node.js経由でphantomjsが実行できることを確認します。

```bash
$ node
```

```js
var exec= require('child_process').exec;
exec('phantomjs -v',function(error,stdout){
  console.log(stdout);// 2.0.0
});
```

## レンダリング用の実行ファイルを作成する
`$ phantomjs render.js url`コマンドで、クローラーの代わりにページを訪問し、__任意のタイミングまで待ってから__、レンダリング結果を取得するプログラムを作成します。

```js:./render.js
// Dependencies
var system= require('system');
var webpage= require('webpage');

// Environment
var url= system.args[1];

// Setup webpage
var page= webpage.create();
page.open(url);
page.onCallback= function(html){
  system.stdout.write(html);
  phantom.exit(0);
};
```

このプログラムは、ローカルファイルで動作を確認できます。

```html:./index.html
<script>
if(window.callPhantom){
  window.callPhantom(document.documentElement.outerHTML);
}
</script>
```

```bash
$ phantomjs render.js index.html
# <html><head><script>if(window.callPhantom){window.callPhantom(document.documentElement.outerHTML);}</script></head></html>
```

webpage内には、[window.callPhantomメソッドが用意されており](http://phantomjs.org/api/webpage/handler/on-callback.html)、render.jsの`page.onCallback`で結果を受け取ります。

これを利用して、任意のタイミングで、クローラーにレンダリング結果を渡すことが可能です。

```html:./index.html
<!DOCTYPE html>
<html lang="en" ng-app="myApp">
<head>
  <meta charset="UTF-8">
  <title>{{meta['og:site_name'] + ' | ' + title}}</title>
  <meta content="{{value}}" property="{{key}}" ng-repeat="(key,value) in meta">

  <script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.3.15/angular.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/angular-ui-router/0.2.14/angular-ui-router.js"></script>
  
  <script>
angular.module('myApp',['ui.router'])
.run(function($rootScope,$window,$location,$state){
  $rootScope.$on('$viewContentLoaded',function(){
    var renderedTemplate= $window.document.body.innerHTML.trim().length>0
    if(renderedTemplate){
      $rootScope.title= 'Welcome clawlers!';
      $rootScope.meta= {
        'og:site_name':$state.current.name,
        'og:title':$rootScope.title,
        'og:url':$location.absUrl(),
        'og:type':'article',
        'og:description':angular.element($window.document.body).text(),
      }

      if(window.callPhantom){
        setTimeout(function(){
          window.callPhantom(document.documentElement.outerHTML);
        },0);
      }
    }
  });
})
.config(function($stateProvider){
  $stateProvider.state('private',{
    url:'/private',
    template:'<h1>private</h1>',
  });
  $stateProvider.state('second',{
    url:'/second',
    template:'<h1>second</h1><a ui-sref="first">first</a>',
  });
  $stateProvider.state('first',{
    url:'*path',
    template:'<h1>first</h1><a ui-sref="second">second</a>',
  });
})
;
  </script>
</head>
<body ui-view></body>
</html>
```

```bash
$ phantomjs render.js index.html
# <html lang="en" ng-app="myApp" class="ng-scope"><head><style type="text/css">@charset "UTF-8";[ng\:cloak],[ng-cloak],[data-ng-cloak],[x-ng-cloak],.ng-cloak,.x-ng-cloak,.ng-hide:not(.ng-hide-animate){display:none !important;}ng\:form{display:block;}</style>
#   <meta charset="UTF-8">
#   <title class="ng-binding">first | Welcome clawlers!</title>
#   <!-- ngRepeat: (key,value) in meta --><meta content="firstsecond" property="og:description" ng-repeat="(key,value) in meta" class="ng-scope"><!-- end ngRepeat: (key,value) in meta --><meta content="first" property="og:site_name" ng-repeat="(key,value) in meta" class="ng-scope"><!-- end ngRepeat: (key,value) in meta --><meta content="Welcome clawlers!" property="og:title" ng-repeat="(key,value) in meta" class="ng-scope"><!-- end ngRepeat: (key,value) in meta --><meta content="article" property="og:type" ng-repeat="(key,value) in meta" class="ng-scope"><!-- end ngRepeat: (key,value) in meta --><meta content="file:///Users/59naga/Downloads/hoge.html" property="og:url" ng-repeat="(key,value) in meta" class="ng-scope"><!-- end ngRepeat: (key,value) in meta -->
# 
#   <script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.3.15/angular.js"></script>
#   <script src="https://cdnjs.cloudflare.com/ajax/libs/angular-ui-router/0.2.14/angular-ui-router.js"></script>
# 
#   <script>
# angular.module('myApp',['ui.router'])
# //...
#   </script>
# </head>
# <!-- uiView:  --><body ui-view="" class="ng-scope"><h1 class="ng-scope">first</h1><a ui-sref="second" class="ng-scope" href="#/second">second</a></body></html>
```

&lt;meta&gt;, &lt;title&gt;, &lt;body&gt;をレンダリングした状態で、ソースコードを取得できました。

## サーバーから実行する
上記を[Express4](http://expressjs.com/4x/api.html#router)から使用する場合、以下のように実装できます。

```bash
$ npm install express
$ node app
# listening at 59798
$ curl -A Googlebot http://localhost:59798/
# Prerendered source-code
```

```js:./app.js
// Environment
var port= 59798;

// Dependencies
var express= require('express');
var exec= require('child_process').exec;

// Setup prerender
var prerender= express.Router();
prerender.use(function(req,res,next){
  var ua= req.headers['user-agent'] || '';
  if(ua.match('Googlebot')==null){
    return next();
  }

  var scriptFile= require.resolve('./render.js');
  var uri= req.protocol+'://'+req.get('host')+req.originalUrl;
  var script= 'phantomjs '+scriptFile+' '+uri;
  exec(script,function(error,stdout,stderr){
    if(error){
      res.status(403);
      res.end(stderr);
      return;
    }
    res.set('Content-Type','text/html');
    res.end(stdout);
  });
});

// Setup express
var app= express();
app.use(prerender);
app.use(function(req, res) {
  res.sendFile(__dirname + '/index.html');
});

// Boot
app.listen(port, function() {
  console.log('listening at %s', port);
});
```

## 注意
* 記事内のコードは、例外処理を省いた最低限のコードです。
* 実行コストが高く、レスポンスに１秒以上掛かることも少なくありません。実際に使用する場合は、タイムアウト、ブラックリスト・ホワイトリスト、キャッシュが必要になるでしょう。
* 前２機能は[express-turnout](https://github.com/59naga/express-turnout)というライブラリに実装しました。ui-routerを使ったページに使用して、Googleが訪問した後の検索結果が[こんな感じです](https://google.com/search?q=site%3Aedgy.black)。

## 参考
* [Google ウェブマスター ツール](https://www.google.com/webmasters/tools/home?hl=ja)
* [Twitter Developers | Twitterカード 概要](https://dev.twitter.com/ja/cards/overview)