chromedpでGopherのための自動化を目指す

Retty Advent Calendar 2019 - Qiita 7日目の記事です。

昨日は渡邉さんの記事で、あなたのエリアは何処から? ~地理空間クラスタリングとの差分検証~ - Retty Tech Blog でした。

はじめに

技術部の李です。Gopherです。Goが大好きです。

日々の業務でWebページから何かしらの数字とグラフを取得することはよくあることです。
その中で定期的に取得する必要があるようなことも少なくはないでしょう。

そんなこと、エンジニアだったら誰でも自動化したくなるはずです。

プログラムでブラウザを起動し、指定したWebページにアクセスして情報を取得する方法はたくさんあります。
一番知られているのは SeleniumHQ Browser AutomationPuppeteer を利用することでしょう。

でも、Goが大好きの私はもちろんGoを使わなければいけないのです。
ここで本題に入ります。

chromedp

github.com

chromedpは Chrome DevTools Protocol をサポートしているブラウザをコントロールできるGoのパッケージです。

Chrome DevToolsChrome DevTools Protocol をサポートしているため、chromedpを使えば、GoでChromeを完全にコントロールすることは可能です。

chromedpをはじめて使う場合は examples をまず見てみることをおすすめします。そちらでよく使うActionの例は大体あげらています。

なぜchromedp

  • 開発において
    • Goが使える(Goの良いところは全部持ってくるので、Goが使えるだけで十分なんですね。)
    • 純粋なGoの実装なので、Cなど他のライブラリーへの依存がなく、インストールが楽
  • chromedpを利用して開発したツールとして
    • ソースコードgithubにあげるだけで配布できるので楽
    • 環境依存が極限に少ない(他のNodeJS, Pythonで扱うツールと比べるとツールとしての安定性が十分に得られる。)

chromedpの使い方

まず、contextを理解する

chromedpはGoのcontextを活用した実装になっています。

func main() {
    ctx, cancel := chromedp.NewContext(context.Background())
    defer cancel()

    if err := chromedp.Run(ctx); err != nil {
        log.Fatal(err)
    }
}

上記は一番簡単な例です。

  • chromedp.NewContextでcontextを初期化する。
  • chromedp.Runにcontextを渡してChromeのプロセスを起動する。
  • contextのcancelが呼ばれてChromeのプロセスを終了する。

chromedpのcontextはGoの標準のcontextなので、timeout, cancelなどの制御も普通のGoのプラグラムと同じように簡単にできます。

そして、headlessモードを理解する

chromedpはデフォルトで headlessモード で起動するので、上の例だと、何も見えずに、プログラムが終了してしまいます。
(headlessモード:名前の通り、UIが起動せずに、バックグラウンドでChromeのプロセスを起動するということです。)

ChomeをUI付きで起動する方法もあります。

func newChromedpContext(ctx context.Context, headless bool) (context.Context, context.CancelFunc) {
    var opts []chromedp.ExecAllocatorOption
    for _, opt := range chromedp.DefaultExecAllocatorOptions {
        opts = append(opts, opt)
    }
    if !headless {
        opts = append(opts,
            chromedp.Flag("headless", false),
            chromedp.Flag("hide-scrollbars", false),
            chromedp.Flag("mute-audio", false),
        )
    }

    allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
    ctx, cancel := chromedp.NewContext(allocCtx)

    return ctx, func() {
        cancel()
        allocCancel()
    }
}
  • DefaultExecAllocatorOptions(デフォルトでheadlessモードになってる起動オプション)を取ってくる。
  • headlessモードオプションを全部オフにして追加する。
  • NewExecAllocator/NewContextで新しいheadlessモードがオフになっているcontextを初期化する。

最初の例でこちらのnewChromedpContextを使ってcontextを初期化すれば、一瞬Chromeが立ち上がることが見れると思います。

最後に、Actionを理解する

func main() {
    ctx, cancel := chromedp.NewContext(context.Background())
    defer cancel()

    if err := chromedp.Run(ctx, 
        chromedp.Navigate("https://retty.me/"),
        chromedp.Sleep(time.Second),
    ); err != nil {
        log.Fatal(err)
    }
}

chromedp.Runはcontext以外、複数のchromedp.Actionも渡せます。
渡されたActionは順番にChrome上で実行されます。
上記の例だと、「https://retty.me/ を開いて、1秒待つ」のように実行されます。

type Action interface {
    Do(context.Context) error
}

chromedp.Actionは上記のように定義されたinterfaceなので、必要に応じてカスタマイズのActionも簡単に作れます。
すでに用意してくれているのはfunctionで便利に定義できるchromedp.ActionFuncと複数のActionを集合として定義できるchromedp.Tasksです。

よく使うAction

基本なActionと実装例は examples を確認してください。
その中、いくつか紹介したいと思います。

ページの指定したエリアのキャプチャーを取ってファイルに保存する
var CaptureAndSaveAction = chromedp.ActionFunc(func(ctxt context.Context) error {
    var buf []byte
    tasks := chromedp.Tasks{
        emulation.SetDeviceMetricsOverride(1680, 2048, 0, false),
        chromedp.CaptureScreenshot(&buf),
    }
    if err := tasks.Do(ctxt); err != nil {
        return err
    }
    if err := ioutil.WriteFile("capture.png", buf, 0644); err != nil {
        return err
    }
    return nil
})

* emulation - "github.com/chromedp/cdproto/emulation"SetDeviceMetricsOverride以外もいくつか便利なfunctionが用意されているので、詳細は Go Doc を確認してください。)

Cookieを取得/設定する

サイトによってログインなどのActionを短時間でやり過ぎるとBANされることがあるので、ログインした後のCookieを保存しておいて、再度アクセス際にCookieを設定することをよくやります。
ここで"github.com/chromedp/cdproto/network"を利用します。

  • 取得する
var GetCookiesAction = chromedp.ActionFunc(func(ctx context.Context) error {
    cookies, err := network.GetAllCookies().Do(ctx)
    if err != nil {
        return err
    }
    log.Printf("cookies got: %v", cookies)
    // e.g. save to file
    return nil
})
  • 設定する
var SetCookiesAction = func(cookies []*network.Cookie) chromedp.Action {
    return chromedp.ActionFunc(func(ctx context.Context) error {
        cc := make([]*network.CookieParam, 0, len(cookies))
        for _, c := range cookies {
            cc = append(cc, &network.CookieParam{
                Name:   c.Name,
                Value:  c.Value,
                Domain: c.Domain,
            })
        }
        return network.SetCookies(cc).Do(ctx)
    })
}
ページの中の指定したリクエストのURLを取得する

Chrome DevToolsを使う際に、Networkパネルを開いて、ある文字列が含まれているのリクエストのURLを取得することはよくあるでしょう。
例えば、ページ内の.cssファイルのURLを集めるとしましょう。
ここでnetwork.EnableActionを使います。

func main() {
    ctx, cancel := chromedp.NewContext(context.Background())
    defer cancel()

    chromedp.ListenTarget(ctx, func(ev interface{}) {
        switch ev := ev.(type) {
        case *network.EventRequestWillBeSent:
            url := ev.Request.URL
            if strings.HasSuffix(url, ".css") {
                log.Printf("css got: %v", url)
            }
        }
    })

    if err := chromedp.Run(ctx,
        network.Enable(),
        chromedp.Navigate("https://retty.me/"),
        chromedp.Sleep(time.Second),
    ); err != nil {
        log.Fatal(err)
    }
}

* network.EnableActionはnetworkパネルの機能を有効にしてくれるので、chomedp.Navigateなど他のActionの前に渡す必要がある、そうでなければ、networkパネルが無効のままなので、chromedp.ListenTargetは何も受け取れません。

おわりに

早速chromedpを使ってみたくなりましたでしょうか?
Webページで情報集める時の自動化、chromedpの存在はGopherとして本当嬉しいことです。
こうしてみんなでたくさんの自動化ツールを作って共有し合うのも非常に楽しいです。

最後に、今RettyでgPRCのサービスを日々Goで楽しく開発しています。Goが好きな仲間が増えるととても嬉しいので、興味ある方はぜひ!!

おまけ

www.youtube.com

動画の通り、この記事はchromedpで自動的に書かれたものでした。