くりにっき

フルスタックキュアエンジニアです

ccc_privacy_bot を支える技術

はじめに

これは クローラー/スクレイピング Advent Calendar 2014 - Qiita の9日目です

8日目

id:dkfj さんの クローラー/スクレイピングのWebサービス 「Kimono」のユースケース - プログラマになりたい でした

9日目:ccc_privacy_bot を支える技術

f:id:sue445:20141207023641p:plain

先日書いたエントリがめでたく580はてブいきました。

気づいたらGIGAZINEさんにも取り上げてもらえました。

このボットで使ってるスクレイピングとクローリングのTipについて解説します

ソースコード

ソースコードgithubに公開しています。

クローラでやってること

  1. 30分に1回、提供先企業一覧のPDFをダウンロードできるページにいく
  2. ページをスクレイピングしてPDFをダウンロード
  3. ダウンロードしたPDFをスクレイピングして提供先企業一覧を取得
  4. 新着があればボットでつぶやく

使っている技術

HTMLのスクレイピング

Mechanize を使ってます。

  • メリット
    • 軽い
    • Ruby単体で動く
  • デメリット
    • htmlのbodyのparseしかしないので、jsでDOMが操作されている場合には使えない

Ajaxがふんだんに使われたページをスクレイピングするには phantomjs + Capybara + poltergeist の組み合わせが鉄板だと思います。(が、phantomjsはデバッグしづらいのが。。。)

参考

実際のソースコードで解説します

https://github.com/sue445/ccc_privacy_crawler/blob/672bc0dbe8c88f431603b4913c2d1236df6e2d99/lib/workers/pdf_crawl_worker.rb#L28-39

  def download_ccc_pdf(dest_pdf_file)
    # mechanizeのインスタンスを初期化
    agent = Mechanize.new

    # http://qa.tsite.jp/faq/show/25129 を開く
    agent.get("http://qa.tsite.jp/faq/show/25129")

    # <a href="/attachment_file/〜.pdf"> のようなリンクを探す
    download_link = agent.page.link_with(href: %r(/attachment_file/.+\.pdf))

    # リンクが見つからなければエラー
    raise "Not found download_link" unless download_link

    # リンク先のPDFをダウンロードしてファイルに保存する
    pdf_content = agent.get_file(download_link.href)
    File.open(dest_pdf_file, "wb") do |file|
      file.write(pdf_content)
    end
  end

pdfのスクレイピング

pdf-reader というgemを使ってます。

pdfをrubyから読むためのgemはいくつか使ってみたのですが、今回はこのgemじゃないとうまくテキストで取得出来ませんでした。(cccのpdfはExcelをpdfに変換してるみたいなのですが、他のgemだとセルの中のテキストが列単位でしか取得できない。pdf-readerだとpdfとしてレンダリングされる時の実際の座標もある程度考慮してくれる模様)

ダウンロードしたpdfをテキストで読み込む

https://github.com/sue445/ccc_privacy_crawler/blob/672bc0dbe8c88f431603b4913c2d1236df6e2d99/lib/workers/pdf_crawl_worker.rb#L65-74

  def read_pdf(pdf_file)
    pdf_content = ""

    reader = PDF::Reader.new(pdf_file)
    reader.pages.each do |page|
      pdf_content << page.text
    end

    pdf_content
  end

これ自体は特別なことをしていないんですが、そのままだと下記のようにpdf内の日付がうまく取得できませんでした

1 TSUTAYA・蔦屋書店                      2014/10/2提携先:TSUTAYAフランチャイズチェーン加盟企業
2 JX日鉱日石エネルギー株式会社                   2014/10/2提携先:ENEOS
3 株式会社アプラス                          2014/10/2提携サービス:Tカードプラス, Tカードプラスα ,TSUTAYAWカード
4 株式会社Misumi                        2014/10/2提携先:BOOKSmisumi,Misumiグループ(ガス・水)
5 JR九州ドラッグイレブン株式会社                  2014/10/2提携先:ドラッグイレブン

モンキーパッチで文字描画の位置を無理矢理変えて対応してます。

https://github.com/sue445/ccc_privacy_crawler/blob/672bc0dbe8c88f431603b4913c2d1236df6e2d99/lib/pdf-reader.rb

class PDF::Reader::PageLayout
  # fix rate: 1.05 -> 1.5
  def col_count
    @col_count ||= ((@page_width  / @mean_glyph_width) * 1.5).floor
  end
end

pdfを文字列で取得でした後は正規表現でparseしてます

https://github.com/sue445/ccc_privacy_crawler/blob/672bc0dbe8c88f431603b4913c2d1236df6e2d99/lib/workers/pdf_crawl_worker.rb#L41-62

  def parse_ccc_pdf(pdf_file)
    companies = []
    read_pdf(pdf_file).each_line do |line|
      line = line.strip

      matched_data = %r(
        ^(?<no>[0-9]+)\s*
        (?<company_name>.+)\s*
        (?<receipted_date>[0-9]{4}/[0-9]{1,2}/[0-9]{1,2})
        (?<destination_name>.+)$)x.match(line)
      next unless matched_data

      companies << Company.new(
        no:               matched_data[:no].to_i,
        company_name:     matched_data[:company_name].strip,
        receipted_date:   matched_data[:receipted_date].strip,
        destination_name: matched_data[:destination_name].strip,
      )
    end

    companies
  end

ソースコード中の正規表現

%r(
^(?<no>[0-9]+)\s*
(?<company_name>.+)\s*
(?<receipted_date>[0-9]{4}/[0-9]{1,2}/[0-9]{1,2})
(?<destination_name>.+)$)x.match(line)

/^([0-9]+)\s*(.+)\s*([0-9]{4}\/[0-9]{1,2}\/[0-9]{1,2})(.+)$/ =~ line

と同等ですが下記のような工夫があります

クローラ

Heroku Scheduler だと1日1回以上cronを動かすには 有料になりますが、sidekiq-cron で自前でcronすることで無料枠で30分に1回cronを動かしています

詳しくはこちらを参照

sidekiqでcron処理を行うにはいくつかgemがありますが、starの数で比較するとsidetiqがメジャーみたいですね。

今回は会社で使い慣れていたsidekiq-cronを使いました。

追記:2015/12/6

Herokuの料金体系が変わってインスタンスを24時間フル稼働する方式だとお金がかかるようになり、Heroku Schedulerも1時間に1回の実行も無料になっていたので、sidekiq-cronはやめてHeroku Schedulerでrakeタスクを実行するようにしています

f:id:sue445:20151206005941p:plain

Herokuのアドオン一覧

f:id:sue445:20141207023701p:plain

  • Deploy Hooks
    • デプロイした時にRollbarに通知を送るためのwebhook(が、半日遅れくらいでRollbarからSlackに通知がくるのであてにならないw)
    • Rollbar - Tracking Deploys with Heroku
  • Heroku Postgres
    • pdfからparseした会社情報を保存する先のデータベース
  • New Relic
    • アプリケーションのパフォーマンス解析。無料なので入れたけどボットだから意味なかったw
  • Papertrail
    • アプリのログをwebから見るためのアドオン
  • Redis Cloud
    • sidekiqを使うためのKVS
    • 他に Redis To Go もあったのですが無料枠どうしだとRedis Cloudの方がスペック高かったのでこっちを使ってます。(Redis Cloudだとメモリ25MBでRedis To Goだと5MB)
  • Rollbar
    • エラー検知
    • 無料枠だと他に Bugsnag があったんですが同じ無料枠どうしだと月に受信できるエラーの件数が多いRollbarを選択(Rollbarが3000件でBugsnagが100件)

CircleCI

Circle CI

ビルドやHerokuへのデプロイは全部CircleCIでやってます(トピックブランチがmasterにmergeされたら自動的にherokuにデプロイ)

ローカルからいちいち

git push heroku master

する必要がないのはいいですね

Wercker を使うという手もあったのですが、公開設定していてもログインしないとビルドの一覧が見れないのが不便なのでCircleCIを選択。*1

TravisCIはpush後のビルドが始まるのに時間が掛かるのでマトリクステストが必要になるgem作成以外では使ってないです。。。

Slack

f:id:sue445:20141207030107p:plain

デプロイやRollbarのエラーの通知を自分のチャット部屋に送ってます

10日目

furandon_pig さんの シェルスクリプトを使って「これから毎日金相場をスクレイピングしようぜ?」という話 - Qiita です

追伸

人間ドックでバリウム飲んていまだに気持ち悪い

*1:Githubのトップに貼るバッジがログインしないと見れないのは違和感あった