Technically Impossible

Lets look at the weak link in your statement. Anything "Technically Impossible" basically means we haven't figured out how yet.

Rubyでのスクレイピングー正規表現の場合、nokogiriの場合。

とあるRubyの本を読んでいる。システム・トレードの特にデータ収集と1銘柄単位のシミュレーションにフォーカスしている。特にデータ収集は、スクレイピングと呼ばれる技術だ。
とあるサイトからスクレイピングすることになっているのだが、そのサイトはスクレイピング禁止を明記しているため、書名と対象サイトは伏せる。

この本は2014年に出版されたもので、Ruby 1.9.3と標準ライブラリだけを用いる前提としている。製品や言語の進歩は著しいため、古い技術書は敬遠されがちだが、必要な知識の上書を自分で対応できるならば、それが新たな学習のきっかけにもつながる。
2018年現在、Rubyは2.5系が主流だが、写経についてはVSCodeのLintが支援してくれるおかげで、全く支障はない。ただ標準ライブラリだけを用いるスクレイピングは、nokogiriを利用して効率化すべきだと思った。

紹介されているスクレイピングは、取得したHTMLを行単位で読み込み、正規表現を用いて目当ての情報を取得するものだ。これをnokogiriを利用して書き直してみた。
www.nokogiri.org

HTMLの検証

まず目当てのサイトにアクセスしてHTMLコードを確認する。ブラウザが提供する開発支援機能、例えばChromeならばDeveloper toolsを用いるところだが、注意が必要だ。ブラウザでアクセスする場合と、Rubyからアクセスする場合では、サーバーが同一のHTMLを返すとは限らないからだ。

次のコードで出力されたHTMLを確認する。目的の情報がどこに記述され、どのようにすれば取得できそうかの目星を付ける。

require 'open-url'
require 'nokogiri'

url = 'https://~'
page = open(url)
puts Nokogiri::HTML.parse(page, nil, page.charset)

正規表現を用いたスクレイピング

本では次のようにして、目的の情報を取得していた。

def parse(text)
  data = Hash.new
  sections = []
  reg_market = /yjSb">([^< ]+) ?</
  reg_unit = %r!<dd class="ymuiEditLink mar0"><strong>((?:\d|,)+|---)</strong>株</dd>!

  text.lines do |line|
    if line =~ reg_market
      sections << $+
    elsif line =~ reg_unit
      data[:market_section] = sections[0]
      data[:unit] = get_unit($+)
      return data
    end
  end

  data
end

このコード(旧コード)では、参照しているHTMLのほぼ全ての行に対して、正規表現を用いた検証をすることになる。また、指定されたページに目的の情報が含まれていない場合、同様の検証を無駄に実施することにもなる。

これをnokogiriを利用して書き直すに際し、本で紹介されているプログラム全体の整合性を意識して、引数と戻り値の形式を維持することにする。

nokogiriを用いたスクレイピング

旧コード中、reg_**という変数で正規表現を定義している。これらは業種(銀行業、製造業、ETFREITなど)、単元株数を取得するための正規表現だ。事前に確認したHTMLを参照すると、業種は次のタグに記述されていることが分かった。

<dd class="category yjSb">

旧コードでは次の箇所で、正規表現に合致した行から、後方参照で取得した変数を配列sectionsに追加している。

if line =~ reg_market
  sections << $+

これは新コードでは、次のように書き直した。

type_path = '//dd[@class="category yjSb"]'
sections << val.xpath(type_path).text

単元株数は厄介だった。次のタグに記述されていることが判明したが、不必要な情報を含む同一タグが複数記述されていた。

<dd class="ymuiEditLink mar0">

旧コードでは、次の箇所で正規表現に合致した行から、後方参照で取得した変数をget_unitメソッドで整形している。data変数に目的の情報を格納し、メソッドを終了している。

elsif line =~ reg_unit
  data[:market_section] = sections[0]
  data[:unit] = get_unit($+)
  return data
end

これは新コードでは、次のように書き直した。

val.xpath(unit_path).each do |node|
  if node.inner_html =~ reg_unit
    data[:market_section] = sections[0]
    data[:unit] = get_unit($+)
    return data
  end
end

unit_pathで取得できるタグが複数存在するため、NodeSetから目的の情報を含むnodeを特定しなければならない。その特定に旧コードの正規表現を利用するのだが、nokogiriでパースされた影響で旧コードとは改行位置が変化している。若干の修正をして次の正規表現を用いることにした。

reg_unit = %r{^<strong>((?:\d|,)+|---)</strong>株$}

さらに必要な情報が含まれていないページの対策も盛り込む。必要な情報が含まれているHTML、含まれていないものを比較した結果、次のタグの記述有無で判別できることが分かった。

<div class="selectFinTitle yjL">

このタグが記述されていればメソッドを終了し、記述されていなければ処理を継続する。

if val.xpath(nomatch_path).text != ''
  return data
end

これらを駆使して旧コードを書き直すと、次のようなコードになった。旧コードの引数はtextだったのだが、nokogiriが提供するアトリビュートtextと混同しやすいので、新コードの引数はvalとした。

def parse(val)
  data = Hash.new
  sections = []
  nomatch_path = '//div[@class="selectFinTitle yjL"]'
  type_path = '//dd[@class="category yjSb"]'
  unit_path = '//dd[@class="ymuiEditLink mar0"]'
  reg_unit = %r{^<strong>((?:\d|,)+|---)</strong>株$}

  # 銘柄が見つからなかった場合
  if val.xpath(nomatch_path).text != ''
    return data
  end

  # 業種、種別を取得
  sections << val.xpath(type_path).text

  # 単元株数を取得
  val.xpath(unit_path).each do |node|
    if node.inner_html =~ reg_unit
      data[:market_section] = sections[0]
      data[:unit] = get_unit($+)
      return data
    end
  end
end