Puppeteer をヘッドフルにしてあげた

Puppeteer君、きみはよくがんばった。褒美に画面をあたえよう。

ということで、スクレイピングやE2Eテストなど裏方で活躍している Headless Chrome Node API の [Puppeteer](https://github.com/GoogleChrome/puppeteer) に特に理由もなく Web フロントエンドを実装しました。


## 動かしてみよう

デモサイト（重すぎるのであまりおすすめではありません）
https://boiyaa-headful-puppeteer.appspot.com

または、

https://github.com/boiyaa/headful-puppeteer
をクローンして、

```sh
$ npm i
$ npm run dev
```

して、 http://localhost:8080 にアクセス。

ブラウザの中のブラウザ風UIの中のアドレスバーに入力されている URL の画面が表示されますが、HTMLではなく、 Pupeteer で叩いて取得したスナップショット画像を表示しています。
このアドレスバーに例えば GitHub の URL を入力すれば GitHub のスナップショット画像に切り替わります。
また、画像ですが、画像内のマウスクリック座標を渡して Pupeteer が代わりにリンクをクリックして遷移するように仕上げてみました。
![headful1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142668/8c3e8124-522d-fb1f-472a-7a55ea934ee3.gif)

クリックを受け取れる機能を利用してちょっと試してみます。
アドレスバーの左に、この Headful Puppeteer に遷移するホームボタンを用意しました。
これを押すと、Puppeteer で Headful Puppeteer を開いてその中の Puppeteer が Puppeteer のWebサイトにアクセスしている状態になります。
その画像の中のホームボタンを押す、を繰り返しただけ増殖します。
![headful2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142668/9086c265-ae6d-fdae-4aa8-243107fc2974.gif)

ん〜実にヘッドフル。

Chrome を意気揚々と操っている Puppeteer 自身もまた何者かに操られている、という社会派アートができました。

以上です。


## やったこと
![sequence.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142668/11676710-40c4-a3de-941d-ed25d49213cf.png)


## 画面の実装

```tsx
import React, { useState, useEffect } from "react";
import axios from "axios";

type ReactFC<P> = React.FC<P> & { getInitialProps?: (context: { req: { query: { url: string } } }) => Promise<P> };

interface Props {
  initialUrl: string;
}

type Url = string | undefined;

type Position = {
  x: number;
  y: number;
} | null;

const Index: ReactFC<Props> = ({ initialUrl }) => {
  const [mouseOffset, setMouseOffset] = useState<Position>(null);
  const [inputValue, setInputValue] = useState<Url>(
    initialUrl ? initialUrl : "https://developers.google.com/web/tools/puppeteer/"
  );
  const [query, setQuery] = useState<{
    url: Url;
    position: Position;
  }>({ url: inputValue, position: mouseOffset });
  const [imgSrc, setImgSrc] = useState<string | null>(null);

  useEffect(() => {
    if (!query.url) {
      return;
    }

    // (2) 入力されたURL、画面サイズを渡す
    // (8) 現在のURL、画面サイズ、クリックした座標を渡す
    const url = query.url;

    const browserWindow = document.getElementById("window");
    const width = browserWindow ? browserWindow.clientWidth.toString() : "";
    const height = browserWindow ? browserWindow.clientHeight.toString() : "";

    const params = query.position
      ? new URLSearchParams({
          url,
          width,
          height,
          "position[x]": query.position.x.toString(),
          "position[y]": query.position.y.toString()
        })
      : new URLSearchParams({ url: query.url, width, height });

    const browser = `/api/browse?${params.toString()}`;

    axios.get(browser).then(response => {
      // (6) (12) ウィンドウにスナップショットを表示
      setImgSrc(response.data.screenshot);
      setInputValue(response.data.url);

      history.pushState(null, "", `?url=${encodeURIComponent(url)}`);
    });
  }, [query]);

  return (
    <>
      <section>
        <h1>Headful Puppeteer</h1>

        <div id="container">
          <div id="address-bar">
            <button
              onClick={() => {
                setQuery({ url: "http://localhost:8080/", position: null });
              }}
            >
              🏠
            </button>
            {/* (1) アドレスバーにURLを入力 */}
            <input
              type="text"
              value={inputValue}
              onChange={event => setInputValue(event.target.value)}
              onKeyDown={event => (event.keyCode === 13 ? setQuery({ url: inputValue, position: null }) : null)}
            />
          </div>

          {/* (7) スナップショット上のリンクをクリック */}
          <div
            id="window"
            onMouseMove={event => setMouseOffset({ x: event.nativeEvent.offsetX, y: event.nativeEvent.offsetY })}
            onClick={() => setQuery({ url: inputValue, position: mouseOffset })}
          >
            {imgSrc ? <img src={`data:image/png;base64,${imgSrc}`} /> : ""}
          </div>
        </div>
      </section>
    </>
  );
};

Index.getInitialProps = async (context: { req: { query: { url: string } } }) => ({ initialUrl: context.req.query.url });

export default Index;
```


## APIの実装

Express のハンドラの中で Puppeteer を動かしています。
参考: [Page クラスのドキュメント](https://pptr.dev/#?product=Puppeteer&show=api-class-page)

```ts
import { Request, Response } from "express";
import puppeteer from "puppeteer";

const dev = process.env.NODE_ENV !== "production";

export default async (req: Request, res: Response) => {
  console.log(req.query);

  const browser = await puppeteer.launch({ args: ["--no-sandbox"] });
  const page = await browser.newPage();
  await page.setViewport({ width: parseInt(req.query.width, 10), height: parseInt(req.query.height, 10) });

  // (3) Puppeteerでアクセス
  await page.goto(req.query.url, { waitUntil: dev ? "networkidle2" : "networkidle0" });

  if (req.query.hasOwnProperty("position")) {
    // (9) Puppeteerでアクセスし、指定座標でマウスクリックし、画面遷移する
    await Promise.all([
      page.mouse.click(parseInt(req.query.position.x, 10), parseInt(req.query.position.y, 10)),
      page.waitForNavigation({ waitUntil: dev ? "networkidle2" : "networkidle0" })
    ]);
  }

  // (4) (10) スナップショット取得
  const screenshot = await page.screenshot({ encoding: "base64" });
  const url = page.url();

  await browser.close();

  // (5) (11) スナップショットとURL状態を返す
  res.json({ screenshot, url });
};
```


## おすすめのデプロイ先
リポジトリの方に設定ファイル入れていますが、 [Google App Engine](https://cloud.google.com/appengine/) がおすすめです。

同じ Google 製ということもあるし、「[Puppeteer でのヘッドレス Chrome の使用](https://cloud.google.com/appengine/docs/standard/nodejs/using-headless-chrome-with-puppeteer)」と公式ドキュメントで説明もありますのでサクッと動かす環境作れます。


## まとめ
React Hooks コンポーネントのテストめんどそう