RubyでWebスクレイピング #2 初めてのNokogiri

10 min read読了の目安(約9700字

#1 はじめに
#3 Nokogiriを使いこなす

準備

まずは今回使うRubygem、Nokogiriをインストールしておく。インストールに失敗する場合、その原因は人によって様々なので、各自で解決すること。ちなみに他のgemと比べてインストールにかかる時間は格段に長い。また、私はプロジェクトごとに使うgemをBundlerで管理するのを推奨しているが、その部分についてもポリシーがあると思うので強制はしない。
何もかも面倒だという方は、下記リポジトリをcloneしてREADMEに従うこと。
GitHub zenn_scraping

実際のコードとその実行結果

まずは下記のようなfirst_scraping.rbを作成する。リポジトリをcloneしてきた方は、変数urlが空の文字列になっているので、Yahoo! JAPANのトップページのURLを入力する。

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

url = 'https://www.yahoo.co.jp/'

# urlにアクセスしてhtmlを取得する
html = URI.open(url).read

# 取得したhtmlをNokogiriでパースする
doc = Nokogiri::HTML.parse(html)

# htmlのtitleを取得して出力する
title = doc.title
puts title

実行すると以下のようになるはず。Bundlerを使っていない方はbundle execは不要。

$ bundle exec ruby first_scraping.rb 
Yahoo! JAPAN

当たり前だが、このスクリプトを間髪を入れずに連続して実行すると対象サーバへの連続アクセス攻撃になってしまうので、絶対にしないように。そんなことをする物好きはいないだろうが、念のため。
単発のアクセスならこれでいいが、より高度なWebスクレイピングを行う場合はページ遷移を行うこともあるだろう。その際気をつけるべきことは、また後ほど。

よくわかる解説

簡単な解説はコード中にコメントで示したが、これだけではちょっと不親切すぎるので、挙動を確認するためのコードも交えて解説を進める。
最初のrequireは、open-uriとNokogiriを使うためのもの。ある程度Rubyを書いたことのある人なら問題なく受け入れられるはずだが、open-uriあるいはNokogiriは初めて、という方もいるかもしれないので。今後しばらくはこれらのrequireが登場するコードが続く見込み。

open-uri

次のurlの部分はさすがに省略して、その次のhtmlを取得している部分について解説。ここではopen-uriで、Yahoo! Japanのトップページにアクセスしている。open-uriでは様々なメタ情報も取得しているが、今回必要なのはhtmlで、また文字コードUTF-8のhtmlが取得できることも事前に分かっているため、余計なことをせずにreadした。一応、メタ情報が取得できていることを確認するために、下記のようなスクリプトを作成して実行してみよう。リポジトリにも含まれているが、例によってURLを手動で指定してほしい。

first_scraping_comment1.rb
require 'open-uri'

url = 'https://www.yahoo.co.jp/'

# 取得されたメタ情報のうちContent-Typeとcharsetを確認する
URI.open(url) {|f|
  p f.content_type
  p f.charset
}
$ bundle exec ruby first_scraping_comment1.rb 
"text/html"
"utf-8"

こんな具合に、Content-Typeとcharsetが取得されていることが分かる。charsetについては、全てのWebページがUTF-8を使っているわけではないため、Nokogiriも含めた実際の挙動と対応方法はこの記事の後の方で解説する。
open-uriについてより詳しく知りたい方は、以下の公式リファレンスを参考にしよう。
参考: library open-uri
メタ情報の解説はこれぐらいにして、本当にhtmlを取得できているのか、確認のためのスクリプトを作成して、実際に確認してみよう。

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

url = 'https://www.yahoo.co.jp/'

# urlにアクセスしてhtmlを取得する
html = URI.open(url).read
puts html
$ bundle exec ruby first_scraping_comment2.rb > yahoo.html

取得したhtml(文字列)をそのまま標準出力に吐き出すスクリプト。リダイレクトしなくてもいいが、コンソールがhtmlに占拠されてしまう上に、ブラウザで開いて確認することもできないため非推奨。
さて、yahoo.htmlの中身を見てみよう。lessでもいいし、普段使っているテキストエディタでもいい。馴染みのある(?)htmlの姿を確認できるはずだ。
次に、yahoo.htmlを普段使っているブラウザで開いてみよう。コンソールでopen系コマンドを使うのがお手軽だが、GUIを使っているのなら当該htmlファイルのアイコンをブラウザのウィンドウにドラッグ&ドロップしてもいいし、ファイルパスをブラウザのアドレスバーに入力してもいい。とにかくブラウザで開いてみよう。ブラウザで普通にYahoo! JAPANのトップページを開いたときとは似ても似つかない、殺風景なページが表示されたはずだ。この原因はいくつかあるが、主な理由は、画像ファイルが読み込まれていないため画像が表示されない、CSSファイルが読み込まれていないためCSSが反映されない、JavaScriptなどで動的に描画されるコンテンツが描画されない、といったところだ。
ここまでやってみて、何故こんなことをやらせるのだろう、と思った方もいるかもしれないが、実は結構重要な作業だったりする。open-uriで取得したhtmlには、普通にブラウザでアクセスして開いたときに表示されるコンテンツの多くが欠けていることは先ほど確認できた。つまりこれは、この先いくらNokogiriで頑張っても、欠けているコンテンツ内の情報を取得することは不可能であることを意味している。このことを知らないと、「ブラウザで開くと取得できるのに、open-uriで開いてNokogiriを使ってみても取得できない!コードが間違っているのか!Nokogiriのバグか!」という事態になりかねない。前回の記事の最後で、「Webアクセスによるコンテンツ(ほとんどの場合html)の取得」と「取得したコンテンツから必要な情報を切り出す作業」を区別すると宣言したのはこのためである。Webスクレイピングをする際は、まずは目的の情報を含むコンテンツを取得できたことを確認してから、先に進む癖をつけよう。open-uriで取得できないコンテンツを取得する方法は、また後ほど。

Nokogiri

さらにその次のNokogiriを使っている部分の解説に進む。例によって、下記のようなスクリプトを作成して実行してみよう。

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

url = 'https://www.yahoo.co.jp/'

# urlにアクセスしてhtmlを取得する
html = URI.open(url).read

# 取得したhtmlをNokogiriでパースする
doc = Nokogiri::HTML.parse(html)
pp doc
$ bundle exec ruby first_scraping_comment3.rb > nokogiri.txt

Nokogiriでパースした結果を標準出力に吐き出すスクリプト。勘の良い方はお気づきかもしれないが、7行目でopen-uriを使ってhtmlを取得している箇所は、先ほど出力したhtmlファイルを読み込んでhtmlを取得しても良い。今回は気にせず再度アクセスしてhtmlを取得しているが、今後「取得したコンテンツから必要な情報を切り出す作業」の処理を、ああでもないこうでもないと書いていく場合、ちょっと書き直す度にアクセスするのはあまりお行儀が良いとは言えないため、「Webアクセスによるコンテンツ(ほとんどの場合html)の取得」ができた段階で、取得できたコンテンツをローカルに保存して、そのコンテンツを対象にコードを書いていくことを強く推奨する。
本題に戻ろう。パース結果をpで出力すると人間には大変読みにくいためppで出力している。先ほどやってみたhtmlのときとは比べ物にならないぐらいの分量なので、やはりリダイレクトした方がいい。また、今回出力されるのはhtmlではないため、ブラウザで開く必要はない。
nokogiri.txtの中身を確認してみよう。Yahoo! JAPANのトップページが更新されるとパースした結果も変わるが、現時点で手元で実行してみた結果の一部を抜粋したものを以下に示す。

    #(Element:0x514 {
      name = "html",
      attributes = [ #(Attr:0x528 { name = "lang", value = "ja" })],
      children = [
        #(Element:0x53c {
          name = "head",
          children = [
            #(Element:0x550 {
              name = "meta",
              attributes = [
                #(Attr:0x564 { name = "charset", value = "utf-8" })]
              }),
            #(Element:0x578 {
              name = "meta",
              attributes = [
                #(Attr:0x58c {
                  name = "http-equiv",
                  value = "X-UA-Compatible"
                  }),
                #(Attr:0x5a0 { name = "content", value = "IE=edge,chrome=1" })]
              }),

上から順に簡単に読んでいくと、nameがhtmlのElementがあって、そのElementのattributesには、nameがlangでvalueがjaのAttrがあり、またそのElementのchildrenには、nameがheadのElementがあって、さらにそのElementのchildrenには、nameがmetaのElementが複数あって……、というような具合である。最低限、タグの属性名と属性値がセットで正しくパースされていて、なおかつhtmlタグの子要素にheadタグがあって、headタグの子要素にmetaタグが複数あって、というような階層構造も正しくパースされていることを、ボンヤリとでもいいので感じ取ってもらいたい。
さて、最初のコードではtitleを取得していたが、せっかくなのでブラウザのメイン画面に表示されるテキストを取得してみよう。手始めにニュースの見出し一覧だ。Nokogiriの各種メソッドの解説は次回に譲るので、今はとりあえず手元で動かしてみて、Nokogiriではこんなこともできるのか、と思う程度で大丈夫。
一足早くNokogiriの各種メソッドについて知りたくなったせっかちな方は、Nokogiriの公式を当たろう。
参考: 鋸 Nokogiri
Yahoo! JAPAN
ここの見出し一覧を取得する

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

url = 'https://www.yahoo.co.jp/'

# urlにアクセスしてhtmlを取得する
html = URI.open(url).read

# 取得したhtmlをNokogiriでパースする
doc = Nokogiri::HTML.parse(html)

# ニュースの見出しを取得して出力する
doc.at_css('section#tabpanelTopics1 ul').css('h1').each do |h1|
  puts h1.text.strip
end
$ bundle exec ruby first_scraping_comment4.rb 
災害級の大雪 北陸今夜も警戒
都 4日ぶり感染2000人下回る
都内人出 前回宣言時より増加
宣言判断 不満にじむ大阪知事
レッドブル 241円→190円に
感染怖く力士引退 協会側苦言
筧美和子 テラハ出演は好奇心
スマホ解約 鈴木光さんの受験

無事ニュースの見出し一覧を取り出して出力できた。このスクリプトを実行したときに掲載されている見出しが出力されるので、手元で実行すると違う見出しが出力されるだろう。また、Yahoo! JAPANのトップページのhtml構造が変更されると、このスクリプトが期待通りの挙動をしなくなる可能性が高いが、もしそうなってしまった場合でもご容赦いただきたい。

文字コード

先ほどopen-uriのメタ情報のところで後回しにした、UTF-8以外の文字コードが使われているhtmlを読み込んだときの挙動や対応方法を解説する。今時UTF-8以外の文字コードで書かれているWebサイトを探すのは大変だが、質実剛健なイメージの強い株式会社マキタの公式ページがShift JISで書かれていたため、ここではこのページを対象に、挙動を確認する。
参考: 株式会社マキタ
もしかしたらそのうち文字コードがUTF-8に変更されてしまうかもしれないが、やはりその場合もご容赦いただきたい。

方法その1 open-uriで取得したメタ情報をNokogiriに渡す(非推奨)

open-uriでメタ情報を取得できるのは先ほど確認した通り。そしてNokogiriのparseメソッドでは、パース対象のhtmlの文字コードを指定することができる。
参考: Module: Nokogiri::HTML
では、open-uriで取得したメタ情報(文字コード)をNokogiriに渡してやれば解決するはずだ。実際にNokogiri入門みたいなブログ記事では、このような書かれ方をしていることが多い。しかし、実はこの方法はうまくいかないことが多いので非推奨だ。

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

url = 'https://www.makita.co.jp/'

# urlにアクセスして文字コードがShift JISのhtmlと文字コードを取得する
charset = nil
html = URI.open(url) {|f|
  charset = f.charset
  f.read
}

# 取得したhtmlをNokogiriでパースする
doc = Nokogiri::HTML.parse(html, nil, charset)

# htmlのtitleを取得して出力する
title = doc.title
puts title
$ bundle exec ruby shift_jis1.rb 
ʔή??Ѓ}?L?^

見事に文字化けしてしまった。何故このようなことが起きてしまったかというと、charsetを正しく取得できていないためだ。

shift_jis1-1.rb
require 'open-uri'
require 'nokogiri'

url = 'https://www.makita.co.jp/'

# urlにアクセスしてhtmlを取得し、その文字コードを確認する
charset = nil
html = URI.open(url) {|f|
  charset = f.charset
  f.read
}
puts charset
$ bundle exec ruby shift_jis1-1.rb 
utf-8

どういうわけか、文字コードがutf-8ということになってしまっている。元のhtmlを見てみると以下のような記述があるが、open-uriの仕様なのかバグなのか、とにかく文字コードの情報を正しく取得できていないのは間違いない。

<meta http-equiv="Content-Type" content="text/html; charset=shift_jis">

方法その2 文字コードを手動で指定する

最初の例(Yahoo! JAPAN)では、文字コードがUTF-8であることが事前に分かっていたため、Nokogiriのparseメソッドでも文字コードを指定せず、デフォルトのUTF-8を使うようにしていた。今回の場合は文字コードがShift JISであることが事前に分かっているため、Shift JISを使うよう手動で指定してやればいい、というわけだ。

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

url = 'https://www.makita.co.jp/'

# urlにアクセスして文字コードがShift JISのhtmlを取得する
html = URI.open(url).read

# 取得したhtmlをNokogiriでパースする
doc = Nokogiri::HTML.parse(html, nil, 'sjis')

# htmlのtitleを取得して出力する
title = doc.title
puts title
$ bundle exec ruby shift_jis2.rb 
株式会社マキタ

今度は文字化けすることなくtitleを出力できた。

方法その3 取得したhtmlの文字コードをUTF-8に変換する(推奨)

最後は、取得したhtmlの文字コードをUTF-8に変換する方法。Rubyには便利なライブラリが用意されているので、それを使う。
参考: instance method String#toutf8

shift_jis3.rb
require 'open-uri'
require 'nokogiri'
require 'kconv'

url = 'https://www.makita.co.jp/'

# urlにアクセスしてhtmlを取得し、その文字コードをUTF-8に変換する
html = URI.open(url).read.toutf8

# 取得したhtmlをNokogiriでパースする
doc = Nokogiri::HTML.parse(html)

# htmlのtitleを取得して出力する
title = doc.title
puts title
$ bundle exec ruby shift_jis3.rb 
株式会社マキタ

やはり、文字化けすることなくtitleを出力できた。この方法は元のhtmlがUTF-8であっても問題なく使える(気になる方はURLをYahoo! JAPANのものにして確かめてみよう)ので、いちいち文字コードを確認する手間が省ける。この記事では、事前に文字コードが分かっているページしか対象にしなかったが、後々ページ遷移を行ったり、あるいは大量のURLを元に機械的に何かを集めてくる場合のことを考えると、文字コードを手動で指定するのは現実的ではない。望ましい変換が行われない、そもそも変換に失敗する、などのケースも考えられるが、これらのデメリット以上にメリットが大きいため、文字コード対策にはこの方法を推奨する。

まとめ

初めてのNokogiriと題して、open-uriを使ったhtmlの取得と、Nokogiriを使ったhtmlのパースについて解説し、ついでに文字コードへの対応方法も合わせて解説した。htmlの取得とhtmlのパース、この2つを分けて捉える癖をしっかりつけてほしい。

次回以降の予定

次回は、Webスクレイピングでよく使うNokogiriの各種メソッドを、挙動の確認や使用例とセットで紹介する。
それ以降は、スクリプト内でページ遷移を行う場合のことを書くか、html以外のものを取得する場合のことを書くか、あるいは他のことを書くか、といったところ。要するに未定。

#1 はじめに
#3 Nokogiriを使いこなす