🐤

サンプルを動かして理解するchromedp (GoでChromeを操作する)

2021/01/24に公開2

動機

  • Puppeteer でJavaScriptの実行込みで簡単なWebスクレイピングをしたいと思ったが、Nodeよくわからん。
  • chromedp ってのが Puppeteer と同じことができるGo言語のライブラリなので触ってみたいが、「なんでGoでJavaScriptが動くの??」ってくらいに理解がない(※この理解は間違っている)。
  • とりあえず、動作イメージを掴む。

※WebScrapingは用法用量を守って正しくお使いください。Webサイトの利用規約を遵守してください。

chromedpとは

  • chromedp/chromedp: A faster, simpler way to drive browsers supporting the Chrome DevTools Protocol.
  • chromedpChromeを操作する ためのGo言語のパッケージです。
    • chromedp そのものがChromeを再現しているわけではなく、既存のChromeに対してGo言語で指示を出せるということです。
  • デフォルトでheadlessモード(UIのウィンドウなし)のChromeを用います。
  • Chrome DevTools Protocol(CDP) というプロトコルを用いてChromeを操作します。
  • chromedp と同様にCDPをサポートするライブラリにNode(JavaScript)製の Puppeteer があり、こちらはChromeチームが公式にサポートしているライブラリです。

なにができるの?

  • Webページのスクレイピング
  • Webページのスクリーンショットを取る

など。

なにが嬉しいの?

最近のWebサイトは、JavaScriptを用いてクライアント側でゴリゴリ処理してその結果がレンダリングされてページ内容を形作ることが多いです。Chromeそのものをレンダリングエンジンとして用いることで、普段のブラウジングと同様にレンダリングをしてくれて、当然JavaScriptも実行してくれて、その結果のDOMに対してスクレイピングを行うことができます。

一方で、JavaScriptの結果に左右されないようなWebページのスクレイピングの場合は、レンダリングが不要なことが多いですので、DOMパーサーを使ったほうが動作が速いかもしれません。

この記事の実行環境

  • macOS Catalina 10.15.7
  • go 1.15.7
  • github.com/chromedp/chromedp v0.6.5
  • Google Chrome 87.0.4280.141(Official Build) (x86_64)
    • 普段使っているGoogle Chrome

サンプル1 GoDocのExecAllocator example

chromedpのGoDocの中にはいくつかのExampleコードがあります。

https://pkg.go.dev/github.com/chromedp/chromedp#example-ExecAllocator

まずはExecAllocatorのExampleの内容を実行してみます。

01_exec_allocator.go
package main

import (
	"bytes"
	"context"
	"fmt"
	"io/ioutil"
	"log"
	"os"
	"path/filepath"

	"github.com/chromedp/chromedp"
)

func main() {
	dir, err := ioutil.TempDir("", "chromedp-example")
	if err != nil {
		panic(err)
	}
	defer os.RemoveAll(dir)

	opts := append(chromedp.DefaultExecAllocatorOptions[:],
		chromedp.DisableGPU,
		chromedp.UserDataDir(dir),
	)

	allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
	defer cancel()

	// also set up a custom logger
	taskCtx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf))
	defer cancel()

	// ensure that the browser process is started
	if err := chromedp.Run(taskCtx); err != nil {
		panic(err)
	}

	path := filepath.Join(dir, "DevToolsActivePort")
	bs, err := ioutil.ReadFile(path)
	if err != nil {
		panic(err)
	}
	lines := bytes.Split(bs, []byte("\n"))
	fmt.Printf("DevToolsActivePort has %d lines\n", len(lines))

}

実行してみましたが、処理しているのかどうかさっぱりわかりません。

$ go run 01_exec_allocator.go 
DevToolsActivePort has 2 lines

が、実は1個だけ変化がありました。実行時に、macOSのDockでChromeアイコンが飛び跳ねていました。

とりあえず、 起動には成功した と見て良さそうです。

サンプル2 Webスクレイピングする

起動しただけだとつまらないので、Webページの内容を読み取ってみたいと思います。今回は個人的に分かりやすかったScrape the Web Faster, in Go with Chromedp | by Lewis Fairweather | ITNEXTの内容に従って github.com のH1タグを読み取ってみます。

02_web_scraper.go
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/chromedp/cdproto/emulation"
	"github.com/chromedp/chromedp"
)

func main() {
	// create chrome instance
	ctx, cancel := chromedp.NewContext(
		context.Background(),
		chromedp.WithLogf(log.Printf),
	)
	defer cancel()

	// create a timeout
	ctx, cancel = context.WithTimeout(ctx, 15*time.Second)
	defer cancel()
	start := time.Now()
	// navigate to a page, wait for an element, click
	var res string
	err := chromedp.Run(ctx,
		emulation.SetUserAgentOverride("WebScraper 1.0"),
		chromedp.Navigate(`https://github.com`),
		// wait for footer element is visible (ie, page is loaded)
		chromedp.ScrollIntoView(`footer`),
		// chromedp.WaitVisible(`footer < div`),
		chromedp.Text(`h1`, &res, chromedp.NodeVisible, chromedp.ByQuery),
	)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("h1 contains: '%s'\n", res)
	fmt.Printf("\n\nTook: %f secs\n", time.Since(start).Seconds())
}

※ 元の記事では chromedp.WaitVisible(footer < div) はコメントアウトされていませんが、これがあると先に進まなかったのでコメントアウトしています。
実行してみます。

$ go run 02_web_scraper.go 
h1 contains: 'Where the world
builds software'

Took: 2.758275 secs

参考までに、以下が実行時点でのgithub.comの様子です。(今回のサンプルでスクリーンショットを撮ったものではなく、別途Google Chromeで開いて画面切り抜きキャプチャしたものです。)

一番大きな文字列「Where the world builds software」の部分がH1タグのテキスト内容なのですが、確かに読み取っていますね。
それにしても 2.7秒、、、ちょっと重いかも?

サンプル3 現在のHTMLソースコードを取得する

さきほどの記事のサンプルには続きがあり、実行するアクションを変更してDOMを取得しています。

03_print_source_code.go
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/chromedp/cdproto/dom"
	"github.com/chromedp/cdproto/emulation"
	"github.com/chromedp/chromedp"
)

func main() {
	// create chrome instance
	ctx, cancel := chromedp.NewContext(
		context.Background(),
		chromedp.WithLogf(log.Printf),
	)
	defer cancel()

	// create a timeout
	ctx, cancel = context.WithTimeout(ctx, 15*time.Second)
	defer cancel()
	start := time.Now()
	// navigate to a page, wait for an element, click
	var res string
	err := chromedp.Run(ctx,
		emulation.SetUserAgentOverride("WebScraper 1.0"),
		chromedp.Navigate(`https://github.com`),
		// wait for footer element is visible (ie, page is loaded)
		chromedp.ScrollIntoView(`footer`),
		chromedp.Text(`h1`, &res, chromedp.NodeVisible, chromedp.ByQuery),
		chromedp.ActionFunc(func(ctx context.Context) error {
			node, err := dom.GetDocument().Do(ctx)
			if err != nil {
				return err
			}
			res, er := dom.GetOuterHTML().WithNodeID(node.NodeID).Do(ctx)
			fmt.Print(res) // print HTML source code
			return er
		}),
	)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("\n\nTook: %f secs\n", time.Since(start).Seconds())
}

実行してみます。

$ go run 03_print_source_code.go 
<!DOCTYPE html><html lang="en" class="html-fluid"><head>
    <meta charset="utf-8">
  <link rel="dns-prefetch" href="https://github.githubassets.com">
〜〜〜省略〜〜〜
<div aria-live="polite" class="sr-only"></div></body></html>
Took: 2.581220 secs

長すぎたので省略しましたが、取得できているようです。

サンプル4 スクリーンショットを取る

さきほどの記事には更に更に続きがあり、驚くほど簡単にスクリーンショットを撮る事ができるようです。
すこしだけコードを修正してスクリーンショットを撮ってみました。

package main

import (
	"context"
	"fmt"
	"io/ioutil"
	"log"
	"time"

	"github.com/chromedp/cdproto/emulation"
	"github.com/chromedp/chromedp"
)

func main() {
	// create chrome instance
	ctx, cancel := chromedp.NewContext(
		context.Background(),
		chromedp.WithLogf(log.Printf),
	)
	defer cancel()

	// create a timeout
	ctx, cancel = context.WithTimeout(ctx, 15*time.Second)
	defer cancel()
	start := time.Now()
	// navigate to a page, wait for an element, click
	var res string
	var imageBuf []byte
	err := chromedp.Run(ctx,
		emulation.SetUserAgentOverride("WebScraper 1.0"),
		chromedp.Navigate(`https://github.com`),
		chromedp.Text(`h1`, &res, chromedp.NodeVisible, chromedp.ByQuery),
		// wait for footer element is visible (ie, page is loaded)
		chromedp.ScrollIntoView(`h1`),
		chromedp.WaitVisible(`h1`, chromedp.ByQuery),
		chromedp.Screenshot(`h1`, &imageBuf, chromedp.NodeVisible, chromedp.ByQuery),
	)
	if err != nil {
		log.Fatal(err)
	}
	if err := ioutil.WriteFile("h1-screenshot.png", imageBuf, 0644); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("h1 contains: '%s'\n", res)
	fmt.Printf("\nTook: %f secs\n", time.Since(start).Seconds())
}

実行してみます。

$ go run 04_screenshot.go 
h1 contains: 'Where the world
builds software'

Took: 13.923399 secs

約14秒?ちょっと重すぎやしないか、、、
とはいえ正常終了したので、フォルダを見に行くと、、、
h1-screenshot.png に画像が保存されていました!

h1-screenshot.png

こんなに!簡単に!!スクリーンショットが撮れるなんて!!!
しかも指定した要素(h1)にフォーカスした画像ができるんですね。

後述の参考資料の中には、この特定要素のスクリーンショット機能を活用してSlackアイコンを生成している人もいました。

参考になる資料

今回は扱いませでしたが、ググって見つけた資料のリンクを貼っておきます。

Discussion

uechocouechoco

uta_mory さん、情報提供ありがとうございます!

niboshi さん https://scrapbox.io/stream/niboshi/ の書籍のようです。
書籍の説明文をこちらに引用しておきます。

  • 技術書典5で頒布した同人誌です
  • Go言語でのCLI作成の基礎と、Chrome DevTools Protocolを扱うライブラリであるchromedpの使い方、それらを使った技術書典CLIツール作成の例について書いてます
  • 表紙が最高に可愛いことで評判です

可愛い