Python
Chrome
Selenium

Python + Selenium で Chrome の自動操作を一通り

はじめに

Python + Selenium + Chrome で、要素の取得、クリックなどの UI系の操作、待機、ページ全体のスクリーンショットなど、一通り試してみます。

PhantomJS はもう更新されないということなので、ブラウザは Chrome にします。

この記事には、Selenium の API に関する情報と Chrome に特化した情報がありますが、前者の Selenium の使い方に関する情報は Firefox など別のブラウザでも使えます。

注意事項

ウェブの自動テストやスクレイピングで使われる技術です。特にスクレイピングでは、著作権の問題や、サーバー側の負荷、各種規約(会員としてログインする場合の会員規約等)やマナーなどを考慮する必要があります。
たとえば、Twitter など利用規約で明示的にスクレイピングが禁止されていることや、robot.txt などでクローリングが禁止されていることもあります。

参考:

オフィシャル + alpha 情報

環境

Selenium と ブラウザ(今回は Chrome)の間に WebDriver(今回は ChromeDriver)が必要になります。OS の依存性は低そうですが、Windows で試すことにします。

  • Windows 10 Pro (64bit)
  • Chrome 69.0.3497.100
  • ChromeDriver 2.42.591088
  • Python 3.5.4 (3.6以降でも変わらないと思われます)
  • selenium 3.14.1

Python は Anaconda でインストールし、Python 3.5の仮想環境にて selenium を pip しました。

pip install selenium

Chrome は最近のものであれば --headless を付けて実行するとヘッドレスモードが使えるようです。

ChromeDriver のインストール

バイナリを直接ダウンロードする場合

OS にあった ChromeDriver を http://chromedriver.chromium.org/downloads からダウンロードし、実行ファイルを PATHの通った場所にコピーするか、環境設定で PATH を通します。

(確認)コマンドラインから chromedriver を実行すれば Starting ChromeDriver 2.42.591088... のようなメッセージが表示されます。実際にはコマンドラインから実行するわけではないですが、これで PATH が通っていることを確認できます。

環境変数の PATH に追加せず、プログラム中でパス指定することも可能です。

pip でインストールする場合

ChromeDriver を Python でのみ利用するなら、pip install chromedriver-binary でインストールすることもできます。
環境変数等でパスを通す必要はなく、プログラム中で import chromedriver_binary のようにインポートすればパスが通ります。
バイナリのフルパスを表示してくれる chromedriver-path というコマンドもインストールされるので、これを利用してコマンドラインで PATH に加えておくこともできます。

参考: https://pypi.org/project/chromedriver-binary/

以降の例では、事前にパスが通っているものとします。

非headlessモードでテスト

ChromeDriver オフィシャルサイトの Getting Started にあるサンプルコードをほぼそのまま実行してみます。

  1. Chrome が立ち上がり、5秒後に ChromeDriver という文字列でGoogle検索が行われます。(Chromeのウィンドウには「Chrome は自動テスト ソフトウェアによって制御されています。」と表示されます。)
  2. さらに5秒経つと自動で閉じます。
import time
from selenium import webdriver

driver = webdriver.Chrome()
driver.get('https://www.google.com/')
time.sleep(5)
search_box = driver.find_element_by_name("q")
search_box.send_keys('ChromeDriver')
search_box.submit()
time.sleep(5)
driver.quit()

プログラムでのパス指定

環境設定で ChromeDriver に PATHを通していない場合、プログラム中で指定できます。
ChromeDriverを c:\opt\chromedriver_win32 に展開した場合は、以下のように実行ファイルのパスを指定します。

driver = webdriver.Chrome(executable_path='c:\\opt\\chromedriver_win32\\chromedriver.exe')

headless モードのテスト

driver = webdriver.Chrome() のあたりを以下のように変更します。動作しているかどうかよくわからないので、途中でウェブページのタイトルを表示しつつ、スクリーンショットをとってみます。

from selenium import webdriver

options = webdriver.ChromeOptions()
options.add_argument('--headless')
driver = webdriver.Chrome(options=options)  # 今は chrome_options= ではなく options=

driver.get('https://www.google.com/')
print(driver.title)

search_box = driver.find_element_by_name("q")
search_box.send_keys('ChromeDriver')
search_box.submit()
print(driver.title)

driver.save_screenshot('search_results.png')
driver.quit()

ページによっては以下も必要になるようです。

options.add_argument('--disable-gpu')

要素を取得する

ページ内の要素は、ID、クラス名、タグ名、リンクのinnerText、CSSセレクタ、XPathで取得出来ます。

参考:

以下では Google の検索ページを例にしますが、DOMの構造は記事作成時のものを仮定します。

By CSS

まずは CSSセレクタを使ってみます。
Google 検索の結果は div class="g" で並びますが、その孫要素の h3 に各ページのタイトルがあるようなので、子孫結合子を使って表示してみます。

CSSセレクタの指定方法: https://developer.mozilla.org/ja/docs/Web/CSS/CSS_Selectors

for g_h3 in driver.find_elements_by_css_selector(".g h3"):
    print(g_h3.text)
    # print(g_h3.text.encode('cp932', 'ignore').decode('cp932'))  # Windowsのコマンドプロンプトを使う場合

.text を使うと innerText 値を取り出すことができます。

Windows のコマンドプロンプトだと print の標準出力時に自動でcp932へ変換されるが、変換できない文字があると UnicodeEncodeError が出てエラーになります。これは、コメントアウトされている行にあるように、いったん ignore オプション付きで byte に変換し、再び string に戻すことで回避できます。(ただし、以降の例ではこの手順は省略します。)

参考: https://qiita.com/butada/items/33db39ced989c2ebf644

By ID

例として Google 検索のヒット件数を表示してみます。IDで引っかけられるのであれば、By ID を使うのが最も効率がよいそうです。

from selenium.webdriver.common.by import By
# ... (省略) ...
# 以下の6つの指定方法ではどれも同じものが返ります
stats = driver.find_element(by=By.ID, value="resultStats").text
stats = driver.find_element_by_id("resultStats").text
# elements のように複数形にするとリストを取得できます
stats = driver.find_elements(by=By.ID, value="resultStats")[0].text
stats = driver.find_elements_by_id("resultStats")[0].text
# CSSセレクタも使えますが By ID の方が効率がよいはず
stats = driver.find_element_by_css_selector("#resultStats").text
stats = driver.find_elements_by_css_selector("#resultStats")[0].text
print(stats)

find_element(by=... を使うには1行目の import が必要です。また、引数がこの順序なら by=value= は省略できます。

  • find_element_... のように単数形にするとひとつ目にマッチした要素を取得します。見つからなければ例外 NoSuchElementException が投げられます。
  • find_elements_... のように複数形にするとリストを取得できます。見つからなければ空のリストが返ります。したがって上の例だと、対応する要素がなければ IndexError が起こります。

ページにつき ID は一意に複数回でないように書くことになっていますが、実際には同じ ID が複数回使われているページもあるので find_elements_by_id の出番もあるのですかね。

By Class Name や By Tag Name

Google の検索結果は div class="g" の中に div class="r"div class="s" があり、それぞれ、リンク a href とタイトル h3、サマリー span class="st" を含みます。これを、クラス名とタグ名で抽出してみます。

for i, g in enumerate(driver.find_elements_by_class_name("g")):
    print("------ " + str(i+1) + " ------")
    r = g.find_element_by_class_name("r")
    print(r.find_element_by_tag_name("h3").text)  # タイトル
    print("\t" + r.find_element_by_tag_name("a").get_attribute("href"))  # URL
    s = g.find_element_by_class_name("s")
    print("\t" + s.find_element_by_class_name("st").text)  # サマリー

これは find_element のみで以下のように書くこともできます。

for i, g in enumerate(driver.find_elements(By.CLASS_NAME, "g")):
    print("------ " + str(i+1) + " ------")
    r = g.find_element(By.CLASS_NAME, "r")
    print(r.find_element(By.TAG_NAME, "h3").text)
    print("\t" + r.find_element(By.TAG_NAME, "a").get_attribute("href"))
    s = g.find_element(By.CLASS_NAME, "s")
    print("\t" + s.find_element(By.CLASS_NAME, "st").text)

By Name

name 属性を使って要素を探します。Googleの検索フィールドの inputname="q" という属性を持つので以下のように検索フィールドを見つけることができます。(これははじめの例で出てきました。)

driver.find_element_by_name("q")
# 以下でも同様
driver.find_element(By.NAME, "q")

By Link Text や By Partial Link Text

リンク要素を、<a href=...></a> の間のテキスト (innerText) を使って見つけます。

# 完全マッチ
for a in driver.find_elements_by_link_text("このページを訳す"):
    print(a.get_attribute("href"))

# 部分マッチ
for a in driver.find_elements_by_partial_link_text("訳す"):
    print(a.get_attribute("href"))

find_elements を使うなら By.LINK_TEXTBy.PARTIAL_LINK_TEXT と組み合わせます。

By XPath

XPath も使えるので、たとえば上の例は以下のように書くことができます。

for a in driver.find_elements_by_xpath("//a[text()='このページを訳す']"):
    print(a.get_attribute("href"))

for a in driver.find_elements_by_xpath("//a[contains(text(), '訳す')]"):
    print(a.get_attribute("href"))

兄弟(姉妹)要素なども XPath ならば簡単に取得できます。以下では弟(妹)要素を取得してみます。
まず、element = driver.find_element... のように要素を取得しておいたとします。この要素 element と同階層の後方にあり、かつ class 属性が hogehoge を含む div 要素は次のように探します。

element.find_element_by_xpath("./following-sibling::div[contains(@class, 'hogehoge')]")

はじめの ./ は無くてもよいです。
following-siblingpreceding-sibling にすれば兄(姉)要素になります。
関係ないですが、英語の sibling (性別を指定しない兄弟)に一対一対応する日本語が欲しいですね。

参考:

要素の状態を調べる

以下のメソッドで、見つけた要素の状態を調べることができます。

  • element.is_displayed()
  • element.is_enabled()
  • element.is_selected()

ユーザー入力(クリックや選択など)

クリックする

取得した要素に対して .click() でクリックできます。

サブミットする

  • 単純には driver.find_element_by_id("submit").click() のような感じサブミットボタンなどをクリックすればよいです。
  • submit() というメソッドも用意されています。要素 element がフォーム内にあるならば、element.submit() でフォームをサブミットできます。

文字列を入力する

テキストフィールドやテキストエリアに文字列を入力するには send_keys() を使います。
Keys を使うには from selenium.webdriver.common.keys import Keys しておきます。 

  • element.send_keys("文字列")
  • element.send_keys(Keys.RETURN) などで特殊キー入力
  • element.send_keys("文字列", Keys.RETURN)(続けて書く場合)
    • 例: driver.find_element_by_name("q").send_keys("python", Keys.RETURN)
  • element.clear() でクリア(念のため send_keys の前に入れておくなど)

select 要素内の option を選択する

  • select = Select(driver.find_element...) select 要素を見つけ
  • 選択
    • select.select_by_index(index)
    • select.select_by_visible_text("text")
    • select.select_by_value(value)
  • 選択解除
    • select.deselect_by_index(index)
    • select.deselect_by_visible_text("text")
    • select.deselect_by_value(value)
    • select.deselect_all() ですべて選択解除
  • その他
    • select.all_selected_options 選択されているオプション要素のリスト
    • select.options 選択できるオプション要素のリスト
      • 例: option_texts = [opt.text for opt in select.options]

select 要素に特化しているので、Google フォームなど、select 要素を使わないような(背後でスクリプトが動くような)フォームの入力には使えません。最近のフォームに対しては、要素の状態チェックや,条件指定による待機、クリックやホバー(マウスオーバー)などを組み合わせて入力していくことになりそうです。

ActionChains を使う

ホバー(マウスオーバー)する

スクリプトが裏で動いているページではよく使うかもしれません。

from selenium.webdriver.common.action_chains import ActionChains
# ...省略...
actions = ActionChains(driver)
actions.move_to_element(
    driver.find_element_by_class_name("name")
).perform()  # hoverする

ドラッグ・アンド・ドロップする

まだ試していないですが Drag & Drop もできるようです。

src = driver.find_element_by_name("source")
tgt = driver.find_element_by_name("target")
actions.drag_and_drop(src, tgt).perform()

ページやウィンドウ間などの移動

ページ間の移動

  • driver.get(<URL>)
  • driver.forward()
  • driver.back()

ウィンドウやフレームへ移動

未テストですが、APIのページにあるものを並べておきます。

  • driver.switch_to.window("windowName")
    • <a href=... target="windowName">リンク</a> で開いたウィンドウへ移動する場合
  • driver.switch_to.frame("frameName")
    • driver.switch_to_frame("frameName.0.child")
    • driver.switch_to_default_content()

以下のようにすると複数ウィンドウを順に移動できるようです。

for handle in driver.window_handles:
    driver.switch_to.window(handle)

ポップアップダイアログに応える

ポップアップしてきたダイアログボックスへもアクセスできるようです。(未テスト)

  • alert = driver.switch_to_alert()

待つ

基本的には読み込まれるまで(onloadイベントまで)待ってくれます。ですが、Ajax ベースでいろいろ書かれていると、要素の追加タイミングがいろいろなので対応できません。その場合は要素の存在や状態を調べるような待ち方が必要です。

条件を指定(明示的待機)

expected_conditions を使います。(インポートしておきます。)

from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

# ...省略...
# タイムアウト時間を固定するならまとめておく
wait = WebDriverWait(driver, 10)  # Timeout 10秒(最大待ち時間)

# 例1: すべて読み込まれて"Google"を含むタイトルが表示されるまで待つ
driver.get('https://www.google.com')
wait.until(EC.title_contains("Google"))

# 例2: 何かをクリックしてから、innerTextに"ほげ"を持ちかつclass属性が hoge である要素が現れるのを待つ。
element = find_element...
element.click()
wait.until(
    EC.text_to_be_present_in_element((By.CLASS_NAME, "hoge"), "ほげ")
)

# 例3: class属性が fuga である要素が visible になるのを待ってからクリックする。
xpath = "//a[@class='fuga']"
a = wait.until(
    EC.visibility_of_element_located((By.XPATH, xpath))
)
a.click()

条件を指定するためのクラスはかなりいろいろあります。その一部を紹介します。

ロケーターは (By.ID, "id") のようなタプルで指定するため、from selenium.webdriver.common.by import By しておく必要があります。
タプルの第一要素は、「要素を取得する」と同様に、By.CLASS_NAMEBy.XPATHBy.CSS_SELECTOR など、いくつかの指定方法があります。

  • 値を指定
    • title_is
    • title_contains
  • ロケーターを指定
    • presence_of_element_located
    • visibility_of_element_located
    • presence_of_all_elements_located
    • frame_to_be_available_and_switch_to_it
    • invisibility_of_element_located
    • element_to_be_clickable
    • element_located_to_be_selected
  • 要素を指定
    • visibility_of
    • element_to_be_selected
    • staleness_of
  • ロケーターと値を指定
    • text_to_be_present_in_element (textを指定)
    • text_to_be_present_in_element_value (textを指定)
    • element_located_selection_state_to_be (期待するselectionの状態をBooleanで指定)
  • 要素と値を指定
    • element_selection_state_to_be (期待するselectionの状態をBooleanで指定)
  • 引数なし
    • alert_is_present

独自にクラスを作ることもできます(詳しくはAPIのドキュメント)。

ポーリング時間指定(非明示的待機)

要素がすぐに見つからない場合の待ち時間(ポーリング時間)を指定します。デフォルトは 0 です。

  • driver.implicitly_wait(秒数)

time.sleep() を使う

サーバーに負荷をかけ過ぎないように、import time + time.sleep(秒数) なども積極的に入れる必要がありそうです。

expected_conditions などではうまくいかない場合にも使います。

スクリーンショット

画面のキャプチャを詳細にみてみます。

著作権をよく考慮する必要があります。

画面サイズを変えてスクリーンショット

サイズ変更やスクロール

driver.set_window_size(1280, 720)
# driver.maximize_window()  # 最大化する場合(ヘッドレスモードでは効かない)
"""
print(driver.get_window_size())
print((driver.execute_script("return window.innerHeight;"),
       driver.execute_script("return window.innerWidth;")))
print((driver.execute_script("return window.outerHeight;"),
       driver.execute_script("return window.outerWidth;")))
"""
driver.execute_script("window.scrollTo(0, 600);")  # スクロールして撮る場合
driver.save_screenshot('screenshot.png')
# driver.get_screenshot_as_file('screenshot.png')  # これも可

driver.execute_script(...) で JavaScript を実行することができます。
これでスクロールとスクリーンショットを繰り返すこともできます。

ヘッドレスモードにした方が調整しやすい & ずれないようです。
通常(非ヘッドレス)モードの場合、指定した値は outerWidth, outerheight に一致するようです。
一方、スクリーンショットの結果は innerWidth, innerHeight に対応する領域です。
ヘッドレスモードではこれらはずれませんが、通常モードでは少し設定が必要です(後述)。

Chrome 起動時からウィンドウのサイズを変えておくなら、driver.set_window_size() ではなく、Chrome 起動前に以下のオプションを指定します。

options.add_argument('--window-size=800,600')
# options.add_argument('--start-maximized')  # 最大サイズでスタート
# options.add_argument('--start-fullscreen')  # 全画面表示でスタート

スクロールバーをなくす

driver.execute_script("document.body.style.overflow = 'hidden';")

通常モードでサイズのずれをなくす

以下の1から3をいずれも設定すれば、通常(非ヘッドレス)モードでも思ったようなサイズのスクリーンショットを得ることができます。つまり、ブラウザの outerWidth(Height) が innerWidth(Height) に一致し、さらに set_window_size の指定解像度とも一致します。

  1. OS のディスプレイ設定で、拡大縮小を 100% にすることで解像度のずれをなくす。
    • 最近の高解像度のグラフィックボードに合わせて、150% や 200% などになっていることが多いです。
    • Windows では、デスクトップで右クリック > 「ディスプレイ設定」>「拡大縮小とレイアウト」
  2. Chrome のオプションに --disable-infobars を追加して「Chrome は自動テスト ソフトウェアによって制御されています。」の表示を消す。
  3. Chrome のオプションに --start-fullscreen を追加するか、スクリーンショットを撮る前に driver.fullscreen_window() を入れる。

ただし最後の設定でフルスクリーン(F11を押した状態、全画面表示)にしているため、画像サイズが画面解像度になってしまいます。

もし小さめのサイズにしたいなら、あらかじめ innerWidth(Height) に対して outerWidth(Height) がどれだけ大きいかを計算しておけば、その分あらかじめ大きめに driver.set_window_size(...) で指定できます。
この場合は、上記の1の設定(OS側での解像度の拡大をオフにする)だけ行い、2と3は不要です。

# 通常モードでサイズのずれをなくす方法
driver = webdriver.Chrome()
h_add = driver.execute_script("return window.outerHeight - window.innerHeight;")
w_add = driver.execute_script("return window.outerWidth - window.innerWidth;")

driver.get("https://www.google.com")
driver.find_element_by_name("q").send_keys("python", Keys.RETURN)

# 1280 x 720 でスクリーンショットを撮りたい場合
driver.set_window_size(1280 + w_add, 720 + h_add)  # outerWidth, outerHeight を指定する
driver.save_screenshot('test2.png')  # 取得される画像サイズは innerWidth x innerHeight

ファイルではなくメモリへ保存

driver.get_screenshot_as_png()driver.get_screenshot_as_base64() を使って、ファイル保存することなく、メモリ上で画像を扱うことができます。

下の例では、いったん Pillow (PIL) の Image.open(...) で読み込むために、io.BytesIO(...) を使ってメモリ上の png バイナリデータをファイルオブジェクト(ストリーム)に変換しています。

import io
from PIL import Image
# ... (省略) ...
img_png = driver.get_screenshot_as_png()
img_io = io.BytesIO(img_png)
img = Image.open(img_io)
img.show()  # 表示してみます

ページ全体のスクリーンショット

ページサイズを JavaScript で取得しておくことで、ページ全体を撮ることもできます。

ヘッドレスモードの場合

検索結果ページ全体のスクリーンショットを撮ってみます。

from selenium import webdriver
from selenium.webdriver.common.keys import Keys

options = webdriver.ChromeOptions()
options.add_argument('--headless')
driver = webdriver.Chrome(options=options)

driver.get("https://www.google.com")
driver.find_element_by_name("q").send_keys("python", Keys.RETURN)

width = driver.execute_script("return document.body.scrollWidth;")
height = driver.execute_script("return document.body.scrollHeight;")
driver.set_window_size(width, height)
driver.save_screenshot('screenshot-full.png')

参考: https://blog.amedama.jp/entry/2018/07/28/003342

通常モードの場合

通常(非ヘッドレス)モードだと画面サイズがウィンドウのサイズに制限されてしまうようなので、スクロールしながらスティッチング(画像貼り合わせ)してみます。

調べたところ「Chromeでフルサイズのスクリーンショットを撮るためのパッチ」など、すでにいろいろとあるようです。このコードを参考にしつつ、メモリ上でスティッチングするように変えてみます。

コードを Gist に置きます。

使い方

  • OS 側のディスプレイ設定で、拡大を 100% にしておきます。(windows であればデスクトップで右クリックし「ディスプレイ設定」>「拡大縮小とレイアウト」)
  • 同じフォルダに fullscreenshot.py がある場合、以下のように save_fullscreenshot(...) を呼びます。
  • スクロールしても固定される要素(スタイルが position: fixed; のものなど)があれば、呼び出す前に解除しておきます(コード内のコメントを参照のこと)。
how_to_use_fullscreenshot.py
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import fullscreenshot as fss

driver = webdriver.Chrome()
driver.get("https://www.google.com")
driver.find_element_by_name("q").send_keys("python", Keys.RETURN)

# driver.set_window_size(800, 600)  # 小さいウィンドウサイズでのテスト用
# スクロールについてくるヘッダやメニューなど(style.position が 'fixed' のもの)
# は適宜解除しておく。以下はあくまで例。
# driver.execute_script("document.getElementById('g-header').style.position = 'static';")
# driver.execute_script("document.getElementsByClassName('js-navi-menu-wrapper')[0].style.position = 'static';")

fss.save_fullscreenshot(driver, 'screenshot-full.png')

driver.quit()

その他

close() vs quit()

  • driver.close() は現在フォーカスが当たっているブラウザウィンドウが閉じます。
  • driver.quit() は全てのブラウザウィンドウを閉じるとともにその WebDriver セッションを終了します。

参考: https://code.i-harness.com/ja/q/e5e7e3

クッキー関係

  • 追加
    • driver.add_cookie({'name':'key', 'value':'value'})
    • 他に追加できる項目
      • path: 文字列 (/...)、domain: 文字列
      • secure: True/False、expiry: ミリ秒(エポック)、など
  • 取得
    • driver.get_cookies()
      • 名前だけ表示してみる: print([cookie['name'] for cookie in driver.get_cookies()])
  • 削除
    • driver.delete_cookie("CookieName")
    • driver.delete_all_cookies()

その他の参考ページ