一覧ページを巡回していると、なぜか「次へ」だけ引っかかる
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
これで巡回が最後まで進んでurlsにリンクが溜まっていけば成功です。終了条件を「次へ」ボタンの有無で判定しているのもポイントです。find_elements(複数形)で件数を見てから分岐すれば、find_element(単数形)のように要素が無いときに例外を投げる仕様に頼らずに済みます。「次のページが無い=巡回終了」という正常系の判定を例外処理でやってしまうと読みにくくなるので、存在チェックには複数形を使う。地味に効く習慣です。
offsetとリトライ回数はどこまで詰めるべきか
offsetの値は固定ヘッダーの高さに合わせて決めるのが基本ですが、サイトによってヘッダーの高さが可変(スクロールで縮む、通知バーが出たり消えたりする)だと、固定値では追いつかないことがあります。今回は多少余裕を持たせた固定値で運用していますが(サイトによってはこのoffset値がまだ足りないこともあるかもしれません)、ヘッダー要素自体の高さをその場で取得してoffsetに反映する方が本質的には安全です。
また、このリトライは1回しか用意していません。これは「大半のケースは1回のスクロールで直る」という経験則に基づいた割り切りです。裏を返せばこれはあくまで対症療法で、座標がずれる原因そのものを直しているわけではありません。もっと不安定なページを相手にする場合は、以前書いたelement click intercepted撲滅の記事で紹介したクリック手段そのものを複数用意するフォールバック方式と組み合わせると、座標補正だけでは救えないケースにも対応しやすくなります。
座標がらみのクリック失敗はページャ以外でも意外とよく出会う不具合です。同じような挙動で困っている方がいたら、参考にしてもらえればと思います。


コメント