NodeでChromeを操作してTwitterシェア用画像を生成するサーバー作った

Node.jsにはPuppeteerという画面を表示せずにChromeを扱えるライブラリがあります。それを利用し、URLをパラメータとして与えるだけでスクリーンショットを画像としてブラウザで表示させることができるサーバーアプリケーションを作成しました。

実際にこのような画像が作成できます。例として下記はボード内投稿にコードを入れた場合に表示される画像です。

下記は記事の画像です。

HTML, CSSから生成しているためドロップシャドウとかもこんな感じで簡単に効かせる事ができています。

GitHubで公開していますので、とりあえず使って見たいという方は記事を読み飛ばして最後の方にあるリンクをご利用ください(こらえきれぬ涙で頬を濡らしながら)

何が便利なのか

WebサービスのURLをTwitter等でシェアするだけで、URLだけでなくツイートに画像も表示されるのをよく見ると思います。

これは画像をアップしているわけではなく、そのページ内のHTMLに書かれているメタタグで指定されている画像を勝手にSNS側で表示してくれるという便利な、いわゆるOGPというものです。

Webサービスでの困りごと

普通のサイトであれば画像を指定するだけですが、Webサービスの場合は投稿された内容をもとにしたOGPを動的に作りたいという場合があります。

この場合画像を動的に作成しなければならないのですが、これがなかなか面倒です。

サーバーサイドでやると、負荷がかかったりそもそも描画がプログラムによる命令で構築されることが多く、メンテナンスが非常に面倒です。フロントでやることもできますが、ブラウザの閲覧環境によって表示に違いが出てしまったりします。

解決策

そのため、専用のPuppeteerを用いたOGP生成サーバーを用意することで前述の全ての問題を解決することができます。

OGPはURLを指定するだけのため、サーバーを別にすることもできますので、分けておけばアプリケーションサーバー側に負荷は一切ありません。また、OGPサーバーのブラウザで表示を行いスクリーンショットをとるため、ユーザーの環境にも依存しません。

具体的な仕組み

今回作成したものは、URLを指定するとそのURLのスクリーンショットを撮って画像として表示してくれる、というだけの非常にシンプルなものです。

例えば最初の記事の画像であれば、実際の なぜCrieitを作ろうと思ったか のページにアクセスし、ソースを見るとわかりますが、下記のようなメタタグがあります。

    <meta property="og:image" content="https://ogp.crieit.net/posts/Crieit-5b91bd1569dbd/ogp.png">  
    <meta name="twitter:card" content="summary_large_image">  
    <meta name="twitter:image" content="https://ogp.crieit.net/posts/Crieit-5b91bd1569dbd/ogp.png">

このog:imagetwitter:imageに指定されている画像が今回紹介する仕組みで作成されている画像です。

具体的には、crieit.netの全く同じパスのスクリーンショットを撮る仕組みになっています。つまり、上記であれば下記のURLのスクリーンショットを撮っています。実際に見ていただくことも可能です。(拡張子はつけれるようにしています。理由は後述します)

https://crieit.net/posts/Crieit-5b91bd1569dbd/ogp

アクセスしていただくとわかりますが、OGP画像をそのまま単なるHTMLとCSSで作っているだけになります。単にそのページのスクリーンショットを撮っているだけです。この例ですとフォントが違うと思いますが、それはOGPサーバー内にインストールしているのでOGPサーバー内のChromeでは正しいフォントで表示されます。

ちなみにブラウザでのレンダリングのため、JavaScriptも動きます。今回のコード表示の例もhighlight.jsを動かしています。

URLに拡張子

前述の例のように、OGPサーバー側のURLには拡張子をつけることができます。これにより、CloudflareのようなCDNを介する場合、ちゃんとキャッシュしてくれるようになります。

つまり、そのURLに一度誰かがアクセスしていればその後はCDNで配信されるようになるため、OGPサーバー側にはアクセスが来なくなります。

実際に前述の画像を開いてリロードするとめちゃくちゃ速いのがわかると思います。たいした処理ではないにしろ、本来はブラウザでページを開くだけの時間がかかりますので全然変わってきます。

とはいえそもそもOGPはだいたいSNS側でキャッシュされるためあまりメリットは大きくないのですが、改造すれば他の用途にも使用することができますので、この仕組みを覚えておけば色々と便利ではあると思います。

構成要素

Puppeteerを使うのでNode.jsのサーバーとなります。

サーバーはExpressで、expressコマンドで作成したソースのほとんどそのままです。複雑なことはしていないので数十行書いたくらいで完成しました。(そのため不要な処理もたくさん残っていて作り自体は雑です…)

functions系の利用は断念

現在色々なPaasやfunctionsサービスがありますが、色々調べたり試した結果利用は断念し、最終的にシンプルにExpressによるNode.jsのサーバーで作ることにしました。

断念した理由は下記等です。

  • リクエスト毎にブラウザを起動しなければならないため、画像生成にかなり時間がかかりそう。Expressであれば最初に一度起動しておくだけでよい
  • サービスによってはそもそも使えない
  • サービス上では特殊な実装にしなければならない場合があるので、ローカルで試しづらく開発に時間がかかって面倒な場合がある
  • サービスによってはプロセスの実行時間制限があったりする

…等です。もしうまく簡単に動かせるサービスがあったらぜひ教えてください。

サーバー

実際のサーバーの作り方を解説していきます。

Chromeをインストール

一番重要な仕事をしてくれるものです。

ChromeDriverを試していた時の名残で一部不要なものが混じってるかもしれません。これはDockerfileの中身そのままです。

apt-get update  
apt-get install -y libappindicator1 fonts-liberation unzip  
curl -O https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb  
dpkg -i google-chrome*.deb || apt update && apt-get install -f -y

フォントをインストール

Webフォントだけでページを作れば不要ですが、そうでない場合や不足のフォントがありそうな場合にはインストールしておきます。

apt-get install fonts-ipafont-gothic fonts-ipafont-mincho

Node.jsをインストール

とりあえずnpmとyarnを入れておきます。公式サイトに書かれている手順で入れておけば問題ないと思います。

npmのモジュールーインストール

Chromeはすでにインストールしているため、それをスキップする形でインストールします。違うパターンでインストールしたい場合は適宜変更してください。

PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true yarn

Nginxをインストール

これは必須ではないのですが、ポート3000で動いているNode.jsサーバーを80にプロキシしたかったのと、SSLに対応したかったため同じくLet’s Encryptのcertbotが使用するURLもプロキシして放置で証明書が更新されるようにしたかったためNginxで動かすようにしています。(ここもわりと適当なため適宜変更してください)

        listen 443 ssl default_server;  
        ssl_certificate /etc/letsencrypt/live/ogp.example.net/fullchain.pem;  
        ssl_certificate_key /etc/letsencrypt/live/ogp.example.net/privkey.pem;  

        server_name ogp.example.net;  

        proxy_redirect off;  
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;  
        proxy_set_header Host $http_host;  

        location ^~ /.well-known/ {  
                root /var/www/html;  
        }  

        location / {  
                proxy_pass http://localhost:3000/;  
        }

Systemdでサービス化

下記のように設定しました。

[Unit]  
Description=ogp server  
After=syslog.target network.target  

[Service]  
Type=simple  
ExecStart=/usr/bin/npm start  
WorkingDirectory=/path/to/puppeteer-ogp  
KillMode=mixed  
Restart=always  
User=yourname  
Group=yourgroup  

[Install]  
WantedBy=multi-user.target

だと、理由はまだよく分かりませんがうまくアプリケーションやChromeのプロセスが終了してくれないため、KillMode=mixedにして丸々強制終了するようにしています。

サービスの有効化は下記です。

sudo systemctl enable ogp

あとは下記で起動すれば完全放置できます。

sudo service ogp start

ちなみに僕は無料枠で動かせるGoogle Compute Engineのf1-microを使っています。

公開しました

かなり何もやってないレベルの製作物だったので、作成したものをGitHubで公開しています。是非適当に試してみたり、改造して遊んでみたりしてみてください。

dala00/puppeteer-ogp

下記に重要な注意点を書きます。そのあとローカルでの開発方法もざっと書いておきます

注意点

まだ動かしてからさほど経っていないため、どれくらい安定稼働してくれるかは謎です。というのも、アプリケーションとは別にChromeが動いているので、そのあたりで安定性がどうなのかという部分が全く想像できません。一応最後に調整をしてからは問題ないようなのですが、稼働初期はクラッシュしまくっていました。

ですので利用される場合は自己責任でよろしくお願いします。一応どういう対策を入れたかと、どういう問題がありそうかをメモしておきます。

エラー処理を入れる

ブラウザがクラッシュする前提で、ページを開くときとかにエラーをcatchするようにし、問題があれば再起動するようにします。ただ、確認が不十分のため現在のエラー処理自体が正しくない可能性があります。実際にクラッシュするとプロセスがどんどん増えていく問題を確認済みです。

おそらくですが、デーモン化して確実に再起動される状態であれば、問題が出たらそのままアプリケーションを落としてしまう方が安心では、という気がしています。(そうなると安定性と高頻度アクセスを保証すべきようなアプリケーションでは難しいかもしれませんが)

URLのフィルタを入れる

これが今のところ一番の改善になりました。というのも、サーバーを公開していると.gitフォルダやphpMyAdminを無差別に探してデータを盗もうとする不審なアクセスがあります。これが一瞬のあいだに高頻度で行われるのでChromeへの負荷が高まりクラッシュしていました。

そのため下記のような感じで環境変数にて必要なURLだけをフィルタできるようにし、それ以外のURLの場合はChromeを使わないようにしました。これだけでクラッシュが発生しなくなりました。

URL_FILTER=/articles,/posts

とにかく、いかにChromeを使用しないか、というところが重要になってくる気がします。アクセスが多い場合、待機させるようにして同時にたくさんのページを開かないような形にすると安定性が上る可能性もありそうです。(ただしレスポンスが悪くなるとSNSがOGPを認識してくれない可能性も出てきそうですが)

多分結構スペックが高いサーバーの方が良さそう

Chrome自体が低スペック環境でガンガン快適に動くようなものではないので、サーバーもスペックが高いほうが良さそうな気がします。実際に現在使っているGoogle Compute Engineのf1-microインスタンスでは、マシンタイプをグレードアップしたほうが良い、というアラートがずっと出っぱなしになっています。

また、おそらくスペックが高いほうがクラッシュする可能性も低くなるのでは…と想像しています。

参考にした情報

たしか下記あたりを参考にしました。

ローカルでの開発方法

基本的にはGitHubにあげているREADMEそのままですが、Dockerとdocker-composeさえ入っていれば誰でもすぐ試せるようになっています。Getting StartedとDevelopmentのところをそのまま行うだけで動きます。

まとめ

ちょっと改造すればひとつのサーバーで複数のサービスのURLに対応することも可能ですし、特に個人開発している人はひとり一OGPサーバーを持っておくとなにかと便利な気がします。

コードのOGPはよろしければ下記で色々投稿して試してみてください!

コードのOGPを試してみるボード

もし問題やプルリクエストがある場合はお気軽にGitHubに立ててください。(余裕のあるときしか見れないかもしれませんが…)