Puppeteerで次ページへの遷移を待つ

[Puppeteer](https://github.com/GoogleChrome/puppeteer/)で次ページへの遷移を待つ場合の書き方について。

# 通常の遷移の場合

新規ウインドウが開かないような通常の遷移を待つ場合、以下の書き方が決まり文句だという認識です。

```js
await Promise.all([
  page.waitForNavigation(),
  page.click('a')
]);
```

ページ遷移を待ち受けてからページ遷移を実行して両方の完了を待つ、という処理です。

`Promise.all()`を使わない変形として次のような書き方も可能です（私は最初の例の方が見やすいと思いますが）。

```js
let loadPromise = page.waitForNavigation();
await page.click('a');
await loadPromise;
```

次のような書き方をしている人を見かける気がしますが、これは完璧とは言えません。大抵はうまく動くのですが、低確率でタイムアウトエラーが出ます。


```js
page.click('a');
await page.waitForNavigation();
```

というのも、この処理順だと `page.waitForNavigation()` を実行する前に `page.click()` によるページ遷移が終わっていることがあるのです。そうなると`page.waitForNavigation()`はページ遷移を待ち続けてタイムアウトしてしまいます。

非同期処理って難しいですよね…

# 通常の遷移の場合（さらに慎重なパターン）

上の書き方にはまだ問題が残っています。

実は、`page.waitForNavigation()`のデフォルト挙動ではページ読み込みが終わったタイミング（load）までしか待ってくれません。つまり、このタイミングではCSSやJavaScriptの解釈が終わっていない可能性があります。JavaScriptでHTMLを動的に変更するようなページでは、通信が最後に発生してから500ms待ってくれる `{waitUntil: 'networkidle0'}` や  `{waitUntil: 'networkidle2'}` などを使うと良いでしょう[^networkidle]。

[^networkidle]: networkidle0はコネクション数が0本になってから500ms待ち、networkidle2はコネクション数が2本以下になってから500ms待ちます。

一方で、常に`load`より`networkidle2`の方が良いという話でもありません。非力なマシン（Raspberry Piなど）だと、通信が終わって500ms後でもHTMLのレンダリングが終わっていないことがあるのです。

つまり、次のように両方を指定するのが良さそうです[^waituntil]。

[^waituntil]: waitUntilは配列で複数イベントを指定することができます。その場合、すべてのイベントが発火するまで待ちます。

```js
await Promise.all([
  page.waitForNavigation({waitUntil: ['load', 'networkidle2']}),
  page.click('a')
]);
// 次ページ用の処理
```

これでもまだJavaScriptの解釈が終わっている保証はありませんから、状況次第ではさらに特定要素の出現を待つなどする必要があるでしょう。

# 次ページの要素の出現を待つ

すでに説明した通り、`page.waitForNavigation()`では確実にページ遷移を待てるとは言えません。`page.waitForNavigation()`が不確実な状況では、単に次ページの要素の出現を待つ方が良いかもしれません。

```js
await page.click('input[type="submit"]');
await page.waitForSelector('div.result'); // <div class="result">が遷移後ページのみ存在する前提
```

今のページには存在せず、次のページに必ず存在する要素がある場合は上のように記述すれば遷移完了を待てるというわけです。

次ページが2パターンある場合も同様に記述できます。例えば結果ページかエラーページのどちらかに遷移する場合は次のように書けるはずです[^nextpage]。

[^nextpage]: 次ページのHTML構造が十分特徴的なら、という条件つきですが

```js
await page.click('input[type="submit"]');
await page.waitForSelector('div.result, div.error');
```

ただし、これだけだと結果ページに遷移したかエラーページに遷移したかはわかりません。区別したい場合は遷移後に改めて`page.$()`で確認すれば良いでしょう。

ちなみに、私はこのために専用の関数を用意しています。自画自賛ですが、なかなか便利だと思います。

```js
const {Page} = require('puppeteer/lib/api');
Page.prototype.waitForSelectors = async function (selectors, options) {
  const ret = await this.waitForSelector(selectors.join(','), options);
  return Promise.all(selectors.map(async (selector) => this.$(selector)));
}

（略）

await page.click('input[type="submit"]');
const [result, error] = await page.waitForSelectors(['div.result', 'div.error']);
```

# サブウインドウの表示を待つ

たとえば「確認」ボタンをクリックするとJavaScriptでサブウインドウが出てくるような場合には、次のようにサブウインドウの要素を指定して`page.waitForSelector()`で待ちましょう。

```js
    await page.click('a');
    // サブウインドウ待ち
    await page.waitForSelector('input[name="foo"]', {visible: true});
```

このとき、第二引数の`visible: true`は重要です。サブウインドウの類はDOMとしては最初から存在していることが多いので、これをつけないとinvisibleなうちに次の処理に進んでしまいます。

# 別タブや別ウインドウの遷移を待つ

`<a href="..." target="_blank">` や `window.open()` によるリンクの場合もサブウインドウの場合と考え方は同じですが、遷移後の`Page`を取り出すのに`EventEmitter`が絡んでくるので少し複雑になります。また、この書き方はPuppeteer1.6.0以降が必要です。

```js
const newPagePromise = new Promise(resolve => browser.once('targetcreated',
                                                           target => resolve(target.page())));
await page.click('a');
const newPage = await newPagePromise;
await newPage.waitForSelector('input[name="foo"]', {visible: true});
```

注意点として、`target.page()`が呼ばれた直後は新ウインドウが作られていてもページ遷移していない可能性があるので、ページ遷移を`waitForSelector()`やその他の方法で待つ必要があります。

`Promise.all()`を使えば多少オシャレに(?)書き直すこともできます。

```js
const [newPage] = await Promise.all([
  new Promise(resolve => browser.once('targetcreated', target => resolve(target.page()))),
  page.click('a')
]);
await newPage.waitForSelector('input[name="foo"]', {visible: true});
```

また、Puppeteer 1.10.0以降では次のように書くこともできます。

```js
const [newPage] = await Promise.all([
  browser.waitForTarget(t => t.opener() === page.target()).then(t => t.page()),
  page.click('a')
]);
await newPage.waitForSelector('input[name="foo"]', {visible: true});
```

`browser.waitForTarget()`の引数が若干わかりにくいですが、Promiseを直接扱うよりは読みやすいので私はこちらの方が好みです。

# 参考URL

- https://github.com/GoogleChrome/puppeteer/issues/1412
- https://github.com/GoogleChrome/puppeteer/issues/386