QR Send

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

使ってみる →

SeleniumのStaleElementReferenceErrorを根絶する:要素を掴み直す再取得パターン(Ruby)

Selenium・自動化

StaleElementReferenceErrorはなぜ突然発生するのか

Seleniumで一覧画面やフォームを操作するスクリプトを書いていると、さっきまで正常に動いていた処理が、ある操作を挟んだ途端にこんな例外を吐くようになることがあります。

Selenium::WebDriver::Error::StaleElementReferenceError: stale element reference:
element is not attached to the page document

これは要素そのものが画面から消えたという意味ではありません。Seleniumが保持している要素の参照は、実際にはブラウザ内部のDOMノードに紐づいたIDのようなものです。ReactやVueに限らず、素朴なjQueryのテンプレート差し替えでも、一覧の並び替えやフィルタリング、非同期での再描画が起きると、見た目上は同じ内容に見えていても裏側では古いノードが破棄され、新しいノードに丸ごと差し替えられていることがよくあります。

このとき、差し替え前のノードを指していた要素の参照は「もう存在しないノードを指したまま」になります。見た目は変わっていないので原因に気づきにくく、「さっきfind_elementで取れたのだから、この変数はまだ使えるはずだ」という思い込みが、この手のエラーの温床になります。

変数に握った要素を使い回すアンチパターン

典型的にハマるのは、要素を一度取得して変数に入れ、その後の処理でも同じ変数を使い回してしまうパターンです。

# アンチパターン: 要素を変数に握ったまま使い回す
submit_button = driver.find_element(:css, ".submit-button")

apply_filter(driver, "active") # 一覧の絞り込みで画面が再描画される
sleep 1

submit_button.click # StaleElementReferenceError!

このapply_filterのように、途中で画面の一部を書き換える処理を挟むと、submit_buttonが指していたノードはすでに破棄されており、次に触った瞬間に例外が飛びます。厄介なのは、このapply_filterのような再描画を伴う処理が、必ずしも見た目からは分からないことです。ローディングスピナーも出さずに一覧の中身だけを差し替えるUIは珍しくなく、「画面は変わっていないから要素も無事だろう」という判断が裏切られます。

もう一つのよくある形は、ページ内を何度もスクロールしたり、他の要素をクリックしたりする一連の操作の途中で、最初に取得しておいた要素を最後にまとめて使おうとするケースです。操作の合間にどこか一箇所でも再描画が挟まれば、保持していた参照はまとめて無効になります。

使う直前に毎回findする

根本的な対策は単純で、「要素は使う直前に毎回取得し直す」ことです。取得してから実際に操作するまでの時間が空くほど、その間に再描画が起きて参照が無効化されるリスクは高くなります。

def click_submit(driver)
  driver.find_element(:css, ".submit-button").click
end

この程度であれば単にfindし直せば済みますが、複数の操作をまとめて行いたい場合や、再描画のタイミングが読めない場合には、これだけでは不十分です。findした直後にクリックしても、その一瞬の間に再描画が走ってしまえば結局StaleElementReferenceErrorは起こり得ます。そこで、操作自体をブロックとして受け取り、StaleElementReferenceErrorが発生したら要素の取得からやり直す、というリトライの仕組みを用意します。

操作をブロックで包んでリトライするwith_retry_on_stale

考え方は、「要素の取得」と「要素への操作」を1つのブロックにまとめて渡し、そのブロックの実行中にStaleElementReferenceErrorが起きたら、ブロックそのものを最初からやり直すというものです。ブロックの中でfindをやり直すようにしておけば、リトライのたびに最新のDOMから要素を取り直すことになります。

def with_retry_on_stale(max_attempts: 3, interval: 0.3)
  attempts = 0

  begin
    attempts += 1
    yield
  rescue Selenium::WebDriver::Error::StaleElementReferenceError => e
    raise e if attempts >= max_attempts

    warn "要素が失効したため再取得します(#{attempts}/#{max_attempts}): #{e.message}"
    sleep interval
    retry
  end
end

使う側は、要素の取得も操作もまとめてブロックの中に書きます。

with_retry_on_stale do
  submit_button = driver.find_element(:css, ".submit-button")
  submit_button.click
end

ここでの肝は、find_elementをブロックの外に出さないことです。もしfind_elementだけを外で1回実行し、ブロックの中では操作だけを行うようにしてしまうと、リトライしても同じ失効した参照を使い回すことになり、何度リトライしても同じ例外を繰り返すだけになります。ブロックの中に「取得」と「操作」の両方を含めて初めて、リトライが意味を持ちます。

最大リトライ回数を設けているのは、再描画が止まらない、あるいは対象の要素自体が本当に消えてしまったようなケースで、無限にリトライし続けないようにするためです。数回リトライしても解決しない場合は、そもそも待ち方やセレクタの設計を見直すべきサインとして、例外をそのまま呼び出し元に伝えます。

ループ内で要素配列がまとめて無効化される問題

with_retry_on_staleだけでは解決しない、もう一つ別の落とし穴があります。それは、一覧の要素を配列としてまとめて取得し、その配列をループで回しながら1件ずつ操作するケースです。

# アンチパターン: 配列を最初に一括取得してループで使い回す
items = driver.find_elements(:css, ".item")

items.each do |item|
  item.find_element(:css, ".select-button").click
  sleep 0.5 # クリックのたびに一覧が再描画される
end

1件目の.select-buttonをクリックした結果、一覧全体が再描画される仕様のUIでは、items配列に入っていた2件目以降の要素はすべて古いDOMを指したままになります。1件目の処理は問題なく終わるので気づきにくく、2件目以降でまとめてStaleElementReferenceErrorが発生してから初めて原因を調べる、という展開になりがちです。

この問題はwith_retry_on_staleでループ全体を包んでも解決しません。なぜなら、ループの中で参照しているのは配列の要素であり、リトライしても同じ配列の同じ古い要素を指し続けるからです。ここで必要なのは、配列そのものを毎回取り直す、つまり「インデックス」または「一意なセレクタ」を使って、ループの各回で改めてfindし直すことです。

インデックスで再取得する場合は、件数だけを先に数えておき、ループの中では毎回そのインデックスに対応する要素を取り直します。

def item_count(driver)
  driver.find_elements(:css, ".item").size
end

count = item_count(driver)

count.times do |index|
  with_retry_on_stale do
    item = driver.find_elements(:css, ".item")[index]
    raise "対象の要素が見つかりません(index=#{index})" if item.nil?

    item.find_element(:css, ".select-button").click
  end
  sleep 0.5
end

ただし、この方法にも弱点があります。クリックした結果、一覧から対象の項目自体が消えたり並び替わったりするUIでは、同じインデックスが指す中身がずれてしまい、意図しない項目を操作してしまう恐れがあります。並び替えが起きる可能性がある場合は、インデックスではなく、各項目が持つ一意な識別子(data-item-idのような属性)をあらかじめ控えておき、それをセレクタに組み込んで再取得する方が安全です。

item_ids = driver.find_elements(:css, ".item").map { |item| item.attribute("data-item-id") }

item_ids.each do |item_id|
  with_retry_on_stale do
    driver.find_element(:css, "[data-item-id='#{item_id}']")
          .find_element(:css, ".select-button")
          .click
  end
  sleep 0.5
end

最初のループで識別子だけを文字列として控えておけば、その後の再描画で要素自体が作り直されても、識別子をキーにして毎回新しいノードを引き当てられます。インデックスに比べるとコード量は増えますが、順序が入れ替わるUIを相手にするときには、この一手間が結果の正しさを左右します。

まとめ

  • StaleElementReferenceErrorは要素が消えたのではなく、保持していた参照先のDOMノードが再描画で作り直されて無効になることで起きる
  • 要素を変数に握って時間を置いてから使い回すのはアンチパターン。取得と操作の間隔が空くほど、再描画に巻き込まれるリスクが高まる
  • 「使う直前に毎回findする」ことが基本方針だが、それだけでは取得直後の再描画までは防げないため、操作全体をブロックにしてStaleElementReferenceError時に取得からやり直すwith_retry_on_staleのようなヘルパーを用意する
  • ループで配列をまとめて取得して使い回すと、1件目の操作をきっかけに2件目以降がまとめて失効することがある。ループの各回でインデックスまたは一意なセレクタを使って要素を取り直す
  • 順序や中身が入れ替わる可能性のある一覧では、インデックスではなくdata-item-idのような一意な識別子で再取得する方が、意図しない項目を操作してしまう事故を防げる

コメント

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