QR Send

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

使ってみる →

Seleniumで「element click intercepted」を撲滅する4段フォールバック実装(Ruby)

Selenium・自動化

element.clickが急に効かなくなる

Rubyでスクレイピングや自動操作のSeleniumスクリプトを書いていると、昨日まで動いていたはずのelement.clickが、ある日突然こんなエラーを吐くようになることがあります。この手のエラー、Seleniumを触っていれば一度は踏みますよね。

Selenium::WebDriver::Error::ElementClickInterceptedError: element click intercepted:
Element ... is not clickable at point (x, y). Other element would receive the click: ...

原因はだいたい決まっていて、

  • 通知バナーやモーダル、ツールチップなど別の要素が上に被っている
  • 目的の要素がまだビューポート外にあってスクロールが必要
  • CSSアニメーションやフェードイン処理の途中で座標がずれている
  • 要素は存在するがまだenabledになっていない

のどれかです。厄介なのは、1つのサイトの中でもページによって原因が違うことがある点です。あるページではモーダルが被っているだけなのに、別のページではアニメーション中にクリックしてしまっている、といったことが普通に起きます。そこで、単一の対策に頼るのではなく、複数のクリック手段を順番に試す「フォールバック方式」のクリック処理を作りました。

4段フォールバックの全体像

考え方はシンプルで、クリック方法を「成功率は高いが副作用も大きい順」あるいは「安全だが弱い順」に並べて、上から順に試し、失敗したら次の方法にフォールバックするというものです。

  1. JavaScriptクリックelement.click()をJS側から直接呼ぶ。ブラウザの実クリックイベントを経由しないぶん、オーバーレイに邪魔されにくい
  2. ActionChainsによるクリック — マウスを要素の位置まで動かしてからクリックする、より「本物のユーザー操作」に近い方式
  3. 座標クリック — 要素の中心座標を計算し、その座標に対してクリックする。要素の内部構造に依存しない
  4. 通常のクリック(element.click) — 一番素朴な方法。ここまで残しておくのは、稀に前の3つよりむしろ素直なclickの方が通ることがあるため

前処理としてオーバーレイの除去とスクロールを行い、それでも失敗する場合に備えてこの4段を順に試し、それでもダメなら少し待って全体をリトライする、という多重構造になっています。まずは前処理から見ていきましょう。

邪魔なオーバーレイを消して、要素までスクロールしておく

クリックを試す前に、まず画面の上部に固定表示されがちなオーバーレイ要素を検出して、あれば非表示にします。

OVERLAY_SELECTORS = [
  ".overlay",
  ".modal",
  ".popup",
  ".tooltip",
  ".dropdown-menu",
  "[style*='position: fixed']",
  "[style*='position: absolute']"
].freeze

def hide_top_overlays(driver)
  OVERLAY_SELECTORS.each do |selector|
    driver.find_elements(:css, selector).each do |overlay|
      begin
        # 画面上部にある要素だけを対象にする(本文中の意図的なfixed要素まで消さないため)
        next if overlay.location.y >= 200

        driver.execute_script("arguments[0].style.display='none';", overlay)
      rescue
        # 個々のオーバーレイ処理の失敗は無視して続行
      end
    end
  end
  sleep(0.5)
rescue => e
  warn "オーバーレイ確認中にエラーが発生しましたが続行します: #{e.message}"
end

position: fixedposition: absoluteまで対象にしているのは、ヘッダーの通知バーやCookie同意バナーなど、クラス名が現場ごとにバラバラなものを網羅的に拾うためです。ただし全部を無差別に消すとページ本来のレイアウト崩れの原因になるので、「画面の上部200px以内にあるものだけ」に絞り込んでいます。ここは注意が必要な設計上の割り切りです。

続けて、対象の要素までスムーズスクロールしておきます。

def scroll_into_view(driver, element)
  driver.execute_script(
    "arguments[0].scrollIntoView({behavior: 'smooth', block: 'center'});",
    element
  )
  sleep(1.2) # スムーズスクロールのアニメーションが終わるのを待つ
rescue => e
  warn "スクロールエラー(継続): #{e.message}"
end

behavior: 'smooth'を指定しているぶん、スクロールが完了するまでにある程度の待ち時間が必要です。ここを省略すると、スクロールアニメーションの途中でクリック判定してしまい、座標がずれてelement click interceptedを誘発することがあります(1.2秒という数字はブラウザやページの重さによって前後する経験則で、正直これが最適とは言い切れません)。

クリック方法を配列にして、順番に試す

4つのクリック方法をラムダの配列にしておき、element click intercepted系のエラーが出たら次の方法へフォールバックします。

def try_click_methods(driver, element)
  click_methods = [
    ->(el) { driver.execute_script("arguments[0].click();", el) },
    ->(el) { driver.action.move_to_element(el).click.perform },
    ->(el) {
      location = el.location
      size = el.size
      center_x = location.x + size.width / 2
      center_y = location.y + size.height / 2
      driver.action.move_to_location(center_x, center_y).click.perform
    },
    ->(el) { el.click }
  ]

  click_methods.each do |method|
    begin
      method.call(element)
      return true
    rescue => e
      next if e.message.include?("element click intercepted") ||
              e.message.include?("Other element would receive the click")

      next # インターセプト以外のエラーも、ここでは次の方法にフォールバックする
    end
  end

  false
end

それぞれの手段には向き不向きがあります。

  • JSクリックは最も成功率が高い一方、実際のマウス操作を経由しないため、mouseoverイベントに依存したドロップダウンメニューの開閉などでは期待通りに動かないことがあります
  • ActionChainsはブラウザから見て「本物のクリック」に近いので副作用は少ないですが、要素が別の要素に隠れていると同じくinterceptedで失敗します
  • 座標クリックは要素そのものではなく画面上の座標を狙うので、要素の重なりに強い反面、ページのズームやスクロール位置がずれていると全く関係ない場所をクリックしてしまうリスクがあります
  • 通常のクリックは一番素直ですが、オーバーレイやアニメーションの影響を最も受けやすい方法です

この順序にしているのは、「副作用は小さいが失敗しやすい方法」から先に試し、それでもダメなら「多少乱暴だが成功率の高い方法」に落としていく、というバランスを取っているためです。どれか1つが通ればそこで打ち切り、次には進みません。

最後は状態確認とリトライで固める

クリックの前に、要素が表示・操作可能な状態かどうかも確認しておきます。

def clickable?(element)
  return false unless element.displayed?
  return false unless element.enabled?

  true
rescue
  true # 状態確認自体が失敗した場合はクリック側の判定に任せる
end

そして全体を、失敗したら少し待って再試行する構造でラップします。

def safe_click(driver, element, attempt = 0, max_attempts = 3)
  return false if attempt >= max_attempts

  hide_top_overlays(driver)
  scroll_into_view(driver, element)
  return false unless clickable?(element)

  return true if try_click_methods(driver, element)

  false
rescue => e
  if attempt < max_attempts - 1
    sleep((attempt + 1) * 2) # 待ち時間を試行ごとに伸ばす
    return safe_click(driver, element, attempt + 1, max_attempts)
  end

  false
end

外側のリトライで待ち時間を(attempt + 1) * 2秒と徐々に伸ばしているのは、原因がアニメーションや非同期の描画更新である場合、単純に「もう少し待てば消える」ケースが多いためです。即座に同じ処理を繰り返しても意味がないので、一呼吸置いてから再試行します。

使う側はsafe_clickを呼ぶだけです。コピペで大丈夫です。

if safe_click(driver, submit_button)
  puts "クリック成功"
else
  puts "クリックできませんでした。手動確認が必要です"
end

trueが返ってくればクリック成功です。falseのときは4段すべて試した上でダメだった、ということなので、ここでリトライを重ねるより手動確認に回した方が早いです。

これで大抵のinterceptedは潰せますが、100%ではありません。4段すべて失敗する場合は、素直にログを見て手動確認する運用にしています。他にうまくいく方法があれば、ぜひコメント欄で教えてほしいです。

コメント

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