Ruby: Nokogiri で XPath 検索

# はじめに
最近は、Ruby でスクレイピングには Nokogiri を使うようです。
。。。というか、もっと主流なのがあるのかどうが自分には全然わかりません。

とにかく、スクレイピングをしたかったので Nokogiri を使うことにしました。
で、使い方を調べると、大抵「欲しい情報の XPath を指定して...」と書いてあります。
そもそも「欲しい情報の XPath」がわからないし、便利なツールも知らないので、
「指定パターンにマッチするテキストを持つ要素の XPath を出力する」スクリプトを作りました。
名前は noko-grep.rb です。(本稿末尾に掲載)
自分にとっては、Nokogiri を使った習作であり、今後のプロトタイプにしようと思ってます。


# スクリプトについて
基本的な使い方は以下です。
ローカルの Webサーバ(Apache)のトップページから 'welcome' を検索したところです。
結果として XPath と テキストが表示されます。

![usage-nogo-grep.png](https://qiita-image-store.s3.amazonaws.com/0/60624/b4584604-4135-2ccc-bad7-b0640ca0c3c9.png)

スクリプトの骨格は以下のコードと同じです。(が、実際のスクリプトはもっと肥大化してます)

```rb
require 'open-uri'
require 'nokogiri'

doc = Nokogiri::HTML open '入力ソースのURI'
puts (doc/'//*/text()').select{|t| t.text =~ /パターン/}.map{|t| "#{t.path}\n#{t.text}"} * "\n"                      
```

##コマンドライン書式

```shell-session
ruby noko-grep.rb [オプション] [検索パターン [入力ファイル名]]
```

検索パターンは(基本的に)正規表現で指定します。(省略時の解釈は '`.`' です)
入力ファイル名には URI も指定できます。省略した場合は標準入力を入力とします。

### 検索パターンについてのオプション
「`-i`」は、検索パターンのマッチで大文字小文字を区別しないようにします。
「`-s`」は、検索パターンを正規表現でなく文字列とみなします。(この時「`-i`」は無効です)
文字列の場合は、検索パターンがノードのテキストに含まれるか否かで判定します。

### 色付き出力についてのオプション
「`-c N`」は、出力結果テキストのマッチした部分に付ける色を指定します。N は ANSI エスケープシーケンスでのコードです。デフォルトは 31 (赤)です。色付けしない場合は、「`-c 0`」を指定します。
標準出力が TTY でない(パイプやリダイレクトされている)場合、色付けは抑止します。ただし、「`-C`」を指定した場合は抑止しません。「`less -R`」のように色コードを認識するコマンドをパイプでつないでいる場合に使うことを想定しています。

### 出力結果についてのオプション
「`-p N`」は検索でヒットしたノードの N 段上の親ノードをたどって結果を出力します。
「`-u TAG`」は名前が TAG の親ノードまでたどって結果を出力します。
「`-a`」は結果出力の XPath と一緒に属性情報も表示します。(エレメントノード以外では実質有効でない)
「`-q`」は結果出力でテキストを出力しません。XPath のみ出力されます。

### 入力ソースのエンコーディングについてのオプション
「`-e ENC`」は入力 IO の外部エンコーディングを指定します。ENC には Ruby の Encoding で使われる名前を指定します。例えば、「UTF-8」「EUC-JP」などです。

### XPath 式についてのオプション
デフォルト動作では、検索対象テキストは XPath 式「`//*/text()`」でサーチしています。

「`-x EXPR`」で上の XPath 式を任意の EXPR に指定できます。例えば「`//img/@src`」を指定すると検索対象テキストが img 要素の src 属性の値になります。
「`-M MODE`」で XPath 式をモードにより指定できます。モードは、XPath 式につけた名前です。デフォルトのモード定義は、DATA (スクリプト末尾の仮想ファイル) に YAML 形式で定義されています。例えば、「`-M img`」の指定は「`-x '//img/@src'`」と指定したことと同じです。

```yaml
---
modeset:
  text:    //*/text()     
  a:       //a/@href
  img:     //img/@src
  video:   //video/@src
  charset: //meta/@charset
  js:      //script/@src
  script:  //script/text()
  css:     //link/@href
  style:   //style/text()
  title:   //title/text()
  h1:      //h1/text()
  canvas:  //canvas/@width|//canvas/@height
```

モードセットはモードの集合で、モードがキー、対応する XPath が値のハッシュです。モードセット自体 YAML のトップレベルにあるハッシュに、キー 'modeset' に対応する値として格納されています。
「`-k NAME`」は、上のキー'modeset'の代わりになるキー NAME を指定します。
それにより YAML の中に別の名前でモードセットを定義して指定することができます。

モード定義ファイル(モードセットを定義した YAML ファイル)は、デフォルトで DATA に格納されています。
「`-f PATH`」でデフォルトのモード定義ファイル(DATA)の代わりに、外部ファイルを指定することができます。モード定義ファイルの形式は同じです。(YAML でありハッシュのハッシュ構造を持つ)
「`-m ENC`」で、モード定義ファイル(DATA も外部ファイルも)の外部エンコーディングを指定できます。

# 用例
例として、Wikipedia の「日本」を検索します。
まず「首都」を検索。

```shell-session
$ ruby noko-grep.rb '首都' http://ja.wikipedia.org/wiki/%E6%97%A5%E6%9C%AC
/html/body/div[3]/div[3]/div[4]/dl[1]/dd[5]/table/tr[2]/th/a/text()
  首都
/html/body/div[3]/div[3]/div[4]/dl[1]/dd[6]/ol/li[2]/span/text()[1]
  日本の首都を東京と定める法令は現存しない。詳しくは
/html/body/div[3]/div[3]/div[4]/dl[1]/dd[6]/ol/li[2]/span/a/text()
  日本の首都
/html/body/div[3]/div[3]/div[4]/dl[12]/dd[4]/a[1]/text()
  首都圏
/html/body/div[3]/div[3]/div[4]/p[167]/text()[4]
  などの問題も起きている。しかし近年では、特に首都圏では、東京都心部の土地の値段が下落し
```

title モードで title を検索。

```shell-session
$ ruby noko-grep.rb -M title '.' http://ja.wikipedia.org/wiki/%E6%97%A5%E6%9C%AC
/html/head/title/text()
  日本 - Wikipedia
```

img モードで、拡張子が「.jpg」の画像を src にしている img 要素を検索。属性情報も出力。

```shell-session
$ ruby noko-grep.rb -M img -u img -a -s '.jpg' http://ja.wikipedia.org/wiki/%E6%97%A5%E6%9C%AC
/html/body/div[3]/div[3]/div[4]/div[8]/div/a/img (alt=""  src="//upload.wikimedia.org/wikipedia/commons/thumb/6/6b/Emperor_Jimmu.jpg/150px-Emperor_Jimmu.jpg"  width="150"  height="231"  class="thumbimage"  srcset="//upload.wikimedia.org/wikipedia/commons/thumb/6/6b/Emperor_Jimmu.jpg/225px-Emperor_Jimmu.jpg 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/6/6b/Emperor_Jimmu.jpg/300px-Emperor_Jimmu.jpg 2x"  data-file-width="438"  data-file-height="674")
  
/html/body/div[3]/div[3]/div[4]/div[11]/div/a/img (alt=""  src="//upload.wikimedia.org/wikipedia/commons/thumb/d/d8/Satellite_image_of_Japan_in_May_2003.jpg/240px-Satellite_image_of_Japan_in_May_2003.jpg"  width="240"  height="298"  class="thumbimage"  srcset="//upload.wikimedia.org/wikipedia/commons/thumb/d/d8/Satellite_image_of_Japan_in_May_2003.jpg/360px-Satellite_image_of_Japan_in_May_2003.jpg 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/d/d8/Satellite_image_of_Japan_in_May_2003.jpg/480px-Satellite_image_of_Japan_in_May_2003.jpg 2x"  data-file-width="5800"  data-file-height="7200")
                        :
                     (以下、略)
```

js モードで、外部スクリプトを検索。 (あ、PHP!? JavaScript じゃない。。。)

```shell-session
$ ruby noko-grep.rb -M js  '.' http://ja.wikipedia.org/wiki/%E6%97%A5%E6%9C%AC
/html/head/script[1]/@src
  //bits.wikimedia.org/ja.wikipedia.org/load.php?debug=false&lang=ja&modules=startup&only=scripts&skin=vector&*
```

script モードで、スクリプトの内容を検索。

```shell-session
$ ruby noko-grep.rb -M script  '.' http://ja.wikipedia.org/wiki/%E6%97%A5%E6%9C%AC
/html/head/script[2]/text()
  if(window.mw){
mw.config.set({"wgCanonicalNamespace":"","wgCanonicalSpecialPageName":false,"wgNamespaceNumber":0,"wgPageName":"日本","wgTitle":"日本","wgCurRevisionId":53919968,"wgRevisionId":53919968,"wgArticleId":1864744,"wgIsArticle":true,"wgIsRedirect":false,"wgAction":"view","wgUserName":null,"wgUserGroups":["*"],"wgCategories":["外部リンクがリンク切れになっている記事/2014年1月","外部リンクがリンク切れになっている記事/2011年3月","外部リンクがリンク切れになっている記事/2010年1月-4月","編集半保護中の記事","出典を必要とする記述のある記事/2011年4月","無効な出典が含まれている記事/2012年","書きかけの節のある項目","言葉を濁した記述のある記事 (いつ)
                         :
                     (以下、略)
```
# noko-grep.rb
```rb:noko-grep.rb
#!/usr/bin/env ruby

require 'optparse'
require 'open-uri'
require 'ostruct'
require 'yaml'
require 'nokogiri'

opt = OpenStruct.new ARGV.getopts 'isc:Cp:u:aqe:x:M:k:f:m:', 'help'

if opt.help
  puts %(
usage: #{File.basename $0} [options] [pattern [filename]]
  options are:
    -i      : 検索時に大文字小文字を区別しない
    -s      : 検索パターンを正規表現でなく文字列とみなす
    -c N    : 結果出力を色付けする。N は色コード
              [N:色コード] ANSIエスケープシーケンスのコード(デフォルト:31)
                      (例) 31: 赤, 32: 緑, 33: 黄, 34: 青, 35: 紫, 36: 水色,
                            0: 色付けなし, 1: 強調, 4: 下線, 7: 反転 など
    -C      : 結果出力時に出力先が TTY でなくても色付けを抑止しない
    -p N    : N 段上の親ノードまでたどって結果出力する
    -u TAG  : 名前が TAG の親ノードまでたどって結果出力する(例:TAG: 'p' など)
    -a      : 結果出力に属性の情報も付加する
    -q      : 結果出力にテキストを出力しない
    -e ENC  : 入力時の外部エンコーディングを ENC にする。
              [ENC:エンコーディング名] Ruby の Encoding クラスでの定義名
                       (例) UTF-8, EUC-JP, ISO-2022-JP, Shift_JIS など
    -x EXPR : ノード検索式を EXPR (XPath 式) にする(例:EXPR: '//*/text()' など)
    -M MODE : ノード検索式をモードから選択する
              [MODE:モード名] モード定義ファイル(YAML)に定義されたキー
                       (例) text, a, img など (モード定義ファイルに依存する)
                       (注) モード定義ファイルのデフォルトは DATA
    -k NAME : モードセットを指定するキー (デフォルト: 'modeset')
    -f PATH : モード定義ファイル(YAML)を PATH から読み込む
    -m ENC  : モード定義ファイルの外部エンコーディングを ENC にする
    --help  : このヘルプメッセージを出力して終了する
  )
  exit 0
end

# 引数の取得
pattern  = ARGV.shift
filename = ARGV.shift

# match (選択判定用ラムダ) を作る
re    = opt.s ? pattern || ''
              : Regexp.new(pattern || '.', opt.i)
match = -> t { t.text.match re }

# parent (親要素をたどるラムダ) を作る
found  = opt.u ? -> t, n { t.name == opt.u }
               : -> t, n { n >= opt.p.to_i }
parent = -> t, n=0 { found.(t,n) ? t : parent.(t.parent, n + 1) rescue t }

# format (出力書式整形ラムダ) を作る
escseq   = -> s, code=(opt.c || 31) { "\e[#{code}m#{s}\e[m" }
colorize = -> s { (opt.C || $>.tty?) ? s.gsub(re){escseq.($&)} : s }
text     = opt.q ? -> t {}
                 : -> t { "#{$/}  #{colorize.(t.text)}" }
attrs    = opt.a ? -> t { " (#{((t/'./@*').map(&:to_html) * ' ').strip})" }
                 : -> t {}
format   = -> t { "#{t.path}#{attrs.(t)}#{text.(t)}" }

# expr (XPath式) を決定する
enc     = -> io, ename { ename ? io.set_encoding(ename) : io }
fopen   = -> fname, default { fname ? open(fname) : default }
yaml    = -> { YAML.load enc.(fopen.(opt.f, DATA), opt.m) }
modeset = -> { (yaml.() || {})[opt.k || 'modeset'] }
mode    = -> { (modeset.() || {})[opt.M] }
expr    = opt.x || mode.() || '//*/text()'

# doc (Nokogiri で解析した Document) を取得する
source = -> fname { enc.(fopen.(fname, ARGF), opt.e) }
doc    = Nokogiri::HTML source.(filename)

# 出力する
puts (doc/expr).select(&match).map(&parent).map(&format) * $/

__END__
---
modeset:
  text:    //*/text()     
  a:       //a/@href
  img:     //img/@src
  video:   //video/@src
  charset: //meta/@charset
  js:      //script/@src
  script:  //script/text()
  css:     //link/@href
  style:   //style/text()
  title:   //title/text()
  h1:      //h1/text()
  canvas:  //canvas/@width|//canvas/@height
```
---
動作確認は以下の環境で行っています。
Ruby

```shell-session
$ ruby -v
ruby 2.1.5p273 (2014-11-13 revision 48405) [x86_64-linux]
```

Nokogiri

```shell-session
$ gem list nokogiri

*** LOCAL GEMS ***

nokogiri (1.6.5)
```

Ubuntu Linux (OS)

```shell-session
$ lsb_release -a
No LSB modules are available.
Distributor ID:	Ubuntu
Description:	Ubuntu 14.04.1 LTS
Release:	14.04
Codename:	trusty
```

# おわりに
実装は、Ruby (正規表現、YAML、エンコーディング等)、Nokogiri (XPath等) に依存していて、スクリプトの機能はそれらの仕様により制約を受けます。
URI オープンは open-uri を利用していますが、HTTPS リダイレクトに対応してないようです。
Qiita のページは検索できませんでした。(。。。残念)