31
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

クローラー/WebスクレイピングAdvent Calendar 2015

Day 16

おうちカクテルで学ぶWebスクレイピング

Last updated at Posted at 2015-12-15

クローラー/Webスクレイピング Advent Calendar 2015の16日目の記事です。

・2015/12/16 追記
コメント欄にてriocamposさんにご指摘いただいた点を元に記事を修正しました。
ありがとうございます。

・2017/07/06 追記
久々にコードを見たら酷いコードが残されていたのでリファクタリングしました。
GitHubにも置いてありますので、良ければ使ってみてください。

リファクタリングついでに、文章も修正して再アップロードしようと思います。

今回の目的

rubyでWebスクレイピングする際の処理を練習する。

特定のページからカクテル情報を収集する

  • カクテル名
  • カクテルを作るのに必要な材料
  • 作り方

・持っている材料で作る事が出来るカクテルの列挙

準備

rubyとbundlerがあれば大体動くと思います。
・ruby >= 2.1
・bundler

大抵の場合は下記のコマンドを実行すればbundlerをインストール出来ます。
パーミッション系のエラーで怒られたらsudo付きで実行してください。

gem install bundler

一番初めに行う事(重要)

  1. クロール対象のWebサイトの規約を確認し、対象のデータをクロールすることが問題ない事を確認する。
  2. robots.txtを確認し、対象のデータをクロールすることが問題ない事を確認する。

今回クロール対象としているカクテルタイプ(http://www.cocktailtype.com/)では、規約らしきページは見つからないので2番のrobots.txtを確認します。
カクテルのレシピページである/recipe/以下のディレクトリはDisallowで指定されていない事を確認し、クロールを始めます。

クロール

クロールを行う際には、ディスク容量が許せばクロール処理とスクレイピング処理を分割する事をお勧めします。
理由として、
・途中でクロール処理が止まった際、再取得を行ってサーバに負荷を掛けることが無くなる
・バグが発生した際に再クロールせず手元の環境で試すことが出来る
・スクレイピング処理の実装時に保存されているファイルを使用することで対象サーバに負荷を掛けることなく実装が行える
といった利点が存在します。

目的のデータをどのように列挙すればよいかを考えます。
今回は/recipe/以下全てのページを収集したいと思っているので、
・全てのカクテルのレシピページのURLを含んでいるカクテル名から選ぶ(あいうえお順一覧)ページを最初にクロール
・最初に取得したページのURLから/recipe/を含むURLのみを列挙
することにより、目的のURL一覧を取得することが出来ます。

Anemoneを利用して上記の処理を行う際には、

Anemone.crawl(URL, depth_limit: 1, skip_query_strings: true, delay: 3) do |anemone|

最初にクロールしたページの1階層下までクロールを行うループを実行して

anemone.focus_crawl do |page|
  page.links.keep_if { |link|
    include_recipe = link.to_s.match(%r(/recipe/))
    not_crawl_html = !File.exist?(save_filename(link.to_s))

    include_recipe && not_crawl_html
  }
end

keep_if内のブロックにて、再取得を行うURLを判断します。
keep_if内でtrueが返った場合のみ、再取得を行うURLであると判断されます。
今回は
・URLに/recipe/を含む
・クロール済みで無い
という条件を満たす場合のみ、再取得を行うURLであると判断されるように実装します。

crawl.rb
require 'anemone'
require 'fileutils'

URL = 'http://www.cocktailtype.com/cocktail/cocktail_name_n_0001.html'.freeze
HTML_DIR = File.expand_path('../html', __FILE__)

def save_filename(url_str)
  File.join(HTML_DIR, File.basename(url_str))
end

if $PROGRAM_NAME == __FILE__
  FileUtils.mkdir_p(HTML_DIR)

  Anemone.crawl(URL, depth_limit: 1, skip_query_strings: true, delay: 3) do |anemone|
    anemone.focus_crawl do |page|
      page.links.keep_if { |link|
        include_recipe = link.to_s.match(%r(/recipe/))
        not_crawl_html = !File.exist?(save_filename(link.to_s))

        include_recipe && not_crawl_html
      }
    end

    anemone.on_every_page do |page|
      p File.basename(page.url.to_s)
      File.write(save_filename(page.url.to_s), page.body.encode('UTF-8', 'Shift_JIS'))
    end
  end
end

実行は下記のコマンドで実行します。
初回実行時とGemfileを更新時のみ、bundle install --path vendor/bundle を実行する必要があります。
必要なgemをインストールする処理になります。

bundle install --path vendor/bundle # 初回実行時とGemfileを更新時のみ!
bundle exec ruby crawler.rb

スクレイピング

ローカルに保存したHTMLを読み込んで、nokogiriを使用して処理していきます。
Nokogiri::HTMLに対してHTMLを与えることで、xpathで走査する事が出来るようになります。
今回の対象となるカクテルタイプのページでは、タグにidが割り振られていないので対象を絞り込むのがしんどいですが、
大抵のページにはid要素がついているので、上手くxpathを利用して目的のデータを取得しましょう。

カクテルクラスを作成し、コンストラクタで名前、材料、作り方をhtmlから抜き出しています。
html本体をメンバ変数として保持しておくと、メモリを食ってしまうので初期化の時点でパース処理を行います。
(今更ですが、パースしてから結果をコンストラクタに渡してnewでも良かったですね。)
メモリが十分にあるなら全く問題ありませんが、htmlを保存して後でスクレイピングを行う際にはhtmlの参照を持ちすぎないという点を覚えておきましょう。

cocktail.rb
require 'nokogiri'

Material = Struct.new(:name, :volume)

class Cocktail
  attr_reader :name, :make, :materials

  def initialize(html)
    doc = Nokogiri::HTML(File.read(html))

    @name = doc.xpath('//div[@class="cnt"]/strong').text
    @make = doc.xpath('/html/body/table/tr/td[2]/div[8]/table/tr/td[2]').text
    @materials = doc.xpath('//table/tr/td[@width="50"]/div[@align="center"]/../../../tr')[1..-1]
                    .map { |item| item.xpath('td') }
                    .map { |td| Material.new(td[0].text.strip, td[1..-1].text) }
  end

  def create?(my_materials)
    @materials.map(&:name).all? { |item| my_materials.include?(item) }
  end

  def recipe
    print("カクテル名:#{@name}\n")
    print("材料:\n")
    @materials.each { |material| print("#{material.name}#{material.volume}\n") }
    print("作り方:\n")
    print("#{@make}\n")
  end
end
parser.rb
require_relative 'cocktail'

HTML_DIR = File.expand_path('../html', __FILE__)

if $PROGRAM_NAME == __FILE__
  my_materials = File.read(File.expand_path('../my_materials.txt', __FILE__)).strip.split("\n").map(&:strip)
  cocktails = Dir.glob(File.join(HTML_DIR, '*recipe*'))
                 .map { |html| Cocktail.new(html) }
                 .select { |cocktail| cocktail.create?(my_materials) }

  cocktails.each { |cocktail| print("#{cocktail.recipe}\n") }
end

実行は下記のコマンドで実行します。
windows環境で実行する際には、-E-utf8を付けておくと文字コードエラーが発生しにくくなるので付けておきましょう。

bundle exec ruby -Eutf-8 parser.rb

実行を行うとmy_materials.txtに記載されている材料により作れるカクテルのレシピが描画されます:tada:

おわりに

・収集したいデータのURLはどのように列挙出来るか
・XPathやテキストを無理矢理分割してHTMLからどのようにデータを収集するか
を考えて、上手くやっていけばWebスクレイピングは難しくありません。
自分の興味がある分野の情報をスクレイピングしてみて、練習するのが一番勉強になると思っているので、手を動かしましょう:sparkles:

久しぶりに昔のコードを見たら恥ずかしい気持ちになったので、今書くならこんな感じだろうな~といったノリで編集してみました。
仕事でクロール処理を行う際には、mechanizeを使ったりrspecでテストする際にはseleniumを使ったりであまりanemoneは使わないですが、今回のケースでは楽そうだったので使用してみました。

31
32
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
31
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?