19
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Selenium for Python 要素の状態変化のための待機と取得に関して統一的な方法を模索する

Posted at

はじめに

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している前提で進めていきます。

python
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): elementis_selected() == Trueかどうか
  • EC.element_located_to_be_selected(locator): locatorで示される要素がis_selected() == Trueかどうか
  • EC.element_selection_state_to_be(element, is_selected): elementis_selected() == is_selectedかどうか
  • EC.element_located_selection_state_to_be(locator, is_selected): is_selected() == is_selectedかどうか
  • EC.visibility_of(element): elementis_displayed() == Trueかどうか
  • EC.visibility_of_element_located(locator): locatorで示される要素がis_displayed() == Trueかどうか
  • EC.invisibility_of_element(element): elementis_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の関数は、

python
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()です。
これは、以下のように実行すると、

python
func = EC.visibility_of_element_located(locator)
element = func(driver)

funcには、引数を1つ(driverを想定)取る関数が返されます。
そして、funcdriverを渡して実行すると、locatorで示される要素がdisplayedならばその要素を、displayedでないならばFalseが返されます。
つまり、funcは、失敗しても例外を投げないfind_element(locator)ような関数となります。

また、find_element()は、以下のようにすると、

python
relativeLocator = (By.XPATH, "./div[@class='meow']") # 相対パス
child = element.find_element(*relativeLocator)

element配下の要素(child)を取得することも出来ます。
EC.element_to_be_clickableで同じような事をしようとすると、以下のようになります.

python
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以外を返すもの

以下のように記述した場合、

python
timeout = 30
poll_frequency = 1
wait = WebDriverWait(driver, timeout, poll_frequency)
element = wait.until(method)

wait.until(method)の動作は、

  1. methodには、driver変数の内容(通常driverインスタンスを想定される)が渡される。
  2. method(driver)が成功する(False以外を返す)まで、1秒間隔で最大30秒の間実行し続ける。
  3. method(driver)が成功したらその戻り値が返される。
  4. 30秒経っても成功しなければ例外が投げられる。

となります。

組み合わせ

以上の説明から、下記のように記述した場合、

python
locator = (By.XPATH, "//div[@id='cat']")
element = WebDriverWait(driver, 30).until(EC.visibility_of_element_located(locator))

locatorが示す要素が存在しdisplayedであるならばその要素がelementに代入されます。
もし、30秒経っても要素が見つからないもしくはdisplayedにならない場合は、例外が投げられます。
お気づきだと思いますが、以下のようにするとelementからの相対要素を取得できます。

python
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を作ることになります。
一番単純に作ると、以下のようになります。

python
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

これは、以下のように使えます。

python
locator = (By.XPATH, "//div[@id='cat']")
element = WebDriverWait(driver, 30, 1).until(IsEnabled(locator))

expected_conditionsには、locatorを取るタイプとelementを取るタイプが存在しています。
しかし、作成したものは、locatorを指定しなければいけません。そして相対パスにも対応していません。
これらにも対応できるように改良してみます。

python
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

こうすることで、以下のように使えるようになります。

python
# 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()と併用すると混乱のもとになりかねません。

python
element = WebDriverWait(driver, 30, 1).until(CheckState(element=element, state="clickable"))

そこでfind_element()変わるラッパー関数を定義します。
個人的には、関数の名前から要素を取得しているように思えないのもあります。

あまりラッパー関数を作りすぎて何を使っているか分からなくなるのも問題だとは思いますが。

python
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

これは、以下のように使います。

python
locator = (By.XPATH, "//div[@id='cat']")
element = findElement(driver, locator=locator):

最後に

これで、すっきりと記述できるようになったかと思います。
ただ、サイトの挙動によっては、色々と調整する必要があったりするのが難点です。
今までうまく行っていても、サイトの反応が鈍くて思わぬところで躓いたりするのも良くあります。
結局、time.sleep()が手放せなかったりします
自分が下手なだけという話もありますが(´・ω・`)

19
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?