Goではじめてみたブラウザの自動操作

# はじめに

面倒なWEBブラウザの定型作業を自動化したくて。

WEBブラウザの自動操作には定番のSeleniumを利用する。
Seleniumは主にウェブブラウザのテストに利用されているが、テスト用途以外でも利用はできる。

なおウェブスクレイピングが目的であれば、[scrape](https://github.com/yhat/scrape)とか[goquery](https://github.com/PuerkitoBio/goquery)などを利用するほうが簡単。

それでもSeleniumを利用するのは、
実際のブラウザが利用できるという点であり、以下のような利点があると思っている。

* IEなど特定のブラウザのみをサポートしているサイトの自動操作
* ごりごりのJavascriptやFlashを利用されているサイトの自動操作
* 証跡として画面のスクリーンショットを取得できる

## 前提知識

<img width="282" alt="スクリーンショット 2017-01-11 22.11.28.png" src="https://qiita-image-store.s3.amazonaws.com/0/14471/a676c1d5-0169-1385-55a7-65dc65fa892f.png">

* WebDriverを介することで、スクリプトとしてJava,C#,Pythonなど多くの言語から利用できる
* ブラウザごとにWebDriverが用意されており、１つスクリプトで各種ブラウザでの自動操作が可能
* 内部的には[Json Wire Protocol](https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol)という仕様が利用されている
* とはいえスクリプトごとにライブラリが提供され、Json Wire Protocol を意識する必要は無い

## Go言語を利用する理由

様々な言語に対応しているのに、
なぜGo言語を利用するかといえばGo言語が楽しいから。

そもそもGoをはじめていなかったのでその勉強も兼ねているので、
特別Go言語がSeleniumと相性が良いというわけではない。

どの言語を使おうがSelenium自体の考え方は同じ。

# 準備編

基本はどんなプラットフォームでも同じような手順。
Go環境の導入自体は、WEB上にも多くの情報があるかと思うので略。

## agouti の導入

今回、Go言語で利用するにあたり[agouti](http://agouti.org/)というライブラリを利用させてもらう。

普通に`go get`するだけ。

```bash
$ go get github.com/sclevine/agouti
```

## WebDriverの導入

自動操作したいブラウザに対応したWebDriverをインストールする。
インストール方法は各環境に依存しますが、例えばWindowsでChromeを利用するなら[ここ](https://sites.google.com/a/chromium.org/chromedriver/downloads)から。

コマンドからインストールしたほうが簡単ですが。

```bash
# for windows(chocolatey利用)
$ cinst selenium-chrome-driver
# for mac
$ brew install chromedriver
```

最終的に`chromedriver`(windowsなら`chromedriver.exe`)にパスが通っていればどんな方法でも問題ないはずです。

なおagoutiでは、Edgeには対応していますが、
IEは非対応のため、IEでの利用方法については後述します。

# 自動化してみる

例えば、[https://qiita.com/](https://qiita.com/) にアクセスして "もっと詳しく" をクリックしてみる。

![trans.png](https://qiita-image-store.s3.amazonaws.com/0/14471/7664f86b-3a78-ac78-a3e3-27700ee14fa2.png)

エラーチェックを省略するとこんな感じです。

```go
package main

import "github.com/sclevine/agouti"

func main() {
	// Chromeを利用することを宣言
	agoutiDriver := agouti.ChromeDriver()
	agoutiDriver.Start()
	defer agoutiDriver.Stop()
	page, _ := agoutiDriver.NewPage()

	// 自動操作
	page.Navigate("https://qiita.com/")
	log.Info(page.Title())
	page.Screenshot("Screenshot01.png")

	page.FindByLink("もっと詳しく").Click()
	log.Info(page.Title())
	page.Screenshot("Screenshot02.png")
}
```

最初の図は上記のスクリプトを実行して得られたキャプチャ画像を利用しています。

その他の主要なAPIもだいたいそろっています。
https://godoc.org/github.com/sclevine/agouti

# 参考

## ユーザプロファイルを指定してログオン状態を引き継ぎたい

上記サンプルにて、ChromeでQiitaにログオン済みの場合、
[https://qiita.com/](https://qiita.com/)にアクセスすると自分のホーム画面が表示されるかと。

しかしSeleniumで起動されたChromeでは、
ユーザプロファイルが毎回新規作成されるためログオン状態などは引き継がれない。

利用されているユーザプロファイルの場所は、
Chromeにて`chrome://version/`にアクセスすることで確認可能です。

Windowsの場合だと、
`C:\Users\hoge\AppData\Local\Google\Chrome\User Data\Default`みたいな感じ。


Chromeの場合、起動オプションにてプロファイルの場所を変更できるので、
以下のようなオプション指定で、毎回`D:\Tmp`を利用させることができる。

```go
	page, _ := agoutiDriver.NewPage(agouti.Desired(agouti.Capabilities{
		"chromeOptions": map[string][]string{
			"args": []string{
				"user-data-dir=D:\\Tmp",
			},
		},
	}),
	)
```

ちなみに普段使いのプロファイルを利用したくて、
ここに直接`C:\Users\hoge\AppData\Local\Google\Chrome\User Data`などのように指定してもうまく動かなかった。

代わりに、普段使いの`Default`フォルダを`D:\Tmp`にコピーしてやれば、いつもと同じ環境を利用できました。

なおIEの場合は特に何もしなくてもログオン状態は引き継がれました。


## セレクタを自動で取得する

Seleniumでは各要素を指定するセレクタとしてCSSやXPathが利用可能ですが、
Chromeの場合、以下のように自動的にセレクタを取得できる機能があるみたいです。

![selector.png](https://qiita-image-store.s3.amazonaws.com/0/14471/3b24273f-6eae-4625-9cf1-e56a473d339e.png)


実際に前述の例で登場した "もっと詳しく" リンクを取得してみると、
`body > div.landingHeader > div > div > div.col-sm-7 > div > div > a`となっちゃいました。

つまり`page.FindByLink("もっと詳しく")`は、
`page.Find("body > div.landingHeader > div > div > div.col-sm-7 > div > div > a")`と書けるわけですが。
実用には厳しいので、とりあえず動かして確認したい場合にのみ利用します。


## 共通処理をまとめる

aogutiのpageを拡張して、
よく利用する操作手順を定義しておくと再利用できて便利でした。

例えば、認証処理は通常以下のようなステップなので共通化できて

1. ユーザ名の入力
2. パスワードの入力
3. ログオンボタンのクリック

これを定義すると

```go
// 既存のPageを埋め込む
type ExPage struct {
	*agouti.Page
}

// ページ情報
type PageInfo struct {
	Url, Title string
}

// インプットフィールド
type InputField struct {
	Name, Value string
}

// ログオン情報
type LogonInfo struct {
	Page   *PageInfo
	User   *InputField
	Pass   *InputField
	Submit string
}

// 認証手続きの定義
func (page *ExPage) Logon(info *LogonInfo) {
	log.Debug("認証画面へ遷移")
	page.Navigate(info.Page.Url)

	log.Debug("認証情報の入力")
	page.Find(info.User.Name).Fill(info.User.Value)
	page.Find(info.Pass.Name).Fill(info.Pass.Value)

	log.Debug("ログオン実行")
	page.Find(info.Submit).Click()
}
```

こうしておけば、
あるサイトにログオンする処理はこんな感じで書けます。

```go
	page, _ := agoutiDriver.NewPage()
	expage := &ExPage{page}

	expage.Logon(&LogonInfo{
		Page: &PageInfo{
			Url: "https://sample.desu/",
		},
		User: &InputField{
			Name:  "#user",
			Value: "user",
		},
		Pass: &InputField{
			Name:  "#password",
			Value: "pass",
		},
		Submit: `[value=ログイン]`,
	})
```

## Javascriptを実行したい

対象のサイトで直接Javascriptを実行する関数も普通にサポートされています。

```go
	var number int
	page.RunScript(
		"return test + 10;",                    // 実行スクリプト: 引数(test)に10を足して返す
		map[string]interface{}{"test": 100},    // 引数(test = 100)をマップで渡す
		&number)                                // 戻り値の取得用変数
    fmt.Println(number)                         // --> 110
```


## IEでも自動操作したい

agoutiはデフォルトでは`IE`には対応していないので、以下のように導入します。

まずIE用のWebDriverを[公式サイト](http://www.seleniumhq.org/download/)より取得し、適当な場所に展開しパスを通しておきます。

これもコマンド(chocolatey)のほうが楽ですね。

```bash
$ cinst selenium-ie-driver
```

後はWebDriverの初期化にて、以下のように変えます。

```go
// 参考：クロームの場合
agoutiDriver = agouti.ChromeDriver()

// IEの場合
command := []string{"IEDriverServer.exe", "/port={{.Port}}"}
agoutiDriver = agouti.NewWebDriver("http://{{.Address}}", command)
```

ただしIEの場合ブラウザ側の設定も必要で、私の環境では以下が必要でした。

* ゾーン4つに対して「保護モードを有効にする」のチェック
    * インターネット
    * ローカルイントラネット
    * 信頼済みサイト
    * 制限付きサイト
* 表示倍率を100%にしておく

### IEがうまく動かない場合の確認方法

IEが起動できないような場合、agoutiは詳細なエラーを出してくれないので、
その場合は直接WebDriverとお話をしてエラーの原因を探るのが有効です。

まずIEのWebDriverである`IEDriverServer.exe`起動する。

```bash
$ IEDriverServer.exe
Started InternetExplorerDriver server (32-bit)
2.53.1.0
Listening on port 5555
Only local connections are allowed
```

この状態で直接`5555`ポートにセッション生成の命令を投げてみると

```bash
$ curl -X POST -H "Content-Type: application/json" -d '{"desiredCapabilities":{}}' http://localhost:5555/session

{"sessionId":"af361cd2-26b9-4d83-ba6c-5e4c9f137e56",
 "status":6,
 "value":{
  "message":"Unexpected error launching Internet Explorer. Protected Mode settings are not the same for all zones. Enable Protected Mode must be set to the same value (enabled or disabled) for all zones."
}}
```

保護モードを有効にしていないことが原因だと教えてくれる。

あるいは

```bash
$ curl -X POST -H "Content-Type: application/json" -d '{"desiredCapabilities":{}}' http://localhost:5555/session

{"sessionId":"be02dba6-b365-4e0f-9585-a63b222d51c8",
 "status":6,
 "value":{
  "message":"Unexpected error launching Internet Explorer. Browser zoom level was set to 114%. It should be set to 100%"
}}
```

ブラウザの倍率が100%になっていないことが原因だと分かる。
こんな感じで直接WebDriverとお話しすれば、いろんな情報をこっそり教えてくれます。

## ヘッドレスブラウザを利用して定期的に実行したい

自動操作をさせるだけなら、実際にブラウザ画面を表示させる必要なんてないので、
その場合にヘッドレスブラウザであるPhatomJSを利用する。

利用方法はあらかじめ、PhatomJSのWebDriverを取得しておけば

```bash
# for windows(chocolatey利用)
$ cinst phantomjs
# for mac
$ brew install phantomjs
```

あとは、WebDriverの取得の際に`agouti.PhantomJS()`とするだけです。

```bash
// 参考：クロームの場合
agoutiDriver = agouti.ChromeDriver()

// PhantomJSの場合
agoutiDriver = agouti.PhantomJS()
```

ブラウザ画面が表示されない分、起動が早く、画面キャプチャなども同様に取得できます。

## ヘッドレスでChromeも利用したい

PhatomJSを利用すれば、デスクトップ画面を持たないUnixマシンでも実行が可能となりました。

ところが実際のブラウザではないため、
微妙に挙動が異なり、正しく動いてくれない場合があったりします。

その場合に利用可能なのが、仮想ディスプレイを提供してくれる`Xvfb`です。
仮想ディスプレイ内でChromeを動かすことで、あたかもヘッドレスに実行可能です。

環境構築が面倒なのでDockerイメージの[selenium/standalone-chrome](https://hub.docker.com/r/selenium/standalone-chrome/)を利用する。

ただし、このDockerイメージは、画面キャプチャを取得した際に日本語が文字化けするため、日本語フォントを追加したイメージを用意し、それを利用しました。

なので実際には以下のコマンドだけで、
コンテナ内で`Selenium Standalone Server`が立ち上がり`4444`ポートにフォワーディングされます。（外部よりコンテナ内のWebDriverに接続することが可能)

```bash
$ docker run -d -p 4444:4444 --name sc s0829/selenium-chrome-jp
# 一応、ポート確認
$  docker port sc
4444/tcp -> 0.0.0.0:4444
```

この結果agouti側は、WebDriverは不要となるので、
Dockerコンテナで起動している`Selenium Standalone Server`を利用するよう変更します。

```go
func main() {
	// WebDriverは不要
	// agoutiDriver := agouti.ChromeDriver()
	// agoutiDriver.Start()
	// defer agoutiDriver.Stop()
	// page, _ := agoutiDriver.NewPage()

	// 上記を以下のように変更するだけ
	url := "http://localhost:4444/wd/hub"
	options := []agouti.Option{agouti.Browser("chrome")}
	page, _ = agouti.NewPage(url, options...)

	page.Navigate("https://qiita.com/")
}
```

で、なにがすごいってWindows環境で作成した実行ファイルが、
そのままUnix環境にもっていくだけで、そのまま動作しちゃうことです。
ここにきてGoの素晴らしさを実感します。

### [参考] Xvbfでのキャプチャ画像

ちなみに今回利用したDocker環境は言語が英語なので、Qiitaも英語版のサイトが表示されてました。

![comp_xvfb.png](https://qiita-image-store.s3.amazonaws.com/0/14471/8808e72e-847d-0af3-e101-b6ba6bf0dda8.png)

## 非同期処理の完了を待ちたい

agoutiでは"明示的な待機"をする処理が提供されていない。
というのも、agouti が Selenium をテスト目的で利用する想定で作成されており、
非同期のテストには [gomega](http://onsi.github.io/gomega/) というライブラリーを組み合わせて利用するようになっているからだと思われる。

とはいえwait処理を自前で実装するのそれほど難しくなさそう。
でもやっぱり面倒だったのと、興味本位で強引にgomegaのEventuallyを利用してみたのが以下。

```go
import (
	"github.com/onsi/gomega"
	"github.com/sclevine/agouti"
	"github.com/sclevine/agouti/matchers"
	log "github.com/sirupsen/logrus"
)

func main() {
	// Eventually 失敗時の処理を登録しておく
	gomega.RegisterFailHandler(func(message string, callerSkip ...int) {
		log.Error("Assertion Error\n", message)
	})

	agoutiDriver := agouti.ChromeDriver()
	agoutiDriver.Start()
	defer agoutiDriver.Stop()

	page, _ := agoutiDriver.NewPage()
	page.Navigate("http://localhost:8080/test.html")

	// #click をクリックすると非同期で #test が動的に生成される状況を想定
	page.Find("#click").Click()

	// Eventually を利用して #test の出現を最大 5s 待つ
	gomega.Eventually(page.Find("#test"), "5s").Should(matchers.BeFound())

	// もし Eventually が無いとここで失敗する
	if err := page.Find("#test").Click(); err != nil {
		log.Error(err)
	}
}
```

waitできました。

## glide を利用する場合の注意点

glideの詳細などは以下を参照。

[glide - パッケージ管理のお困りの方へ -](http://qiita.com/tienlen/items/8e192e68d6b18bec3b4a)

で、このglideを使ってagoutiをインストールする場合、

```bash
$ go get github.com/Masterminds/glide
# glide.yamlの作成
$ glide create
## --- ここまで準備 ---
# agoutiのインストール 
$ glide get github.com/sclevine/agouti
...
[INFO]  The package github.com/sclevine/agouti appears to have Semantic Version releases (http://semver.org).
[INFO]  The latest release is 2.0. You are currently not using a release. Would you like
[INFO]  to use this release? Yes (Y) or No (N)
#--> ここで Y を選ぶと 2.0 が利用されるようになる
```

この`2.0`というのは、gitにおける`2.0`タグがつけられたコミットを指しているようですが、
このバージョンは古いため`FindByID`関数などが入っていない。

なので上記プロンプトでは`N`を選択して`master`ブランチのコミットを入れるようにする。
このバージョン管理まわりの問題はすでにissueにも上がっているが([これ](https://github.com/sclevine/agouti/issues/87)とか[これ](https://github.com/sclevine/agouti/issues/84))、現時点では進展なし。

## ログを色つきで出力させたい

Seleniumとは直接関係ない話だが、log出力に[logrus](https://github.com/sirupsen/logrus)を利用している。
で、Windows環境でも色つきで出力させる方法のメモ。

```go
import (
	colorable "github.com/mattn/go-colorable"
	log "github.com/sirupsen/logrus"
)

...
	log.SetFormatter(&log.TextFormatter{ForceColors: true})
	log.SetOutput(colorable.NewColorableStdout())

	log.Info("succeeded")
	log.Warn("not correct")
	log.Error("something error")
```

Windows(cmd.exe)でも色付きでログが出る。
![log.png](https://qiita-image-store.s3.amazonaws.com/0/14471/1f6b7108-96d7-4880-fe2d-05924f527976.png)

結果、何もしない白が一番見やすい。


# まとめ

フレームを利用したサイトや、ポップアップのあるサイトでも利用でき、
おかげで面倒な定型作業を自動化することができた。

### 念のため

あくまで人が行うような定型作業の自動化を目的としており、
特に外部サイトへアクセスする際には節度ある利用をお願いします。