退屈なことは Selenium にやらせよう ― Selenium と Excel を使った CMS の自動操作サンプル

CMS に登録された数百ページの入力更新を Selenium で自動化するという機会がありました。
復習がてら簡単なサンプルを作成したので公開させていただきます。:sunglasses: 
Selenium API に関する説明はあまり詳しく触れていませんが、CMS 更新を自動化する上でポイントとなるであろう部分について書いてみようと思います。

# 動作確認環境
本サンプルは以下の環境で動作確認をしています。

- Windows 7 / Mac OS 10.13.6
- Node.js 8.x
- Chrome

# 準備
サンプルはこちらの GitHub リポジトリに UP しています。
:octocat: https://github.com/megurock/selenium_example

```shell
$ git clone https://github.com/megurock/selenium_example.git
```

## Node モジュール
`npm install ` で、必要な Node モジュールをインストールします。

```shell
$ cd selenium_example
$ npm install
```

## Chrome ドライバ
Selenium を動かすためには、操作するブラウザ毎に専用のドライバが必要になります（Safari は不要）。今回は Chrome を利用しますので、以下のページから環境に合った Chrome ドライバをダウンロード＆解凍し、作業ディレクトリのルートに保存しておきます。

[chromedriver 2.4.3](https://chromedriver.storage.googleapis.com/index.html?path=2.43/)

# A. 編集作業の流れ

Selenium による自動化を行う前に、CMS 上での一連の編集作業の流れを、画面遷移と併せて確認しておきたいと思います。
実際にブラウザを使って画面を進める場合は、以下のコマンドを実行するとウェブサーバが起動しますので、その状態で http://localhost:3000 にアクセスします。

```shell
$ npm run serve
Listening on port 3000...
```

なおこのアプリケーションは、今回のサンプルのために作成した Express 製なんちゃって疑似 CMS ですので、細かいツッコミはご容赦ください！:sweat:

## A-1. ログイン
`http://localhost:3000`
CMS 管理画面へログインします。基本認証が設定されているので、ユーザ名**「foo」**、パスワード**「bar」**を入力します。
![auth.png](https://qiita-image-store.s3.amazonaws.com/0/37412/bed6512e-9419-e184-80fa-5411e06a2028.png)
ログイン画面です。ログイン ID **「guest」**、パスワード**「xyz」**を入力します。
![login.png](https://qiita-image-store.s3.amazonaws.com/0/37412/281c494f-5cd2-4f30-3f58-f370a76b3add.png)

## A-2. ログイン成功～検索画面へ
`http://localhost:3000`
ログインに成功しました。**「検索画面へ」**ボタンをクリックします。
![welcome.png](https://qiita-image-store.s3.amazonaws.com/0/37412/dd0c09f5-b117-62d8-0486-15bc30f04d09.png)

## A-3. ページ検索
`http://localhost:3000/search`
検索画面です。ページ ID に**「A001」**を入力し、**「ページ検索」**ボタンをクリックします。
![search.png](https://qiita-image-store.s3.amazonaws.com/0/37412/c576a644-c47e-1b84-ce30-c3540c9c8275.png)

## A-4. ページ編集
`http://localhost:3000/edit/A001`
ページ編集画面です。予め登録されている内容が表示されますので、適当に編集し**「保存」**ボタンをクリックします。
![before.png](https://qiita-image-store.s3.amazonaws.com/0/37412/b9ab733b-5f1b-b9a0-4d5a-0cce905e6e1c.png)

## A-5. 編集完了
`http://localhost:3000/done/A001`
編集完了画面です。これで **A001** ページの編集が完了しました。
この後プログラムではさらに **A-3.「検索画面」**へと戻り、引き続き **A002** ページの編集を行う、というような流れになります。
![done.png](https://qiita-image-store.s3.amazonaws.com/0/37412/93027a3f-6fdb-1a20-bb14-90cc9261a090.png)


# B. Selenium による自動化

それでは先の一連の処理を Selenium で行います。

`npm run serve` にてウェブサーバを起動した状態で、別のターミナルから以下のコマンドを実行します。

```shell
Listening on port 3000...

$ npm start
```

Chrome が自動的に立ち上がり、2 ページ分の編集作業を行います。

また、作業ディレクトリの **results** フォルダ配下に、**20181101_202355.csv** のような名前で、各フィールドの編集前後の値をログファイルとして出力するようにしてあります。併せて確認してみてください。（この CSV ファイルの文字コードは UTF-8 でエンコードされています。Excel で開く場合は、予め Shift_JIS に変換しないと文字化けをしますので、ご注意ください。）

```csv:20181101_202355.csv
"ID",名前[前],性別[前],言語[前],熟練度[前],名前[後],性別[後],言語[後],熟練度[後]
A001,山田太郎,man,js,junior,鈴木花子,woman,"js,python,ruby",intermediate
A002,田中一郎,man,"ruby,php",intermediate,後藤卓,man,js,junior
```

## B-1. データの準備

CMS 上で入力するテキストや、選択したいラジオボタンの値などは、事前にすべて Excel (**list.xlsx**) に記載しておきます。

今回は**「target」**というカラムを用意し、自動化プログラムの対象にしたいページについては、何かしらの値（サンプルでは「1」）を入力するということにしています。また、シートには**「pages」**と命名をしていることにも留意ください。

**list.xlsx**
![list.png](https://qiita-image-store.s3.amazonaws.com/0/37412/65bbfd5d-830b-73ff-7623-a7f52a676d68.png)


## B-2. Excel のパース

Excel のパースには、[js-xlsx](https://github.com/SheetJS/js-xlsx) という Node モジュールを利用しています。
先に設定した Excel のシート名「**pages**」と「**target**」カラムを用い、作業対象のデータを抽出し、配列に格納します。

なお以降に記載するプログラムは `index.js` からの抜粋になります。
説明の簡潔さのためにコードを変更＆省略している箇所もありますので、詳しくはソースコードを参照ください。:bow:

```js:index.js
const xlsx = require('xlsx')

/**
 * Excel を読込み、パース結果を JSON 配列として返却
 */
function getPages(pathToExcel) {
  const workbook = xlsx.readFile(pathToExcel)
  return xlsx.utils.sheet_to_json(
    workbook.Sheets['pages']                  // 対象シートを指定
  ).filter(page => page.target !== undefined) // 対象データを抽出
}

/**
 * 初期実行関数
 */
(function init() {
  const pages = getPages('./list.xlsx')
  // => [{ id: 'A001', name: '山田花子', sex: 'woman', language: 'js, python, ruby, php', level: 'senior', target: 1 },.. ]
})()
```

## B-3. Selenium の起動

ここからが Selenium の操作です。各々の API の詳細は、[Selenium API](https://seleniumhq.github.io/selenium/docs/api/javascript/index.html) を参照ください。

Selenium API の多くは、Promise オブジェクトを返す非同期メソッドです。最近の JavaScript には、[async](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/async_function) や [await](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/await) といった、遅延実行処理をスマートに捌くための機能が用意されているで、積極的に利用していくことをｵｽｽﾒします。

画面遷移を挟む場合はもちろんのこと、何かしらのユーザアクションの後に DOM の表示/非表示に少しでも時間がかかる場合は、ブラウザの処理の完了を確実に待つようにします。遅延処理の結果を待つべきところで、うっかりと `await` を書き損じると、「ﾂﾙｯ」とプログラムが流れてしまい、意図せぬエラーを生む原因となるのでご注意ください。

```js:index.js
const { Builder } = require('selenium-webdriver')
// const chrome = require('selenium-webdriver/chrome')　ヘッドレスモードで起動する場合は必要
let driver

/**
 * Chromeドライバを生成
 */
async function createDriver(option = { width: 480, height: 720 }) {
  return new Promise(async resolve => {
    // Chromeドライバを起動
    const driver = await new Builder()
                    .forBrowser('chrome')
                    // ヘッドレスモードで起動する場合は、以下をアンコメント
                    //.setChromeOptions(new chrome.Options().headless())
                    .build()
    // ウィンドウサイズを指定してブラウザを開く
    await driver.manage().window().setRect(option)
    resolve(driver)
  })
}

/**
 * 初期実行関数
 */
(async function init() {
  const pages = getPages('./list.xlsx')
  driver = await createDriver()
})()
```

## B-4. ログイン

基本認証が設定されている場合、例えばログイン URL が `https://example.com/login` だとすると、Seleniumでは
`https://[認証ID]:[認証パスワード]@example.com/login` というフォーマットの URL にアクセスをすると、基本認証を越えることができます。

```config.js
// ログインに必要な ID やパスワード等
module.exports = {
  basicAuthId: 'foo',                 // 基本認証ID
  basicAuthPassword: 'bar',           // 基本認証パスワード
  loginUrl: 'http://localhost:3000',  // ログインURL
  loginId: 'guest',                   // ログインID
  loginPassword: 'xyz',               // ログインパスワード
}
```

```js:index.js
const { Builder, By, Key, until } = require('selenium-webdriver')
const config = require('./config.js')

/**
 * ログイン処理
 */
async function login() {
  return new Promise(async (resolve) => {
    // http://foo:bar@localhost:3000
    const url = config.loginUrl.replace(/(https?:\/\/)(.+)/, `$1${config.basicAuthId}:${config.basicAuthPassword}@$2`)
    // 基本認証のIDとパスワードを設定しつつ管理画面へアクセス
    await driver.get(url)
    // 省略
  })
}

/**
 * 初期実行関数
 */
(async() => {
  const pages = getPages('./list.xlsx')
  driver = await createDriver()
  await login()
})()

```

無事ベーシック認証を越えて、ログイン URL へのアクセスが成功したら、ログイン入力フィールドに必要な情報を入力の上ログインを実行します。

`findElement(s)` や `By` を用い DOM 要素を参照する処理や、`wait` によるブラウザの完了待機は Selenium 操作の基本になります。

```js:index.js
/**
 * ログイン処理
 */
async function login() {
  return new Promise(async (resolve) => {
    // 省略
    await driver.get(url)
    // ログインIDを入力
    await driver
      .findElement(By.css('input[name=username]'))
      .sendKeys(config.loginId)
    // ログインパスワードを入力し、[RETURN]キー押下げ（ログイン実行）
    await driver
      .findElement(By.css('input[name=password]'))
      .sendKeys(config.loginPassword, Key.RETURN)

    // ログイン成功メッセージが表示されるまで最大3秒待ちます。
    await driver
      .wait(async () => {
        const message = await driver.findElement(By.id('message')).getText()
        return message.indexOf('こんにちは') === 0
      })

    resolve()
  })
}
```


## B-5. ページの編集

ページの編集は `edit` という関数で行います。この関数は `loopEdit` 内にて、すべてのページの編集作業が終わるまで、1 ページ毎に呼び出されます。
`edit` 内の実装は、比較的シンプルですが記載するには少々長いので割愛しますが :sweat:、 [このような内容](https://github.com/megurock/selenium_example/blob/master/index.js#L136-L190) となっています。


```js:index.js
let countDone = 0

/**
 * ページ編集処理
 */
async function edit(page) {
 // ここに各ページの編集処理を記述
}

/**
 * すべてのページの編集が終わるまでループ
 */
async function loopEdit(pages) {
  return new Promise(async resolve => {
    const totalPages = pages.length
    if (countDone < totalPages) {
      console.log(`${countDone + 1}ページ目の編集を開始します。`)
      const page = pages[countDone]
      await edit(page)
      // 編集作業が完了したら完了カウントを加算
      countDone++
    }
    // 全ページの編集が完了したらPromiseを解決
    if (countDone === totalPages) {
      console.log('全ページの編集が完了しました。')
    // まだ作業が完了していないページがあれば再帰呼び出し
    } else {
      await loopEdit(pages)
    }
    resolve()
  })
}

/**
 * 初期実行関数
 */
(async function init() {
  const pages = getPages('./list.xlsx')
  driver = await createDriver()
  await login()
  await loopEdit(pages)
  await driver.quit() // ドライバを停止
})()
```

## B-6 編集結果の出力

各ページの編集結果は 1 ページ処理を行う毎に、CSV にログ出力するようにしました。
出力するのはページ ID と、編集前と編集後の各フィールドの値です。

```js:index.js
const fs = require('fs-extra')
const path = require('path')
const moment = require('moment')

// CSVのヘッダ設定
const ws = xlsx.utils.json_to_sheet([], { 
  header: ['ID', '名前[前]', '性別[前]', '言語[前]', '熟練度[前]', '名前[後]', '性別[後]', '言語[後]', '熟練度[後]'] 
})
// ログファイルの出力設定。プログラム実行時の日時ベースの名前とする
const pathToOutputResults = `results/${moment().format('YYYYMMDD_HHmmss')}.csv`

/**
 * ログを出力する
 */
function writeResults(result) {
  xlsx.utils.sheet_add_json(ws, [result], { skipHeader: true, origin: -1 })
  const csv = xlsx.utils.sheet_to_csv(ws)
  fs.outputFileSync(pathToOutputResults , csv)
  console.log(`結果を出力しました。`)
}

/**
 * すべてのページの編集を再帰的に行う（抜粋）
 */
async function loopEdit(pages) {
  return new Promise(async resolve => {
    // edit 関数は、ページID、編集前/編集後の各要素の値をPromise解決時に渡す
    const result = await edit(page)
    // プロパティの順番はワークシートに合わせる
    writeResults(result)
    resolve()
  })
}
```

# C. TL;DR

駆け足での説明となりましたが、最後に CMS 操作を自動化してみた感想や、気を付けたほうが良い点などをいくつかﾀﾞﾗﾀﾞﾗと綴り、締めさせていただきます。

## C-1. 自動化のメリット

今後も長期的に CMS の更新作業が見込まれる運用案件は、自動化を検討してみてもよいのではないでしょうか。自動化することによるメリットは、作業時間の短縮よりも、正確性の向上にあると思います。（もちろん作業時間自体も、人力での作業よりは短縮されますが、CMS のレスポンスが悪い場合は、Selenium であっても意外と時間がかかります。）

## C-2. 人が操作するのと同じ動きを心がける

例えば今回のサンプルにおいて画面遷移を行う際に、ボタンクリックではなく、ブラウザの URL を直接変更している箇所があります。こういった実際のユーザ操作とは異なるアクションを Selenium で行うことはよろしくありません。アプリケーションの動作を把握している場合は問題ないのですが、本来はボタンクリックをトリガーにして、目に見えない部分で JS が実行されているかもしれませんし、サーバに何かしら値が送信されているかもしれません。このようにブラウザの処理はなかなかに複雑で分からないことが多いので、できるだけ実際に人間が操作するときと近しい動きを Selenium でも行うようにしてあげると、予期せぬ事態を招くのを回避する可能性が高まるのではないかと思います。

## C-3. エラーハンドリングはしっかりと！

今回のサンプルではエラー処理をほとんど入れてませんが、数百、数千ページの更新をプログラムを終了させずに、数時間に渡って実行させつづけるのは、容易なことではありません。プログラムを万全に書いたつもりでも、CMS 側の予期せぬレスポンスや、はたまた不可解な挙動に悩まされることもあるかもしれません。

エラーが起きないようなプログラムを心がけるのはもちろんですが、たとえエラーが発生した場合でもプログラムが落ちてしまわないように、エラー処理を丁寧に書いておくことが大切です。プログラムが落ちさえしなければ、重度なエラーが発生した場合でも、問題が発生したページをスキップしたり、場合によっては Selenium ドライバを停止し、再起動してあげることも可能です。

JavaScript の非同期プログラミングのエラーハンドリングは、通常のそれより少々難しいのですが、ES6 の新機能である [Generator](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Generator) を使うと、非同期処理で発生したエラーをキャッチしやすくなります。Generator をコマンドパターンへ応用した（と個人的に解釈している）[co](https://github.com/tj/co) というライブラリも便利なので、気になる方は調べてみてください。

退屈な作業は自動化して、エンジニアがより興味の持てることに時間をさけると良いですね！
ハッピーコーディング！ :sunglasses: