QR Send

スマホとPCのChrome間で、URLやテキストをQRコード1回のペアリングで送り合える自作Chrome拡張。アカウント不要・無料。

使ってみる →

Seleniumでselect・日付ピッカーを確実に操作する:Selectクラスとカレンダー操作、選択後の検証

Selenium・自動化

select要素を通常のclickで操作してはいけない

Rubyで書いたSeleniumスクリプトにフォーム入力の自動化を追加するとき、<select><option>に対しても他の要素と同じ感覚でelement.clickを使ってしまうことがあります。トグルを開くつもりでselect.clickした後、目的の<option>要素を探してoption.clickする、という書き方です。

これがうまくいかない場合があります。ネイティブの<select>要素はブラウザ側でOS標準のドロップダウンUIとして描画されるため、開いた状態のオプション一覧はDOM上には存在していてもブラウザの実描画レイヤーの外にあり、Seleniumから見るとElementNotInteractableErrorになったり、クリック自体は例外を出さずに終わるのに実際のvalueは変わっていない、ということが起こります。ヘッドレスモードだと症状が変わることもあり、余計に原因が掴みにくくなります。

<select>を確実に操作する正攻法は、専用のSelenium::WebDriver::Support::Selectクラスを使うことです。

Support::Selectでvalue/text/indexを指定して選択する

Support::Selectはネイティブの<select>を対象にしたヘルパークラスで、内部でJavaScript経由の値設定とchangeイベントの発火を行ってくれます。

require "selenium-webdriver"

def select_option(driver, select_element)
  select = Selenium::WebDriver::Support::Select.new(select_element)

  select.select_by(:value, "13")   # value属性で選択
  # select.select_by(:text, "東京都")   # 表示テキストで選択
  # select.select_by(:index, 5)         # 出現順のインデックスで選択
end

3つの指定方法にはそれぞれ落とし穴があります。

  • value指定は表示テキストの表記ゆれ(全角/半角、末尾の空白、多言語対応での文言変更)に影響されないため最も安定していますが、対象サイトのHTMLソースを確認してvalue属性の実際の値を把握しておく必要があります
  • text指定は人間にとって分かりやすい一方、表示テキストが前後の空白付きだったり、&nbsp;が混ざっていたりするとNoSuchElementErrorになりやすく、select.select_by(:text, " 東京都 ".strip)のように事前にトリムしておいた方が安全です
  • index指定は選択肢の並び順に依存するため、サイト側でoption一覧が動的に増減する場合は狙った項目とずれるリスクがあります

存在しない値を指定した場合はNoSuchElementErrorが発生するため、呼び出し側でrescueして「選択肢が想定と違う」ことを検知できるようにしておくと安全です。

def select_option_safely(driver, select_element, value)
  select = Selenium::WebDriver::Support::Select.new(select_element)
  select.select_by(:value, value)
  true
rescue Selenium::WebDriver::Error::NoSuchElementError
  false
end

JS描画のカスタムドロップダウンにはSupport::Selectが使えない

近年のフォームでは<select>を使わず、<div class="dropdown-toggle">をクリックして候補リストの<li><div>を表示し、その中から候補をクリックさせる自前実装のドロップダウンが増えています。この種のUIは内部にoption要素を持たないため、Support::Selectを使おうとすると初期化の時点でSelenium::WebDriver::Error::UnexpectedTagNameErrorになり、そもそも使えません。

この場合は素直に「トグルを開く→候補が表示されるのを待つ→目的のテキストの候補をクリックする」という手順を自分で組み立てます。

def select_from_custom_dropdown(driver, toggle_selector, option_text, timeout = 5)
  wait = Selenium::WebDriver::Wait.new(timeout: timeout)

  toggle = driver.find_element(:css, toggle_selector)
  toggle.click

  option = wait.until do
    candidates = driver.find_elements(:css, ".dropdown-menu li, .dropdown-menu [role='option']")
    candidates.find { |el| el.text.strip == option_text }
  end

  option.click
rescue Selenium::WebDriver::Error::TimeoutError
  warn "候補「#{option_text}」が表示されませんでした"
  false
end

候補一覧はトグルを開いた直後にはまだDOMに描画されていないことが多く、wait.untilで明示的に「候補が見つかるまで」を待っています。ここを省略していきなりfind_elementsすると、開閉アニメーションの途中で空配列を掴んでしまい、原因不明のNoMethodErrornilに対する.click呼び出し)で落ちることがあります。

日付ピッカー:inputへの直接入力が効かない場合はカレンダーUIを辿る

日付ピッカーは実装によって挙動が大きく異なります。単純なものはinput要素にsend_keysで文字列を流し込むだけで反映されますが、readonly属性が付いていたり、keydownイベントを横取りして独自のパース処理をしているタイプでは、send_keysしても値が変わらないことがあります。

そこでまず直接入力を試し、実際に値が反映されたかを確認したうえで、反映されていなければカレンダーUIをクリックで辿るフォールバックに切り替えます。

def set_date_with_fallback(driver, input_element, target_date)
  date_str = target_date.strftime("%Y-%m-%d")

  input_element.clear
  input_element.send_keys(date_str)

  return true if input_element.attribute("value") == date_str

  # 直接入力が効かなかった場合はカレンダーUIを開いて辿る
  input_element.click
  navigate_calendar_to(driver, target_date)
  select_calendar_day(driver, target_date)

  input_element.attribute("value") == date_str
end

カレンダーUIの操作は「表示中の年月を見て、目的の年月に一致するまで『翌月』ボタンを押す」→「該当日のセルをクリックする」という2段階です。無限ループを避けるため、最大の月送り回数に上限を設けています。

def navigate_calendar_to(driver, target_date, max_clicks = 24)
  header = driver.find_element(:css, ".calendar-header")
  next_button = driver.find_element(:css, ".calendar-next")

  max_clicks.times do
    displayed = header.text.strip
    break if displayed.include?(target_date.strftime("%Y年%-m月"))

    next_button.click
    sleep 0.2
  end
end

def select_calendar_day(driver, target_date)
  day_number = target_date.day.to_s
  cells = driver.find_elements(:css, ".calendar-day:not(.is-disabled)")

  cell = cells.find { |el| el.text.strip == day_number }
  raise "日付セル #{day_number} が見つかりません" unless cell

  cell.click
end

:not(.is-disabled)のように無効化されたセルを除外しているのは、前月・翌月の日付が薄く表示されているカレンダーUIで、同じ日番号のセルが複数ヒットしてしまい、意図しない月の日付をクリックしてしまう事故を防ぐためです。

選択後に実際に値が入ったか検証する

select_byもカスタムドロップダウンのクリックもカレンダー操作も、呼び出しが例外を出さずに終わったからといって、目的の値が実際に反映されたとは限りません。特にカスタムUIは非同期でDOMを書き換えるものが多く、クリック直後にはまだ表示が更新されていないことがあります。そこで最後に、それぞれの入力方式に応じた検証を行います。

def verify_select_value(select_element, expected_value)
  select = Selenium::WebDriver::Support::Select.new(select_element)
  select.first_selected_option.attribute("value") == expected_value
end

def verify_custom_dropdown(toggle_element, expected_text)
  toggle_element.text.strip == expected_text
end

def verify_date_input(input_element, expected_date_str)
  input_element.attribute("value") == expected_date_str
end

検証で一致しなかった場合は、その場で例外にするか、1回だけ同じ手順をリトライするかは対象サイトの安定度次第です。カスタムドロップダウンや日付ピッカーはアニメーション中の状態を拾ってしまい「実際には選択できているのに検証時点ではまだ反映前だった」という誤検知もあり得るため、検証にも数百ミリ秒程度の短い待機やリトライを1回挟んでおくと、過検知による無駄な失敗を減らせます。

まとめ

  • ネイティブの<select>は通常のclickではなくSelenium::WebDriver::Support::Selectを使い、value/text/indexのいずれかで選択する。表記ゆれに強いのはvalue指定
  • JSで描画されたカスタムドロップダウンはoption要素を持たないためSupport::Selectが使えない。トグルを開く→候補の描画を明示的に待つ→目的のテキストの候補をクリックする、という手順を自前で組む
  • 日付ピッカーはまずinputへの直接入力を試し、値が反映されなければカレンダーUIを月送りしながら該当日のセルをクリックするフォールバックに切り替える
  • 前月・翌月の薄く表示された日付セルを誤ってクリックしないよう、無効化されたセルは除外して対象を絞り込む
  • select・カスタムドロップダウン・日付ピッカーいずれも、操作が例外を出さなかったことと値が実際に反映されたことは別物なので、最後に選択後の値を読み直して検証する

コメント

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