PhantomJS で Content-Disposition: attachment なレスポンスを取得する方法

## はじめに

とあるサイトに定期的にログインして利用履歴のCSVを取ってくるというプログラムを AppleScript + Safariで書いていたがいちいち Safari が起動してめんどくさいので、PhantomJS で書きなおすことにした。

さっそく PhantomJS の最新版(1.9.2) をインストールして、スクリプトを書いた。コードは下記のとおりである(簡易化してある)。

```coffeescript:download_csv.coffee
page = (require 'webpage').create()

callbacks = [
  -> # ログイン画面の処理
    page.evaluate ->
      document.getElementById('user').value = 'user'
      document.getElementById('pass').value = 'pass'
      
      e = document.createEvent('MouseEvents')
      e.initEvent('click',false,true)
      document.getElementById('submit').dispatchEvent(e)
  -> # ホーム画面の処理
    page.evaluate ->
      e = document.createEvent('MouseEvents')
      e.initEvent('click',false,true)
      document.getElementById('to_history').dispatchEvent(e)
  -> # 履歴画面の処理 -> CSVをダウンロードする！
    page.evaluate ->
      e = document.createEvent('MouseEvents')
      e.initEvent('click',false,true)
      document.getElementById('download').dispatchEvent(e)
]

page.onLoadFinished = ->
  callback = callbacks.shift()
  phantom.exit() unless callback
  callback()

page.open "http://localhost:9292"
```

しかし、肝心のCSVの取得がうまくいかない。というよりも、どうやってCSVのレスポンスを得るのかがわからない。ファイルとしてどこかに保存されているわけでもないようだ。

いろいろ調べた結果、[公式](https://github.com/ariya/phantomjs)に[#10052: File download](https://github.com/ariya/phantomjs/issues/10052)という issue があり、そのスレッドによると、 **Content-Disposition: attachement なレスポンスは取得できない** ようだ。

しかし、[同スレッド](https://github.com/ariya/phantomjs/issues/10052#issuecomment-14990274)で公式から fork された [add_download_capabilities](https://github.com/woodwardjd/phantomjs/tree/add_download_capabilities) というバージョンなら対応してるらしいということが分かった。なお、ベースとなった PhantomJS のバージョンは 1.6 である。

## インストール

さっそく git clone してビルドしてみる(ビルド方法については[公式](http://phantomjs.org/build.html)を参照) 。環境は、CentOS 6.3 x86_64。ビルドには2時間くらいかかった。

```bash
$ sudo yum install gcc gcc-c++ make git openssl-devel freetype-devel fontconfig-devel
$ git clone https://github.com/woodwardjd/phantomjs.git
$ cd phantomjs
$ git checkout add_download_capabilities
$ ./build.sh
```

ビルドが終了した後、所定のディレクトリに移動し PATH を通す。今回は、`/usr/local/phantomjs/add_download_capabilities`とした。

```bash
$ mv phantomjs add_download_capabilities
$ sudo mkdir -p /usr/local/phantomjs/
$ sudo mv add_download_capabilities /usr/local/phantomjs/
$ sudo vi /etc/profile.d/phantomjs.sh
```
```bash:/etc/profile.d/phantomjs.sh
export PATH=/usr/local/phantomjs/add_download_capabilities/bin:${PATH}
```

これでインストールは完了のはずだが、実行すると下記のエラーがでる。

```bash
$ phantomjs --version
phantomjs: error while loading shared libraries: libQtWebKit.so.4: cannot open shared object file: No such file or directory
```

`{PhantomJSディレクトリ}/src/qt/lib`に libQtWebKit.so.4 があったので、`/etc/ld.so.conf.d/phantomjs.conf`にパスを追加した後 ldconfig する。

```bash
$ sudo vi /etc/ld.so.conf.d/phantomjs.conf
```
```bash:/etc/ld.so.conf.d/phantomjs.conf
/usr/local/phantomjs/add_download_capabilities/src/qt/lib
```
```bash
$ sudo ldconfig
```

なんとか PhantomJS のインストールができた。

```bash
$ phantomjs --version
1.6.1
```

## Content-Disposition: attachement なレスポンスの取得

この fork されたバージョンでは、`WebPage`オブジェクトに新たに、 `WebPage#onUnsupportedContentReceived()`コールバックが追加されており、これで Content-Disposition: attachment なレスポンスをフックする。

レスポンスの保存には、`WebPage#saveUnsupportedContent()`メソッドを用いる。第一引数にファイル名、第二引数にレスポンスIDを指定する。なお、レスポンスを文字列として直接得る方法はないようだ。

```coffeescript:download_csv.coffee
page = (require 'webpage').create()

callbacks = [
  -> # ログイン画面の処理
    page.evaluate ->
      document.getElementById('user').value = 'user'
      document.getElementById('pass').value = 'pass'
      
      e = document.createEvent('MouseEvents')
      e.initEvent('click',false,true)
      document.getElementById('submit').dispatchEvent(e)
  -> # ホーム画面の処理
    page.evaluate ->
      e = document.createEvent('MouseEvents')
      e.initEvent('click',false,true)
      document.getElementById('to_history').dispatchEvent(e)
  -> # 履歴画面の処理 -> CSVをダウンロードする！
    page.evaluate ->
      e = document.createEvent('MouseEvents')
      e.initEvent('click',false,true)
      document.getElementById('download').dispatchEvent(e)
]

page.onLoadFinished = ->
  callback = callbacks.shift()
  phantom.exit() unless callback
  callback()

# Content-Disposition: attachment なレスポンスが返された時に呼び出されるコールバック
page.onUnsupportedContentReceived = (response) ->
  page.saveUnsupportedContent('tmp/history.csv', response.id)

page.open "http://localhost:9292"
```

また、`onUnsupportedContentReceived()`の引数、`response`の内容は下記の通りである。

```js
{
  "contentType": "text/csv", // サーバーから返されたContent-Type
  "headers": [ // サーバーから返されたHTTP-Header
    {
      "name": "Content-Type",
      "value": "text/csv"
    },
    {
      "name": "Content-Disposition",
      "value": "attachment; filename=\"history.csv\""
    },
    {
      "name": "Content-Length",
      "value": "94"
    },
    {
      "name": "Server",
      "value": "WEBrick/1.3.1 (Ruby/1.9.3/2012-12-25)"
    },
    {
      "name": "Date",
      "value": "Fri, 22 Nov 2013 12:28:47 GMT"
    },
    {
      "name": "Connection",
      "value": "Keep-Alive"
    }
  ],
  "id": 1, // レスポンスID。saveUnsupportedContent()メソッドで必要となる。
  "redirectURL": null,
  "status": 200, // サーバーが返したHTTPステータスコード
  "statusText": "OK ", // サーバーが返したHTTPステータス説明句
  "time": "2013-11-22T12:28:47.221Z",
  "url": "http://localhost:9292/download_csv" // リクエストURL 
}
```

## おわりに

この問題、結構基本的なことだと思うが、何故かほとんど情報がなく今回の記事を書くこととなった。
公式もはやくこの問題を解決してほしいものである。

なお、この記事で示したコードは [GitHub](https://github.com/mrk21/sample_phantomjs_downloading) にアップロードしてある。