「見えているのにfind_elementで見つからない」の正体
Seleniumでスクレイピングや自動操作のスクリプトを書いていると、ブラウザの画面上には確かにボタンやカレンダー、カート内の数量表示などが見えているのに、driver.find_element(:css, "...")を呼ぶとNoSuchElementErrorになる、という現象に出くわすことがあります。
セレクタを何度見直しても間違っていないし、開発者ツールの「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もこの境界を自動では越えないためです。
なお、シャドウルートにはopenとclosedの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
ポイントは「ホスト要素(<product-cart-widget>)をfind_elementで取得する」→「その要素からshadow_rootを呼んでシャドウルートを取得する」→「シャドウルートに対してfind_elementする」という3段階を踏むことです。ホスト要素とシャドウルートは別物であり、ホスト要素に対して直接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
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_scriptでshadowRoot.querySelectorをJavaScript側で辿り、返ってきたDOM要素をSeleniumの要素として扱うフォールバックが有効- Shadow DOM内の操作も非同期更新を伴うことがあるため、クリック後は少し待ってから結果を検証する


コメント