【Python】Seleniumの基本操作はラップしよ

本ページはプロモーションが含まれています

ブラウザ操作と言えばのSeleniumですが、便利だけど絶妙に使いにくくないですか?

素直に使おうとするとブラウザ処理をPythonの処理が追い越してしまってエラーになるなんてよくある話

そんな悩みを解決するためにもラッパーを作って快適に利用したいね、ってことて最近個人的に作ってみたラッパーのご紹介です

素人作りなのでそのまま使うと言うよりこれらをベースにいい感じに改良してくれれば(そしてコメントで教えてくれると嬉しいです)

前提

後続のプログラムは下記前提で作成していますので

classと__init__などの初期値は下記の通り

import logging
import time
import os
import sys
import atexit
from typing import Optional, Callable
from selenium import webdriver
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait, Select
from enum import Enum


# selectをわかりやすくするだけ
class SelectBy(Enum):
    Index = 'index'
    Value = 'value'
    VisibleText = 'visible_text'

class SeleniumWrapper:
    def __init__(self, timeout=10, log_level=logging.INFO):
        # ログ設定
        logging.basicConfig(
            format='[%(asctime)s] %(levelname)s: %(message)s',
            level=log_level
        )
        self.logger = logging.getLogger(__name__)
        self.timeout = timeout
        self.logger.info("SeleniumWrapper initialized")
        self.selenium_flg = False
        atexit.register(self.driver_close) 

内部関数たち

  • 対象の要素を画面の中央に持ってくる(_scroll_to_center)
  • Timeoutの値チェック(_resolve_timeout)
  • クリック処理をSeleniumでエラーになったときjsでもう一回試みる(_safe_click)
  • WebElementをIndex指定で取得する(_get_element_by_index)
    def _scroll_to_center(self, element: WebElement) -> bool:
        '''
        memo:
        -----------
        - jsで指定の要素を画面中央にする
        '''
        try:
            self.driver.execute_script("arguments[0].scrollIntoView({ behavior: 'smooth', block: 'center' });", element)
            # 最後に画面表示確認する
            self.selenium_wait(condition=EC.element_to_be_clickable(element))
        except Exception as e:
            self.logger.error(f'[ScrollError] Failed to scroll element: {str(e)}', exc_info=True)
            return False
        return True

    def _resolve_timeout(self, timeout: float) -> float:
        '''
        memo:
        -----------
        - タイムアウトを引数初期値(-1)ならinitの値にする
        '''
        return timeout if timeout != -1 else self.timeout

    def _safe_click(self, element: WebElement) -> bool:
        '''
        memo:
        -----------
        - selenium標準のclickが失敗したらjsでクリックをする2段構えclick
        '''
        try:
            element.click()
        except Exception as e:
            try:
                self.driver.execute_script('arguments[0].click();', element)
            except Exception as e2:
                self.logger.error(f'[ClickError] JS click failed: {str(e2)}', exc_info=True)
                return False
        return True

    def _get_element_by_index(self, css_selector: str, idx: int = 0, timeout: float = -1) -> Optional[WebElement]:
        '''
        Parameters:
        -----------
        css_selector:遷移先のURL
        timeout:WebDriverWaitの最大待機秒数
        condition:expected_conditions

        memo:
        -----------
        - elementをidx指定で取得するのでEC.presence_of_all_elements_locatedを使う
        - css_selectorがconditionの状態になるまで待機
        '''
        timeout = self._resolve_timeout(timeout)
        try:
            elements = WebDriverWait(self.driver, timeout).until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, css_selector)))
            if len(elements) <= idx:
                self.logger.warning(f'[NoSuchElement] selector={css_selector}, index={idx}')
                return None
        except TimeoutException:
            self.logger.warning(f'[Timeout] selector={css_selector}, timeout={timeout}')
            return None
        except Exception as e:
            self.logger.error(f'[Exception] selector={css_selector}, index={idx}, error={str(e)}', exc_info=True)
            return None
        return elements[idx]

driverのオープンとクローズ

兎にも角にもchromedriverを動かさないと話にならないので

オプションはかなりの量があるのでお好みでカスタマイズを

    def driver_open(self, headless: bool = False) -> None:
        '''
        Parameters:
        -----------
        headless:ヘッドレスモードで実行するか

        memo:
        -----------
        - chromedriverを起動する
        - なるべくchromedriver感を隠す
        '''
        options = Options()
        ##### 共通オプション
        options.add_argument('--start-maximized')                                   # 初期のウィンドウ最大化
        options.add_argument("--disable-blink-features=AutomationControlled")       # navigator.webdriver=false となる設定。確認⇒ driver.execute_script("return navigator.webdriver")
        options.add_experimental_option("excludeSwitches", ["enable-automation"])   # Chromeは自動テスト ソフトウェア~~ を非表示
        prefs = {
            "credentials_enable_service": False,                                    # パスワード保存のポップアップを無効
            "download_bubble.partial_view_enabled": False,                          # ダウンロードが完了したときの通知(吹き出し/下部表示)を無効にする。
            "plugins.always_open_pdf_externally": True,                             # Chromeの内部PDFビューアを使わない(=URLにアクセスすると直接ダウンロードされる)
        }
        ##### 引数で指定するオプション
        # ダウンロード先フォルダが指定されてればフォルダを作ってパラメータを追加する
        # ヘッドレスモードの判定
        if headless is True:
            options.add_argument('--headless=new') 
        options.add_experimental_option("prefs", prefs)
        service = Service()
        service.creation_flags = 0x08000000                                         # ヘッドレスモードで DevTools listening on ws:~~ を表示させない
        self.driver = webdriver.Chrome(service=service, options=options)
        self.driver.maximize_window()
        self.selenium_flg = True
        self.logger.info("WebDriver started")

    def driver_close(self):
        '''
        chromedriverを終わらせる処理
        '''
        self.logger.info("Quitting WebDriver")
        if self.selenium_flg:
            try:
                self.driver.close()
                self.driver.quit()
            except Exception:
                pass
        self.selenium_flg = False

ページ読み込み完了の検知

全ての動作に入れるべき処理がこれ

動的ページが当然なのでjsの完了を検知してあげたり、要素の出現や変化を検知してあげる必要があります

さらに、、、理屈は分かりませんが、明示的な待機をしても低確率でPython側の処理が先行する謎事象が発生するので固定でほんの少しだけ待機してあげると安定します

    def selenium_wait(self, condition: Any = EC.presence_of_element_located((By.CSS_SELECTOR, 'body')),
                      timeout: float = 10.0, 
                      final_wait_time: float = COMMON_WAIT_SECONDS                      
                     ) -> bool:
        '''
        Parameters:
        -----------
        condition:expected_conditions ※指定がなければbodyがあることを確認する
        timeout:WebDriverWaitの最大待機秒数
        final_wait_time:最後にsleepする秒数

        memo:
        -----------
        - getだけじゃなくclickとかの後でも使えるようにwait部分だけの関数にする
        '''
        timeout = self._resolve_timeout(timeout=timeout)
        try:
            # JavaScriptを使ってページ全体が読み込まれるまで待機
            wait = WebDriverWait(self.driver, timeout)
            # JavaScriptの読み込み完了を待つ
            wait.until(lambda d: d.execute_script("return document.readyState") == "complete")
            # ロード完了後に指定のCSSが読み込まれたか確認する ※初期値のbodyはさすがにあるでしょ
            wait.until(condition)
            # 最後に無条件待機を入れる
            time.sleep(final_wait_time)
        except Exception as e:
            # おそらく発生しないException
            self.logger.error(f'[WaitError] Failed during wait: {str(e)}', exc_info=True)
            return False
        return True

ページ遷移

わざわざ関数にする必要あるのかって気もしますが・・・

getの後に先ほどのselenium_waitをしてあげるだけ

    def selenium_page_load(self, url: str, condition: Any = EC.presence_of_element_located((By.CSS_SELECTOR, 'body')), timeout: float = 10.0, final_wait_time: float = COMMON_WAIT_SECONDS) -> bool:
        '''
        Parameters:
        -----------
        url:遷移先のURL
        timeout:WebDriverWaitの最大待機秒数
        final_wait_time:最後にsleepする秒数
        load_completion_css:読み込み完了を認知するCSS
        condition:expected_conditions

        memo:
        -----------
        - 結局selenium_waitの前にgetしているだけだけど一番頻度が高いので個別に関数化する
        '''
        try:
            self.driver.get(url=url)
            self.selenium_wait(condition=condition, timeout=timeout, final_wait_time=final_wait_time)
        except Exception:
            return False
        return True

文字入力

Seleniumで文字入力をするsend_keyは追記処理なので一度クリアする必要があるのと、入力完了後にjs発火が前提のWebサイトがあるので前後処理が大事

あとはタグを”input” or “textarea”でチェックしていますがイレギュラーがあったら修正するかも

    def selenium_input(self, target: Union[str, WebElement], idx: int = 0, value: str = '', timeout: float = -1.0) -> bool:
        '''
        Parameters:
        -----------
        target: CSSセレクタ(str)または WebElement
        idx: セレクタで複数要素がある場合のインデックス
        value: 入力する文字列
        timeout: タイムアウト秒数

        memo:
        -----------
        - targetがstrなら要素取得して入力、WebElementならそのまま入力
        '''
        timeout = self._resolve_timeout(timeout=timeout)
        # 要素取得
        element: Optional[WebElement] = None
        if isinstance(target, str):
            element = self._get_element_by_index(target, idx, timeout)
        elif isinstance(target, WebElement):
            element = target
        else:
            self.logger.error(f'[InputError] Invalid target type: {type(target)}')
            return False

        # 要素が見つからなかった場合はFalseを返す
        if element is None:
            return False

        try:
            # 入力可能状態まで待機
            self.selenium_wait(condition=EC.element_to_be_clickable(element), timeout=timeout)

            # タグチェック
            if element.tag_name not in ['input', 'textarea']:
                self.logger.error(f'[InputError] Invalid tag for input: {element.tag_name}')
                return False

            # jsで要素を画面中央にスクロール
            self._scroll_to_center(element)
            # 一旦クリアして念のためクリックして入力
            element.clear()
            element.click()
            time.sleep(COMMON_WAIT_SECONDS)
            element.send_keys(value)
            # 入力後にTabキーを送ってフォーカスを外す
            element.send_keys(Keys.TAB)
            time.sleep(COMMON_WAIT_SECONDS)

        except TimeoutException:
            self.logger.error(f'[Timeout] Element not clickable for input: timeout={timeout}')
            return False
        except Exception as e:
            self.logger.error(f'[Exception] Error in selenium_input: {str(e)}', exc_info=True)
            return False
        return True

情報(文字や要素の値)を取得

個人的には情報取得部分はBeautifulsoupでやることが多いのであまりSeleniumでは利用頻度低いのですが、、基本操作だろうということでこれも

利用シーンとして文字取得が主になりますが、属性の値を取得することもあると思うので属性を指定できつつ、初期値はtext or valueの値を指定しているので使い勝手は悪くないはず

    def selenium_get(self, target: Union[str, WebElement], idx: int = 0, att: str = 'text_or_value', timeout: float = -1.0,  default_value: str = '') -> str:
        '''
        Parameters:
        -----------
        target: CSSセレクタ(str)または WebElement
        idx:インデックスの指定が必要な場合 ※初期値は0
        att:取得する対象 ※textかattributeに指定する値
        timeout:タイムアウトまでの秒数 ※初期値は別途設定
        default_value: elementが見つからなかった時の返値

        memo:
        -----------
        - 文字だけでなく指定要素の値も取得できるようにする
        - hidden要素の取得もあるのでpresence_of_element_locatedを使う
        - 要素がない時は引数で制御して初期値空白を返すようにしている
        '''
        timeout = self._resolve_timeout(timeout=timeout)
        # 要素取得
        element: Optional[WebElement] = None
        if isinstance(target, str):
            element = self._get_element_by_index(target, idx, timeout)
        elif isinstance(target, WebElement):
            element = target
        else:
            self.logger.error(f'[InputError] Invalid target type: {type(target)}')
            return default_value

        # 要素が見つからなかった場合はdefault_valueを返す
        if element is None:
            return default_value                

        try:
            # 引数の属性に合わせて取得
            ret: str
            if att == 'text_or_value':
                # textが空ならvalueを返す
                ret = element.text.strip()
                if not ret:
                    ret = str(element.get_attribute('value')).strip()
            elif att == 'text':
                # 明示的にtextが指定された場合はtextしか確認しない
                ret = element.text.strip()
            else:
                # text以外はattの指定にする
                ret = str(element.get_attribute(att)).strip()
        
        except Exception as e:
            self.logger.error(f'[GetError] Failed to get attribute {att}: {str(e)}', exc_info=True)
            return default_value
        return ret

クリックする

Seleniumでクリックするには画面上に対象要素がないとエラーになるので事前に画面スクロールしてあげる必要があります

あとは稀にSeleniumのクリックが謎エラーになることがあるのでSelenium標準クリックで失敗したら念のためjsでクリック処理をもう一度してみる2段階構造

ボタン、ラジオ、チェックボックスなどクリック処理は全てこれでOKだけどis_selectedとかでで現在状態を事前チェックしてあげたほうがベターかも

    def selenium_click(self, target: Union[str, WebElement], idx: int = 0, timeout: float = -1.0) -> bool:
        '''
        Parameters:
        -----------
        target: CSSセレクタ(str)または WebElement
        idx:インデックスの指定が必要な場合 ※初期値は0
        timeout:タイムアウトまでの秒数 ※初期値は別途設定

        memo:
        -----------
        - クリックするため事前に画面中央に移動してクリックする
        - seleniumのクリックが失敗したらjsでクリックするように内部関数を呼び出す
        '''
        timeout = self._resolve_timeout(timeout=timeout)
        # 要素取得
        element: Optional[WebElement] = None
        if isinstance(target, str):
            element = self._get_element_by_index(target, idx, timeout)
        elif isinstance(target, WebElement):
            element = target
        else:
            self.logger.error(f'[InputError] Invalid target type: {type(target)}')
            return False

        # 要素が見つからなかった場合はFalseを返す
        if element is None:
            return False          

        try:
            # clickしようとしてるから element_to_be_clickable を使う
            self.selenium_wait(condition=EC.element_to_be_clickable(element), timeout=timeout)
        except TimeoutException:
            self.logger.warning(f'[Timeout] Element not clickable: tag={element.tag_name}, timeout={timeout}')
            return False
        except Exception as e:
            # おそらく発生しないException
            self.logger.error(f'[Exception] Unexpected error in selenium_click_elm: {str(e)}', exc_info=True)
            return False

        # jsで要素を画面中央にスクロール
        self._scroll_to_center(element=element)
        # clickでエラーになったらjsを試してみる
        return self._safe_click(element)

プルダウンから選ぶ

条件選択をする際によく利用するプルダウンの変更もSeleniumで操作できます

index、value、textのいずれかで指定できますがtextがわかりやすい気がします

指定を誤らないように事前準備していた列挙型の”SelectBy”で制御してあげるとこがポイント

    def selenium_select(self, target: Union[str, WebElement], idx: int = 0, select_by: SelectBy = SelectBy.VisibleText, value: str = '', timeout: float = -1.0) -> bool:
        '''
        Parameters:
        -----------
        target: CSSセレクタ(str)または WebElement
        idx:インデックスの指定が必要な場合 ※初期値は0
        select_by: select_byの値
        value: select_byで指定した値
        timeout:タイムアウトまでの秒数 ※初期値は別途設定

        memo:
        -----------
        入力可能状態まで待機するのでelement_to_be_clickableを使う
        '''
        timeout = self._resolve_timeout(timeout=timeout)
        # 要素取得
        element: Optional[WebElement] = None
        if isinstance(target, str):
            element = self._get_element_by_index(target, idx, timeout)
        elif isinstance(target, WebElement):
            element = target
        else:
            self.logger.error(f'[InputError] Invalid target type: {type(target)}')
            return False

        # 要素が見つからなかった場合はFalseを返す
        if element is None:
            return False

        # selectタグ以外は使わないはず ※select以外で正常タグを見つけたときは修正する
        if element.tag_name != 'select':
            self.logger.error(f'[SelectError] Invalid tag for select: {element.tag_name}')
            return False

        # jsで要素を画面中央にスクロール
        self._scroll_to_center(element=element)

        # 選択可能状態まで待機
        self.selenium_wait(condition=EC.element_to_be_clickable(element))

        # ここから正常時の操作
        select = Select(element)
        try:
            # 引数のselect_byに合わせて選択
            match select_by:
                case SelectBy.Index:
                    select.select_by_index(int(value))
                case SelectBy.Value:
                    select.select_by_value(str(value))
                case SelectBy.VisibleText:
                    select.select_by_visible_text(str(value))
                case _:
                    self.logger.error(f"[SelectError] Invalid select_by value: {select_by}")
                    return False

        except ValueError as e:
            self.logger.error(f"[SelectError] ValueError during selection by {select_by.name}: {e}")
            return False

        except Exception as e:
            self.logger.error(f"[SelectError] Failed to select option by {select_by.name}: {e}", exc_info=True)
            return False

        time.sleep(COMMON_WAIT_SECONDS)
        return True

画面を少しずつスクロール

WEBサイトによってはlazyなどで遅延読み込みされる仕様になっていることがあるのでスクロースして要素を全て表示させる処理

必要になるシーンは多くはないですがあれば便利

    def window_scroll(self, step: int=10, delay: float=0.1):
        '''
        js対応のためにゆっくり画面を一番下にスクロールする
        '''
        try:
            top = 1
            last_height = self.driver.execute_script('return document.body.scrollHeight')
            while top < last_height:
                top = top + step
                self.driver.execute_script(f'window.scrollBy(0, "{str(step)}")')
                last_height = self.driver.execute_script('return document.body.scrollHeight')
                time.sleep(delay)
        except Exception as e:
            self.logger.error(f'[ScrollError] Failed during window scroll: {str(e)}', exc_info=True)

あとがき

Seleniumがあればブラウザ関係はなんでもできるんですが、標準機能だけで戦うにはちょっと足りないので冒頭の繰り返しになりますがラッパーを準備しておくことはかなり重要かと

クラウドワークスでRPA/スクレイピングツール作成の案件を多く受注しているので定期的にアップデートしたいと思います

コメント

タイトルとURLをコピーしました