RubyでWebスクレイピング #4 URLの取得とページ遷移

2021/01/29に公開

#3 Nokogiriを使いこなす
#5 javascript対応テクニック

準備

前回同様Nokogiriをインストールしておけば大丈夫。リポジトリも更新済み。
GitHub zenn_scraping

連続アクセスに関する注意

以前の記事で、Yahoo! JAPANトップページにアクセスしてhtmlを取得してそのタイトルを出力するスクリプトを紹介する際に、「このスクリプトを間髪を入れずに連続して実行すると対象サーバへの連続アクセス攻撃になってしまうので、絶対にしないように」と書いた。このときはスクリプトを手動で(あるいは自動で)連続で実行しない限り攻撃とみなされるような挙動はしなかったが、今回の記事で紹介するページ遷移や画像ファイルの取得をスクリプトで行うと、コードの書き方を誤った場合に攻撃とみなされるような挙動をしてしまう。余計なトラブルを招かないためにも、注意の内容をよく理解した上で、この先のスクリプトの解説に触れてほしい。

1秒以上のスリープ

スクリプトで過剰な連続アクセスを行わないようにするには、sleepメソッドを使うのが単純で分かりやすい。指定の秒数スリープするメソッドだ。リファレンスにも書いてあるように、秒数の指定を忘れると永久にスリープしてしまうので注意。
module function Kernel.#sleep
そしてスリープさせる時間だが、基本は1秒と考えておけば良い。open-uri、あるいは後々の記事で紹介する予定のMechanizeでのhtmlの取得では、JavaScriptでJSONを取得してそれを描画する、などといった動的な処理が発生しないため、1秒空けておけば問題となることはあまりない。きっかり1秒間隔なのが気になるようであれば、randメソッドも併用して間隔をランダムに変動させれば良いだろう。
module function Kernel.#rand

sleep.rb
require 'open-uri'
require 'nokogiri'

url = 'https://arao99.github.io/zenn_scraping/nokogiri_practice.html'

html = URI.open(url).read
doc = Nokogiri::HTML.parse(html)
title = doc.title
puts title
pp Time.now # 現在時刻を出力

sleep 1 # 1秒スリープ

html = URI.open(url).read
doc = Nokogiri::HTML.parse(html)
title = doc.title
puts title
pp Time.now

sleep rand(1.5..3.0) # 1.5〜3.0秒のランダムスリープ

html = URI.open(url).read
doc = Nokogiri::HTML.parse(html)
title = doc.title
puts title
pp Time.now
$ bundle exec ruby sleep.rb 
Nokogiriテスト用ページ
2021-01-27 22:49:17.357264 +0900
Nokogiriテスト用ページ
2021-01-27 22:49:18.414539 +0900
Nokogiriテスト用ページ
2021-01-27 22:49:20.130521 +0900

先ほど問題ない、と言い切らなかったのは、何かしらのクロール避けの仕組みが導入されていて、数秒間隔のアクセスを数時間繰り返していると、アクセスを遮断されたり、画像認証を求められたりするケースや、場合によっては間隔を空けたアクセスでも先方に不具合が発生してしまうケースなどがあるためだ。
余談だが、Selenium + Headless Chromeを使って通常のブラウジングと同等の処理を行う場合は、特定のElementが取得できるまで最大何秒待つといった設定を行うなど、より気を遣う必要がある。

robots.txt

最近のイケているWebサイトには、検索エンジンのクローラーに対して、このページはインデックスしないで、などの指示を書く、robots.txtというファイルが存在する。検索エンジンのクローラーは、まずこのrobots.txtを読んで、どうやってクロールするか、どのページをインデックスするかなどを判断することになっている。今まで特にそういう書き方はしてこなかったが、これまでに書いてきたWebスクレイピングのためのスクリプトは、ごく小規模なクローラーであるとも言える。であれば、robots.txtに書いてある指示には極力従った方がお行儀が良い。アクセス間隔については、Crawl-delayという項目があり、ここでアクセス間隔の秒数を指定している。例として、connpassのrobots.txtを紹介する。
connpass - エンジニアをつなぐIT勉強会支援プラットフォーム
connpass robots.txt

User-agent: *
Crawl-delay: 5
Allow: /
Disallow: /series/optout/
Disallow: /account/

一番上のUser-agentは、どのクローラーに対する指示かを示している。この場合はワイルドカードなので、全てのクローラーに対する指示だ。その下のCrawl-delayが5になっているということは、繰り返しアクセスするときは5秒空けてね、と指示している。つまり、connpassを対象にWebスクレイピングする場合、アクセス間隔は5秒以上とするのが望ましい。
と、ここまで書いてはみたのだが、robots.txtは実は拘束力を持たないため、ここに書いてあることを全く無視したクローラーも実行できてしまう。そして仕様や解釈も割と頻繁に変わり、またクローラーによっても違いがある。以前聞いた話では、「BingのクローラーはCrawl-delayを守ってくれるがGoogleのクローラーは守ってくれない。アクセス間隔を指定したい場合はSearch Consoleでの設定が必要」とのことだったが、現在どうなっているかについては各自で確認してほしい。

API仕様の確認

これはWebスクレイピングというよりWeb APIの利用の話になるのだが、Web APIには15分間に300回までのようなアクセス回数の制限がされていることがある。そのようなAPIを利用して情報の取得を行う場合は、当然その制限を超えないようにアクセス間隔を調整する必要がある。一番ポピュラーな例はTwitterのAPIだろうか。ただこちらも時々仕様が変わるイメージが強いため、現在どうなっているかは各自で確認してほしい。
Docs Twitter Developer

相手のサービスを落とさない、迷惑をかけない

どれぐらい間隔を空ければ大丈夫かは、最終的にはこれに集約されるのかもしれない。CDNで配信されているコンテンツを取得する場合は多少無茶しても問題ないが、1回のリクエストの裏で巨大なDBのいくつものテーブルからデータを検索して取得するようなサービスの場合、十分に間隔を空けたつもりでも迷惑をかけてしまうことがある、といった具合だ。
先ほど少し触れた何かしらのクロール避けに引っかかってしまった場合や、一時的なものを除いたHTTPステータスコード4xx系、5xx系のエラーを取得した場合、あるいは何か様子がおかしいhtmlが確認された場合などは、一旦スクリプトを止めて何が起きているか確認した方が良い。過剰なアクセスが原因で遮断された場合、HTTPステータスコードは429 Too Many Requestsが返ってくる、と言いたいところだが、経験上403 Forbiddenであることの方が多いので注意しよう。Webスクレイピングの性質上、夜寝る前にスクリプトを回し始めて、翌朝結果を確認する、みたいなこともあるだろうが、上記のような場合になるべく動作が止まるようなスクリプトを書いておくのが望ましい。その際、何が原因で止まったのか、後から確認できるようにしておいた方が良いのは言うまでもない。

参考事例

クローラー界隈でよく話題になるのが、岡崎市立中央図書館事件だ。詳細な説明は省くが、2010年3月頃、図書館の蔵書検索システムに対して1秒間に1〜2回程度のアクセスを毎日30分間程度行うクローラーを作成して稼働させていた人が、システムに対する悪質な攻撃を行っているとみなされて、逮捕、勾留、取り調べの後、起訴猶予処分となった、というものだ。クローラー自体に悪質性はなく、またシステムに障害が発生していたのはごく短時間で、そもそも障害の原因がシステム側の不具合によるものだったのに起訴猶予処分となったことなどが問題視されている。
岡崎市立中央図書館事件 - Wikipedia
Librahack : 容疑者から見た岡崎図書館事件
岡崎図書館事件から3年 ~ もう一つの誤認逮捕事件 高木浩光
もう10年以上も前の話だが、念のため気に留めておきたい。

ページ遷移

前置きが長くなってしまったが、スクリプト中でページ遷移を行う方法を解説する。とは言ってもやることはとても単純で、今までのようにopen-uriでhtmlを取得したら遷移先のURLを見つけて1秒待ってまたopen-uriで今度は遷移先のURLにアクセスする、これを繰り返すだけだ。サンプルページとサンプルコードで確認してみよう。

transition.rb
require 'open-uri'
require 'nokogiri'

base_url = 'https://arao99.github.io/zenn_scraping/'

html = URI.open("#{base_url}nokogiri_practice.html").read
doc = Nokogiri::HTML.parse(html)
puts doc.title

href = doc.at_css('a').attribute('href') # href属性値の取得(相対URL)
link_url = "#{base_url}#{href}" # 相対URLから絶対URLを生成

sleep 1 # アクセス間隔を1秒空ける
html = URI.open(link_url).read
doc = Nokogiri::HTML.parse(html)
puts doc.title
$ bundle exec ruby transition.rb 
Nokogiriテスト用ページ
Nokogiriテスト用ページ2

前回の記事でも使ったサンプルページを対象にしたサンプルコードはこんな感じになる。ポイントを挙げるとするならば、href属性値が相対URLになっているので、相対URLから絶対URLを生成する必要があるところだろうか。今回は最初から分かっていたので素朴な方法を用いたが、URIモジュールのparseメソッドやjoinメソッドを使うのも良い。
module URI

さて、せっかくなので、より実践的なページ送りのサンプルも確認してみよう。今回使うサンプルページはこちら。厳密にはページ送りとは少し違うが、細かいところは気にしないでほしい。ちなみに中身はウィザードリィの僧侶呪文一覧だ。ファミコン版#1の取説をもとに作成。
僧侶呪文レベル1
ページを確認すると「次へ」というリンクがあり、そのリンクを繰り返し辿っていけば最後のレベル7まで順番にアクセスできそうだ。最後のレベル7のページには「次へ」というテキストはあるがリンクにはなっていないため、「次へ」というリンクがなければ終了、とすれば良いことも分かる。「次へ」というリンクの特定方法は、rel属性がnextになっているaタグを指定すれば良いだろう。rel属性に関する説明は省く。
さて、レベル1からレベル7まで順番に辿って呪文名を出力するスクリプトはこんな感じになる。

pagination.rb
require 'open-uri'
require 'nokogiri'

base_url = 'https://arao99.github.io/zenn_scraping/'
url = "#{base_url}priest_spell_level1.html"

loop do
  sleep 1
  html = URI.open(url).read
  doc = Nokogiri::HTML.parse(html)
  doc.css('.spell').each do |spell|
    puts spell.text.strip
  end
  next_link = doc.at_css('a[rel="next"]')
  break unless next_link
  next_href = next_link.attribute('href')
  url = "#{base_url}#{next_href}"
end
$ bundle exec ruby pagination.rb 
カルキ
ディオス
バディオス
ミルワ
ポーフィック
マツ
(中略)
ロクトフェイト
マリクト
カドルト

at_cssメソッドは条件に合う要素が見つからない場合nilを返すので、終了する(ループを抜ける)判定はこれで大丈夫だ。また、sleepの位置はloop内の終了判定の後ろの方が効率が良いが、ウッカリsleepを挟み忘れたりコードを書き間違えたりしたときに間隔を開けずにアクセスを繰り返してしまうことを避けるため、open-uriの前にした。stripも今回のケースでは不要だが、あっても困ることはないがないと困ることはあるために付けている。

画像ファイルの取得

画像ファイルの取得もページ遷移と同様に、画像ファイルのURLを見つけてopen-uriでアクセスするだけだが、こちらで取得されるのはhtmlではないので少し工夫が必要。また、サンプルコードでは画像はスクリプトと同じディレクトリにファイルとして保存するが、どこか別のファイルサーバーに保存する場合や、AWS S3などのクラウドストレージに保存する場合は、適宜その方法を調べて対応してほしい。

get_image.rb
require 'open-uri'
require 'nokogiri'

base_url = 'https://arao99.github.io/zenn_scraping/'

html = URI.open("#{base_url}wiz_report.html").read
doc = Nokogiri::HTML.parse(html)
img_src = doc.at_css('img').attribute('src')
img_url = "#{base_url}#{img_src}"

sleep 1
open(img_src, 'w') do |f|
  f.puts URI.open(img_url).read
end
$ bundle exec ruby get_image.rb 
$ ls wiz_muramasa.jpg 
wiz_muramasa.jpg

新しく作成された画像ファイルを開いてちゃんと画像が保存されたか確認しよう。今回は、保存先のファイル名としてsrc属性で指定されている相対URLをそのまま使ったが、実際には絶対URLやスラッシュを含むURLで指定されていることも多いため、先ほどもちょっと紹介したURIモジュールのparseメソッドを使ったり、splitや正規表現を使って最後のスラッシュから末尾までをファイル名としたり、工夫が必要になる。

まとめ

スクリプト内で連続アクセスを行う際の注意点と、ページ遷移方法、画像ファイルの取得方法を実例を交えて解説した。これまでに学んだことを応用してスクリプトを作成し、crontabなどを用いて定期実行するようにしてやれば、日々の作業がちょっと楽になるかもしれない。

次回以降の予定

欲しい情報がブラウザでは取得できるがopen-uriで取得されるhtmlには含まれていない場合の主な対処方法を解説する。これまでのサンプルページはごく単純なものだったが、次回はちょっと凝ったものを作る必要があるので、少し時間がかかりそう。
もしかしたらWebスクレイピングで取得した情報をDB(MySQL)に保存するためのActiveRecordの解説を先にするかもしれないが、こちらの場合も時間がかかってしまいそうだ。

#3 Nokogiriを使いこなす
#5 javascript対応テクニック

Discussion