ShellでSeleniumを作る ~Shellnium~

## はじめに

この記事は[Qiita夏祭り2020](https://qiita.com/summer-festival)のイベントの1つ、パソナテックさんが開催している[「〇〇（言語）のみを使って、今△△（アプリ）を作るとしたら」](https://qiita.com/official-events/5d181aadf5db26b73d33)のテーマで投稿しています。

いやぁ良いお題ですよね。エンジニア心揺さぶられるし、景品も豪華だし、何よりちょっと**ワクワクした**ので参加させていただきました。

今回は「ShellでSeleniumを作る ~Shellnium~」というお題でやっていきます。下記はShellniumで遊んでいるGIFです。
ブラウザの自動操作の模様をiTermの背景として鑑賞することができます。意味不明さがすごいですね。(透明度をいじってるとかではありません)

![demo2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142791/3942496e-94c3-f11e-4ab0-62080835c4b8.gif)

## いろんな言語でSeleniumは扱える

Seleniumは皆さん御存知の通り、ブラウザの自動操作をするツールですね。
SeleniumはWebDriverを介してブラウザの自動操作を行っていくもので、`php`, `python`, `javascript`など様々な言語のWebDriverが存在します。  

例えば`php`でやるなら[facebook-webdriver](https://github.com/php-webdriver/php-webdriver)を使ってこんな感じですね。

```php
// chromedriverの起動
$driver = RemoteWebDriver::create($host, DesiredCapabilities::chrome());
// 指定URLへ遷移
$driver->get('https://www.google.co.jp/');
```

`javascript`だったら[selenium-webdriver](https://github.com/SeleniumHQ/selenium/tree/master/javascript/node/selenium-webdriver)を使ってこんな感じ。

```js
// chromedriverの起動
const capabilities = Capabilities.chrome()
const driver = await new Builder().withCapabilities(capabilities).build()
// 指定URLへ遷移
await driver.get('https://www.google.co.jp/')
```

大体どの言語でも似たような形で書けるので、なんらかの言語でSeleniumを扱ったことがあるならば他の言語でもスイスイと書けると思います。

**そう、ShellScript以外ならね。**

## WebDriverにBashやZshがないのはおかしい

ShellScript用のWebDriverは見た限りどうも作られていないようなので、「ShellでサクッとSelenium試したい」という方には敷居が高いものになります。
`npm install`やら`composer install`やら一々しないと環境が整わない。
こちとらもっと手軽にブラウザ自動操作ができると思ったのに話が違うじゃないか、となってしまうわけですね。

## Shell芸人のためのSelenium 〜Shellnium〜

ということでShellScriptで手軽にブラウザ自動操作をしたい方へおくります。
下記のようにブラウザ自動操作を**ShellScriptだけ**で実現できます。必要なものは`bash`または`zsh`だけ。

https://github.com/Rasukarusan/shellnium

![demo3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142791/0082dff0-4b5b-5c3b-6abe-3b308a376f85.gif)

## Shellでブラウザの自動操作を行う方法

ブラウザの自動操作の流れは下記のような流れです。ブラウザ(のdriver)に対してcurlでJSONをPOSTしまくるだけです。

<img width="1000" alt="api.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142791/19f576ab-e6ce-d62a-1cb4-26cf93976ed3.png">

1. ブラウザ起動
2. URL遷移
3. キーボード入力

を1つずつ実行することで感覚を掴みましょう。


## 0. chromedriverを起動

まずは`chromedriver`を起動しておきましょう。`chromedriver`がない方は`brew install chromedriver`で一撃です。(もしくは[公式HP](http://chromedriver.chromium.org/downloads)から直接ダウンロード)

```sh
$ chromedriver
Starting ChromeDriver 2.40.565386 (45a059dc425e08165f9a10324bd1380cc13ca363) on port 9515
Only local connections are allowed.
```
これで`http://localhost:9515`でchromedriverが待ち受ける状態になります。

## 1. ブラウザの起動＆セッションIDの取得

では早速Shellからブラウザを起動してみましょう。

```sh
curl -X POST -H 'Content-Type: application/json' \
     -d '{"desiredCapabilities": { "browserName": "chrome" }}' \
     http://localhost:9515/session
```

これだけです、簡単ですね。実行してみるとブラウザが起動すると思います。
今後このブラウザに命令を送るために、セッションを指定して命令を送る必要があるのでセッションIDを取得しておきます。

セッションIDは上記のcurlのレスポンスでJSONが返ってくるので、その中にある`sessionId`を抽出します。せっかくShellScriptを使っているので`jq`を使うと楽ですね。もちろん`grep`等で取り出してもGood。

```sh
$ curl ... http://localhost:9515/session | jq -r '.sessionId'
# セッションIDが取得できる
e1c3d895e7c7d42383c110e5e4cd7bbf
```

## 2. 指定のURLに遷移する

```sh
curl -X POST -H 'Content-Type: application/json' \
    -d '{"url":"https://google.co.jp"}' http://localhost:9515/session/${SESSION_ID}/url
```

実行するとGoogleのTOPページに遷移すると思います。ここで指定する`${SESSION_ID}`は先程取得したものですね。


## 3. 要素の取得&キーボード入力

それではSeleniumっぽいことをしていきましょう。まずはname属性から要素を取得する、`findElementByName()`のようなことをしていきます。

```sh
curl -X POST -H 'Content-Type: application/json' \
    -d '{"using":"name", "value": "q"}' http://localhost:9515/session/${SESSION_ID}/element \
    | jq -r '.value.ELEMENT'
```
`{"using":"プロパティ名", "value": "値"}`で要素を取得することができます。Googleの検索ボックスは`name="q"`となっているので`{"using":"name", "value": "q"}`のように指定します。

<img width="970" alt="google.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142791/cbe44234-3075-8d93-59f4-acd2ecd4319e.png">

上記のcurlを実行すると検索ボックスの**要素ID**が取得できます。

```sh
$ curl ... http://localhost:9515/session/${SESSION_ID}/element | jq -r '.value.ELEMENT'
# 要素IDが取得できる
0.5757440182130869-1
```

要素のクリック、キーボード入力、フォーカスなど、要素を利用してなんらかの動作をするには「要素ID」が必要になってきます。

キーボード入力の場合は下記のような形

```sh
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"value": ["タピオカ\n"]}' http://localhost:9515/session/${SESSION_ID}/element/${elementId}/value
```

注意したいのは**配列形式**で入力する文字列を渡してあげるところですね。また、`\n`を末尾に加えることでENTERの役割をしてくれます。

## Seleniumっぽい動きになる

上記を1つのShellScriptにまとめてあげて実行すればもう完全にSeleniumです。

![demo3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142791/0082dff0-4b5b-5c3b-6abe-3b308a376f85.gif)

```sh:test.sh
#!/usr/bin/env bash

main() {
    # Googleのトップページに遷移
    navigate_to 'https://google.co.jp'

    # 検索ボックスの要素を取得
    local searchBox=$(find_element 'name' 'q')

    # 検索ボックスに入力＆検索実行
    send_keys $searchBox "タピオカ\n"
}

get_session_id() {
    curl -s -X POST -H 'Content-Type: application/json' \
        -d '{
            "desiredCapabilities": {
                "browserName": "chrome"
            }
        }' \
        ${ROOT}/session | jq -r '.sessionId'
}

ROOT=http://localhost:9515
SESSION_ID=$(get_session_id)
BASE_URL=${ROOT}/session/${SESSION_ID}


navigate_to() {
    local url=$1
    curl -s -X POST -H 'Content-Type: application/json' -d '{"url":"'${url}'"}' ${BASE_URL}/url
}

find_element() {
    local property=$1
    local value=$2
    curl -s -X POST -H 'Content-Type: application/json' \
    -d '{"using":"'$property'", "value": "'$value'"}' ${BASE_URL}/element | jq -r '.value.ELEMENT'
}

send_keys() {
    local elementId=$1
    local value=$2
    curl -s -X POST -H 'Content-Type: application/json' \
    -d '{"value": ["'$value'"]}' ${BASE_URL}/element/${elementId}/value >/dev/null
}

main
```

とっても簡単ですね。`curl`でJSONを投げる箇所を小さな関数にすることで、main関数の中身だけみるともう完全にWebDriverの記述になっています。
関数にまとめた部分は`selenium.sh`などにまとめておいて下記のように`source`してあげればとってもシンプルな見た目になります。

```sh:test.sh
#!/usr/bin/env bash
source ./selenium.sh

main() {
    # Googleのトップページに遷移
    navigate_to 'https://google.co.jp'

    # 検索ボックスの要素を取得
    local searchBox=$(find_element 'name' 'q')

    # 検索ボックスに入力＆検索実行
    send_keys $searchBox "タピオカ\n"
}
main
```

## ここからがShellScriptの本領発揮

以上でShellScript版のWebDriverっぽいものが出来上がりです。あとは関数を増やしていけばよりWebDriverに近いものが出来上がるでしょう。  



**...ただこんなところで終われません。せっかくShellScriptで書いているのだからもっとShellらしいことがしたい。**
Shellだからといって命令を送るだけじゃなく、もっとビジュアル的に「ああこいつやべえ、、、」と思われたい。ShellScriptにすればありとあらゆるインターフェースを1つにまとめられる、ここにShellScriptの楽しみがある。**私はそう思うんですよね。**

## iTermとSeleniumの夢のコラボを実現する

ということでiTermとSeleniumをShellScriptでくっつけてみました。

![demo2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142791/3942496e-94c3-f11e-4ab0-62080835c4b8.gif)

**美しい...iTermで作業をしながら裏ではブラウザの自動操作が行われている。**
作業をしながらタピオカの情報が得られるので**まるで分身の術を使ったかのような作業効率。**

ソースはちょっと長いので[Github](https://github.com/Rasukarusan/shellnium)にあげておきますが、main関数だけ載せると下記になります。

```sh:shellnium.sh
main() {
    screen_clear
    navigate_to 'https://google.co.jp'

    local searchBox=$(find_element 'name' 'q')
    set_background_image

    # 入力している感を出すため1文字ずつ入力
    local words=('タ' 'ピ' 'オ' 'カ')
    for word in ${words[@]}; do
        send_keys $searchBox $word
        set_background_image
    done

    # ENTER
    send_keys $searchBox '\n'
    set_background_image

    for i in `seq 0 100 1000`; do
        exec_sync_script "window.scroll(0,$i)"
        set_background_image
    done
}
```

## 終わり

今回のは割とタイトル詐欺というか、Shell(Script)でSelenium作ってはいないし厳密にはWebDriverですらない。
Seleniumの仕組みを知っている人にはおわかりだと思いますが、WebDriverが中でやっていることをShellScriptで直接実行しているだけですね。

Shellにはクラスやインターフェースなど、いわゆるオブジェクト指向的な概念が存在しないため、ブラウザの差異を吸収するといったことがなかなか難しい。
今回はとりあえずchromeに絞ってやってみましたが、他のブラウザもサポートするとなると中々泥臭くなりそうです。

それでもShellScriptはやっぱり手軽だし、環境構築がほぼないし、なにより楽しいからやめられないんですよね。
研修とかで「APIを叩いてみよう」みたいなときに、お天気情報をAPIでGET！じゃなくて味変したいときに、今回のブラウザ操作をやってみても面白いかもしれませんね ^^。

## 参考

- [Github-Shellnium](https://github.com/Rasukarusan/shellnium)
- [WebDriver](https://www.w3.org/TR/webdriver/#get-title)
- [Seleniumブラウザー自動化プロジェクト :: Seleniumドキュメント](https://www.selenium.dev/documentation/ja/)