Rubyでanemoneの物足りない部分を補完するクローラーを自作した時のTIPS

# anemoneが人気だけど物足りない

Rubyでクローラーと言えば[anemone](https://github.com/chriskite/anemone)ですよね。ですがanemoneは2012年で更新がほぼ停止しています。

また大きめのサイトをクロールするとページ数が数万を超えて来ますし、プラスアルファで属性情報を収集する必要が出てくるので素のanemoneだと対応が難しくなります。

仕事で技術方向に特化したSEOをしている事もあってanemoneをクローラーとして使うのには物足りませんでした。具体的に物足りなかった機能を列挙します。

- Charsetのサポート、自動判定
- 中断再開機能
- 一時停止後のオプション変更
- 複数サイトに対する平行稼働
- 許可するサブドメインの指定
- 除外するサブドメインの指定
- UserAgentの切り替え（GoogleBotに成りすますなど）
- 除外するパラメータやパスの指定
- 多段リダイレクトの追跡
- クロールするページ上限数の指定や切り替え
- 日本語ドメインへの対応
- エラー処理、リソース制限
- 等々

ざっと上げましたが他にもクローラーとして実用するために足りない機能が多数あった気がします。この記事ではAnemoneを元に又は参考にして、Rubyでクローラーを作る際に気を付ける点を列挙して行きます。

ちなみにSEO目的のサイトクローラーのためスクレイピングする情報はヘッダーのogタグやmetaタグ、ページ内のalt属性、imgタグ、aタグと言った情報です。

# 収集するドメイン

1つのサイトが1つのドメインのみで構成されていれば良いですが、現実のサイトはwwwの有り無し等サブドメインが混ざって1つのサイトになっている場合があります。

これを違うサイトとして扱ってしまうとサイトの一部分が欠けてしまうので以下の選択をできるようにします。

- 全てのサブドメインを許可する
- 指定したサブドメインを許可する（正規表現）
- 指定のドメインのみに限定する

フィルターのようにルールベースで順に処理をパスさせる形にしました。

# 収集するパス

一部パラメーターを除外するようにしないとクロールするページ数が爆発します。そのため収集する対象を絞るか、もしくは収集しない対象を明確にする、一定量を収集した後にこのルールを変更できる仕組みにしました。

これもフィルターのようにルールベースで順に処理をパスさせる形にしました。

# 一時停止と再開

大きなサイトを対象にクローラーを走らせている場合、収集ルールの指定を間違ったからと言ってゼロからやり直して何日もかけてまたクロールしなおす訳には行きません。

そのため一時停止して再開させる機能は必須になります。anemoneにこの機能が備わっていれば楽だったのですがanemoneは基本的に走ったら走りっぱなしなんですよね。

クロールするサイトやその収集ルールを1つのジョブとしてworkerに渡すとクロール開始、そしてジョブは停止や再開できるようにして、任意のタイミングでルールをリロード出来る仕組みを作りました。

と、こうやって文章で書くと短く済みますがこのジョブ管理の実装が結構面倒な所ですね。途中でネットワークエラーから予期せぬエラーは起こりますし、サイトダウンして一時的に繋がらなくなる事もありますし、きちっと異常系への対応をしてクローラーをいつでも再開できる状態にする必要があります。

# Basic認証やプロキシへの対応

サイト公開前にSEOを済ませるのが意外と大切です。そのためサイト公開前に何回かクロールして、サイトを修正するのですが公開前のサイトを「クロールしたいから一時的に公開して下さい！」なんて事は言えない訳です。

そのため特定IPからの接続のみ許可してもらうようにして、そこをプロキシしてクロールする、またはBasic認証のログイン情報を貰って接続するといった対応が出来るようにしました。

Basic認証もプロキシもRuby標準ライブラリのNet::HTTPに機能が備わっています。

以下サンプルコードはhttp://ruby-doc.org/stdlib-2.2.3/libdoc/net/http/rdoc/Net/HTTP.html
からの引用です。

```rb
# Proxyするコード
proxy_addr = 'your.proxy.host'
proxy_port = 8080
Net::HTTP.new('example.com', nil, proxy_addr, proxy_port).start { |http|
  # always proxy via your.proxy.addr:8080
}
```

```rb
# Basic認証するコード
uri = URI('http://example.com/index.html?key=value')

req = Net::HTTP::Get.new(uri)
req.basic_auth 'user', 'pass'

res = Net::HTTP.start(uri.hostname, uri.port) {|http|
  http.request(req)
}
puts res.body
```

# SSLへの対応

HTTPSのサイトも普通にクロールする必要があるのですが、素の状態だとサイトによってはクロールできない場合があります。

プロトコルがHTTPSの場合はuse_sslをtrueにするとアクセス出来るようになります。

```rb
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
```
OpenSSL::SSL::VERIFY_NONEにすることでSSLを検証しなくなります。ですが状況によっては証明書の内容を見る必要があるかも知れませんのでこのオプションの意味は以下リファレンスを確認して下さい。

[Ruby 2.0.0 リファレンスマニュアル OpenSSL::SSL::SSLContext#verify_mode=](http://docs.ruby-lang.org/ja/2.0.0/method/OpenSSL=3a=3aSSL=3a=3aSSLContext/i/verify_mode=3d.html)
[Ruby 2.0.0 リファレンスマニュアル OpenSSL::SSL::VERIFY_NONE](http://docs.ruby-lang.org/ja/2.0.0/method/OpenSSL=3a=3aSSL/c/VERIFY_NONE.html)

# UserAgentに対応が必要な理由

ちょっと巻いて書かないと今日中に公開できない感じになってきました。UserAgentによって返すページを変えているサイトも多いのでUserAgentには以下を指定できるようにしています。

- GoogleBotたち(Googlebot-News,Googlebot-Image,Googlebot-Video...)
- Android
- iPhone / ipad
- フィーチャーフォン
- デスクトップブラウザ

Googleが使っているクローラーは[Search Console ヘルプ Googleクローラ](https://support.google.com/webmasters/answer/1061943?hl=ja)にて公開されています。

後はデバイスによってUserAgentを分ける普通の対応です。

# 壊れたHTMLや指定の文字コードと違うページ

さあこれ困るパターンですよね。UTF-8とcharsetに書いてあるのに実際はEUC-JPだったりSJISだったりするパターンです。SJISをShift_JIS/S-SJIS/s_JISなど表記の揺れもあります。

UTF-8だったら特に何もしなくても良いから楽ですが、そもそもcharsetが書いてなかったら文字コードを推測して扱う必要が出てきます。そのため以下のようにNFKのguessオプションを使って文字コードを取り出すのですが、場合によってはSJISがバイナリとして認識されたり等で単純には行きません（ただ昔と比べて性能上がっている気がします）。

```rb
charset = NFK.guess(html)
```

文字コードの推測については以下情報を参照してください。

[module NKF](http://docs.ruby-lang.org/ja/1.8.7/class/NKF.html)
[標準添付ライブラリ紹介 【第 3 回】 Kconv/NKF/Iconv](http://magazine.rubyist.net/?0009-BundledLibraries#l15)

# URLの結合と取り除く箇所

サイト内のaタグに書かれているURLは絶対パスや相対パスだったり、ドメインから入っていたりと一定ではありません。

このURLの結合は意外と難しい問題なのですが、簡易的に行うにはURL.joinが便利です。

以下サンプルコードは[Ruby リファレンスマニュアル module URI](http://docs.ruby-lang.org/ja/1.8.7/class/URI.html)からの引用です。

```rb
require 'uri'
p URI.join('http://www.ruby-lang.org/', '/ja/man-1.6/')
=> #<URI::HTTP:0x2010017a URL:http://www.ruby-lang.org/ja/man-1.6/>
```

もう少し複雑な状況に対応するには[Addressable](https://github.com/sporkmonger/addressable)が便利です。

例えばURLを扱う時にフラグメント部分、/page.html#title1のこの#後ろですね、を取り除く必要があるのですがAddressable::URI のomitを使うと簡単に取り除けます。

以下サンプルコードは[Class: Addressable::URI](http://addressable.rubyforge.org/api/Addressable/URI.html)からの引用です。

```rb
uri = Addressable::URI.parse("http://example.com/path?query")
#=> #<Addressable::URI:0xcc5e7a URI:http://example.com/path?query>
uri.omit(:scheme, :authority)
#=> #<Addressable::URI:0xcc4d86 URI:/path?query>
```

上記サンプルはschemeとauthorityを取り除いていますが、これでフラグメントを取り除いてみます。

```rb
uri = Addressable::URI.parse("http://example.com/path#flag")
#=> #<Addressable::URI:0xcc5e7a URI:http://example.com/path#flag>
uri.omit(:fragment)
#=> #<Addressable::URI:0xcc4d86 URI:http://example.com/path>
```

キレイにfragmentだけ取り除けました。

# Cookieのサポート

クロールするのにCookieを維持してないといけないサイトもあるのでCookieを同じジョブの中で使いまわす必要があります。注射器の使いまわしのように聞こえますが、これによってジョブが状態を持つことになりますので少し複雑になります。

以下はanemoneの[cookie_store.rb](https://github.com/chriskite/anemone/blob/72b699ee11f618bdeb5a8f198b0b9aa675b4e2fa/lib/anemone/cookie_store.rb#L23)です。WEBrick::Cookieを使う仕組みです。この箇所はほぼそのまま使いました。

```rb
require 'delegate'
require 'webrick/cookie'

class WEBrick::Cookie
  def expired?
    !!expires && expires < Time.now
  end
end

module Anemone
  class CookieStore < DelegateClass(Hash)

    def initialize(cookies = nil)
      @cookies = {}
      cookies.each { |name, value| @cookies[name] = WEBrick::Cookie.new(name, value) } if cookies
      super(@cookies)
    end

    def merge!(set_cookie_str)
      begin
        cookie_hash = WEBrick::Cookie.parse_set_cookies(set_cookie_str).inject({}) do |hash, cookie|
          hash[cookie.name] = cookie if !!cookie
          hash
        end
        @cookies.merge! cookie_hash
      rescue
      end
    end

    def to_s
      @cookies.values.reject { |cookie| cookie.expired? }.map { |cookie| "#{cookie.name}=#{cookie.value}" }.join(';')
    end

  end
end
```
# 他にも諸々

Nokogiriを使ったスクレイピング、内部リンクデータの取得、ogタグの情報取得、エラー処理、ジョブ管理、robots.txtの扱い、ドメインのパース、日本語ドメイン対応などなどあって書きたいけどまた次回にします。

# SEOとクローラーの関係

日が過ぎそうなので強引に〆の文章に移ります。

コンテンツの重複や、競合サイトの調査を網羅的に行うにはSEOに特化したサイトクローラーがあると便利です。足りない情報を補足する、不適切な箇所があれば修正する、期待していないエラーがあれば直す。サイトを厳密にクロールした結果があればこれらを正確に行えます。

人の目では確認できないHTTPリクエストの内容やリダイレクトチェーンも解析して必要な情報はURLと対にして残して後で集計する事も必要になってきます。具体的には[Vary](https://developers.google.com/webmasters/mobile-sites/mobile-seo/configurations/dynamic-serving?hl=ja)の使い方や、ページの応答時間（レスポンスタイム）ですね。

SEO特化クローラーにはWindowsPC内で動くクライアントアプリケーション型の[Screaming Frog SEO Spider Tool](http://www.screamingfrog.co.uk/seo-spider/)がありますが、ずっとクライアントPCで動かし続ける訳にも行きませんし、月に何十万ページとクロールするのでサーバーサイドで並列稼働させてデータベースにデータを残す必要がありました。

SEOで何をすれば良いのか分からなくなっている会社さんはSEOに特化したサイトクローラーを走らせてみると、今まで見えてこなかった修正点や改善点が見えてくるかも知れません。

# さいごに

質問などあればコメント欄にお願いします。直接メッセージや質問をしたい方は[Twitter](https://twitter.com/tkosuga)アカウント宛てにお願いします。