はじめに
Seleniumには、要素の状態変化を調べるのにexpected_conditions
が用意されています。
しかし、
-
enabled/disabled
の状態変化を調べるものが用意されていない -
clickable
の状態変化を調べる関数は、locator
を渡すタイプのものしか用意されていない - XPathで要素を取得した場合、ある要素の相対パスで表される要素の状態変化を調べる場合にどうしたらよいか分からない
など使いづらい面が多くあります。
これらを何とかしようというのがこの記事の目的です。
想定読者
取り合えずseleniumを一通り使ったことある方を対象としています。
かと言って上級者向けでもありません。
XPATHやfind_element()
などが何か分かっている方向けです。
環境
Python 3.8.3
selenium 3.141.0
geckodriver v0.26.0
Firefox 77.0.1 (64 ビット)
結果のソース
取り合えず説明もなしに結果のソースだけ表示します。(Pythonなのにsnake_caseではなくてcamelCaseなので石を投げられそうですが)
自分では、使っていない条件分岐もありますので完全にテストが出来ているわけではありません。
また、この記事では下記のソースに示されるモジュールをimport
している前提で進めていきます。
import logging
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
from selenium.common.exceptions import (UnexpectedAlertPresentException, NoAlertPresentException,
ElementNotVisibleException, TimeoutException, NoSuchElementException)
logger = logging.getLogger(__name__)
logger.addHandler(logging.NullHandler())
class CheckState():
def __init__(self, locator=(), element=None, state="enabled"):
self.locator = locator
self.element = element
self.state = state
def __call__(self, driver):
try:
if self.element is not None and self.locator == ():
element = self.element
elif self.element is not None and self.locator != ():
element = self.element.find_element(*self.locator)
elif self.locator != ():
element = driver.find_element(*self.locator)
else:
return False
if self.state == "enabled":
return element if element.is_enabled() == True else False
elif self.state == "disabled":
return element if element.is_enabled() == False else False
elif self.state == "selected":
return element if element.is_selected() == True else False
elif self.state == "unselected":
return element if element.is_selected() == False else False
elif self.state == "displayed":
return element if element.is_displayed() == True else False
elif self.state == "undisplayed":
return element if element.is_displayed() == False else False
elif self.state == "clickable":
if element.is_enabled() == False:
return False
return element if element.is_displayed() == True else False
else:
return False
except Exception as e:
logger.debug(f"CheckState: {type(e)}, {e}, {self.locator}, {self.element}, {self.state}")
return False
def findElement(driver, locator=(), element=None, state="enabled", must=True, wait=30, interval=0.5, ignore=False):
try:
if element is None and locator == ():
raise ValueError
driverWait = WebDriverWait(driver, wait, interval)
return driverWait.until(CheckState(locator=locator, element=element, state=state))
except TimeoutException:
if must == True and ignore == False:
logger.error(f"findElement: {locator}, {element}, {state}, {must}, {wait}, {interval}, {ignore}")
raise ValueError
return None
except Exception as e:
if ignore == True:
return None
logger.error(f"findElement: {type(e)}, {e}")
raise e
def isDriver(driver):
if isinstance(driver, webdriver.remote.webdriver.WebDriver):
return True
return False
要素の現在の状態を調べる関数
例えば、
element = driver.find_element(by, value)
などで要素を取得した場合、
-
element.is_enabled()
:enabled
かどうか -
element.is_displayed()
: 画面に表示されているかどうか -
element.is_selected()
: 選択されているかどうか -
element.get_attribute(name)
: attributeもしくはpropertyを得る -
element.get_property(name)
: propertyを得る -
element.value_of_css_property(property_name)
: CSS propertyの値を得る
などで状態を調べる事が出来ます。
この記事では、一番単純なelement.is_enabled()
、element.is_displayed()
、element.is_selected()
について考えていきたいと思います。
expected_conditions
で状態変化を調べる関数
expected_conditions
では、
element.is_displayed()
element.is_selected()
の状態変化を調べるのに以下の関数が用意されています。
-
EC.element_to_be_selected(element)
:element
がis_selected() == True
かどうか -
EC.element_located_to_be_selected(locator)
:locator
で示される要素がis_selected() == True
かどうか -
EC.element_selection_state_to_be(element, is_selected)
:element
がis_selected() == is_selected
かどうか -
EC.element_located_selection_state_to_be(locator, is_selected)
:is_selected() == is_selected
かどうか -
EC.visibility_of(element)
:element
がis_displayed() == True
かどうか -
EC.visibility_of_element_located(locator)
:locator
で示される要素がis_displayed() == True
かどうか -
EC.invisibility_of_element(element)
:element
がis_displayed() == False
かどうか -
EC.invisibility_of_element_located(locator)
:locator
で示される要素がis_displayed() == False
かどうか
ここでの引数は、
-
element
:element = driver.find_element(By.XPATH, "//div[@class='cat']")
などで取得した要素element
を渡します。 -
locator
:(By.XPATH, "//div[@class='cat']")
などを渡します。 -
is_selected
: 選択状態を検出したいならばTrue
を、そうでないならばFalse
を渡します。
となります。
一方で、
element.is_enabled()
だけを調べる関数は、用意されておらず代用できるのは、
expected_conditions.element_to_be_clickable(locator)
となります。
しかし、clickable
は、element.is_enabled() and element.is_displayed()
を調べているので 目的に合わないこともしばしばです。
使い方
一般的に、expected_conditions
の関数は、
driver = webdriver.Firefox(executable_path=path, options=options, service_log_path="nul")
driver.get(url)
locator = (By.XPATH, "//div[@id='cat']")
element = WebDriverWait(driver, 30, 1).until(EC.visibility_of_element_located(locator))
のようにWebDriverWait().until()
などと組み合わせて使用します。
これをもう少し詳しく見ていきます。
Expected conditions
まず、EC.visibility_of_element_located()
です。
これは、以下のように実行すると、
func = EC.visibility_of_element_located(locator)
element = func(driver)
func
には、引数を1つ(driver
を想定)取る関数が返されます。
そして、func
にdriver
を渡して実行すると、locator
で示される要素がdisplayed
ならばその要素を、displayed
でないならばFalse
が返されます。
つまり、func
は、失敗しても例外を投げないfind_element(locator)
ような関数となります。
また、find_element()
は、以下のようにすると、
relativeLocator = (By.XPATH, "./div[@class='meow']") # 相対パス
child = element.find_element(*relativeLocator)
element
配下の要素(child
)を取得することも出来ます。
EC.element_to_be_clickable
で同じような事をしようとすると、以下のようになります.
child = EC.visibility_of_element_located(relativeLocator)(element)
他のexpected_conditions
の関数も同じように相対パスの要素を取得できるようです。
ただ、(見つけられた)説明を読んでみるとdriver
からの絶対パスを想定しているようです。
GitHubでソースを見てみると問題ないようですが、少し不安です。
そのため、相対パスを扱う場合には何かほかの手段も用意したいところです。
WebDriverWait
少し戻ってWebDriverWait().until()
も見ていきます。
WebDriverWait()
は、引数として以下のものを取ります(1つ省略)。
-
driver
:driver
を想定 -
timeout
: 最大待機時間 -
poll_frequency
: 試行間隔
そして、wait.until()
は、以下で説明される引数を1つ取ります。
-
method
: 引数としてdriver
を1つとる関数。この関数は、失敗したらFalse
を返し、成功したらFalse
以外を返すもの
以下のように記述した場合、
timeout = 30
poll_frequency = 1
wait = WebDriverWait(driver, timeout, poll_frequency)
element = wait.until(method)
wait.until(method)
の動作は、
-
method
には、driver
変数の内容(通常driver
インスタンスを想定される)が渡される。 -
method(driver)
が成功する(False
以外を返す)まで、1秒間隔で最大30秒の間実行し続ける。 -
method(driver)
が成功したらその戻り値が返される。 - 30秒経っても成功しなければ例外が投げられる。
となります。
組み合わせ
以上の説明から、下記のように記述した場合、
locator = (By.XPATH, "//div[@id='cat']")
element = WebDriverWait(driver, 30).until(EC.visibility_of_element_located(locator))
locator
が示す要素が存在しdisplayed
であるならばその要素がelement
に代入されます。
もし、30秒経っても要素が見つからないもしくはdisplayed
にならない場合は、例外が投げられます。
お気づきだと思いますが、以下のようにするとelement
からの相対要素を取得できます。
relativeLocator = (By.XPATH, "./div[@class='meow']") # 相対パス
child = WebDriverWait(element, 30).until(EC.visibility_of_element_located(relativeLocator))
enabled
/disabled
に対応させる
expected_conditions
には、enabled
(disabled
)に対応した関数が用意されていませんが対応したものは簡単に作ることができます。
WebDriverWait().until()
から呼び出されるのを前提に考えると
- 引数として
driver
を1つとる関数。この関数は、失敗したらFalse
を返し、成功したらFalse
以外を返すもの
という関数を作ればよいことが分かります。
ただ関数だと、大域変数を使うなどしない限りdriver
以外が渡せないことになりますのでClass
を作ることになります。
一番単純に作ると、以下のようになります。
class IsEnabled():
def __init__(self, locator=(), state=True):
self.locator = locator
self.state = state
def __call__(self, driver):
try:
if self.locator == ():
return False
element = driver.find_element(*self.locator)
return element if element.is_enabled() == self.state else False
except Exception as e:
return False
これは、以下のように使えます。
locator = (By.XPATH, "//div[@id='cat']")
element = WebDriverWait(driver, 30, 1).until(IsEnabled(locator))
expected_conditions
には、locator
を取るタイプとelement
を取るタイプが存在しています。
しかし、作成したものは、locator
を指定しなければいけません。そして相対パスにも対応していません。
これらにも対応できるように改良してみます。
class IsEnabled():
def __init__(self, locator=(), element=None, state=True):
self.locator = locator
self.element = element
self.state = state
def __call__(self, driver):
try:
if self.element is not None and self.locator == ():
element = self.element
elif self.element is not None and self.locator != ():
element = self.element.find_element(*self.locator)
elif self.locator != ():
element = driver.find_element(*self.locator)
else:
return False
return element if element.is_enabled() == state else False
except Exception as e:
return False
こうすることで、以下のように使えるようになります。
# locatorが示す要素をenableになったら取得する
element = WebDriverWait(driver, 30, 1).until(IsEnabled(locator=locator))
# elementをenableになったらelementを返す
element = WebDriverWait(driver, 30, 1).until(IsEnabled(element=element))
# elementからの相対要素がenableになったら取得する
child = WebDriverWait(driver, 30, 1).until(IsEnabled(element=element, locator=relativeLocator))
更に別の状態にも対応させたのが最初に示したCheckState
になります。
find_element()
に変わる関数を用意する
これでfind_element()
のように、同じ記述で要素を取得できるようになりました。
その上、こちらは状態の変化を見ながら取得できます。
ただ、毎回以下のように書くのも面倒ですし、find_element()
と併用すると混乱のもとになりかねません。
element = WebDriverWait(driver, 30, 1).until(CheckState(element=element, state="clickable"))
そこでfind_element()
変わるラッパー関数を定義します。
個人的には、関数の名前から要素を取得しているように思えないのもあります。
あまりラッパー関数を作りすぎて何を使っているか分からなくなるのも問題だとは思いますが。
def findElement(driver, locator=(), element=None, state="enabled", must=True, wait=30, interval=0.5, ignore=False):
try:
if element is None and locator == ():
raise ValueError
driverWait = WebDriverWait(driver, wait, interval)
return driverWait.until(CheckState(locator=locator, element=element, state=state))
except TimeoutException:
if must == True and ignore == False:
logger.error(f"findElement: {locator}, {element}, {state}, {must}, {wait}, {interval}, {ignore}")
raise ValueError
return None
except Exception as e:
if ignore == True:
return None
logger.error(f"findElement: {type(e)}, {e}")
raise e
これは、以下のように使います。
locator = (By.XPATH, "//div[@id='cat']")
element = findElement(driver, locator=locator):
最後に
これで、すっきりと記述できるようになったかと思います。
ただ、サイトの挙動によっては、色々と調整する必要があったりするのが難点です。
今までうまく行っていても、サイトの反応が鈍くて思わぬところで躓いたりするのも良くあります。
結局、time.sleep()
が手放せなかったりします
自分が下手なだけという話もありますが(´・ω・`)