QR Send

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

使ってみる →

Seleniumで見えているのに見つからない要素の正体:Shadow DOM内を操作する(Ruby)

Selenium・自動化

「見えているのにfind_elementで見つからない」の正体

画面には確かにボタンが見えている。カレンダーも、カート内の数量表示も、目で見て存在しています。なのにdriver.find_element(:css, "...")を呼ぶとNoSuchElementErrorになる。Seleniumでスクレイピングや自動操作のスクリプトを書いていると、こういう現象に出くわすことがあります。

セレクタを何度見直しても間違っていません。開発者ツールの「Elements」タブでも要素は確かに存在しています。こういうときに疑うべき原因の1つがShadow DOMです。<custom-date-picker><product-cart-widget>のような独自タグ(カスタム要素)を使ったUI部品では、内部のマークアップがattachShadowで作られたシャドウルートの中に隠れていることが多く、これが原因で通常のセレクタが素通りしてしまいます。

通常のセレクタはShadow DOMの境界を越えられない

Shadow DOMは、要素の内部構造を外部のCSSやJavaScriptから隔離するカプセル化の仕組みです。例えば次のようなHTMLがあるとします。

<product-cart-widget>
  #shadow-root (open)
    <button class="add-to-cart">カートに入れる</button>
</product-cart-widget>

このときdriver.find_element(:css, "product-cart-widget")<product-cart-widget>要素自体を取得できます。ですがdriver.find_element(:css, ".add-to-cart")のようにドキュメント全体から直接ボタンを探そうとすると見つかりません。シャドウルートの内側は外側のドキュメントに対して別のDOMツリーとして扱われ、querySelector系のAPIもfind_elementもこの境界を自動では越えないからです。

なお、シャドウルートにはopenclosedの2種類があります。closedで作られたシャドウルートは外部からプログラム的にアクセスする手段が用意されておらず、Seleniumからも取得できません。以降の方法は、実務でよく使うopenモードのシャドウルートが前提です。

Selenium 4のshadow_rootでシャドウルートを取得する

Selenium 4系のRubyバインディングには、要素からシャドウルートを取得するshadow_rootメソッドがあります。取得したシャドウルートはfind_element/find_elementsを持つオブジェクトとして扱え、そこからさらに内部の要素を探せます。では実際に使ってみましょう。

def find_in_shadow(driver, host_selector, target_selector)
  host = driver.find_element(:css, host_selector)
  shadow = host.shadow_root
  shadow.find_element(:css, target_selector)
end

button = find_in_shadow(driver, "product-cart-widget", ".add-to-cart")
button.click

ポイントは3段階です。「ホスト要素(<product-cart-widget>)をfind_elementで取得する」→「その要素からshadow_rootを呼んでシャドウルートを取得する」→「シャドウルートに対してfind_elementする」。ホスト要素とシャドウルートは別物なので、ホスト要素に直接find_elementしても内部の要素は見つからないことに注意してください。

ネストしたShadow DOMを辿る

UIコンポーネントを組み合わせて作られたページでは、シャドウルートの中にさらに別のカスタム要素があり、そちらも独自のシャドウルートを持つ、という入れ子構造になっていることがあります。

<product-cart-widget>
  #shadow-root (open)
    <quantity-stepper>
      #shadow-root (open)
        <button class="increment">+</button>
    </quantity-stepper>
</product-cart-widget>

この場合は、1段ごとに「要素を取得する」→「shadow_rootに入る」を繰り返して潜っていきます。実際のコードで見てみましょう。

def find_nested_in_shadow(driver, selector_chain)
  context = driver
  selector_chain.each_with_index do |selector, index|
    element = context.find_element(:css, selector)
    context = index == selector_chain.length - 1 ? element : element.shadow_root
  end
  context
end

increment_button = find_nested_in_shadow(
  driver,
  ["product-cart-widget", "quantity-stepper", ".increment"]
)
increment_button.click

セレクタの配列を「ホスト要素→次のホスト要素→…→最終的な目的の要素」の順で渡し、最後の要素以外は取得したらすぐshadow_rootに変換して次の探索に使います。境界をまたぐたびにshadow_rootを挟む、というルールさえ守れば、ネストが深くなっても同じロジックで辿れます。これ、意外と使えます。

shadow_rootが使えない場合はexecute_scriptでフォールバックする

shadow_rootメソッドは比較的新しいAPIです。Selenium・WebDriverのバージョンや対象ブラウザの組み合わせによっては期待通りに動かないことがあります(このあたりはバージョンによって挙動が変わりやすいところなので、手元の環境で一度試しておくのが安全です)。そうした場合の代替手段が、JavaScriptのshadowRootプロパティをexecute_script経由で辿り、最終的な要素をSeleniumの要素として受け取る方法です。

def find_in_shadow_via_js(driver, host_selector, target_selector)
  driver.execute_script(<<~JS, host_selector, target_selector)
    const host = document.querySelector(arguments[0]);
    if (!host || !host.shadowRoot) return null;
    return host.shadowRoot.querySelector(arguments[1]);
  JS
end

button = find_in_shadow_via_js(driver, "product-cart-widget", ".add-to-cart")
raise "要素が見つかりません" if button.nil?
button.click

execute_scriptがDOM要素を返した場合、Selenium側でそれを操作可能な要素オブジェクトへ自動的に変換してくれるので、戻り値に対して.click.textをそのまま呼べます。ネストしたシャドウルートも、JavaScript側でhost.shadowRoot.querySelector(...).shadowRoot.querySelector(...)と1行で連結して辿れます。正直、shadow_rootメソッドが不安定な環境ではこちらを基本にしてしまってよいと思います。

実際に操作して検証する

Shadow DOM内の要素は取得できても、期待通りに操作できているかは別途確認したほうが安全です。カートの数量表示のように、操作結果がやはりシャドウルートの中のテキストとして反映されるケースでは、クリック前後の値を比較して検証します。

def read_quantity(driver)
  find_nested_in_shadow(driver, ["product-cart-widget", "quantity-stepper", ".value"]).text.to_i
end

before = read_quantity(driver)
increment_button.click
sleep 0.3
after = read_quantity(driver)

raise "数量が増えていません" unless after == before + 1

after == before + 1になっていれば、数量がちゃんと1つ増えたということなので成功です。Shadow DOM内の要素も通常のDOM要素と同じく非同期の描画更新を伴うことがあるため、クリック直後に即座に値を読むのではなく、短い待機を挟んでから検証する点は他のSelenium操作と変わりません。

まとめ

  • 「要素は見えているのにfind_elementで見つからない」ときは、対象がShadow DOMの中にある可能性を疑ってみてください
  • Shadow DOMは外部のCSSセレクタから隔離されたDOMツリーであり、通常のセレクタは境界を自動では越えられません
  • Selenium 4では要素からshadow_rootを呼んでシャドウルートを取得し、そこに対してfind_elementします
  • ネストしたShadow DOMは「要素を取得→shadow_rootに変換→次の要素を探索」を1段ずつ繰り返して辿ります
  • shadow_rootが使えない環境では、execute_scriptshadowRoot.querySelectorをJavaScript側で辿り、返ってきたDOM要素をSeleniumの要素として扱うフォールバックが有効です
  • Shadow DOM内の操作も非同期更新を伴うことがあるため、クリック後は少し待ってから結果を検証してください

Shadow DOMは正体さえわかれば対処は難しくありません。他にも見つけにくいUIパターンで困ったことがあれば、ぜひコメントで教えてください。

コメント

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