2009年11月16日月曜日

Ruby言語で書いたクローラー

とある不動産投資物件のデータをクロールするために
作ったクローラ
備忘録として残しておく。
Ruby習いたてで書いたので色々修正するところもありそう。



#! ruby -Ks

#  ○○○サイトクローラー
#
#  利用方法:コマンドラインより実施
#  ruby site_crawler.rb 引数1
#
#  引数1:なし →Typeのすべてを取得
#  引数1:種別名称 →Typeに一致するものだけを取得
#  例) ruby site_crawler.rb 投資用マンション   ##投資用マンションのみ取得
#  例) ruby site_crawler.rb                    ##すべて取得
#
#  出力されるCSVは、Shift-JIS タブ区切り 改行コード:CRFL

require 'rubygems'
require 'mechanize'
require 'kconv'
require 'hpricot'

#  サイト毎の基本変更点は以下のとおり
#
#  ①データ一覧のトップページ
#  ②物件詳細URLに含まれるべき文字列(正規表現可能)
#  ③物件リストの次ページリンク文字列(正規表現可能)
#  ④次へページの挙動
#  ⑤CSV出力ファイル名
#  ⑥データ項目
#  ⑦無効データ
#  および、データ項目でHTMLを解析して取得すべきデータのプログラミング

#  ①データ一覧のトップページ
Type = {
  "投資用マンション"=>"http://www.xxxxxx.com/list/t=1",
  "売りアパート"=>"http://www.xxxxxx.com/list/t=2",
  "売りマンション"=>"http://www.xxxxxx.com/list/t=3",
  "戸建賃貸"=>"http://www.xxxxxx.com/list/t=8"
}
if ARGV[0]
  Type.each{|tkey,tvalue|
    if tkey!=ARGV[0]
      Type.delete(tkey)
    end
  }
end

#  ②物件詳細URLに含まれるべき文字列(正規表現可能)
url_match_str = %r!/realestate/!

#  ③物件リストの次ページリンク文字列(正規表現可能)
next_page_link_match_str = %r!次の[0-9]+件!

#  ④次へページの挙動
def nextPage_click(agent, link, nextPageNum)
  #  次へページがURLベースである場合は下記を利用
  nextPage = link.click
  return nextPage

#  #  次へページがjavascriptなど、URLベースで無い場合はここを変更して利用する
#  #  内部HTMLを解析し、パラメータの状態を確認のこと
#  form = agent.page.form_with("page_jump")
#  form["start_index"]=(nextPageNum-1)*20
#  form.submit
#  nextPage = agent.page
#  return nextPage
end

#  ⑤CSV出力ファイル名
CsvFileName = "site_crawler.csv"
tmpCsvFileName = CsvFileName

#  ⑥データ項目
csv_item_ary=[
  #
  #  ここは、データ一意性のための番号が振られます
  "取得番号",         #各取得タイプ内での連番(プログラムで振ります)
  "取得タイプ",       #Type の名称が入ります
  "サイトURL",        #
  "取得年月日",       #

  #
  #  ここからはTRデータ項目(HTML取得出来る名称で設定してください)
  #
  "価格",                 #
  "満室時利回り",         #
  "築年月",               #
  "満室時年収",           #
  "物件名",               #
  "建物構造/階数",        #
  "建物構造",             #
  "交通",                 #
  "住所",                 #
  "土地面積",             #
  "土地権利",             #
  "専有面積",             #
  "間取り",               #
  "建物面積",             #
  "建ペイ率/容積率",      #
  "接道状況",             #
  "用途地域",             #
  "防火/国土法",          #
  "管理費/修繕積立",      #
  "管理会社名/管理方式",  #
  "取引態様",             #
  "問い合わせ",           #
  "現況",                 #
  "引渡",                 #
  "更新予定日物件登録日", #
  "管理ID",               #
  "営業時間",             #
  "定休日",               #
  "備考"                 #
]

#  ⑦無効データ
del_item_ary = [
]

#  1項目内の改行文字を置き換えたもの
Line_Separator_In_Item = "@改行@"

#  開始時刻
s_time = Time.now

#  ページ遷移待ち時間
Wait_Time = 6

#  リトライタイム
Retry_Time = 5

#  CSVデータのセパレータ
Separator_In_Data = "\t"

#  CSV書き出し中フラグ
flg_w = false

#  実データ(1レコード分)格納領域
#  csv_item_aryと同インデックスに値が入る
csv_data_ary = Array.new(csv_item_ary.length)

#  ログ吐き出しメソッドの定義
def puts_log(log)
  puts Time.now.strftime("%H:%M:%S") + ' ' + log.tosjis
end

#  改行コード類を固定文字列に変換する
def my_gsub_kaigyo(tmp_line, del_item_ary)
  tmp_line.gsub!(/\r\n/,Line_Separator_In_Item)
  tmp_line.gsub!(/\n/,Line_Separator_In_Item)
  tmp_line.gsub!(/\r/,Line_Separator_In_Item)
  tmp_line.gsub!(/\t/,'')  #タブコードは、CSVセパレータなので無効にしておく
  del_item_ary.each do |ditem|
    tmp_line.gsub!(ditem,'')
  end
  return tmp_line
end

#
#  ここからはじまり
#

#  ブラウザオブジェクトの生成
WWW::Mechanize.html_parser = Hpricot
agent = WWW::Mechanize.new
agent.max_history = 1  #  履歴記録は現ページのみ(メモリ浪費させないため)
agent.user_agent_alias = 'Windows IE 7'  # 重要:IE7のふりをする

#
#  メイン処理ループ
#  tkey : 取得サイトタイプ名
#  tvalue : 取得サイトトップURL
Type.each {|tkey,tvalue|
  puts_log('---' + tkey + 'の取得開始---')

  site_top_url = tvalue
  #取得サイトトップURLからURIオブジェクトを生成する(相対パスが出てきたときに絶対パス変換で必要)
  parent_uri = URI.parse(site_top_url)

  #  物件詳細URL 記録用配列
  url_ary = Array.new

  #  トップページにアクセス
  page_num = 1  #  ページ番号
  begin
    page = agent.get(site_top_url)    #pageオブジェクトを取得
  rescue
    puts_log(tkey + ':1ページ目 アクセス失敗。リトライします。')
    sleep(Retry_Time)
    retry
  end

  #  一覧リストから、物件ページURLを記録(開始)
  begin
    sleep(Wait_Time)
    #  現在のページに対して、リンク一覧を取得し、未記録の物件ページURLを記録する
    page.links.each do |link|
      begin
        url = parent_uri.merge(link.uri.to_s).to_s
      rescue
        #  linkオブジェクトにはURL変換不能なもの(javascript)なども含まれるため
        #  例外処理としてこのブロックはある。(とくになにもしない)
      end
      #  指定リンク文字列(url_match_str)があるか
      if url!=nil and url.match(url_match_str)
        #  そのURLは格納済みか?
        if not(url_ary.include? url)
          #  未記録URLであれば格納する
          url_ary.push url
        end
      end
    end
    puts_log(tkey + ':' + page_num.to_s + 'ページ目 物件リストURL取得完了')

    #  次ページをめくる処理
    if next_page_link = page.link_with(:text => next_page_link_match_str) and next_page_link.uri!=nil
      page_num += 1  #  ページ番号インクリメント
      begin
        page = nextPage_click(agent, next_page_link, page_num)
      rescue
        puts_log(tkey + ':' + page_num.to_s + 'ページ目 アクセス失敗。リトライします。')
        sleep(Retry_Time)
        retry
      end
    else
      break  #  クリックすべきリンクがない場合は、ループを抜ける
    end
  end while true

  #  一覧リストから、物件ページURLを記録(終了)

  #  物件数(詳細ページURLの格納数)取得
  bukken_count = url_ary.length

  #  最初の書き出しであれば、CSVファイルにヘッダを書き出す
  tmpCsvFileName = CsvFileName
  if flg_w==false
    if ARGV[0]!=nil
      tmpCsvFileName = CsvFileName.gsub(%r!\.!,'_'+tkey+'.')
    end
    open(tmpCsvFileName,"w") do |f|
      flg_w = true
      f.write csv_item_ary.join(Separator_In_Data).tosjis + "\n"
    end
  end

  #  記録済みの物件詳細URLからそのページにアクセスする
  url_ary.each_with_index do |url, idx|
    #  データ記録先配列をクリア
    csv_data_ary.clear
    begin
      sleep(Wait_Time)
      #  物件詳細URLページにアクセス
      page = agent.get(url)
    rescue TimeoutError
        puts_log(tkey + ':' + page_num.to_s + '(タイムアウト)物件詳細ページアクセス失敗。リトライします。 ' + url)
        sleep(Retry_Time)
        retry
    rescue WWW::Mechanize::ResponseCodeError => ex
      case ex.response_code
      when '404' then
        puts_log(tkey + ':' + page_num.to_s + '(404エラー)物件詳細ページアクセス失敗。Jump ' + url)
      else
        puts_log(tkey + ':' + page_num.to_s + '(' + ex.response_code.to_s + 'エラー)物件詳細ページアクセス失敗。リトライします。 ' + url)
        sleep(Retry_Time)
        retry
      end
    end
  
    #  正常にレスポンスがあったページについてデータを取得(開始)
    if page.code=="200"
      #  Hpricotでinner_textでは正しく変換されない文字列を置換しておく
      #  文字化けがある場合は、ここで変換しておく
      page.body.gsub!(%r!^ +!,'')                 #  行頭の空白を削除しておく
      page.body.gsub!(%r! +$!,'')                 #  行末の空白を削除しておく
      page.body.gsub!(%r!\t!,'')                  #  タブを削除しておく
      page.body.gsub!(%r! !,' ')            #  HTML特殊コードの半角空白が正常にinner_textできないので空白に置換しておく
      page.body.gsub!(%r! !,' ')            #  HTML特殊コードの半角空白が正常にinner_textできないので空白に置換しておく
      page.body.gsub!(%r! !,' ')            #  HTML特殊コードの半角空白が正常にinner_textできないので空白に置換しておく
      page.body.gsub!(%r! !,' ')             #  HTML特殊コードの半角空白が正常にinner_textできないので空白に置換しておく
      page.body.gsub!(%r! !,' ')             #  HTML特殊コードの半角空白が正常にinner_textできないので空白に置換しておく
      page.body.gsub!(%r!"!,'')                   #  CSV出力に影響があるため、ダブルコーテーションを無効にしておく

      #  HTML上改行テキストは特殊文字に置換しておく
      page.body.gsub!(%r!\r\n!,Line_Separator_In_Item)
      page.body.gsub!(%r!\n!,Line_Separator_In_Item)
      page.body.gsub!(%r!\r!,Line_Separator_In_Item)

      doc = page.root           #  現ページからドキュメントを取得する
      trs = doc/:tr             #  trタグ一覧を取得
      tr_data_ary = Array.new   #  一時データ格納用

      trs.each do |trs_data|
        tmp_line = trs_data.inner_html
        tmp_line.gsub!(%r!(        tmp_line.gsub!(%r!<("[^"]*"|'[^']*'|[^'">])*>!,'')  #  タグの無効化
        tr_data = tmp_line.split(Line_Separator_In_Item)
        tr_data.delete("")        #  配列要素の無駄な空白は削除
        del_item_ary.each do |ditem|
          tr_data.delete(ditem)   #  無効にすべきデータを削除する
        end
        tr_data_ary.push tr_data  #  一時データ格納用へ追加
      end

      tr_data_ary.each do |tr|
        tr.each do |tr_data|
          tr_data.gsub!(%r!^ +!,'')                 #  行頭の空白を削除しておく
          tr_data.gsub!(%r! +$!,'')                 #  行末の空白を削除しておく
        end
      end

      prev_item_idx = -1  #  項目番号(-1 は対応する項目番号なし)
      flg_d = false       #  データ登録
      tr_data_ary.each do |tr|
        if flg_d==true
          prev_item_idx = -1
          flg_d = false
        end
        tr.each do |tr_data|
        #データが項目名と一致するか
        if now_item_idx = csv_item_ary.index(tr_data)
            #  項目名と一致するので、項目番号をセットする
            prev_item_idx = now_item_idx
            flg_d = false
          else
            now_item_idx = -1  #-1は非項目を表す(すなわち記録する可能性の高いデータ)
            #  prev_item_idx>=0 すなわち、項目内であることがわかっている場合
            if prev_item_idx>=0
              # データを記録する(初めての場合はそのまま、何か入っている場合は Line_Separator_In_Item をはさんで記録)
              if not csv_data_ary[prev_item_idx]
                csv_data_ary[prev_item_idx] = tr_data
              else
                csv_data_ary[prev_item_idx] = csv_data_ary[prev_item_idx] + Line_Separator_In_Item + tr_data
              end
              flg_d = true
            end
          end
        end
      end
  
      #
      #  基本的にここは変更しない(ここから)
      #
      #  取得番号
      csv_data_ary[csv_item_ary.index("取得番号")] = (idx+1)
      #  取得タイプ
      csv_data_ary[csv_item_ary.index("取得タイプ")] = tkey
      #  サイトURL
      csv_data_ary[csv_item_ary.index("サイトURL")] = url
      #  取得年月日 YYYYmmdd形式
      csv_data_ary[csv_item_ary.index("取得年月日")] = s_time.strftime("%Y%m%d")
      #
      #  基本的にここは変更しない(ここまで)
      #
    
      #
      #  CSVとしてデータを書き出す
      #
      open(tmpCsvFileName,"a") do |f|
        f.write csv_data_ary.join(Separator_In_Data).tosjis + "\n"
      end
      puts_log(tkey + ':' + 'CSV書出し ' + (idx+1).to_s + "/" + bukken_count.to_s)
    else
      puts_log(tkey + ':' + '404エラー:データ取得不能 次へJUMP' + url)
    end
    #  正常にレスポンスがあったページについてデータを取得(終了)


  end
  puts_log('---' + tkey + 'の取得終了---')
}

puts_log(tmpCsvFileName + ' 処理時間 '+(Time.now-s_time).to_s + 's')

0 件のコメント:

コメントを投稿