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回だけ書きます(とはいえ、サイトによっては3層に綺麗に収まらない例外もまだあるかもしれません)。

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

まずはセレクタの違いを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])のように設定を差し替えるだけで新サイトを扱えます。クラスを1行も書かずにいけるので、簡単ですね。

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

日付の書式が特殊など、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

実行するとhttps://list-example-a.com/items: 12件取得のように表示されます。件数がちゃんと出ていれば動いています。

ただ、この3層設計も万能ではありません。ページ全体がJavaScriptで動的に組み立てられるような、極端に特殊な構造のサイトでは、無理に3層へ当てはめようとせず素直に専用コードを書いたほうが早いこともあります。

コメント

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