プログラマでありたい

おっさんになっても、プログラマでありつづけたい

Ruby製の構文解析ツール、Nokogiriの使い方 with Xpath

f:id:dkfj:20140415021125p:plain

 RubyでHTMLやXMLをパースする構文解析ツールの定番は、Nokogiriです。スクレイピングする際の必需品で、なくてはならないモジュールの1つです。ただ色々なことが出来る反面、どこから取りかかれば良いのか解り難い部分もあります。自習を兼ねて、Nokogiri概要と主要な機能を紹介してみます。

Nokogiriとは何か?



 ReademeによるとNokogiriとは、「HTMLとXMLとSAXとXSLTとReaderのパーサー」で、特徴としては、XPathとCSS3セレクター経由で探索する機能を持つことのようです。他にもHTMLやXMLのビルダーの機能を持っていますが、HTMLとXMLのパーサー(構文解析器)と覚えておけばよいでしょう。

Nokogiriのクラス構造



 Nokogiriは、なかなか巨大なライブラリです。10以上のモジュールと70以上のクラスで構成されていて、yardでダイアグラム図を作ってみると下記のように壮大なものになります。

f:id:dkfj:20140413193256p:plain

 正直、どこから見ていけばよいのか途方に暮れると思います。Nokogoriを使う前に、次のことだけ覚えてください。何となく頭に入りやすくなると思います。

3つのクラスをおさえる

 Nokogiriを理解する上では、Nokogiri::XML::Nodeのメソッドと挙動を覚えるのが大事です。ダイアグラム図を見れば解るように、Nokogiri::XML::Nodeを継承しているオブジェクトが多数あります。また、Nokogiri::HTML::Document < Nokogiri::XML::Documentの関係から解るように、HTMLモジュールもXMLモジュールを継承しているものが多数あります。

Nokogiri::HTML::Document < Nokogiri::XML::Document < Nokogiri::XML::Node

 挙動が解らなければ、Nokogiri::XML::Nodeのソースをじっくり読みとくと良いです。あとは、Nokogiri::XML::DocumentとNokogiri::HTML::Documentの2つです。この3つのクラスをおさえておくと、Nokogiriが出来ることが大体把握できます。

http://nokogiri.org/Nokogiri/XML/Node.html
http://nokogiri.org/Nokogiri/XML/Document.html
http://nokogiri.org/Nokogiri/HTML/Document.html

NokogiriでHTMLを解析する



 一番基本的なHTMLの解析は以下の通りです。open-uriはrubyの組み込みライブラリであるKernel#openを再定義したもので、ファイルと同様の操作でhttp/ftpにアクセスすることができます。Nokogiriと一緒に利用すると便利です

require 'nokogiri'
require 'open-uri'

doc = Nokogiri::HTML(open('http://www.yahoo.co.jp'))

Nokogiri::HTMLもしくはNokogiri::HTML#parseを呼び出すと、内部的にNokogiri::HTML::Document#parseメソッドを実行します。返り値としては、Nokogiri::HTML::Documentを返します。下記と同じです。

doc = Nokogiri::HTML.parse(open('http://www.yahoo.co.jp'))

 生成したNokogiri::HTML::Documentから、特定のタグを抽出するには下記のとおりです。

require 'nokogiri'
require 'open-uri'

doc = Nokogiri::HTML(open('http://www.yahoo.co.jp'))
title = doc.xpath('/html/head/title')
objects = doc.xpath('//a')

NodeとNodeSet



 XPathやcssの検索結果は、Nokogiri::XML::NodeSetを返します。NodeSetは、Nokogiri::XML::Nodeのリストです。NodeSetのメソッドの1つであるNodeSet#inner_text()は、リスト内の全てのNodeのinner_textを返します。textは、inner_textのエイリアスです。HTMLのTitleタグのようにドキュメント中に1つしかないタグの場合は、下記のように全て同じ結果を返します。また、HTML::Documentには、Titleタグを抜き出す特別のメソッドがあります。

require 'nokogiri'
require 'open-uri'

doc = Nokogiri::HTML(open('http://www.yahoo.co.jp'))

puts doc.title # => Yahoo! JAPAN

nodesets = doc.xpath('//title')
puts nodesets.text # => Yahoo! JAPAN 
puts nodesets.inner_text # => Yahoo! JAPAN
puts nodesets.first.inner_text # => Yahoo! JAPAN

nodesets.each{|nodeset|
  puts nodeset.content() # => Yahoo! JAPAN
  puts nodeset.text # => Yahoo! JAPAN
  puts nodeset.inner_text # => Yahoo! JAPAN
}

 複数の要素がヒットする場合、結果は違ってきます

nodesets = doc.xpath('//a')
puts nodesets.inner_text

nodesets.each{|nodeset|
  puts nodeset.inner_text # => Yahoo! JAPAN
}

NodeとNodeSetの検索メソッド



 NodeとNodeSetには、様々な検索方法があります。検索に関しては、同じメソッドが利用可能です。下記の例は、全て同じ結果を返します。

puts doc%'//title'
puts doc/'//title'
puts doc.at('//title') # => 検索にヒットした最初のノードを返す
puts doc.at_xpath('//title') # => xpathの検索にヒットした最初のノードを返す
puts doc.at_css('title') # => cssの検索にヒットした最初のノードを返す
puts doc.css('title') # => cssで検索。NodeSetを返す
puts doc.css('title')[0] # => cssで検索。NodeSetから最初のノードを返す
puts doc.search('title') # => xpathかcssで検索。NodeSetを返す
puts doc.search('title')[0] # => xpathかcssで検索。NodeSetから最初のノードを返す
puts doc.xpath('//title') # => xpathで検索。NodeSetを返す
puts doc.xpath('//title')[0] # => xpathで検索。NodeSetから最初のノードを返す
puts doc.xpath('//title').first # => xpathで検索。NodeSetから最初のノードを返す

NodeとNodeSetの参照メソッド

 NodeとNodeSetは、参照に関してほぼ同じメソッドが使えます。一部NodeSetでは使えないメソッドがあります。また、同一の結果を返すメソッドの多くはエイリアスとして設定されているものです。

#Nodeの参照
#HTMLタグ含む
puts doc.at('//title').to_html
puts doc.at('//title').to_xhtml
puts doc.at('//title').to_xml
puts doc.at('//title').to_s

#HTMLタグで囲まれた文字列
puts doc.at('//title').text
puts doc.at('//title').inner_html
puts doc.at('//title').inner_text
puts doc.at('//title').text
puts doc.at('//title').to_str

#属性値の取得
puts doc.at('//a').[]('href')
puts doc.at('//a').attribute('href')
puts doc.at('//a').get_attribute('href')

#NodeSetの参照
#HTMLタグ含む
puts doc.xpath('//title').to_html
puts doc.xpath('//title').to_xhtml
puts doc.xpath('//title').to_xml
puts doc.xpath('//title').to_s

#HTMLタグで囲まれた文字列
puts doc.xpath('//title').text
puts doc.xpath('//title').inner_html
puts doc.xpath('//title').inner_text
puts doc.xpath('//title').text
#puts doc.xpath('//title').to_str

検索方法いろいろ



 NokogiriというかXPathの検索方法です。idやclassなどの属性値で検索することが多いですが、実は属性値であれば、なんでも使えます。属性値検索の場合は、[]で指定します。@部分が属性値の名前です。

require 'nokogiri'
require 'open-uri'

doc = Nokogiri::HTML(open('http://www.hatena.ne.jp/'))

#class指定でh2タグを検索
puts doc.xpath("//h2[@class='title']")

#id指定でdivタグを検索
puts doc.xpath("//div[@id='copyright']")

#カスタムの属性値でdivタグを検索
puts doc.xpath("//div[@data-component-term='tweet']")

#id指定で全てのタグを検索
puts doc.xpath("//*[@id='copyright']")

#絞込検索
puts doc.xpath("//div[@id='copyright']//ul")

#リンク先のURLを抜き出す
doc.xpath('//a').each do |item|
  puts item[:href]
end

NodeSetなのかElementなのか



 検索結果から属性等を参照しようとして、次のようなエラーが出る場合があります。

`[]': no implicit conversion of String into Integer (TypeError)

 殆どの場合は、Elementを取得したと思っていたのに、NodeSetを取得していたというパターンが多いでしょう。よくある間違いとしては、一意の要素をとってきても、その下にタグがあってNodeSetになっている場合です。

 つまったら、取得したデータがNodeSetなのかElementなのかを確認しましょう。classで確認できます。

doc.xpath("//div[@data-component-term='tweet']").each { |tweet|
  puts tweet.class #=> Nokogiri::XML::NodeSet
  puts tweet[0].class #=> Nokogiri::XML::Element

NokogiriでXMLを解析する



 Nokogiriは、HTMLのみならずXMLも解析できます。XMLは構造もシンプルなので、解析はHTMLよりも簡単に出来ると思います。しかし、一点だけ注意が必要です。Nokogiriを使ってXPathで検索する場合、そのXMLが名前空間がある場合は必ず指定する必要があるという点です。指定しないと、全く検索にヒットしません。
 下記の例は、はてなのホットエントリーのRSSフィードを抽出する例です。RSS1.0なので名前空間を持ちます。

require 'nokogiri'
require 'open-uri'

url = 'http://feeds.feedburner.com/hatena/b/hotentry'
xml = open(url).read

doc = Nokogiri::XML(xml)

namespaces = {
  "rss" => "http://purl.org/rss/1.0/", #デフォルト名前空間
  "rdf" => "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
  "content" => "http://purl.org/rss/1.0/modules/content/",
  "dc" => "http://purl.org/dc/elements/1.1/",
  "feedburner" => "http://rssnamespace.org/feedburner/ext/1.0"
}

#channel
channel = doc.xpath('//rss:channel', namespaces)
#Xpathでtitleを検索
puts channel.xpath('rss:title', namespaces)
puts channel.xpath('feedburner:info', namespaces)
lis = channel.xpath('//rdf:li', namespaces)
lis.each {|li|
  puts li.attribute("resource")
}

NokogiriとHpricot



 Ruby製のHTML/XMLパーサーとしては、hpricotも選択肢の1つでした。しかしながら、今では公式に「Hpricot is over.」と宣言され、Nokogiriを使いましょうとなっています。Nokogiriとhpricotは、APIなど共通点が多いのですが、一点だけ大きな違いがあります。Nokogiriは内部の処理をUTF-8で行うに対して、hpricotは与えられた文字コードをそのまま扱い変換をしません。その為、文字コード絡みのトラブルを避ける為に、今でもhpricotを利用している人もいるようです。

XPathとCSS3セレクタのどちらを使うか



 Nokogiriのノードの探索は、主にXPathかCSS3セレクターを使う方法があります。両方共覚える必要はありません。どちらかの使い方を覚えれば充分です。CSSの方が得意であれば、CSS3セレクターを使いましょう。どちらも得意でないのであれば、比較的サンプルが多いXPathを使えば良いのでしょう。デザイナー系であればCSSの方が得意という人も多いでしょう。その場合は、CSSを使えばよいと思います。

Nokogiriの解析の実装



 NokogiriのXML,HTMLのパースは、基本的にはLibXML2に依存しています。内部のソースを見ていると、LibXML2に文句を言いつつ足りない機能を補完しようとしているのが解ります。Nokogiriとは極論すると、LibXML2のRubyのラッパーモジュールです。それを頭に入れておくと、HTMLでもXMLでも同じ考え方で解析しているのが解ります。

Nokogiriの各メソッドのエイリアス



 Nokogiriのちょっと取っ付きにくい理由として、同じ処理を様々なパターンで記述できるところにあります。色々なサンプル見ても、多種多様で初めての人は面食らいます。その一因となっているのが、メソッドのエイリアスの多さ。基本的には、自分が馴染んだ名前で使うのがよいでしょう。

$ grep -R alias *
css/parser_extras.rb:        alias :cache_on? :cache_on
css/parser_extras.rb:        alias :set_cache :cache_on=
css/tokenizer.rb:  alias :scan :scan_str
xml/attr.rb:      alias :value :content
xml/attr.rb:      alias :to_s :content
xml/attr.rb:      alias :content= :value=
xml/document.rb:      alias :to_xml :serialize
xml/document.rb:      alias :clone :dup
xml/document.rb:      alias :<< :add_child
xml/document_fragment.rb:      alias :serialize :to_s
xml/node/save_options.rb:        alias :to_i :options
xml/node.rb:      alias :/ :search
xml/node.rb:      alias :% :at
xml/node.rb:      alias :next           :next_sibling
xml/node.rb:      alias :previous       :previous_sibling
xml/node.rb:      alias :next=          :add_next_sibling
xml/node.rb:      alias :previous=      :add_previous_sibling
xml/node.rb:      alias :remove         :unlink
xml/node.rb:      alias :get_attribute  :[]
xml/node.rb:      alias :attr           :[]
xml/node.rb:      alias :set_attribute  :[]=
xml/node.rb:      alias :text           :content
xml/node.rb:      alias :inner_text     :content
xml/node.rb:      alias :has_attribute? :key?
xml/node.rb:      alias :name           :node_name
xml/node.rb:      alias :name=          :node_name=
xml/node.rb:      alias :type           :node_type
xml/node.rb:      alias :to_str         :text
xml/node.rb:      alias :clone          :dup
xml/node.rb:      alias :elements       :element_children
xml/node.rb:      alias :delete :remove_attribute
xml/node.rb:      alias :elem? :element?
xml/node.rb:      alias :add_namespace :add_namespace_definition
xml/node_set.rb:      alias :<< :push
xml/node_set.rb:      alias :remove :unlink
xml/node_set.rb:      alias :/ :search
xml/node_set.rb:      alias :% :at
xml/node_set.rb:      alias :set :attr
xml/node_set.rb:      alias :attribute :attr
xml/node_set.rb:      alias :text :inner_text
xml/node_set.rb:      alias :size :length
xml/node_set.rb:      alias :to_ary :to_a
xml/node_set.rb:      alias :+ :|
xml/parse_options.rb:      alias :to_i :options
xml/reader.rb:      alias :self_closing? :empty_element?
xml/sax/push_parser.rb:        alias :<< :write

まとめ



 イマイチまとまりませんでしたが、Nokogiriの使い方について自分なりに理解が深まったような気がします。Ruby使うのであれば、使いこなしたいライブラリの上位に入ると思います。少し時間を掛けてドキュメントやソースを読むことで理解は格段にあがるので、ぜひ一度お試しあれ!!


追記:
そんで、どうやってXPathを抽出するのという話を書きました
FireFoxやChromeを使って任意のノードのXPathを簡単に抽出する方法について - プログラマになりたい

追記2:
この辺りの話をまとめた、「Rubyによるクローラー開発技法」という本を出しています。


See Also:
Ruby製のクローラー Anemoneの文字化け対策
あらためてRuby製のクローラー、"anemone"を調べてみた
オープンソースのRubyのWebクローラー"Anemone"を使ってみる
JavaScriptにも対応出来るruby製のクローラー、Masqueを試してみる
複数並行可能なRubyのクローラー、「cosmicrawler」を試してみた
takuros/anemone · GitHub


参照:
sparklemotion/nokogiri · GitHub
Tutorials - Nokogiri 鋸


Rubyによるクローラー開発技法 巡回・解析機能の実装と21の運用例

Rubyによるクローラー開発技法 巡回・解析機能の実装と21の運用例

Spidering hacks―ウェブ情報ラクラク取得テクニック101選

Spidering hacks―ウェブ情報ラクラク取得テクニック101選

バッドデータハンドブック ―データにまつわる問題への19の処方箋

バッドデータハンドブック ―データにまつわる問題への19の処方箋