一覧ページを巡回していると、なぜか「次へ」だけ引っかかる
Rubyのselenium-webdriverで検索結果や一覧ページを巡回し、リンクを集めてから次のページへ進む、というのはスクレイピングの定番パターンです。素直に書くと次のようになります。
driver.find_element(:xpath, '//button[contains(text(), "次へ")]').click
ところが、これだけだとページによってElementClickInterceptedErrorのような例外で止まってしまうことがあります。しかもリンク一覧の取得自体は毎回成功しているのに、ページ送りのタイミングだけ不安定という、なんとも切りの悪い挙動です。
調べてみると原因はだいたい一つで、ページャのボタンがブラウザの表示領域(ビューポート)の外、あるいは固定ヘッダーやフッターの裏に隠れた位置にある状態でクリックしようとしていました。要素自体はDOM上に存在しているのでfind_elementは成功しますが、実際に画面へ描画されている位置までスクロールしていないと、クリックイベントが正しい座標に届きません。
対処その1: クリック前に要素の座標までスクロールする
Seleniumの要素オブジェクトはlocationメソッドでページ内での座標(x, y)を取得できます。これを使い、クリック対象がちょうど見える位置までwindow.scrollをJavaScript経由で実行しておきます。
def scroll_to_element(driver, element, offset: 200)
y = element.location[1] - offset
driver.execute_script("window.scroll(0, #{y});")
end
offsetを入れているのは、固定ヘッダーの直下ぎりぎりまでボタンが見えていても、実際にはヘッダーの裏側にわずかに重なっていてクリック判定が届かないケースがあるためです。少し余裕を持って上にスクロールしておくだけで、この手のクリック失敗はかなり減りました。
対処その2: クリックが失敗したらもう一度スクロールしてリトライする
一度スクロールすれば大抵は解決しますが、画像の遅延読み込みなどでページの高さが動的に変わるサイトでは、スクロールした直後にレイアウトがずれて、狙った座標がまた合わなくなることがあります。そこで、クリックの失敗を検知したら座標を取り直し、スクロールとクリックをもう一度やり直すようにしました。
def click_next_button(driver, xpath, offset: 200)
button = driver.find_element(:xpath, xpath)
scroll_to_element(driver, button, offset: offset)
sleep 0.2
begin
button.click
rescue Selenium::WebDriver::Error::ElementClickInterceptedError,
Selenium::WebDriver::Error::ElementNotInteractableError
# レイアウトが変わっている可能性があるので要素を取り直してから再試行
button = driver.find_element(:xpath, xpath)
scroll_to_element(driver, button, offset: offset)
button.click
end
end
ここで地味に重要なのが、リトライ時に古い要素オブジェクトをそのまま使い回さず、find_elementをもう一度呼び直している点です。スクロールでレイアウトが変化した後は座標だけでなく要素の状態自体が変わっている可能性があり、参照を使い回すと今度はStaleElementReferenceErrorを誘発してしまいます。「座標がずれたら要素も取り直す」をセットで覚えておくと、この種の座標系トラブルにはひとまず対応できます。
ページャ巡回のメインループに組み込む
一覧の取得とページ送りを組み合わせると、次のようなループになります。
loop do
driver.find_elements(:css, '.result-list a.item-link').each do |item|
urls << item.attribute("href")
end
next_buttons = driver.find_elements(:xpath, '//button[contains(text(), "次へ")]')
break if next_buttons.empty?
click_next_button(driver, '//button[contains(text(), "次へ")]')
end
終了条件を「次へ」ボタンの有無で判定しているのもポイントです。find_elements(複数形)で件数を見てから分岐すれば、find_element(単数形)のように要素が無いときに例外を投げる仕様に頼らずに済みます。「次のページが無い=巡回終了」という正常系の判定を例外処理でやると読みにくくなるので、存在チェックには複数形を使う、というのは地味に効く習慣です。
offsetとリトライ回数はどこまで詰めるべきか
offsetの値は固定ヘッダーの高さに合わせて決めるのが基本ですが、サイトによってヘッダーの高さが可変(スクロールで縮む、通知バーが出たり消えたりする)だと、固定値では追いつかないことがあります。今回は多少余裕を持たせた固定値で運用していますが、ヘッダー要素自体の高さをその場で取得してoffsetに反映する方が本質的には安全です。
また、このリトライは1回しか用意していませんが、これは「大半のケースは1回のスクロールで直る」という経験則に基づいた割り切りです。もっと不安定なページを相手にする場合は、以前書いたelement click intercepted撲滅の記事で紹介したような、クリック手段そのものを複数用意するフォールバック方式と組み合わせると、座標補正だけでは救えないケースにも対応しやすくなります。
まとめ
- 「次へ」ボタンのクリック失敗は、ボタンが画面外や固定要素の裏に隠れていることが原因のことが多い。
locationで座標を取得しwindow.scrollで見える位置まで補正する - 一度のスクロールで直らない場合に備えて、クリック失敗時は要素を取り直してからもう一度スクロール+クリックするリトライを入れる
- リトライ時は古い要素参照を使い回さず、
find_elementをやり直すことでStaleElementReferenceErrorを避ける - ページャの終了判定は「次へ」ボタンの有無を
find_elementsの件数で見るのがシンプルで、例外処理に頼らずに済む - オフセットは固定ヘッダーの高さに合わせて調整し、座標補正だけで救えない不安定なページには、クリック手段自体を複数用意するフォールバック方式を組み合わせるとよい


コメント