QR Send

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

使ってみる →

Seleniumで複数サイトを巡回するスクレイパーを「共通設計」にまとめる

Selenium・自動化

サイトが増えるたびにコードがコピペで増殖していく

Seleniumで一覧ページを巡回するスクレイパーは、最初の1サイトならすぐ動きます。しかし対象サイトが増えると「動いているコードをコピーして、セレクタだけ書き換える」という対応をしがちです。

これは短期的には速いのですが、リトライ処理やログの形式を直したいとき、コピーした先すべてに同じ修正を入れて回る羽目になります。サイトごとの差分と、共通のはずの処理(待機・リトライ・ログ)が同じファイルに混ざり、どこを直せば全サイトに効くのか分からなくなるのが根本的な問題です。

3層に分けて考える

スクレイパー全体を次の3層に分けます。

  • 共通インターフェース: fetch_list / parse_item / next_page_urlの3メソッドで統一する
  • アダプタ: サイトごとの差分をサイト1つにつき1クラスへ閉じ込める
  • 設定(Hash): 「値が違うだけ」の部分はコードでなく設定として外出しする

リトライ・ログ・待機は、アダプタでなく実行側に1回だけ書きます。

アダプタの基底クラスと設定

セレクタ差分をHashとして受け取る、汎用的なアダプタの基底クラスです。

class ListSiteAdapter
  attr_reader :config

  def initialize(driver, config)
    @driver = driver
    @config = config
  end

  def fetch_list(url)
    @driver.get(url)
    @driver.find_elements(:css, config[:item_selector])
  end

  def parse_item(item)
    {
      title: item.find_elements(:css, config[:title_selector]).first&.text&.strip,
      url: item.find_elements(:css, config[:link_selector]).first&.attribute("href")
    }
  end

  def next_page_url(_current_url)
    @driver.find_elements(:css, config[:next_selector]).first&.attribute("href")
  end
end

セレクタなどの差分はコードではなくHashにまとめます。

SITE_CONFIGS = {
  site_a: { item_selector: ".result-row", title_selector: ".result-title",
            link_selector: "a.result-link", next_selector: "a.pager-next" },
  site_b: { item_selector: "li.entry", title_selector: "h3.entry-title",
            link_selector: "h3.entry-title a", next_selector: "a[rel='next']" }
}

ListSiteAdapter.new(driver, SITE_CONFIGS[:site_a])のように設定を差し替えるだけで新サイトを扱え、クラスを書く必要すらありません。

セレクタ以外の差分はサブクラスで上書きする

日付の書式が特殊など、Hashで表現しきれない差分は、基底クラスを継承したサブクラスで該当メソッドを上書きします。

class SiteBAdapter < ListSiteAdapter
  def parse_item(item)
    raw = item.find_elements(:css, ".entry-date").first&.text
    super.merge(published_on: raw && Date.parse(raw.tr("年月", "--").delete("日")))
  end
end

共通処理は基底クラスのまま再利用しつつ、特殊事情はSiteBAdapterの1ファイルに閉じ込められ、他には手を入れません。

共通処理は実行側に1回だけ書く

アダプタは取得方法だけを知っていればよく、リトライやログは実行側のScraperRunnerにまとめます。

class ScraperRunner
  def initialize(driver, adapter)
    @driver = driver
    @adapter = adapter
  end

  def run(start_url, wait: 1.0, max_retry: 3)
    url = start_url
    results = []

    while url
      items = with_retry(max_retry) { @adapter.fetch_list(url) }
      items.each { |item| results << @adapter.parse_item(item) }
      url = @adapter.next_page_url(url)
      sleep wait
    end

    results
  end

  private

  def with_retry(max_retry)
    retries = 0
    begin
      yield
    rescue => e
      retries += 1
      raise e if retries > max_retry
      warn "取得失敗(#{retries}回目): リトライします"
      sleep(1.0 * retries)
      retry
    end
  end
end

with_retryをここで1回直せば全サイトに反映され、コピペで1サイトだけ直し忘れる事故もなくなります。複数サイトを処理する実行側も短く書けます。

driver = Selenium::WebDriver.for :chrome

adapters = {
  "https://list-example-a.com/items" => ListSiteAdapter.new(driver, SITE_CONFIGS[:site_a]),
  "https://list-example-b.com/items" => SiteBAdapter.new(driver, SITE_CONFIGS[:site_b])
}

adapters.each do |start_url, adapter|
  results = ScraperRunner.new(driver, adapter).run(start_url)
  puts "#{start_url}: #{results.size}件取得"
end

driver.quit

まとめ

  • サイトごとにスクリプトをコピペして増やすと、共通処理の修正がサイト数分の手作業になり、直し忘れの事故が起きやすい
  • fetch_list / parse_item / next_page_urlという共通インターフェースを決め、サイトごとの差分はアダプタクラスに閉じ込める
  • セレクタなど「値が違うだけ」の差分はHash(設定)として外出しし、多くの場合は新しいクラスすら不要にする
  • セレクタで表現しきれない差分だけ、サブクラスでメソッドを上書きする
  • リトライ・待機・ログは実行側に1回だけ書き、新しいサイトの追加を「アダプタ1ファイル、または設定1エントリ」に収める

コメント

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