こしごぇ(B)

旧:http://d.hatena.ne.jp/koshigoeb/

Mechanize で fetch した HTML を任意のウェブブラウザに表示させる簡単な方法を知りたい

Ruby の Mechanize を使って書いたプログラムの途中経過を簡単に目視点検する方法を知りたい。

以下は、Mechanize::HTTP::Agent#fetch の後処理として無理矢理実装したもの。HTML をローカルファイルに書き出して、それをウェブブラウザに読み込ませるというもの。

既に上手いことやってる人がいると思うのですが、探すことができずこんなありさまです。

LiveReload あたりを使うとかっこよく出来る様な気がするんだけど、気がするだけかな…。そもそも、LiveReload 自体をあまり把握できてない…。

  • HTML の書き出し先を固定して LiveReload の監視下におく
  • 古い HTML ファイルは退避してもよいかも
  • HTML が書き出される度にブラウザが更新されて途中経過を目視出来る

みたいな?

Mechanize で Cookie を手動でセットする

Mechanize::Cookie.new のパラメータ指定でミスっていたので自戒のメモ。

require 'rubygems'
require 'mechanize'
require 'logger'

agent = Mechanize.new do |agent|
  agent.log = Logger.new($stdout)
end

[
  'http://www.google.com',
  'http://www.google.co.jp',
].each do |url|
  uri = URI.parse(url)
  cookie_params = { :domain => uri.host, :expires => Time.now + 86400, :path => '/' }
  cookie = Mechanize::Cookie.new('COOKIE_NAME', uri.host, cookie_params)
  agent.cookie_jar.add uri, cookie
end

agent.get 'http://www.google.com'

# $ ruby test.rb | grep cookie | grep request
# D, [2012-08-06T18:06:44.236636 #42887] DEBUG -- : request-header: cookie => COOKIE_NAME=www.google.com
# D, [2012-08-06T18:06:44.323225 #42887] DEBUG -- : request-header: cookie => COOKIE_NAME=www.google.co.jp
  • Mechanize::Cookieインスタンスを Mechanize インスタンスの .cookie_jar.add で追加したら良い
  • Mechanize::Cookieインスタンスは、Mechanize::Cookie.parse でもいいし、Mechanize::Cookie.new でも良い
    • どこかから持ってきた Set-cookie ヘッダの内容を使うなら parse が楽だと思う
    • 自分で組み立てるなら、日付フォーマットを考えなくて良い分 new が楽な気がする
      • パラメータの指定を忘れると Cookie を送れなくなるという落とし穴がある
        • domain, expires, path を指定しておけば大丈夫なはず

Phusion Passenger Enterprise

とても気になる。英語読めない。

Rolling Restarts

Unicorn とかは、既存プロセスと新規プロセスで2倍のメモリが必要な実装になっている。
一方、Phusion Passenger Enterprise では、一つずつ置き換えていく方法らしく、全体の2倍のリソースを消費するという問題は避けられるとのこと。たぶん。

Deployment error resistance

アプリケーションの問題で起動に失敗した場合、オープンソース版だとエラー画面が表示されてサービスがダウンしてしまう。
一方、Phusion Passenger Enterprise では、先述の Rolling Restarts で新しいアプリケーションを起動させるが、アプリケーションの起動に失敗した場合はプロセスの置き換えを行わず、エラーをログに出力して起動処理を中断するとのこと。たぶん。

Mass deployment

バーチャルホストのドメインと同名のディレクトリにアプリケーションを配備した場合、バーチャルホスト設定無しにウェブアクセス可能にする、ぽい。
実運用上はともかく、開発環境としてはpow要らずということだろうか。

Live IRB console

稼働中のアプリケーションプロセスに irb でアタッチできるとのこと。たぶん。これは興奮する。

Resource control and limiting

リソース消費をコントロール出来るとのこと。

  • request time
  • memory usage
  • per-application maximum process count

Ruby debugger support

稼働中のアプリケーションプロセスに ruby-debug でアタッチできるとのこと。たぶん。これは興奮する。

でも、お高いんでしょう?

いいえ! 今なら驚きの $199/year ですよ!しかも、1ライセンスあたり2 OS インスタンスでご利用いただけます。

OSX 用のドキュメントブラウザ Dash

App Store を眺めていたら、Dash というドキュメントブラウザがある事を知りました。

マシンのメモリに大分余裕が出来たこともあり、ダウンロードして使ってみることにしました。

なんとなく良さそうな印象ですが、用意されているドキュメントが最新版のみだという点が困りもの。というわけで、マニュアルを参考に自作してみるわけですが、作り方がいまいちよく分かりません。

作りたいドキュメントは Ruby 1.8.7 の docset。標準で用意されている Ruby 1.9.3 の docset を参考に、適当に作業を進めてみます。

ドキュメントを RDoc で作っている様なので、使っているっぽいテンプレートをインストールしておきます(出来上がりが異なったので、以下はちょっと違うかも)。

$ git clone https://github.com/Neurogami/rdoc-rubydoc.git
$ cd rdoc-rubydoc
$ gem install hoe --no-rdoc --nori
$ rake install_gem

次に、Ruby のソースが必要なのでダウンロードして、ソースから RDoc でドキュメントを作り docset にします。インデックスデータベースのデータをどうやって作ったものかよく分からず、1.9.3 のデータベースをそのまま使ってます。

$ wget ftp://core.ring.gr.jp/pub/lang/ruby/1.8/ruby-1.8.7-p174.tar.gz
$ tar xvzf ruby-1.8.7-p174.tar.gz
$ cd ruby-1.8.7-p174
$ mkdir -p Ruby-1.8.7.docset/Contents/Resources
$ rdoc -f rubydoc -a -t "Ruby 1.8.7" -o Ruby-1.8.7.docset/Contents/Resources/Documents *.c
$ curl -s http://kapeli.com/Dash/Info.plist | sed "s/jQuery/ruby-1.8.7/g" > Ruby-1.8.7.docset/Contents/Info.plist
$ cp ~/Library/Application\ Support/Dash/DocSets/Ruby/Ruby.docset/Contents/Resources/docSet.dsidx Ruby-1.8.7.docset/Contents/Resources/

こうして作成した docset を Dash の環境設定から追加して、表示と検索が出来る所まで確認出来ました。

んー、CHMがいいか、ウェブブラウザで見たら良いか、どうだろうか。

rubyのYAMLによるシリアライズについて

全く中身を把握していなかった事に気づかされたので、少しだけ調べてみました。

以下は Object#to_yaml のソース。

# yaml/rubytypes.rb
class Object
  yaml_as "tag:ruby.yaml.org,2002:object"
  def to_yaml_style; end
  def to_yaml_properties; instance_variables.sort; end
  def to_yaml( opts = {} )
    YAML::quick_emit( self, opts ) do |out|
      out.map( taguri, to_yaml_style ) do |map|
        to_yaml_properties.each do |m|
          map.add( m[1..-1], instance_variable_get( m ) )
        end
      end
    end
  end
end

クラス名とインスタンス変数を記録している様です。

(001): >> require 'yaml'
=> true
(002): >> class Hoge
  def initialize(a, b, c)
    @a, @b, @c = a, b, c
  end
end
=> nil
(007): >> puts Hoge.new(1, 2, 3).to_yaml
--- !ruby/object:Hoge
a: 1
b: 2
c: 3
=> nil
(008): >> YAML.load(Hoge.new(1, 2, 3).to_yaml).instance_eval{ p @a, @b, @c }
1
2
3
=> nil

確かに、インスタンス変数が記録され、復元するとその値がインスタンス変数にセットされています。

肝心の復元方法ですが、ぱっとソースを眺めた感じよく分かりませんでした。挙動から想像する限り、Class.allocate して instance_variable_set とかしてるんだろうと思います。
以下は allocate で grep した結果見つかったソースです。

    def YAML.object_maker( obj_class, val )
        if Hash === val
            o = obj_class.allocate
            val.each_pair { |k,v|
                o.instance_variable_set("@#{k}", v)
            }
            o
        else
            raise YAML::Error, "Invalid object explicitly tagged !ruby/Object: " + val.inspect
        end
    end

ユーザ定義のクラスをYAMLシリアライズする場合、allocateによるインスタンス化とインスタンス変数の代入によってオブジェクトが復元出来る様にしておく必要があるという事でしょうか。

そうだとすれば、initialize による初期化処理が必要な場合に困る事があるのでしょうかね?

『Mobageを支える技術』を読んだ

Mobageを支える技術 ~ソーシャルゲームの舞台裏~ (WEB+DB PRESS plus)

Mobageを支える技術 ~ソーシャルゲームの舞台裏~ (WEB+DB PRESS plus)

購入してから大分時間が経ちましたが、最近ようやく読み終えました。

貴重なケーススタディな感じで興味深い内容だと思います。特に、大規模サービスの運用に関わったことがない自分にとって、将来的に参考に出来る事がある様な気がします。

諸事情でほとんど斜め読みしてしまったので、細かい内容は余り頭に入ってませんが運用のヒントが欲しいときに手にとっても良いのかなと思います。

Mechanize(2.5.1) のエンコーディング周りに関するメモ

Mechanize#get などによって取得されたリソースは、Mechanize::Page のインスタンスとして返される。その際の Mechanize::Page の初期化プロセスの中で、取得したリソースの文字エンコーディングを推定している。

エンコーディング候補

以下で得られるエンコーディングを、エンコーディング候補として @encodings に格納する。

ボディのデータから推定する
  • Mechanize::Util.detect_charset(body)

NKF.guess を利用して文字エンコーディングを推定する。
推定できた場合は ISO-2022-JP, EUC-JP, SHIFT_JIS, UTF-8, UTF-16, UTF-32 の様なエンコーディング文字列が得られる。
推定できなかった場合はデフォルト値として ISO-8859-1 を得る。

レスポンスヘッダから推定する
  • Mechanize::Page.response_header_charset(response)

Content-Type ヘッダの値に charset という文字列が含まれている場合に、その値部分を取り出す。取り出した値が none という文字列であった場合は、取り出せなかったものとして扱う。

force_encoding

String#force_encoding が使える場合、ボディに対して ASCII-8BIT で force_encoding しておく。

body.force_encoding 'ASCII-8BIT' if body.respond_to? :force_encoding
meta 要素(1)
  • Mechanize::Page.meta_charset body

charset 属性を持つ meta 要素があれば、その属性値をエンコーディングとして取り出す。
charset 属性を持つ meta 要素がなければ、http-equiv 属性値が content-type である meta 要素を探す。見つかればその content 属性値を取り出し、その中から charset= に続く部分をエンコーディングとして取り出す。

  class << self
    def charset content_type
      charset = content_type[/;(?:\s*,)?\s*charset\s*=\s*([^()<>@,;:\\\"\/\[\]?={}\s]+)/i, 1]
      return nil if charset == 'none'
      charset
    end

    alias charset_from_content_type charset
  end

  def self.meta_charset body
    # HACK use .map
    body.scan(/<meta .*?>/i).map do |meta|
      if meta =~ /charset\s*=\s*(["'])?\s*(.+)\s*\1/i then
        $2
      elsif meta =~ /http-equiv\s*=\s*(["'])?content-type\1/i then
        meta =~ /content\s*=\s*(["'])?(.*?)\1/i

        m_charset = charset $2 if $2

        m_charset if m_charset
      end
    end.compact
  end

default_encoding

Mechanize#default_encoding がセットされていれば、それもエンコーディング候補としておく。

エンコード(パース)

以下は Mechanize::Page#parser のソース。

  def parser
    return @parser if @parser
    return nil unless @body

    if @encoding then
      @parser = @mech.html_parser.parse html_body, nil, @encoding
    elsif mech.force_default_encoding then
      @parser = @mech.html_parser.parse html_body, nil, @mech.default_encoding
    else
      @encodings.reverse_each do |encoding|
        @parser = @mech.html_parser.parse html_body, nil, encoding

        break unless encoding_error? @parser
      end
    end

    @parser
  end

Mechanize::Page#encoding は内部で Mechanize::Page#parser.encoding を実行している。つまり、エンコーディングは最終的にHTMLパーサによって決められる。

ケース(1)

Mechanize::Page の @encoding は、Mechanize::Page#encoding= で指定する事が出来る(標準ではnil)。これで指定していると、そのエンコーディングを使用する事になる。

@parser = @mech.html_parser.parse html_body, nil, @encoding
ケース(2)

Mechanize#force_default_encoding を真にしている場合、Mechanize#default_encoding をエンコーディングとして強制する。

@parser = @mech.html_parser.parse html_body, nil, @mech.default_encoding
ケース(3)

ケース1,2に該当しない場合、先に推定しておいたエンコーディング候補で順にパースを試し、エンコーディングによるエラーが発生しなければそれが正しいエンコーディングであるとする。

      @encodings.reverse_each do |encoding|
        @parser = @mech.html_parser.parse html_body, nil, encoding

        break unless encoding_error? @parser
      end

エンコーディングによるエラーが発生しているかは以下で判断する。

  def encoding_error?(parser=nil)
    parser = self.parser unless parser
    return false if parser.errors.empty?
    parser.errors.any? do |error|
      error.message =~ /(indicate\ encoding)|
                        (Invalid\ char)|
                        (input\ conversion\ failed)/x
    end
  end

まとめ

Mechanize のエンコーディング周りについてざっくりと見てみました。特にまとめられる事もありませんが、あえてあげるとすれば「Nokogiriによるパースが文字コード関係で失敗すると、別のエンコーディングとして処理される事がある」という事でしょうか。

UTF-8のHTMLがあるとして、その中でUTF-8ではない文字が埋め込まれている場合、そのHTMLをMechanizeに処理させると ISO-8859-1 として扱われたりします。

他にも、内部的に iconv に依存しているため、iconv で扱えないエンコーディングは扱えない等といった問題もあった気がしますが、そこは整理できていません。
(OSX 10.6 の iconv が Windows-31J に対応していなくて例外終了するなど)