ASP.NETで作られたわりとレガシな外部WebアプリケーションをPuppeteerで操作させたときの備忘録

## 本記事の内容

ASP.NETで作られた外部Webアプリケーションに対して、数百件の登録作業を行いたいが、レガシーなシステムのためRest/SOAP等のAPIが存在しない！しかたがないので、Puppeteerを使ってブラウザを自動操作させ楽しよう、としたときに、実装した基本的な処理や躓いた箇所などを忘れないようにするための備忘録です。

Puppeteerだけでなく、node.jsにもそれほど詳しくない人間が書いているので、基本的なところから記述していきます。


[Puppeteer GitHub](https://github.com/GoogleChrome/puppeteer)
[Puppeteer API document](https://github.com/GoogleChrome/puppeteer/blob/v1.17.0/docs/api.md)


### 何はともあれプロジェクトのためのディレクトリ作る

取りあえず新規にディレクトリを作り、その中にコードやパッケージを突っ込んでいきます。
npm initコマンドでpackage.jsonを作り、取りあえずこの時点でgitのローカルリポジトリを作って最初のコミットをしておきます。

```bash
mkdir puppeteer
cd puppeteer
npm init
git init
git add .
git commit -m initial
```


### Puppeteerをパッケージに追加

```bash
npm install --save puppeteer
```

package.jsonの依存パッケージにpuppeteerが加わったことが確認できればOK。


app.jsというファイルを新規に作りガシガシコードを書いていく。



### 基本的な実装

PuppeteerのGitHubのReadmeに書いてある通り、基本的には下記のコードの/\*なんらかの処理\*/に色々追記していくことになります。

```javascript
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();  //pageはChromium上の1つのタブに相当するオブジェクト
  await page.goto('https://example.com'); //Todo: ここにレガシな外部WebアプリケーションのURLを入れる。
  
  /*なんらかの処理*/

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

### 実装パターン

ここからはポイントを絞ってコードのスニペットを記述していきます。


#### inputタグに文字列を流し込んで、最後にSubmitボタンを押下したい

まずはユーザーIDとパスワードを入力して外部Webにログインしなければはじまりません。
フォームにテキストを流し込むところまでは下記のコードでできます。

```javascript
await page.type('#{ここにユーザーIDを入力するためのinputタグに付与されたidを記述}', '{ここに入力したいユーザーID}');
await page.type('#{ここにパスワードを入力するための(以下略)', '{ここに入力したいパスワード}');
```

その後、サブミットボタンを押下して、ページ遷移を待つコードは下記のコードで実現できます。

```javascript
await Promise.all([
    page.waitForSelector('{ログイン後に現れる何らかのページ要素のCSSセレクターをここに}', {visible: true}), //特定の条件まで待機する指示
    page.click('{クリックするボタンを指定するためのCSSセレクターをここに}') // クリックの指示.
]);
```


#### 処理が定義通り進んでいるか画面見ながら実装したい

外部Webアプリケーションなので、当然DOMの要素について細かいことまで事前に予見することができません。
動かしてみて、Try&Errorを繰り返しながら実装を進めたくなります。
しかし、デフォルトではUIを画面に表示しないまま処理が進むので、どこまで処理が正しく進んだか分かり難いです。
その様なときには、前述の基本的な実装の `puppeteer.launch()`のパラメータを指定してやると画面が表示されます。

```javascript
    const browser = await puppeteer.launch({
        headless: false
    });
```


#### Puppeteerの自動実行の操作にWebアプリケーション側が追い付かないのでゆっくり操作させたい

ASP.NETですので生成されるDOMも非常に重たくてもたつきます。こんな時は、Puppeteerでの自動処理をゆっくりにさせたいときがあります。
これも、前の例と同様puppeteer.launchの引数の中で、slowMoというパラメータを与えてあげると処理を遅くすることができます。
また、ブラウザのページ遷移などが遅くPuppeteerが待ちきれずタイムアウトが発生してしまう場合、デフォルトのタイムアウト値を変更することができます。

前の例と併せて次のように書くことができます。

```javascript
    const browser = await puppeteer.launch({
        headless: false,
        slowMo: 100,
        timeout: 60000 //単位はミリ秒
    });
```


#### iframeの中身を操作したい

Puppeteerの記事でよく目にするサンプルでは、pageオブジェクトに対してメソッドを呼び出して操作を行うものが多いですが、iframeの中に操作対象がある場合、サンプルのままではうまく動きません。
そこで、次のようにしてFrameオブジェクトを取り出して、このオブジェクトに対してAPIで操作を指示していきます。


```javascript
    const mainframe = await page.frames().find(f => f.name() === 'mainFrame');
```

この例では、iframeのname属性を条件にFrameオブジェクトを取り出しています。


#### ポップアップされるウィンドウも操作したい

ポップアップしたウィンドウで何らかのインプットをして閉じる、一昔前のUIではよくありました。
これもPuppeteerで対応できます。


```javascript
    const newPagePromise = new Promise(x => browser.once('targetcreated', target => x(target.page())));
    await page.click('{ポップアップウィンドを表示させるトリガーとなるボタンのCSSセレクターをここに}');
    const pp = await newPagePromise; //先のppがポップアップしたウィンドウのPageオブジェクトになるので、このオブジェクトに対してAPIで操作を指示していくことができます。
```


#### 特定の条件の子要素をもつ親要素を見つけて操作したい

Gridの中から特定の行を選びたいときがあります。XPATHを利用すると割とスッキリ書けました。
下記は、GridTableというidのついたtable要素の子要素の内で、classがRowである要素を選び出します。
Rowの名前の通り、tableの行数分だけ要素があり、必要なものを条件で選んだうえで、そのRawの内側にあるチェックボックスにチェックをしたい、というときのコードスニペットです。
Rawの中の列に対応する要素はOneCellというクラスで定義されています。少々雑ですが、一つの行の中に"ABC"というテキストを含むOncCellというクラス名のtd要素と、"2019"というテキストを含むOneCellというクラス名のtd要素がある、という条件を満たすものを検索します。

```javascript
        const grid_row = await page.$x(`id("#GridTable")//*[@class="Row" and ./td[@class="OneCell"]//text()="ABC" and ./td[@class="OneCell"]//text()="2019"]`)
        const check_box = await grid_row[0].$('input[type="checkbox"]')
        await check_box.click();
```


#### 正しく操作していてもWebサーバー側のエラーで処理が最後までできないのでリトライしたい

普通に手で操作していても、負荷に耐え切れずしばしばエラーページに突然リダイレクトされてしまう、そんな外部Webアプリだったとします。このような時にPuppeteerは、次に想定するDOMの要素が見つからずに例外を吐いて処理を停止してしまうでしょう。この例外を捕まえてもう一度最初から再処理するために、今回はpromise-retryを使いました。


下記のnpmコマンドを打って、promise-retryを追加します。package.jsonにも追加しておきましょう。

```bash
npm install --save promise-retry
```

app.jsの冒頭を次のように書き換えます。

```javascript
const promiseRetry = require('promise-retry')
const puppeteer = require('puppeteer');
```

そして次のように実装します。

```javascript
const q = source.map(con=> async ()=> {
    await promiseRetry( (retry, number) => doSomething(con).catch(retry) );
});
Promise.all(q);
```

ここで、`source`は、冒頭で述べた処理しなければいけない数百件の対象がArrayとして格納されていると思って下さい。
doSomethingの処理の中では、それぞれの要素に対して`const browser = await puppeteer.launch();～`以降の処理を実行します。
このdoSomethingの処理の中で、予期しないエラーページへのリダイレクトに起因して例外が吐かれると、promise-retryにより処理を繰り返してくれます。
リトライの階数は、デフォルトでは10回ですが、`promiseRetry({retries: 5}, (retry, number) => doSomething(con).catch(retry) );`のようにpromiseRetryの最初のパラメータでretriesパラメータを渡しておくと、回数を変えることができます。

詳しいドキュメントは下記にあります。
[promise-retry -npm](https://www.npmjs.com/package/promise-retry)




#### ブラウザを同時並行に立ち上げて一斉に処理したい

数百件の登録処理です。自動化したといえど順次処理していると数時間かかってしまいました。
そこで同時並行処理を考えます。しかし、ただ単純に数百件を同時に処理してしまうと、いくらなんでもクライアントのリソース（主にメモリー）が枯渇してしまいますし、サーバーサイドの負荷も甚大です。そこでThrottlingしながら 処理したくなります。ここではpromise-parallel-throttleといういいものがあったので、これを使って次のように解決しました。


下記のnpmコマンドを打って、promise-parallel-throttleを追加します。package.jsonにも追加しておきましょう。

```bash
npm install --save promise-parallel-throttle
```

app.jsの冒頭を次のように書き換えます。

```javascript
const Throttle = require('promise-parallel-throttle')
const promiseRetry = require('promise-retry')
const puppeteer = require('puppeteer');
```

処理の本体は、先述のpromise-retryと併せて次のように書くことができます。

```javascript
const q = source.map(con=> async ()=> {
    await promiseRetry( (retry, number) => doSomething(con).catch(retry) );
});
await Throttle.all(q, {maxInProgress: 5});
```
maxInProgressの値でスロットルを制御しています。同時に5つのdoSomething処理しか実行されなくなりました。


### 最後に

以前にもブラウザを自動操作するiMacrosなどを使ってみたことがありますが、比べるとPuppeteerは非常に処理が安定していて、
さすがgoogle謹製と思いました。また、slowMoパラメータに対応していたり、ポップアップもハンドリングできたりと、かゆいところにも手が届いてよかったです。