puppeteerで黒い画面から日報を提出する

日報書いてますか？
私は(ほぼ)毎日のように書いています。

私の所属する会社には社内向けのポータルサイトがあって、そのシステムの一部として日報を書ける仕組みが提供されているのですが、普段は常駐先で業務を行っているため「自社のポータルサイトを閲覧することが憚られる」という悩みがありました。お客さんによっては「何やってるの？」って思う人もいますからね(一応許可は貰ってました)。

そこで、私はエンジニアなので**「黒い画面だったら業務しているように見えるんじゃね？」**という浅はかな考えのもと、puppeteerでポータルサイトをスクレイピングして投稿まで出来るようにしました。この記事ではpuppeteerのAPIを一部紹介しながら、日報の投稿まで完了する方法を紹介します。

## 実装方法:hammer_pick:
前提として以下のツールや技術スタックを用います。

| ツール | 
|---|
| OSX |
| ターミナル |
| Node.js(v10.15.1) |
| puppeteer  |

~~※ターミナルなどのsshクライアントは黒ければなんでもいいです。~~
実装の手順は下記のカンタン3ステップになります。

1. 環境構築
2. ログイン情報を入力してログインする
3. 日報を書いて送信する


### 1. 環境構築
まずは環境を作ります。大したことはしてないですが一応書いておきます。

```bash:ターミナル
$ mkdir nippo
$ cd nippo
$ npm init -y
$ npm install -D puppeteer
$ touch index.js
```

```javascript:index.js
const puppeteer = require('puppeteer')

(async function() {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()

  /*
   * ここに処理を書いていきます。
   */

  await browser.close()
})()
```

上記でプロジェクトディレクトリ配下でpuppeteerを使う準備ができました。

`puppeteer.launch()`で puppeteer moduleで提供されるChromiumインスタンスを立ち上げます。これにより、DevTool Protocolを介したChromiumの操作が可能となります(Google ChromeでDOM Elementsを編集しているイメージ)。
`browser.newPage()`で新しいページのオブジェクトが生成され、最後の`browser.close()`で Chromium とすべてのページを閉じます。

即時関数をasyncにしている理由は、puppeteerのAPIのほとんどがPromiseを返すからです。これによりawait演算子の使用が可能となり、ブラウザ操作の完了を待つことができます。

### 2. ログイン情報を入力してログインする

それでは実際にログインページでログインする処理を書いていきます。
前提として、ログインページは下記のようなDOMを持っています。

```html:login.html
<form>
  <label>email</label>
  <input id="email" type="text" name="email" />
  <label>password</label>
  <input id="password" type="password" name="password" />
  <button type="submit">Login</button>
</form>
```

このフェーズでやりたきことは下記のようになるかと思います。

2-1. ログインページへ行く
2-2. メールアドレスとパスワードを入力する
2-3. ログインボタンを押す

この処理をpuppeteerでは下記のように表現します。

```javascript:index.js
const puppeteer = require('puppeteer')

(async function() {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()
  // ログインページへ行く
  await page.goto('https://example.com/login')
  // メールアドレスとパスワードを入力する
  await page.type('#email', 'slncu@example.com')
  await page.type('#password', 'secret')
  // ログインボタンを押す
  const loginButton = await page.$('button[type=submit]')
  await loginButton.click()

  await browser.close()
})()
```

まず、`page.goto(url)`でログインページへ遷移します。このAPIの良い点として、複数のリダイレクトが走った場合には、最終的にリダイレクトされた先のURLで解決されることです。そのため、開発者はネットワーク周りの煩わしさに関与する必要がありません。
`page.type(selector, text)`で指定したセレクタに対してtextを入力します。
最後に`page.$(selector)`でログインボタンを指定して、`loginButton.click()`とします。この`page.$(selector)`はjQueryではなく単に`document.querySelectorAll`の糖衣構文的なものです。
これらのAPIはすべてPromiseを返すため、先述したようにawaitすることによってすべての操作完了を同期的に行うことができます。

### 3.日報の内容を書く

日報ページのDOMは下記のようになります。

```html:report.html
<form>
  <label>日付</label>
  <input type="date" name="date" value="2019-03-06" />
  <label>出勤時間</label>
  <input type="time" name="start_at" value="10:00:00" />
  <label>退勤時間</label>
  <input type="time" name="end_at" value="19:00:00" />
  <label>休憩時間</label>
  <input type="time" name="break_at" value="01:00:00" />
  <label>概要</label>
  <textarea name="summary"></textarea>
  <label>所感</label>
  <textarea></textarea>
  <label>連絡事項</label>
  <textarea name="info"></textarea>
  <button type="submit" class="btn btn-primary">登録</button>
 </form>
```

このDOMで操作したいものは「概要」「所感」「連絡事項」となります。
日付や勤務時間ももちろん操作できるんですが、**弊社のシステムではデフォルトプリセットされているので割愛します**(ありがとう弊バックエンドエンジニアさん)。
「定時で帰宅できないから勤務時間も弄りたい！」って人は定時で帰れるように頑張ってください。

実際に処理を実装していくのですが、注意しないといけないことがあって、このページは**前ページ(ログインページ)から遷移した状態からスタートする**ことです。そのため「ログインボタン押下→次のページのDOM読み込みが完了」という処理が発生することを考えなければなりません。手順としては以下のようになります。

(前ページから遷移してきた)
3-1. 日報を書く
3-2. 日報を送る

```javascript:index.js
const puppeteer = require('puppeteer')

(async function() {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()
  // ログインページへ行く
  await page.goto('https://example.com/login')
  // メールアドレスとパスワードを入力する
  await page.type('#email', 'slncu@example.com')
  await page.type('#password', 'secret')
  // ログインボタンを押す
  const loginButton = await page.$('button[type=submit]')
  await loginButton.click()
  // (前ページから遷移してきた)
  await page.waitForNavigation({ waitUntil: 'domcontentloaded' })
  // 日報を書く
  await page.type('textarea[name=summary]', 'バグ修正')
  await page.type('textarea[name=report]', 'IE8のみ発生するバグを直しました。')
  await page.type('textarea[name=info]', '明日は13:00に出社します')
  // 日報を送る
  const submitButton = await page.$('button[type=submit]')
  await submitButton.click()
  // ナビゲーション完了を待たないと、送信する前にブラウザを閉じてしまう
  await page.waitForNavigation({ waitUntil: 'domcontentloaded' })

  await browser.close()
})()
```

`page.waitForNavigation(options)`というAPIを使用しました。このAPIを使用することによって、アンカーリンクやform submitによって新しいURLに遷移した際に、`options.waitUntil`で指定した値でナビゲーションの完了(つまりページのロード)を待つことができます。`waitForHogehoge`系のメソッドは多く用意[^refer]されているので用途によって使い分けてみるのが良いと思います。

[^refer]: https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md

上記でpuppeteer部分の実装は完了となります。
下記のコマンドで日報が送信できるようになりました:clap:

```bash:ターミナル
$ node index.js
```
しかし、このままでは「概要」「所感」「連絡事項」を書くために毎日ソースコードを書き換えなくてはなりません。

そこでNode.jsのコマンドライン引数を活用して、項目の入力をターミナル上で完結できるようにします。ソースコードを下記のように修正します。

```javascript:index.js(完成版)
const puppeteer = require('puppeteer')
const { argv } = process
const summary = argv[2]
const report = argv[3]
// 毎日連絡事項があるわけではないので'なし'を入れるようにする
const info = argv[4] === undefined ? 'なし' : argv[4]

(async function() {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()
  // ログインページへ行く
  await page.goto('https://example.com/login')
  // メールアドレスとパスワードを入力する
  await page.type('#email', 'slncu@example.com')
  await page.type('#password', 'secret')
  // ログインボタンを押す
  const loginButton = await page.$('button[type=submit]')
  await loginButton.click()
  // (前ページから遷移してきた)
  await page.waitForNavigation({ waitUntil: 'domcontentloaded' })
  // 日報を書く
  await page.type('textarea[name=summary]', summary)
  await page.type('textarea[name=report]', repot)
  await page.type('textarea[name=info]', info)
  // 日報を送る
  const submitButton = await page.$('button[type=submit]')
  await submitButton.click()
  // ナビゲーション完了を待たないと、送信する前にブラウザを閉じてしまう
  await page.waitForNavigation({ waitUntil: 'domcontentloaded' })

  await browser.close()
})()
```

```bash:ターミナル
$ node index.js バグ修正 IE8のみ発生するバグを直しました。
```
以上で、黒い画面から日報が送信できるようになりました:clap::clap::clap:

## まとめ

今回はpuppeteerの練習がてらこのような~~くだらない~~ものを錬成しましたが、業務においてもe2eテストなどで十分実践可能なモジュールと感じました。昨今のフロントエンド動向を見てもテストを書くことの意義は十分にありますので、この機会に是非puppeteerを書いてみるのは如何でしょうか？

また、この記事の説明では、簡単のためすべて同期的に書いていますが、そのような必要のない箇所に関しては、`Promise.all()`などを使っても良いと思います。

余談ですが、**ターミナル経由だと改行を表現できない**ため、もう一度このツールを使おうという意志はまったくなく、おとなしくスマホから日報を書いていこうと思います:smile: