Ruby / Rails hikaru

Rubyで簡易Webクローラーを作ってみた

Rubyで簡易Webクローラーを作ってみた

クローラーを作ってページ一覧を取得してみる

クローラーとは、「Web ページを自動で巡回して情報を集めるプログラム」のこと。あるページを読み込み、そこに含まれるリンクをたどって次のページへ進みまた同じことを繰り返す、という動きをします。Google などの検索エンジンもクローラーで Web 上のページを集めています。ところで、なんでクローラーなんかを作ってみようと思ったかというと、BFS(幅優先探索、Breadth-First Search の略)や DFS(深さ優先探索、Depth-First Search の略)を使って何かの処理を書いてみたいと思ったからです。ところが実際に書いてみると、探索アルゴリズムそのものよりも、それ以外の部分で粗や配慮すべき点がいろいろと見つかりました。この記事ではその内容をまとめてみようと思います。

なお、今回は巡回対象となる Web サイトはローカル環境(http://localhost:3010)で動かしている自社コーポレートサイトとしました。

※動作確認は Ruby 4.0.6 / nokogiri 1.19 で行いました。nokogiri は Ruby に標準で付属していないので、事前に gem install nokogiri でインストールしておく必要があります。

クローラーを構成する3つの処理

コードを書き始める前に、中心となる3つの部品を確認してみましょう。

1. HTML を取得する(open-uri)

http://localhost:3010/ という URL を例にすると、このページの中身を取得した結果、実際にはこういう生の HTML テキストが返ってきます(抜粋)。

<!DOCTYPE html>
<html lang="ja" class="scroll-pt-18">
<head>
<title>i Nations 株式会社</title>
...
</head>
<body>
...
<a href="/about" class="...">会社概要</a>
<a href="/recruit" class="...">採用情報</a>
...
</body>
</html>

この HTML をネットワーク越しに取得するのが、Ruby 標準ライブラリの open-uri です。require 'open-uri' すると URI.open(URL) が使えるようになり、これを呼ぶと指定した URL に HTTP リクエストが送られます。ただし、URI.open の戻り値は HTML の文字列そのものではありません。.read を呼んで初めて、HTML が文字列として取り出せます。

response = URI.open('http://localhost:3010/')
html = response.read
html.class # => String

なお、古い記事では open('http://...') と素の open でページを取得している例を見かけますが、Ruby 3.0以降ではこの書き方は使えず、「そのようなファイルは存在しない」というエラー(Errno::ENOENT)になります。URL を開くときは URI.open を使います。

2. リンクを取り出す(Nokogiri)

.read で取り出した HTML は、Ruby から見るとただの長い文字列です。この中から <a href="/about">会社概要</a> のような <a> タグだけを取り出そうとすると、「<a href=" という並びを探して、次の " までを切り出す」といった文字列処理を自分で書くことになります。しかし属性の順番や有無はタグごとに異なるため、この方法は意外と面倒で壊れやすくなります。そこで使うのが、HTML や XML を解析する gem の Nokogiri です。Nokogiri::HTML(html) のように HTML の文字列を渡すと、タグの親子関係をたどれる木構造のオブジェクトに変換してくれます。実際に http://localhost:3010/ のトップページで試してみると、次のようになります。

require 'open-uri'
require 'nokogiri'

doc = Nokogiri::HTML(URI.open('http://localhost:3010/'))
links = doc.css('a')

links.class # => Nokogiri::XML::NodeSet(<a>タグを表すオブジェクトが集まったもの)
links.size # => 84(このページに<a>タグが84個あった、ということ)

tag = links[6]
tag.class # => Nokogiri::XML::Element(1つの<a>タグを表すオブジェクト)
tag['href'] # => "/about"

doc.css('a') が返すのは文字列ではなく、<a> タグ1つ1つを表すオブジェクト(Nokogiri::XML::Element)の集まり(Nokogiri::XML::NodeSet)です。このページには <a> タグが84個ありました。そこから1つ取り出して ['href'] と書くと、そのタグの href 属性の値、つまりリンク先の URL が文字列として取り出せます。これをすべての <a> タグに対して行うと、リンク先の URL を集めた配列が手に入ります。

hrefs = links.map { |a| a['href'] } # => ["/", "/services", "/services#development", "/services#development-flow", "/cases", ...]

なお、<a> タグの中には href 属性を持たないものもあり、その場合は nil が返ってきます。

3. ページを順番に回る(BFS)

リンクをたどってページを回る方法には、大きく分けて BFS と DFS の2つがあります。どちらも「見つかったページをいったん列に並べておき、そこから1件ずつ取り出して処理する」という点は同じで、違うのは列のどこから取り出すかです。

  • BFS:列の先頭から取り出します。先に見つかったページから順に処理するので、トップページから近いページを一通り見終わってから、次の階層へ進みます。
  • DFS:列の末尾から取り出します。最後に見つかったページから処理するので、1つのリンクをたどれるところまで深くたどってから、戻って別のリンクに進みます。

たとえばトップページに「会社概要」「採用情報」「ブログ」のリンクがあったとします。BFS ではまずこの3ページをすべて見てから、それぞれのページの中にあるリンクへ進みます。DFS ではどれか1つ(たとえば「ブログ」)に入り、その中の記事、さらにその記事の中のリンク…と奥まで進んでから、戻って「採用情報」などに移ります。

今回は自分が直感的にわかりやすいと感じた BFS でページを回ります。BFS の「列の末尾に追加し、先頭から取り出す」という動きは、Ruby の配列を使うと次のように書けます。


pending_pages = []
pending_pages.push('http://localhost:3010/about')
pending_pages.push('http://localhost:3010/recruit')
pending_pages
# => ["http://localhost:3010/about", "http://localhost:3010/recruit"]

pending_pages.shift
# => "http://localhost:3010/about"(配列の先頭から1件取り出せる)
pending_pages
# => ["http://localhost:3010/recruit"](取り出した分は配列から無くなっている)

push で配列の末尾に追加し、shift で配列の先頭から取り出します。このように使う列を「キュー」と呼びます。ちなみに shift を pop(末尾から取り出す)に変えるだけで、DFS になります。

ページを開いて新しいリンクを見つけるたびに push で列に積み、shift で1件ずつ取り出しては同じ処理を繰り返します。これを列が空になるまで続ければ、最初に見つけたリンクだけでなくそこからたどれるすべてのページを処理できるはずです。これが最初に考えた大まかな流れでした。

この繰り返しの部分だけを取り出すと次のように書けます。

pending_pages = ['http://localhost:3010/']
while !pending_pages.empty?
current_page = pending_pages.shift # 列の先頭から1件取り出す
puts current_page # 今処理しているURLを表示する
# ここで current_page を開き、そこにあるリンクを取り出す(今は省略)
new_links = [] # 実際にはここに見つかったリンクの配列が入る
pending_pages.push(*new_links) # 見つけたリンクを列の末尾に積む
end

動きそうなものを書いてみた

1〜3の処理がそろったので、この考えをそのままコードにしてみましょう。

require 'open-uri'
require 'nokogiri'

top_page_url = 'http://localhost:3010/'

pending_pages = [top_page_url]

while !pending_pages.empty?
current_page = pending_pages.shift
puts current_page

doc = Nokogiri::HTML(URI.open(current_page))
links = doc.css('a').map { |a| a['href'] }.compact

pending_pages.push(*links)
end

ただしこれをそのまま実行すると、間隔を空けずに大量のリクエストを送り続けることになり、相手のサーバーからは攻撃のように見えてしまいます。また同じサイト内のリンクかどうかを確認せずに開いていくので、関係のない外部サイトまでどこまでもたどっていってしまいます。そこで実行時の引数で「対象にするサイトの URL」「ページを開く最大回数」「アクセスの間隔(秒)」を指定できるようにし、あわせて同じサイト内のリンクだけを列に積むように絞り込みを入れます。

require 'open-uri'
require 'nokogiri'

top_page_url = ARGV[0] # 実行時の1番目の引数: クロールの対象にするURL
max_open_count = ARGV[1].to_i # 実行時の2番目の引数: ページを開くのを最大何回までにするか
interval = ARGV[2].to_f # 実行時の3番目の引数: 1回のアクセスごとに何秒空けるか
target_host = URI.parse(top_page_url).host # top_page_urlからホスト名だけを取り出しておく(今回は'localhost')

pending_pages = [top_page_url]
open_count = 0

while !pending_pages.empty? && open_count < max_open_count
current_page = pending_pages.shift
puts current_page

sleep(interval)
doc = Nokogiri::HTML(URI.open(current_page))
open_count += 1
links = doc.css('a').map { |a| a['href'] }.compact
# 見つかったリンクのうち、target_hostという文字列を含むもの(=同じサイト内のリンクのつもり)だけに絞り込む
same_site_links = links.select { |link| link.include?(target_host) }

pending_pages.push(*same_site_links) # push(*links) ではなく、絞り込んだ same_site_links の方を積むように変更
end

試しに間隔1秒・最大30回の設定で実行してみると、結果は次のとおりでした。

http://localhost:3010/

トップページを1件処理しただけで pending_pages が空になって終わってしまいました。調べてみると same_site_links が0件になっています。

links.select { |link| link.include?(target_host) }
# => []

今回の対象のサイトではサイト内のリンクが '/services' や '/about' のように、ドメイン部分を省略した相対パスで書かれていました。文字列に target_host(今回は 'localhost')が含まれているかで判定していたので、相対パスのリンクはどれも当てはまらなかったわけですね。そこでリンクを絞り込む前に、今開いているページの URL を基準にしてリンクを絶対 URL に変換する処理を入れてみましょう。

# 見つかったリンクを、今開いているページ(current_page)を基準に絶対URLへ変換してから絞り込む
links = doc.css('a').map { |a| a['href'] }.compact.map { |link| URI.join(current_page, link).to_s }
same_site_links = links.select { |link| link.include?(target_host) }

URI.join(current_page, link) を使うと、今開いているページを基準に相対パスを絶対 URL に変換できます。これで '/services' は 'http://localhost:3010/services' になり、同じサイト内のリンクとして拾えるようになりました。それではまた気を取り直して再実行してみます。

http://localhost:3010/
http://localhost:3010/
http://localhost:3010/services
http://localhost:3010/services#development
http://localhost:3010/services#development-flow
http://localhost:3010/cases
http://localhost:3010/blog
http://localhost:3010/about
http://localhost:3010/about#company-info
http://localhost:3010/about#access
http://localhost:3010/about#ceo-message
http://localhost:3010/recruit
http://localhost:3010/recruit/environment
http://localhost:3010/recruit#day-timeline
http://localhost:3010/recruit/stories
http://localhost:3010/recruit#requirements
http://localhost:3010/recruit#selection-flow
http://localhost:3010/contact
http://localhost:3010/services
(…同じような並びがそのまま繰り返される…)
http://localhost:3010/about
http://localhost:3010/about#company-info
http://localhost:3010/about#access

これはよくないですね。

2行目でいきなりトップページ(http://localhost:3010/)が再び出てきています。ロゴなどトップページへ戻るリンクはページ内の複数か所に置かれています。それをそのまま「まだ見ていないページ」として積むと、トップページをもう一度開き、そこで同じリンクをまた丸ごと積み直す…ということを繰り返してしまいます。

原因はすでに見たページかどうかをまったく確認していなかったことです。「見た/まだ見ていない」を管理する仕組みを足さないと、このクローラーは安心して動かせません。

最低限動くものを書いてみた

そこですでに見たページかどうかの重複チェックを入れて書き直したのが、次のコードです。アクセス間隔と最大回数の制限、同じサイト内かどうかの判定、相対パスを絶対 URL に変換する処理は前のコードからそのまま引き継いでいます。

require 'open-uri'
require 'nokogiri'

top_page_url = ARGV[0]
max_open_count = ARGV[1].to_i
interval = ARGV[2].to_f
target_host = URI.parse(top_page_url).host

# すでに見た(処理した)ページのURLを入れておく配列。最初は空
processed_pages = []
pending_pages = [top_page_url]
open_count = 0

while !pending_pages.empty? && open_count < max_open_count
current_page = pending_pages.shift
processed_pages.push(current_page)
puts current_page

sleep(interval)
doc = Nokogiri::HTML(URI.open(current_page))
open_count += 1
links = doc.css('a').map { |a| a['href'] }.compact
absolute_links = links.map { |link| URI.join(current_page, link).to_s }
# ヘッダーとフッターの両方に同じリンクがあるなど、1ページ内で同じURLが複数回出てくるため、先に uniq で取り除く
same_site_links = absolute_links.select { |link| link.include?(target_host) }.uniq

# same_site_linksの中から、「まだ見ていないページ一覧(pending_pages)」にも「すでに見たページ一覧(processed_pages)」にも入っていないものだけを選ぶ
new_links = same_site_links.select { |link| !processed_pages.include?(link) && !pending_pages.include?(link) }
pending_pages.push(*new_links)
end

同じサイト内かどうかの判定は前のコードと同じく、リンクの文字列に target_host(今回は 'localhost')が含まれているかを見るだけのかなり雑なやり方のままにしています。これで実行を試したところ、トップページから重複を除いて36件のサイト内リンクを拾い、そこからさらに「採用情報」ページの「Web エンジニア」「営業職」などのリンクを拾い…と、とりあえずそれっぽく動きました。

しかしここでよく確認してみるといくつかの粗が目につきます。

見えてきた粗を直していく

「localhost を含む」という判定が雑すぎた

これまでの判定はリンクの文字列に 'localhost' が含まれているかを見ているだけでした。これだとたとえば 'localhost.example.com' のような紛らわしい URL がサイト内に置かれていた場合、それも同じサイトのページだと判定してしまいます。実際にはそうそう起きないでしょうが原理的には抜け道になっていますね。そこで URL を単なる文字列として扱うのをやめ、ホスト名同士が完全に一致するかどうかで判定するように直します。

# before: リンクの文字列にtarget_hostが含まれているかどうかだけで判定していた
same_site_links = absolute_links.select { |link| link.include?(target_host) }

# after
# URI.parse(文字列) で、URLの文字列を「ホスト名」「パス」などに分解できるURIオブジェクトに変換する
# .host は、そのURIオブジェクトからホスト名(例: 'localhost')だけを取り出すメソッド
# 各リンクをURIオブジェクトに変換し、そのホスト名がtarget_hostと完全に一致するものだけを残す
same_site_links = absolute_links.select { |link| URI.parse(link).host == target_host }

これで 'localhost.example.com' のような URL は、ホスト名が 'localhost' と一致しないので、正しく除外されるようになりました。

同じページの別の場所を指すリンクを別ページ扱いしてしまっていた

'/about#access' と '/about#company-info' のように、ページ内の特定の位置へ飛ぶリンク(いわゆるアンカーリンク)を別々のページとして扱っていました。URL の # 以降の部分はフラグメントと呼ばれページ内の位置を示しているだけです。どちらも指しているページ自体は同じ /about なのにフラグメントを取り除かずに比較していたため、同じページに何度もアクセスしていました。実際、先ほどの実行結果にも /services#development や /about#access が別の行として並んでいました。

uri = URI.parse(link)
uri.fragment = nil # fragmentは'#'より後ろの部分を保持する属性。nilを代入することで取り除ける
normalized_link = uri.to_s

URI.parse でオブジェクトに変換し、fragment に nil を代入してから文字列に戻すことで、# 以降を取り除いた形で記録・比較するようになりました。

1ページがエラーを返すと全体が止まってしまう

よく見るとここまでのコードは、どこかの1ページが500エラーなどを返すとそこで例外が出てクローラー全体が止まってしまいます。1ページのエラーで全体が止まると、それ以降の正常なページを一切収集できません。そこでページを開く処理を begin / rescue で囲み、エラーが返ってきたページはスキップして次のページに進むようにします。

最終的なコード

ここまでの改善をすべて反映し次のようなコードになりました。ただ、1本のスクリプトにそのまま並べると長くなってきたので、最後に「URL を開いて同じサイト内のリンクを取り出す処理」と「クロール処理全体」を、それぞれメソッドに切り出しています。

require 'open-uri'
require 'nokogiri'

# 指定したURLを開いて、そのページ内にある同一サイト内のリンク一覧を返すメソッド
# 取得に失敗したときは nil を返す(リンクが0件のときは空の配列 [] を返す)
def fetch_links_from(url, target_host:)
begin
doc = Nokogiri::HTML(URI.open(url))
# HTTPエラーだけでなく、接続拒否・DNS失敗・タイムアウト・SSLエラーでも全体を止めない
rescue OpenURI::HTTPError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError => e
puts "#{url} の取得に失敗したためスキップします(#{e.message})"
return nil
end

links = doc.css('a').map { |a| a['href'] }.compact
absolute_links = links.map { |link| URI.join(url, link).to_s }
same_site_links = absolute_links.select { |link| URI.parse(link).host == target_host }

normalized_links = same_site_links.map do |link|
uri = URI.parse(link)
uri.fragment = nil
uri.to_s
end
normalized_links.uniq
end

# クロール処理全体を実行するメソッド
def crawl(top_page_url:, target_host:, interval:, max_open_count:)
processed_pages = []
pending_pages = [top_page_url]
open_count = 0

while !pending_pages.empty? && open_count < max_open_count
current_page = pending_pages.shift
processed_pages.push(current_page)

sleep(interval)
open_count += 1 # 取得に失敗した試行も1回と数える
same_site_links = fetch_links_from(current_page, target_host: target_host)
next if same_site_links.nil? # 取得に失敗したページはURLを表示せず、次のページに進む

puts current_page
new_links = same_site_links.select { |link| !processed_pages.include?(link) && !pending_pages.include?(link) }
pending_pages.push(*new_links)
end
end

top_page_url = ARGV[0]
max_open_count = ARGV[1].to_i
interval = [ARGV[2].to_f, 1.0].max
target_host = URI.parse(top_page_url).host

crawl(top_page_url: top_page_url, target_host: target_host, interval: interval, max_open_count: max_open_count)

上のコードを crawler.rb として保存し、次のように実行します。引数は順に、対象の URL・ページを開く最大回数・アクセスの間隔(秒)です。

ruby crawler.rb http://localhost:3010/ 30 1

ちなみに Ruby 3.1以降では変数名と引数名が同じ場合に interval: interval を interval: と省略して書けますが、値が書かれていないと何を渡しているのかがわかりにくく感じたので今回は省略せずに書いています。

これで簡易クローラーが完成しました。

余談:岡崎市立中央図書館事件

クローラーは出来上がりましたが、ここで1つ疑問が残ります。それはローカルではない実際の Web サイトに対して、どの程度のペースでアクセスして大丈夫なのか、ということです。毎秒1回以上でも大丈夫なのか、毎秒5回や10回でもいいのか。これについては、「一概には言えない」というのが答えになります。

法律に回数の線引きがあるわけではなく、問題になるかどうかは相手のサーバーにどれだけ負荷がかかったか、どういう意図だったかなどさまざまな事情をふまえて判断されるようです。ただし目安になるものはあり、その1つが robots.txt です。これは「このページにはアクセスしないでください」といった希望を伝えるためのテキストファイルで、https://サイトのドメイン/robots.txt で誰でも見ることができます(用意されているサイトの場合)。「アクセスの間隔を空けてください」などといった旨が記載されていることもあり、法律上の強制力はありませんがクローラーを動かす側はこれに従うのがマナーとされています。さらにサイトの利用規約で自動アクセスが禁止されていることもあるので、あわせて確認しておく必要があります。

そして関連する興味深い事件があります。それが岡崎市立中央図書館事件(通称 Librahack 事件)です。

2010年のこと、図書館の新着図書の情報をもっと便利に見られるようにしたいと考えたある男性が、その情報を集めるクローラーを自作して動かしました。アクセスは1秒に1回程度で、1つのリクエストの応答を待ってから次を送るというごく控えめなものでした。それでも図書館のシステムはつながりにくくなり、利用者からの苦情を受けて図書館が警察に被害届を提出しました。そして男性は偽計業務妨害の疑いで逮捕され20日間勾留されたようです。しかしその後、朝日新聞の依頼で複数の専門家や企業がシステムを検証したところ、システム側に不具合があると指摘されたと報じられました。このシステムには1時間に400回を超えるアクセスがあると、他の利用者のリクエストを受け付けられなくなる不具合があったとも報じられています。男性は最終的に、業務妨害の強い意図は認められないとして起訴猶予処分になりました。

この事件では、アクセスした側が攻撃をしていたわけでも相手への配慮を欠いていたわけでもなかったようです。実際、逮捕された後になって報道をきっかけにシステム側の不具合が明らかになりました。自分でクローラーを動かすときは、robots.txt や利用規約を確認し間隔を空けるなどの配慮をして、悪意がないことを示せるようにしておくのがいいですね。

参考:

岡崎市立中央図書館事件 - Wikipedia

クローラ作者の逮捕とエンジニアの不安――“librahack事件”まとめ - はてなニュース