62
34

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-05-24

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

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

動かしてみよう

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

または、

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

$ npm i
$ npm run dev

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

ブラウザの中のブラウザ風UIの中のアドレスバーに入力されている URL の画面が表示されますが、HTMLではなく、 Pupeteer で叩いて取得したスナップショット画像を表示しています。
このアドレスバーに例えば GitHub の URL を入力すれば GitHub のスナップショット画像に切り替わります。
また、画像ですが、画像内のマウスクリック座標を渡して Pupeteer が代わりにリンクをクリックして遷移するように仕上げてみました。
headful1.gif

クリックを受け取れる機能を利用してちょっと試してみます。
アドレスバーの左に、この Headful Puppeteer に遷移するホームボタンを用意しました。
これを押すと、Puppeteer で Headful Puppeteer を開いてその中の Puppeteer が Puppeteer のWebサイトにアクセスしている状態になります。
その画像の中のホームボタンを押す、を繰り返しただけ増殖します。
headful2.gif

ん〜実にヘッドフル。

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

以上です。

やったこと

sequence.png

画面の実装

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 クラスのドキュメント

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 がおすすめです。

同じ Google 製ということもあるし、「Puppeteer でのヘッドレス Chrome の使用」と公式ドキュメントで説明もありますのでサクッと動かす環境作れます。

まとめ

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

62
34
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
62
34

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?