とある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_**という変数で正規表現を定義している。これらは業種(銀行業、製造業、ETF、REITなど)、単元株数を取得するための正規表現だ。事前に確認した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