サイトが増えるたびにコードがコピペで増殖していく
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層へ当てはめようとせず素直に専用コードを書いたほうが早いこともあります。


コメント