Selemiumで認証が必要な画像を保存する(Ruby版)

最近、認証情報を必要とする画像ファイルを保存する必要が出てきました。

「Selenium 画像保存」でググると情報は出てくるんですが、その殆どが取得した画像URLに対してopen-uriを使って保存するものでした。

画像ファイル自体に認証情報が必須になっているサービスの場合、そのままopen-uri等で開こうとしても、open-uriに認証情報がないため、当然開けません。

これを開けるようにするには、大まかに2つの手段が思いつきます。

1. open-uriにSeleniumで取得した認証用Cookieを渡す
2. Seleniumで画像バイナリを取得して、それを保存する。

今回は2.の方法を使いました。

## 作成したコード

```ruby
require 'uri'

  def save_binary(driver, url, filepath)
    now_window = driver.window_handle
    driver.execute_script("window.open()")
    driver.switch_to.window(driver.window_handles.last)
    driver.get(url)

    js = <<EOS
    var getBinaryResourceText = function(url) {
        var req = new XMLHttpRequest();
        req.open('GET', url, false);
        req.overrideMimeType('text/plain; charset=x-user-defined');
        req.send(null);
        if (req.status != 200) return '';

        var filestream = req.responseText;
        var bytes = [];
        for (var i = 0; i < filestream.length; i++){
            bytes[i] = filestream.charCodeAt(i) & 0xff;
        }

        return bytes;
    }
EOS
    js += "return getBinaryResourceText(\"#{url}\");"

    uri = URI.parse(url)
    file_name = File.basename(uri.path)

    data_bytes = driver.execute_script(js)

    File.open(filepath + file_name, "w") do |f|
      f.write(data_bytes.pack('C*'))
    end

    driver.close
    driver.switch_to.window(now_window)
  end
```

### 使い方
第1引数にseleniumのdriver、第2引数に保存対象のURL、題3引数に保存場所のPathを指定します。

ファイル名はURLから自動推測されます。

```ruby
require 'selenium-webdriver'

driver = Selenium::WebDriver.for :chrome

# 対象とする画像ファイルに必要な認証情報を取得する
## e.g. 予め対象のサイトにログインしておく

save_binary(driver, "保存したい画像のURL", "/tmp/save_img/")
```

## 解説
大まかな流れは以下の通りです。
1. 取得したい画像を新しいタブで開く
2. 画像ファイルをJavaScriptで読み込み（バイナリデータ）
3. JavaScriptで読み込んたバイナリデータをruby側に渡し、ファイルに保存する
4. 開いたタブを閉じて、selenium_driverのコントロール先を元のタブに戻す

それぞれのフローについて詳しく解説していきます。

### 1. 取得したい画像を新しいタブで開く

```ruby 
    now_window = @driver.window_handle
    driver.execute_script("window.open()")
    driver.switch_to.window(@driver.window_handles.last)
    driver.get(url)
```

ここではSeleniumで新しいタブを開いた上で、そこで画像URLを開こうとしています。

対象の画像ファイルをあらかじめ開いておく理由として、対象の画像ファイルのドメインが違っている場合のCORS対策や、別ドメインからのリクエストをNGにしているサイトへの対応などの考えることを極力減らすためです。

逆にリファラー等で特定のページレベルで制限しているサイトを対象にする場合、移動したらNGになる可能性もあるのでこの節の処理と4.の処理は削除してください。

別タブとして開いている理由は、この一連の処理を別タブ内で完結させることで、driverの持っている状態をメソッド呼び出し前と呼び出し後で変えないようにするためです。

### 2. 画像ファイル読み込むJavaScriptを用意する

対象の画像ファイルをバイナリデータとしてJavaScriptで読み込みます。

JavaScriptで画像のバイナリデータを取得する処理部分は。こちらの「[Seleniumで画像などのバイナリデータを保存する](https://qiita.com/reneice/items/38569567c6c2f186aeb8) 」で使われているコードをほぼそのまま使用させていただきました。

### 3. JavaScriptでバイナリデータを読み込み、ファイルに保存する

2,で定義したJavaScriptを実行して、実行結果（=画像ファイルのバイナリデータ）を取得します。

Selenium Driverの `execute_script`メソッドは引数のJavaScriptの実行、実行結果を返り値として取得できるので、以下のコードで実行結果である画像ファイルのバイナリデータが`data_bytes` に保存されます。

```ruby
    data_bytes = driver.execute_script(js)
```

data_bytesに保存されたデータをファイルに書きこみますが、その前に保存名をURLから生成します。

```ruby
    uri = URI.parse(url)
    file_name = File.basename(uri.path)
```

このコードでは保存対象のURLを[URI.parse](https://docs.ruby-lang.org/ja/latest/method/URI/s/parse.html)で分解、uriのpath部分を[File.basename](https://docs.ruby-lang.org/ja/latest/method/File/s/basename.html)を使い、URL path部分の末尾の文字列を取得しています。

これにより、元のURLにクエリパラメータが含まれても、path部分の末尾のみ取得できます。

もしも、対象ファイルのURLがファイル名として良くない等、ファイル名の命名規則を変えたい場合があれば、`file_name` の生成ルールを変更してください。

最後に取得したデータを保存します。
引数の `filepath` と 生成した`file_name` で保存先を決定し、書き込みます。

JavaScriptの`charCodeAt` は[「0 から 65535 の整数を返す」](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/String/charCodeAt)、すなわち16bitで表現できる値を返しますが、
元コードではそれと `0xff`の論理積をとって `data_bytes`には入れています。
この論理積の意味は[こちらのサイト](https://javascript.g.hatena.ne.jp/edvakf/20100607/1275931930)が詳しく説明してくださっているのでここでは省きます。

ここで重要なのは、実質的にdata_bytesに入っている値が0~`0xff(255)`までしかないことです。
data_bytesの値をRubyの[packメソッド](https://docs.ruby-lang.org/ja/latest/method/Array/i/pack.html)を使い、バイナリ化した上で、対象ファイルに書き込みます。

このとき、値は8bitなので、テンプレートには`C*`を指定します。
[(`C`はunsigned char (8bit 符号なし整数)の指定)](https://docs.ruby-lang.org/ja/latest/doc/pack_template.html)

```ruby
    File.open(filepath + file_name, "w") do |f|
      f.write(data_bytes.pack('C*'))
    end
```


### 4. 開いたタブを閉じて、selenium_driverのコントロール先を元のタブに戻す

最後に、1.で開いたタブをcloseして、selenium_driverがコントロールしている先を元のタブに戻します。

```ruby
    driver.close
    driver.switch_to.window(now_window)
```

もし、1.で新規タブを開いていない場合はこの処理は不要です。

この一連の処理をメソッド内で行うことで、現在のdriverの状態をほぼ維持したまま、実行できるメソッドとなりました。

## 余談
↑と同様に、認証が必要なページをいわゆる`Ctrl + s`で保存するような形(画像等をパス変換した上で保存)もしたいため、出来る方法を探していました。
ただどうにも調べた限りSelenium単体では難しそうで、今はWindows(WSL)上で動かしているrubyスクリプトから無理やり[UWSC](https://www.vector.co.jp/soft/dl/winnt/util/se115105.html)を呼び出して、`Ctrl + s`→保存ボタンのクリックを自動処理して動かしてます。
かなり強引なやり方 & Windowsでしか動かないので汎用的な方法がありましたが、教えていただきたいです。