AngularJSを使ったWebサイトでSEOするためにLobiがやっていること

スマートフォンのネイティブアプリをメインにサービス展開しているLobiですが、 Webブラウザからも利用することができます。

https://web.lobi.co

Webブラウザからでもチャットの投稿・閲覧が行えます。

このサイトは AngularJS を用いて実装されています。

AngularJSとは

AngularJSはGoogleが提供しているJavaScriptライブラリです。

https://angularjs.org/

Webページ内でのユーザーのアクションに対応するコンテンツ生成や表示変更などをJavaScriptで強力に実現します。

SEOとの相性

AnguraJSに限らず、JavaScriptで動的にコンテンツを生成するWebページには、 SEOとの相性が悪いという欠点があります。 これは検索エンジンのクローラーがJavaScriptの実行までは行わないことに起因しています。 真っ白なページにJavaScriptでコンテンツを追加していくような作りになっている場合、 クローラーからは「真っ白なページ」として認識されてしまいます。

これではせっかくコンテンツを用意しても、検索エンジンからは存在しないものとして扱われてしまいます。 LobiのWebブラウザ版(https://web.lobi.co/)はまさにその状態でした・・・。

最初期

当時LobiはiOSとAndroidのネイティブアプリとしてサービス提供を行っていました。 ユーザーからの「PC版はないのか?」という要望から、Webブラウザで動作するLobi(以下「Web版」と略記)を作ることになりました。

ネイティブアプリと同等の操作性を提供したいとの考えから、 AngularJSを用いたSingle Page Applicationとして開発が進められました。

その後、単なるチャットツールではなくコミュニティとしてのLobiを拡散する施策を行うにあたり、 Web版のSEOに着手したのですが・・・上記の通りAngularJSで動的にコンテンツを生成していたため、 クローラーからは「真っ白なページ」として認識されているという事実が大きな足かせになりました。

Prerender.IOでレンダリング

クローラーにコンテンツを認識してもらうためには、 サーバーでページをレンダリングするしかありません。 かといってAngularJSでフル実装されたWeb版を サーバーサイドレンダリング方式に実装し直すには大きなコストがかかってしまいます。

そこで「JavaScriptによるコンテンツ生成を維持したままサーバーでレンダリングする」という手段をとることにしました。 当時使用していたのはperenderというツールです。

https://prerender.io/

これでレンダリング済みのページを返すことでインデックスされるようにはなった・・・のですが、 prerenderのプロセスが頻繁にエラー終了してしまうため代替手段を用いる必要がありました。

暫定対応

暫定対応として、クローラーからのリクエストに対しては コンテンツとして重要な要素(チャットグループのタイトル、最新の発言数件、など)を サーバーでレンダリングして返すような実装を行っていました。

しかし、ユーザーが見るコンテンツとクローラーが見るコンテンツを出し分ける行為は、 「クローキング」というガイドライン違反にあたるため、早急に別の手を打つ必要がありました。

クローキング - Search Console ヘルプ

なお、Googleのクローラーから見たがWebページをどのように表示されているかは、 Search ConsoleのFetch as Googleを使用することで確認することができます。

ウェブサイト用 Fetch as Google を使用する - Search Console ヘルプ

phantomjsでレンダリング

ここからが本番。

prerenderと同じアプローチではありますが、JavaScriptを解釈し、 ブラウザで表示されるものと同じページレンダリングを行う手段として PhantomJSを採用することにしました。

http://phantomjs.org/

レンダリング処理

上述の通りPhantomJSでレンダリングを行なっているわけですが、 JavaScriptを解釈しサーバーでレンダリングするためには、 AngularJSで全コンテンツを動的生成している場合はとりわけ時間がかかります。

Lobiではこの描画処理をバックグラウンドのワーカーに行わせています。 リクエストからレスポンスまでのシーケンス図は以下のようになります。

sequence.png

  • クローラーからリクエストがあった場合、まずレンダリング結果のキャッシュを参照する
  • キャッシュがあればそれを返して終了

新規ページ等はキャッシュが作られていないので、レンダリング処理が実行されます。

  • バックエンドのアプリケーションがレンダリング用ワーカーを起動
  • nginxにはいったんX-Accel-Redircetヘッダーを付けてレスポンスを返す
  • X-Accel-Redirectには「一定時間おきにキャッシュ取得をくりかえす」locationが指定されている
  • キャッシュ取得を繰り返す処理にはngx_lua_moduleを使用
  • ワーカーによるレンダリングが完了し、nginxがキャッシュ取得に成功したらそれをレスポンスとして返す
  • 一定時間以上キャッシュが取得できなかった場合はRetry-Afterヘッダーをつけた上でstatus=503としてレスポンスを返す

X-Accel-Redirectはnginxが独自に解釈するヘッダーで、 upstreamからのレスポンスにこのヘッダーが指定されていた場合、 その値に対応するlocationに処理を引き継ぐことができます。

nginxの設定から該当箇所を抜粋すると以下のようになります。


upstream app {
    server 127.0.0.1:8080;
}

location / {
    if ($http_user_agent ~* "baiduspider|googlebot|bingbot") {
        set $cache_key "$cache_key_prefix$host$request_uri";
        memcached_pass 127.0.0.1:11211;
        error_page 404 503 = @fallback;
    }
}

location @fallback {
    proxy_pass http://app;
    # proxy先から X-Accel-Redirect: /wait_cache が返ってくる
}

location /fetch_cache {
    internal;
    include /etc/nginx/nginx.common.location.proxy.conf;
    memcached_pass 127.0.0.1:11211;
}

location /wait_cache {
    internal;
    default_type "text/html; charset=utf-8";
    memcached_gzip_flag 2;
    gunzip on;

    if ( $upstream_http_x_cache_key ) {
        set $cache_key $upstream_http_x_cache_key;
    }

    access_by_lua '
        while true do
            ngx.sleep( 2 );

            local res = ngx.location.capture( "/fetch_cached", {
                share_all_vars = true
            });

            if res.status == 200 then
                return;
            end

            if 30 < ngx.now() - ngx.req.start_time() then
                ngx.header.Retry_After = 60;
                ngx.header.Content_Type = nil;
                ngx.exit(ngx.HTTP_SERVICE_UNAVAILABLE);
                return;
            end
        end
    ';

    memcached_pass 127.0.0.1:11211;
}

詳しくはnginxのドキュメントを参照して下さい。

https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/

弊社サービスで実際に使われているngx_lua_moduleの活用方法については以下のスライドを参照下さい。

https://speakerdeck.com/fujiwara3/practical-nginx-lua-in-kayac

ストリーミングAPIの罠

LobiはチャットをメインコンテンツとするWebサービスです。 ブラウザでチャットグループを開いておくと新着メッセージが自動的に表示されるようになっています。 この仕組はクライアントとAPIサーバー間でHTTP接続を維持することで実現しています。 この接続は最長10分間継続します。

つまり、PhantomJSで何も考えずにグループチャットのページをレンダリングすると、 処理完了まで10分間かかることになります。

クローラー用のページではその時点で投稿されているメッセージが描画されていれば充分なので、 ストリーミングAPIを使用する必要がありません。 そこで該当APIのリクエストをキャンセルするようにしました。


var page = require('webpage').create();
page.onResourceRequested = function(requestData, networkRequest) {
    if (isStream.test(requestData['url'])) {
        networkRequest.abort();
    }

    ...
});

ストリーミングAPI以外の外部リクエスト(Google AnalyticsへのAPI送信など)についても、 表示されるコンテンツに影響しないものについてはリクエストをキャンセルすることで レンダリング速度を上げる工夫をしています。

(prerenderでレンダリングしていた際には時間が足りず調べきれなかったエラー終了の原因も、 ストリーミングAPIによるものである可能性が高いと考えています)

SEOを考えている場合AngularJSという選択は・・・

先のシーケンス図を見てもらえば分かる通り、AngularJSを使ったサイトできちんとSEOしようとすると かなり複雑な仕組みを用意しなければなりません。 しかもこれは「SEOするための準備」であって、SEOそのものではないのです・・・。

SEOを考えるのであれば、AngularJSをはじめJavaScriptでコンテンツを生成するライブラリの利用は諦めたほうが無難でしょう。

次回

次回はLobiにおけるConsulの活用事例について紹介します。

カヤックではSEOに興味があるエンジニアも募集しています!