はじめに
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()が手放せなかったりします
自分が下手なだけという話もありますが(´・ω・`)