MAGAZINE

ルーターマガジン

クローリング/スクレイピング

Nokogiri でスクレイピングしていて レイアウトが変わると困る!

2024.01.17
Pocket

エンジニアの kanazawa です。 弊社のクローラー開発は100% Ruby で実装されており、全てのプロジェクトでNokogiriをHTMLパーサーとして使用しています。

今回は、クローラーを運用するにあたってつきものなレイアウト変更対応に対する1つの処方箋として、Nokogiri のメソッドをオーバライドして厳しくする方法をご紹介します。 既存の Nokogiri の実装では、エラーとならないものも自前でエラーとしてしまうことで、運用中にレイアウト変更があればプログラムが異常終了し、すぐに気づくことが出来るという流れを目指します。

at_css メソッドは 複数要素がヒットしたらエラーにする

困りポイント

特定の css セレクタに一致するDOM要素が複数あった場合に、at_css は最初に見つかった要素を取得します。 そのため、レイアウト変更によって既存の css セレクタと一致するDOM要素が複数作られた場合に、参照先が変わってしまうリスクを抱えています。

パッチコード

require 'nokogiri'

module Nokogiri::XML::Searchable
  class AtCssMultipleHitsError < StandardError; end

  def at_css(*args)
    res = css(*args)
    raise AtCssMultipleHitsError if res.length > 1

    res.first
  end
end

html = $stdin.read
doc = Nokogiri.parse(html)

サンプルHTML

not_unique の id属性を持つ DOM が複数存在するような HTMLを用意しました。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
  </head>
  <body>
    <p id="unique">Hello</p>
    <p id="not_unique">Apple</p>
    <p id="not_unique">Bell</p>
  </body>
</html>

動作確認

サンプルHTML内の、pタグ内のテキストを以下の Ruby スクリプトで取得します。

html = $stdin.read
doc = Nokogiri.parse(html)

普通に Nokogiri でパースした場合は、not_unique の id 属性を持つ DOM の内、先に見つかった要素を取得しています。

 % ruby parser_normal.rb < test.html
Hello
Apple

パッチを当てた Nokogiri でパースした場合、not_unique の id 属性を持つ DOM が複数存在するため、自前で定義した Nokogiri::XML::Searchable::AtCssMultipleHitsError 例外を吐いてパースに失敗します。

% ruby parser.rb < test.html       
Hello
parser.rb:11:in `at_css': Nokogiri::XML::Searchable::AtCssMultipleHitsError (Nokogiri::XML::Searchable::AtCssMultipleHitsError)
    from parser.rb:35:in `<main>'

内部構造があるDOM に対する text はエラーとする

困りポイント

内部構造をもつ DOM 要素に対して、text メソッドを使うと、内部の文字列を勝手にJOIN して1つの文字列として返してしまいます。これはこれで便利な仕様なのですが、構造化されたデータを勝手に1つの文字列にするという観点では望ましくない挙動です。 そこで、内部構造を持つようなDOM要素に対して text メソッドを使用するとエラーになるパッチを使ってみます。

パッチコード

require 'nokogiri'

class Nokogiri::XML::Node
  class TextMethodCalledForNonTextDom < StandardError; end

  def text
    raise TextMethodCalledForNonTextDom unless children.reject { |n| n.name == 'text' }.empty?
    content
  end
end

html = $stdin.read
doc = Nokogiri.parse(html)

サンプルHTML

以下のように、表の中に br タグで区切られた情報がある HTMLを考えてみます。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
  </head>
  <body>

    <table>
      <tbody>
        <tr>
          <th>名前</th>
          <td>田中太郎</td>
        </tr>
        <tr>
          <th>住所</th>
          <td>〒151-0053<br>東京都渋谷区代々木4丁目28−8</td>
        </tr>
        <tr>
          <th>性別</th>
          <td>男</td>
        </tr>
      </tbody>
    </table>
  </body>
</html>

動作確認

サンプルHTML内の、表の key と value の組み合わせを以下の Ruby スクリプトで取得します。

html = $stdin.read
doc = Nokogiri.parse(html)

doc.css('table tr').each do |tr|
  th = tr.at_css('th').text
  td = tr.at_css('td').text

  puts "#{th} : #{td}"
end

普通に Nokogiri でパースした場合は、住所の項目の値に br タグが含まれていますが、それを無視して JOIN した結果が取得できています。 これだと郵便番号と住所を後から分離するときに困ってしまいます。

 % ruby parser_normal.rb < test.html
名前 : 田中太郎
住所 : 〒151-0053東京都渋谷区代々木4丁目28−8
性別 : 男

パッチを当てた Nokogiri でパースした場合、br タグが含まれる td 要素の中身を文字列に変換しようとしたときに、自前で定義した Nokogiri::XML::Node::TextMethodCalledForNonTextDom 例外を吐いてパースに失敗します。

 % ruby parser.rb < test.html 
名前 : 田中太郎
parser.rb:28:in `text': Nokogiri::XML::Node::TextMethodCalledForNonTextDom (Nokogiri::XML::Node::TextMethodCalledForNonTextDom)
    from parser.rb:39:in `block in <main>'
    from /home/test/.rbenv/versions/3.1.3/lib/ruby/gems/3.1.0/gems/nokogiri-1.14.2-x86_64-linux/lib/nokogiri/xml/node_set.rb:235:in `block in each'
    from /home/test/.rbenv/versions/3.1.3/lib/ruby/gems/3.1.0/gems/nokogiri-1.14.2-x86_64-linux/lib/nokogiri/xml/node_set.rb:234:in `upto'
    from /home/test/.rbenv/versions/3.1.3/lib/ruby/gems/3.1.0/gems/nokogiri-1.14.2-x86_64-linux/lib/nokogiri/xml/node_set.rb:234:in `each'
    from parser.rb:37:in `<main>'
Pocket

CONTACT

お問い合わせ・ご依頼はこちらから