リアル調査案件で使ったPuppeteer

[Meguro.es #15 @ Drecom](https://meguroes.connpass.com/event/85649/)

2018年5月22日

---

## 自己紹介

ちきさん

Twitter/GitHub/Qiita: @ovrmrw 

市ヶ谷のオプトという会社で働いています。初心者です。

![3a2512bb-aa72-4515-af42-1f1721252f39.jpg](https://qiita-image-store.s3.amazonaws.com/0/74793/81628787-0297-a046-e6ed-96cc1094bff0.jpeg)

---

今回のGitHubリポジトリ

https://github.com/ovrmrw/puppeteer-network-crawler

---

## 今日話すこと

- 実際に降り掛かってきた調査案件でPuppeteerを使って地獄を回避した話。

---

## 日々降りかかる調査案件

- 「計測タグの発火タイミングを調べて」

---

＿人人人人人人人人人人＿
＞　ふわっとした依頼　＜
￣Y^Y^Y^Y^Y^Y^Y^Y^Y￣

---

## 課題

- 「発火タイミング」と一口に言ってもページ上の他の要素も絡んで結構ばらつきがあるので、何十回も計った上で平均を取る必要があった。
- 色々な調査パターンの組み合わせがあり、その数は72もあった。
- 72パターン x 10回 としても合計720回。
- 「ツールで自動化した数値はあてにならないから手動でやって」 by PM

---

＿人人人人人人人＿
＞　地味に地獄　＜
￣Y^Y^Y^Y^Y^Y￣

---

#### 手動は無理。使ったことないけどPuppeteerを使うしかない...

---

(JSの話で申し訳ございません。)

---

## Puppeteerとは

https://github.com/GoogleChrome/puppeteer

> Puppeteer is a Node library which provides a high-level API to control headless Chrome or Chromium over the DevTools Protocol. It can also be configured to use full (non-headless) Chrome or Chromium.

> PuppeteerはDevToolsプロトコルでヘッドレスChromeまたはChromiumを制御するための高水準APIを提供するNodeライブラリです。フル（ヘッドレスではない）のChromeまたはChromiumを使用するように設定することもできます。

---

## Tips

---

## プライベートウインドウでChromeを起動し、devtoolsを開いた状態を再現する

```js
const browser = await puppeteer.launch({
  headless: false,
  appMode: true,
  devtools: true
});
```

- なるべく手動で計測するときと同じ環境を再現したい。(ヘッドレスで動かすとCPU負荷が違いすぎる)

---

## 通信速度を制限する

```js
const client = await page.target().createCDPSession();
await client.send('Network.emulateNetworkConditions', {
  offline: false,
  latency: 20, // 20ms
  downloadThroughput: 4 * 1024 * 1024 / 8, // 4Mbps
  uploadThroughput: 2 * 1024 * 1024 / 8 // 2Mbps
});
```

- 通信速度を制限しないと毎回同じ環境で計測することができない。
- 下り4Mbps/上り2Mbps は混雑時の電車の中のスマホに近い環境。

---

## ユーザーエージェントを偽装する。

```js
const page = await browser.newPage();
await page.setUserAgent('bot');
```

- 明らかにボットであることをユーザーエージェントで示したい。

---

## キャッシュを作る

```js
const page = await browser.newPage();
await page.goto(pageUrl, { waitUntil: 'networkidle2' });
await page.close();
```

- 一旦ページを読み込んでからタブを閉じることでブラウザがキャッシュを持っている状態を再現できる。
- キャッシュがある状態とない状態の2パターンで計測する必要があった。

---

## 新しいタブを開いて2秒待ってからページを開く

```js
const page = await browser.newPage();
await new Promise(resolve => setTimeout(resolve, 1000 * 2));
await page.goto(pageUrl);
```

- タブを開いた直後はCPU使用率が高くなっているので少し待つことでbusyの状態を避ける。

---

## ネットワーク通信が残り2本になるまで待つ

```js
await page.goto(pageUrl, { waitUntil: 'networkidle2' });
```

`{ waitUntil: 'networkidle0' }` というオプションもある。

---

## リクエストとレスポンスのイベントを取得する

```js
const networkLogs = [];
page.on('request', request => {
  networkLogs.push({
    ts: Date.now(),
    network: 'request',
    url: request.url()
  });
});
page.on('response', response => {
  networkLogs.push({
    ts: Date.now(),
    network: 'response',
    url: response.url()
  });
});
```

- ネットワーク通信のリクエストとレスポンスのイベントを取得する。
- 上記のコードだと全てのイベントを記録してしまうので、実際には欲しいデータだけをフィルターする仕組みが必要。

---

## ネットワーク処理の待ち合わせ (Rx)

```js
const awaiter$ = new ReplaySubject(1);
page.on('request', request => { ..... awaiter$.next(); });
page.on('response', response => { ..... awaiter$.next(); });
await page.goto(pageUrl);
/* ページ表示後の任意の処理をここに書く */
await awaiter$
  .pipe(debounceTime(1000 * 2), first(), timeout(1000 * 30))
  .toPromise()
  .catch(console.error);
```

- リクエストイベント、レスポンスイベントが発生する度に `awaiter$.next()` しておくと `awaiter$` にストリームが流れてくる。
- イベントが2秒途切れたらネットワーク通信が全て完了したと見なし、処理を次に進める。
    - 30秒経過した場合はタイムアウトしたと見なし、処理を次に進める。
- `await page.goto(pageUrl, { waitUntil: 'networkidle0' })`でも似たようなことはできるが、こちらはネットワーク通信が全て完了しないと処理自体が次に進まない。
 
---

## 任意のJavaScriptを実行する

```js
const script = `window.alert('foo')`;
await page.addScriptTag({ content: script });
```

- ただしページロード直後に上記スクリプトが評価されたとしても実行タイミングは`DOMContentLoaded`よりだいぶ後になるので使い勝手は良くない。

---

## performance.timingを取得する

```js
const performanceTiming = 
  JSON.parse(await page.evaluate(() => JSON.stringify(window.performance.timing)));
```

- [Navigation Timing API
](https://developer.mozilla.org/ja/docs/Web/API/Navigation_timing_API) を使って `DOMContentLoaded` と `Load` イベントのタイミングを取得する。
- `navigationStart` のタイムスタンプを取得しておくことで、各イベントが「ページ表示開始から何秒後に発生したか」を計算することができる。

---

## 最終的に得られた結果

```json:result.json
{
  "pageUrl": "http://www.opt.ne.jp/",
  "metricsUrlFilter": [
    "gtm.js?id=GTM-MJSJPP",
    ".adplan7.com/"
  ],
  "metricsUrlExcludes": [
    ".adplan7.com/cs/"
  ],
  "networkCondition": {
    "latency": 20,
    "downloadUpTo": 4,
    "uploadUpTo": 2
  },
  "requestCount": 117,
  "domContentLoadedEvent": 3043,
  "loadEvent": 6012,
  "finalResponse": 8935,
  "networkLogs": [
    {
      "ts": 1526900554260,
      "network": "performance.timing",
      "type": "navigationStart",
      "diffFromStart": 0
    },
    {
      "ts": 1526900554269,
      "network": "request",
      "type": "document",
      "url": "http://www.opt.ne.jp/",
      "diffFromStart": 9
    },
    {
      "ts": 1526900554545,
      "network": "response",
      "type": "document",
      "url": "http://www.opt.ne.jp/",
      "diffFromStart": 285,
      "diffFromRequest": 276
    },
    {
      "ts": 1526900555633,
      "network": "request",
      "type": "script",
      "url": "http://www.googletagmanager.com/gtm.js?id=GTM-MJSJPP",
      "diffFromStart": 1373
    },
    {
      "ts": 1526900556648,
      "network": "response",
      "type": "script",
      "url": "http://www.googletagmanager.com/gtm.js?id=GTM-MJSJPP",
      "diffFromStart": 2388,
      "diffFromRequest": 1015
    },
    {
      "ts": 1526900557303,
      "network": "performance.timing",
      "type": "domContentLoadedEventStart",
      "diffFromStart": 3043
    },
    {
      "ts": 1526900558032,
      "network": "request",
      "type": "script",
      "url": "https://widget.adplan7.com/s/1.0/ws.js",
      "diffFromStart": 3772
    },
    {
      "ts": 1526900558801,
      "network": "response",
      "type": "script",
      "url": "https://widget.adplan7.com/s/1.0/ws.js",
      "diffFromStart": 4541,
      "diffFromRequest": 769
    },
    {
      "ts": 1526900559103,
      "network": "request",
      "type": "script",
      "url": "https://a191.tracker.adplan7.com/db/pb/191?unisIdExists=false&matchingTagCallback=window._adp._gcb.adpv7_gcb_1526900559088_3993518&jsonpCallback=window._adp._jsonp.adpv7_cb4at_1526900559088_3426562&emailOrUserIdForMatching=&_cb=1526900559087",
      "diffFromStart": 4843
    },
    {
      "ts": 1526900559423,
      "network": "response",
      "type": "script",
      "url": "https://a191.tracker.adplan7.com/db/pb/191?unisIdExists=false&matchingTagCallback=window._adp._gcb.adpv7_gcb_1526900559088_3993518&jsonpCallback=window._adp._jsonp.adpv7_cb4at_1526900559088_3426562&emailOrUserIdForMatching=&_cb=1526900559087",
      "diffFromStart": 5163,
      "diffFromRequest": 320
    },
    {
      "ts": 1526900560064,
      "network": "request",
      "type": "script",
      "url": "https://a191.tracker.adplan7.com/ws/v/j/191?u1=bwCJ2wC74AEjQ2OwyIImb13qRoSQuLGO&t=%E3%82%A4%E3%83%B3%E3%82%BF%E3%83%BC%E3%83%8D%E3%83%83%E3%83%88%E5%BA%83%E5%91%8A%E4%BB%A3%E7%90%86%E5%BA%97%20%E3%82%AA%E3%83%97%E3%83%88&l=http%3A%2F%2Fwww.opt.ne.jp%2F&_cb=1526900560053",
      "diffFromStart": 5804
    },
    {
      "ts": 1526900560149,
      "network": "response",
      "type": "script",
      "url": "https://a191.tracker.adplan7.com/ws/v/j/191?u1=bwCJ2wC74AEjQ2OwyIImb13qRoSQuLGO&t=%E3%82%A4%E3%83%B3%E3%82%BF%E3%83%BC%E3%83%8D%E3%83%83%E3%83%88%E5%BA%83%E5%91%8A%E4%BB%A3%E7%90%86%E5%BA%97%20%E3%82%AA%E3%83%97%E3%83%88&l=http%3A%2F%2Fwww.opt.ne.jp%2F&_cb=1526900560053",
      "diffFromStart": 5889,
      "diffFromRequest": 85
    },
    {
      "ts": 1526900560272,
      "network": "performance.timing",
      "type": "loadEventStart",
      "diffFromStart": 6012
    }
  ]
}
```

---

## まとめ

- Puppeteerを使うとdevtoolsで調べられることは全て調べられる。(多分)
- リクエストとレスポンスのイベントは簡単に取得できる。
- ブラウザを使って調査するような案件はPuppeteerの出番。
- `headless: false`にしたり適度にsleepさせることで、手動でブラウザを操作したときと近い結果を得られる。
- async/awaitを使って書かないと大変なことになる。

---

## Thanks! :raised_hands: