QR Send

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

使ってみる →

Seleniumで「見えているのに操作できない」を解消する:iframe・別タブ・別ウィンドウをまたぐ操作の定石

Selenium・自動化

「見えているのに操作できない」の正体

Seleniumでスクリプトを書いていると、find_elementで要素は取得できているし、スクリーンショットにもボタンやリンクがはっきり写っているのに、clicksend_keysを呼ぶとNoSuchElementErrorElementNotInteractableErrorになる、という現象に出くわすことがあります。

見落としがちな原因は2つです。1つは、目的の要素がiframeの中にあるケース。ブラウザの画面上では1枚のページに見えても、DOM的には別ドキュメントが埋め込まれているだけで、WebDriverが今見ているコンテキスト(フレーム)からは存在しない要素として扱われます。もう1つは、目的の要素がtarget="_blank"などで新しく開いた別タブ・別ウィンドウにあるケースです。この場合もWebDriverの操作対象は元のタブのままなので、いくら探しても見つかりません。

どちらも「要素が実際にDOM上のどこにあるか」と「WebDriverが今どのコンテキストを見ているか」がズレていることが原因で、エラーメッセージだけを見ていると「セレクタが間違っているのでは」と誤解しやすい点が厄介です。

iframeの基本: switch_to.frameとswitch_to.default_content

iframe内の要素を操作するには、まずそのiframe自体を要素として取得し、switch_to.frameでWebDriverの視点をそのiframeの中に切り替えます。

def click_inside_iframe(driver, iframe_selector, target_selector)
  iframe = driver.find_element(:css, iframe_selector)
  driver.switch_to.frame(iframe)

  target = driver.find_element(:css, target_selector)
  target.click

  driver.switch_to.default_content
end

switch_to.frame(iframe)を呼んだ後は、find_elementなどの操作すべてがそのiframeの中のDOMを対象にするようになります。逆に言えば、iframeに入ったままだと、それ以外のページ本体側の要素は一切見えなくなります。そのため操作が終わったらswitch_to.default_contentで最上位のドキュメントに視点を戻す必要があります。

iframeがさらにiframeを内包している場合は、switch_to.frameを入れ子で呼んで段階的に潜っていきます。戻るときはswitch_to.parent_frameで1段ずつ戻るか、switch_to.default_contentでまとめて最上位まで戻るかを選べますが、複数のiframeを行き来する処理では後者の方がバグを作りにくいです。

操作後は必ず元のコンテキストに戻す設計

上のclick_inside_iframeは一見問題なさそうですが、target.clickで例外が起きた場合、switch_to.default_contentが実行されずに処理が終わってしまいます。呼び出し元は「もう最上位に戻っているはず」という前提で次の処理を書いているのに、実際にはiframeの中に取り残されたままになり、以降のfind_elementがすべて失敗する事故につながります。

このズレは再現条件がわかりにくく、「この関数を呼んだ後だけ次の処理がおかしくなる」という形でハマりやすいポイントです。対策はシンプルで、コンテキストを戻す処理をensureに入れ、例外の有無にかかわらず必ず実行されるようにします。

def click_inside_iframe(driver, iframe_selector, target_selector)
  iframe = driver.find_element(:css, iframe_selector)
  driver.switch_to.frame(iframe)

  target = driver.find_element(:css, target_selector)
  target.click
ensure
  driver.switch_to.default_content
end

「iframeに入る処理」と「iframeから出る処理」は必ずセットで書き、その間で何が起きても出る処理だけは保証する、という設計にしておくと、iframeを扱う関数がどれだけ増えても呼び出し側は安心して使えます。

target=_blankで開く新規タブをwindow_handlesで捕捉する

新規タブについても考え方は同じです。WebDriverは開いているウィンドウ・タブそれぞれに対して一意のハンドル文字列を持っており、driver.window_handlesで現在開いている全ハンドルの配列を取得できます。新規タブを開くリンクをクリックする前後でこの配列の差分を取れば、新しく増えたハンドルを特定できます。

def wait_for_new_window(driver, known_handles, timeout_seconds = 5.0, interval = 0.2)
  deadline = Time.now + timeout_seconds

  while Time.now < deadline
    new_handles = driver.window_handles - known_handles
    return new_handles.first unless new_handles.empty?
    sleep interval
  end

  raise "新規タブが開きませんでした(#{timeout_seconds}秒待機)"
end

クリック直後は新しいタブがまだ開ききっていないことがあるため、iframeの状態変化待ちと同様に、即座に判定せず短い間隔でポーリングしています。クリックしてから新規タブを捕まえるまでの流れは次のようになります。

original_handles = driver.window_handles

link = driver.find_element(:css, "a[target='_blank']")
link.click

new_handle = wait_for_new_window(driver, original_handles)
driver.switch_to.window(new_handle)

switch_to.window(new_handle)を呼ぶことで、以降の操作対象が新しいタブに切り替わります。ここでも「新規タブの中で何を操作するか」と「新規タブをどう見つけるか」を分けて考えると、コードが読みやすくなります。

元のタブへ戻る

新規タブでの作業が終わったら、driver.window_handleで覚えておいた元のタブのハンドルにswitch_to.windowし直します。

original_window = driver.window_handle
# ...新規タブを開いて操作...
driver.switch_to.window(original_window)

注意したいのは、新規タブを閉じてから元のタブに戻る場合の順序です。新規タブを閉じるには、そのタブにフォーカスがある状態でdriver.closeを呼びます(driver.quitはブラウザ全体を終了させてしまうので、タブを1枚だけ閉じたいときは使いません)。closeの直後は必ずswitch_to.window(original_window)で元のタブにフォーカスを戻します。closeを呼んだ後のWebDriverは「今どのウィンドウを見ているか」が未定義の状態になるため、戻し忘れると次の操作がどのタブに対して行われるか予測できなくなります。

例外時も必ず元コンテキストに戻すヘルパー化

iframeのときと同様、新規タブに関する一連の処理もensureでくくり、例外が起きても元のタブへ戻ることを保証するヘルパーにまとめておくと安全です。

def switch_to_new_window_and(driver, timeout_seconds: 5.0)
  original_window = driver.window_handle
  original_handles = driver.window_handles

  new_handle = wait_for_new_window(driver, original_handles, timeout_seconds)
  driver.switch_to.window(new_handle)

  yield
ensure
  driver.switch_to.window(original_window) if driver.window_handles.include?(original_window)
end

ensure節でdriver.window_handles.include?(original_window)を確認しているのは、元のタブが何らかの理由で閉じられていた場合、存在しないハンドルへのswitch_to.windowが別の例外を誘発するのを避けるためです。呼び出し側は次のように、トリガーのクリックと新規タブ内での処理をシンプルに書けます。

link = driver.find_element(:css, "a[target='_blank']")
link.click

result = switch_to_new_window_and(driver) do
  driver.find_element(:css, ".confirmation-message").text
end

同じ考え方はiframeにも適用できます。「入る」「処理する」「(例外があっても)出る」の3段構造をヘルパーとして共通化しておけば、iframeか別タブかという違いはヘルパーの中身だけの差になり、呼び出し側のコードは統一的に書けます。

def with_frame(driver, iframe_selector)
  iframe = driver.find_element(:css, iframe_selector)
  driver.switch_to.frame(iframe)
  yield
ensure
  driver.switch_to.default_content
end

まとめ

  • 「要素は見えているのに操作できない」の主な原因は、目的の要素がiframe内にあるか、別タブ・別ウィンドウにあるのに、WebDriverの操作対象が別のコンテキストのままになっていること
  • iframe内を操作するにはswitch_to.frameで視点を切り替え、終わったらswitch_to.default_content(またはswitch_to.parent_frame)で最上位に戻す
  • target="_blank"などで開く新規タブは、クリック前後のwindow_handlesの差分をポーリングして捕捉し、switch_to.windowで切り替える
  • タブを閉じるときはcloseswitch_to.window(original_window)をセットで呼び、次の操作がどのタブに対して行われるか常に明確にしておく
  • コンテキストの切り替えは「入る・処理する・戻る」をセットで扱い、戻る処理はensureに入れて例外時も必ず実行されるようにする
  • iframe用・新規タブ用のヘルパーとして共通化しておくと、呼び出し側は「今どのコンテキストにいるか」を気にせずに書けるようになる

コメント

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