Go+AgoutiでWebブラウザ操作を自動化してみる
こんにちは、株式会社SHIFT、自動化エンジニアの水谷です。
前回の記事ではEchoというフレームワークを使ってWeb APIを作り、Fyneで作ったマンデルブロー集合の計算アプリをWebアプリ化してみました。
Webアプリ化したと言っても、APIが1つだけで表示されるのも1ページだけの小さなものですが、それでも何となくGoをバックエンドにしてWebアプリを開発する流れはつかめてきた感じがします。
今回は、Goでブラウザ操作を自動化する方法を調べ、前回作ったWebアプリの自動操作行ってみたいと思います。
※SHIFTエンジニアが学びながら解説するGo言語というマガジンができました。過去の記事もこちらから読めますので、もし本記事に興味をもっていただけましたら、マガジンの方も是非見ていただければと思います。
Webドライバー
スクレイピング(Webアプリから自動で情報を収集する操作)や、Webアプリのテストなどを行う場合、Webドライバーを使うことがよくあります。
有名なSeleniumなどのWebドライバークライアントを使用することで、Chrome、Firefoxの自動操作はもちろん、Egdeや、一部制限があるもののIEの操作も、ドライバーを変更することで可能となり、とても便利です。
では、その便利なWebドライバーをGo言語で使用することはできるでしょうか?
少し調べてみると、いくつかこれを実現するためのパッケージが見つかります。どれを使うか迷うところですが、今回はその中でも情報量が多いAgoutiを使ってみたいと思います。
今回のお題
Agouti(https://agouti.org/)のドキュメントを読んでみると、どうやらこれはテストフレームワークという位置づけのパッケージのようですが、Webドライバークライアントとして使用することもできます。今回はその部分のみを使うことになります。
※テストフレームワークの部分にも興味はありますので、いつか紹介できるといいな、と思っています。
今回はこのAgoutiを使って、前回作成したマンデルブロー集合を計算して表示するWebアプリを自動操作(計算範囲を指定する入力ボックスに自動的に数値を入れ、「GENERATE」ボタンを押す)します。そして、生成された画像はファイルに保存する操作も行いたいと思います。
Agoutiのインストール
まずは、今回のプロジェクト用のディレクトリを作成して、プロジェクトの初期化を行います(プロジェクト名は"autocapture"としました)。
>mkdir autocapture
>cd autocapture
autocapture>go mod init autocapture
go: creating new go.mod: module autocapture
続いてAgoutiのインストールです。これも"go get"コマンドで簡単に行うことができます。
autocapture>go get github.com/sclevine/agouti
go: downloading github.com/sclevine/agouti v3.0.0+incompatible
go get: added github.com/sclevine/agouti v3.0.0+incompatible
それから、Webドライバーですが、今回はChromeを操作したいと思いますので、Chrome用WebドライバーをGoogleのサイトからダウンロードして、インストールします。
この際、使用しているChromeのバージョンに一致するWebドライバーをインストールする必要がありますので注意してください。私の場合は、Chromeのバージョンが93.0.4577.63でしたので、それに対応するWebドライバーをインストールしました。
※Webドライバー本体である、"chromedriver.exe"にパスを通しておく必要があります。
ブラウザの起動とページの表示
では、アプリを作っていきます。まずはimportに"github.com/sclevine/agouti"を追加します。
import (
...
"github.com/sclevine/agouti"
)
そして、main()関数内で、下のようにChrome用Webドライバーのインスタンスを作成します。また、deferを使って後処理を自動化しておきます。
driver := agouti.ChromeDriver(agouti.Browser("chrome"))
defer driver.Stop()
これで、Webドライバーが使えるようになりましたので、さっそくWebドライバーをスタートさせ、ブラウザを開いてみましょう。
if err := driver.Start(); err != nil {
fmt.Printf("Failed to start driver. %s\n", err)
return
}
page, err := driver.NewPage()
if err != nil {
fmt.Printf("Failed to open a new page. %s\n", err)
return
}
様々な理由でこれらの処理が失敗する可能性がありますので、個人で使うアプリだとしてもエラーチェックは行ったほうが良いでしょう。
そして、操作したいページに移動します。今回は、アプリをhttp://localhost:8888/でホストしているので、下のようになります。
if err := page.Navigate("http://localhost:8888/"); err != nil {
fmt.Printf("Failed to navigate to localhost:8888. %s\n", err)
return
}
要素の選択と入力
続いて、フォームの入力の自動化ですが、これは入力ボックスを特定し、そこに値(文字列)を送り込むことになります。
まずは、要素の特定ですが、Agoutiでは、CSS SelectorやXPathでの指定が可能です。さらに、要素のNameやID、あるいはClass属性で指定することも可能です。
Chromeで対象ページを表示した状態で、F12キーを押すと、デベロッパーツールが開きます。"Elements"タブのを開いて、その左にある矢印アイコンをクリックし、入力ボックス(下のスクリーンショットの左下で赤枠で囲った部分)をクリックすると、その要素が選択されます。
これを見ると、"name"属性が要素ごとに指定されている(ここでは"RealMin")ので、これを使って要素を指定するのが簡単そうです。もちろん、選択されている<input>タグを右クリックして、"Copy" -> "Copy Selector"や"Copy XPath"を選択して、得られた値を使って要素を特定してもOKです。
今回は"name"属性を使うので、コードは下のようになります。
realmin := page.FindByName("RealMin")
これで、この要素に対していろいろな操作が行えますので、さっそく文字列を入力してみましょう。
realmin.Fill("-0.25")
これで実行して見ると、下のようになりました。
※このままではすぐ実行が終わり、ブラウザが閉じるので、"time.sleep(time.Second * 10)"などを追加しています。
……思っていた結果になりません。どうやら、もともと入っていた"-2.0"の後に"-0.25"が追記されてしまったようです。
このメソッドは、要素の内容を指定した内容に変更してくれるのではなく、指定した文字列を、あたかもキーボードを叩いて入力してくれるような動作をしているように感じられます。
となれば、まず元の値を消したいところですが、そのために用意されていると思われるClear()メソッドを下のように実行してみても、うまく機能しませんでした。
realmin.Clear()
少し調べてみると、ちょっと根の深そうな問題があるようだったので、このメソッドを使うのは諦めました。そして、AgoutiのGithub上のIssueでこれについての議論があり、ワークアラウンドとして、キーストロークを文字列の一部として送る方法が書いてあった(https://github.com/sclevine/agouti/issues/61)ので、これを使ってみます。
realmin.Fill("\uE009a\u0008")
realmin.Fill("-0.25")
1行目の\uE009がCTRLキーに対応するコードで、そこに"a"を続けることで全選択(CTRL+A)を表現します。続く\u0008はバックスペースなので、選択された文字列を削除します。これで、入力ボックスが空になり、続く2行目で入力したい文字が入力できることになります。
試してみると、うまくいきました。ちょっと手間ではありますが、「動けば良し」としましょう。同様にRealMax、ImaginaryMin、ImaginaryMaxの要素についても値を入力します。
realmax := page.FindByName("RealMax")
imaginarymin := page.FindByName("ImaginaryMin")
imaginarymax := page.FindByName("ImaginaryMax")
...
realmax.Fill("\uE009a\u0008")
realmax.Fill("-0.20")
imaginarymin.Fill("\uE009a\u0008")
imaginarymin.Fill("0.75")
imaginarymax.Fill("\uE009a\u0008")
imaginarymax.Fill("0.80")
そして、"GENERATE"ボタンを押すのですが、いくつか実装方法が考えられます。
generateButton := page.FindByButton("Generate")
if err := generateButton.Click(); err != nil {
fmt.Printf("%s\n", err)
return
}
Agoutiには、"FindByButton"というメソッドがあり、これはどうやらボタンに特化したメソッドのようです。やや冗長な設計かなと思いましたが、使ってみることにします。これであとはClick()メソッドをコールすれば、ボタンが押され、つまりFormがサブミットされ、マンデルブロー集合の計算のAPIが呼ばれて、画像が更新されます。
画像の保存
続いて、表示された画像をファイルに保存する作業を自動化してみようと思いますが、その前に画像が更新されたことの検出、つまり、画像が更新されるまで待つ処理を追加します。
とはいえ、前回の記事のアプリのままでは、画像の更新が終わったことを検出することは難しいので、アプリ側に少し手を入れたいと思います。その内容は、"GENERATE"ボタンを押した直後に、そのボタン自体を不活性状態(Disabledの状態)にしておき、APIからのレスポンスを受け取って、画像を更新した後に活性状態(Enabledの状態)に戻す、というものです。
そして、今回のアプリ側では、ボタンが活性状態に戻っるまで待つ処理を入れ、戻ったら画像を保存する、という流れにします。
さて、その実装ですが、Agoutiには状態の変化を待つためのメソッドは用意されていないようなので、ループ処理で定期的にEnabled()メソッドを呼び、活性化されるのを待つようにしました。
for {
isEnabled, err := generateButton.Enabled()
if err != nil {
fmt.Printf("Failed to get button status. %s\n", err)
return
}
if isEnabled {
break
}
time.Sleep(time.Millisecond * 100)
}
完璧ではないでしょうが、とりあえず私の環境では失敗することはなかったので、今回はこれで進めます。
あとは画像の保存ですが、これは画像の要素を見つけ、それに対してスクショを取るようなメソッドを呼べばよいのかな、と思いましたがAgoutiでは、ページ全体のスクショを取るメソッドはありましたが、要素単位のスクショを取るメソッドはないようでしたので、これも自分で実装することになりました。
このWebアプリでは、imgタグのsrcに、BASE64エンコードされた画像データが直接入っているので、これをデコードした上で、io.Copyでファイルに書き出してしまうのが一番簡単でしょう。
エラー処理を行わないコードは、下のようになります。
img := page.Find("img")
imgData, _ := img.Attribute("src")
pngImage, _ := base64.StdEncoding.DecodeString(imgData[22:])
out, _ := os.Create("./mandel.png")
io.Copy(out, bytes.NewReader(pngImage))
なお、3行目でDecodeString()メソッドの引数にimgData[22:]としているのは、src属性の先頭は"data:image/png;base64,"となっており、その後(22文字目から)BASE64でエンコードされた画像データが続くためです。
これで、画像がpng形式で保存できるようになりました。
ここまでのコードは以下のようになります。
package main
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"os"
"time"
"github.com/sclevine/agouti"
)
func main() {
driver := agouti.ChromeDriver(agouti.Browser("chrome"))
defer driver.Stop()
if err := driver.Start(); err != nil {
fmt.Printf("Failed to start driver. %s\n", err)
return
}
page, err := driver.NewPage()
if err != nil {
fmt.Printf("Failed to open a new page. %s\n", err)
return
}
if err := page.Navigate("http://localhost:8888/"); err != nil {
fmt.Printf("Failed to navigate to localhost:8888. %s\n", err)
return
}
realmin := page.FindByName("RealMin")
realmax := page.FindByName("RealMax")
imaginarymin := page.FindByName("ImaginaryMin")
imaginarymax := page.FindByName("ImaginaryMax")
generateButton := page.FindByButton("Generate")
realmin.Fill("\uE009a\u0008")
realmin.Fill("-0.25")
realmax.Fill("\uE009a\u0008")
realmax.Fill("-0.20")
imaginarymin.Fill("\uE009a\u0008")
imaginarymin.Fill("0.75")
imaginarymax.Fill("\uE009a\u0008")
imaginarymax.Fill("0.80")
if err := generateButton.Click(); err != nil {
fmt.Printf("%s\n", err)
return
}
time.Sleep(time.Millisecond * 100)
for {
isEnabled, err := generateButton.Enabled()
if err != nil {
fmt.Printf("Failed to get button status. %s\n", err)
return
}
if isEnabled {
break
}
time.Sleep(time.Millisecond * 100)
}
img := page.Find("img")
imgData, _ := img.Attribute("src")
pngImage, _ := base64.StdEncoding.DecodeString(imgData[22:])
out, _ := os.Create("./mandel.png")
io.Copy(out, bytes.NewReader(pngImage))
}
アニメーションGIFを作る
ここからは少しおまけ的な内容になりますが、得られた画像を繋げてアニメーションGIFを作るコードを書いてみたいと思います。マンデルブロー集合の計算範囲をあるルールに従って変化させていくことで、特定のスポットに移動してズームしていくようなイメージです。
このような機能は、Webアプリを操作して行うより、前々回のFyneで作ったアプリに作りこむ方がずっと簡単なのですが、ちょっとだけスクレイピングっぽい操作をしてみたかったのでw、ここで作りたいと思います。
アニメーションGIFの作成は、Goの標準ライブラリでできます。まず、"outGif := &gif.GIF{}"として、アニメーションGIFの"入れ物"を作っておき、新たな画像が得られるたびに一旦Decodeしてimage.Image形式にします。それをgif.Encodeで再度GIF形式にエンコードして、"outGif.Image = append(outGif.Image, pimg)"で画像を追加してきます。また、1枚画像を追加するたびにディレイ時間の情報も追加するのですが、これは特に凝ったことをするわけではないので、"outGif.Delay = append(outGif.Delay, 0)"として「ディレイなし」にしています。
あとは、下のように一気にファイルに書き出せばOKです。
out, _ := os.Create("./test.gif")
gif.EncodeAll(out, outGif)
ということで、こんなアニメーションGIFができました。
全体のコードはこのようになりましたので、ご参考にしていただければ幸いです。
package main
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/color"
"image/draw"
"image/gif"
"image/png"
"os"
"time"
"github.com/sclevine/agouti"
)
func main() {
driver := agouti.ChromeDriver(agouti.Browser("chrome"))
defer driver.Stop()
if err := driver.Start(); err != nil {
fmt.Printf("Failed to start driver. %s\n", err)
return
}
page, err := driver.NewPage()
if err != nil {
fmt.Printf("Failed to open a new page. %s\n", err)
return
}
if err := page.Navigate("http://localhost:8888/"); err != nil {
fmt.Printf("Failed to navigate to localhost:8888. %s\n", err)
return
}
var palette = []color.Color{
color.RGBA{0x00, 0x00, 0x00, 0xff},
color.RGBA{0x00, 0x00, 0xff, 0xff},
color.RGBA{0x00, 0xff, 0x00, 0xff},
color.RGBA{0x00, 0xff, 0xff, 0xff},
color.RGBA{0xff, 0x00, 0x00, 0xff},
color.RGBA{0xff, 0x00, 0xff, 0xff},
color.RGBA{0xff, 0xff, 0x00, 0xff},
color.RGBA{0xff, 0xff, 0xff, 0xff},
}
outGif := &gif.GIF{}
centerX := -0.5
centerY := 0.0
for c := 0.7; c < 54.0; c += 0.7 {
realmin := page.FindByName("RealMin")
realmax := page.FindByName("RealMax")
imaginarymin := page.FindByName("ImaginaryMin")
imaginarymax := page.FindByName("ImaginaryMax")
generateButton := page.FindByButton("Generate")
if c > 0.7 && c <= 14.7 {
centerX += (-1.138 + 0.5) / 20
centerY += (0.2415) / 20
}
r := 1.5 / (c + 1.0)
realmin.Fill("\uE009a\u0008")
realmin.Fill(fmt.Sprintf("%f", centerX-r))
realmax.Fill("\uE009a\u0008")
realmax.Fill(fmt.Sprintf("%f", centerX+r))
imaginarymin.Fill("\uE009a\u0008")
imaginarymin.Fill(fmt.Sprintf("%f", centerY-r))
imaginarymax.Fill("\uE009a\u0008")
imaginarymax.Fill(fmt.Sprintf("%f", centerY+r))
if err := generateButton.Click(); err != nil {
fmt.Printf("%s\n", err)
return
}
time.Sleep(time.Millisecond * 100)
for {
isEnabled, err := generateButton.Enabled()
if err != nil {
fmt.Printf("Failed to get button status. %s\n", err)
return
}
if isEnabled {
break
}
time.Sleep(time.Millisecond * 100)
}
img := page.Find("img")
imgData, _ := img.Attribute("src")
pngImage, _ := base64.StdEncoding.DecodeString(imgData[22:])
mandelImage, _ := png.Decode(bytes.NewReader(pngImage))
pimg := image.NewPaletted(image.Rect(0, 0, 800, 800), palette)
draw.Draw(pimg, mandelImage.Bounds(), mandelImage, image.Point{0, 0}, draw.Src)
outGif.Image = append(outGif.Image, pimg)
outGif.Delay = append(outGif.Delay, 0)
}
out, _ := os.Create("./test.gif")
gif.EncodeAll(out, outGif)
}
関連記事: Go言語マガジン
――――――――――――――――――――――――――――――――――
★ 過去記事をまとめたマガジン「SHIFTエンジニアが学びながら解説するGo言語」もご参照ください。