ブラウザ操作と言えばの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/スクレイピングツール作成の案件を多く受注しているので定期的にアップデートしたいと思います
コメント