きたももんががきたん。

Ruby の Mechanize の使い始めさん向き解説あります

2008-04-10

修正済】RubyのMechanizeではパーセント記述されたURL文字列を処理できない++++(8/21)

8/21更新
7/25更新
6/8 更新
4/18 更新

(8/21) パーセントエンコードの不具合は Mechanize 0.7.7 では直っています。アップグレードをおすすめします。

(7/25) to_absolute_uri には「<a href="?param=hoge">というリンクを踏めない」という不具合も以下省略

(6/8) Mechanize 0.7.6 用の上書きスクリプトを作れたような気がします

(4/18)コメントを受けて absolute_uri を上書きするスクリプトの「てきとうなバグ回避っぷり」が改善されました

以下更新済みの本文

問題バージョン:Mechanize 0.7.5、Mechanize 0.7.6 (2008-05-11)

ええと、ご存知の方もけっこういるかと思われますが、例の to_absolute_uri の話です。
結論から言うと 0.7.6 までの不具合です。意図的に注意深く避けてスクリプトを書くか、自前で問題のメソッドを再定義する必要があります。

agent.get などで外部から URL を指定する場合、URI オブジェクトと URL な文字列の2通りあるわけですが、2バイト文字などの%を含む URL(URLエンコードされたURL)の場合は引数を URI オブジェクトで指定しないとうまく動作しません。

# 文字列で指定するとうまく動作しない例
require "rubygems"
require "mechanize"
require 'kconv'
require 'logger'
logger = Logger.new($stderr)
logger.level = Logger::INFO
url = 'http://d.hatena.ne.jp/keyword/%C6%FC%CB%DC%B8%EC'
agent = WWW::Mechanize.new{|a|
  a.log = logger
  a.user_agent_alias='Windows Mozilla'}
agent.get(URI.parse(url))
puts agent.page.title.toeuc

agent.get(url)
puts agent.page.title.toeuc

結果

I, [2008-04-10T16:31:36.390456 #15028]  INFO -- : Net::HTTP::Get: /keyword/%C6%FC%CB%DC%B8%EC
I, [2008-04-10T16:31:36.769150 #15028]  INFO -- : status: 200
日本語とは - はてなダイアリー
I, [2008-04-10T16:31:37.139741 #15028]  INFO -- : Net::HTTP::Get: /keyword/%C6     ←間違えてる
I, [2008-04-10T16:31:37.203359 #15028]  INFO -- : status: 302
I, [2008-04-10T16:31:37.204632 #15028]  INFO -- : follow redirect to: http://d.hatena.ne.jp/
I, [2008-04-10T16:31:37.206728 #15028]  INFO -- : Net::HTTP::Get: /
I, [2008-04-10T16:31:37.404399 #15028]  INFO -- : status: 200
はてなダイアリー - 快適、安心、シンプルなはてなのブログ

301 のリダイレクト先にパーセントエンコードされた URL が返ってくることのある Wikipedia などではこれはかなり深刻で、

[2008-04-10T16:37:16.258073]  INFO -- : status: 301
[2008-04-10T16:37:16.258641]  INFO -- : follow redirect to: http://ja.wikipedia.org/wiki/%C3%83
[2008-04-10T16:37:16.259963]  INFO -- : Net::HTTP::Get: /wiki/%C3     ←間違えてる
[2008-04-10T16:37:16.392255]  INFO -- : status: 301
[2008-04-10T16:37:16.392824]  INFO -- : follow redirect to: http://ja.wikipedia.org/wiki/%C3%83
[2008-04-10T16:37:16.394154]  INFO -- : Net::HTTP::Get: /wiki/%C3     ←間違えてる
[2008-04-10T16:37:16.527068]  INFO -- : status: 301
[2008-04-10T16:37:16.527640]  INFO -- : follow redirect to: http://ja.wikipedia.org/wiki/%C3%83
[2008-04-10T16:37:16.529057]  INFO -- : Net::HTTP::Get: /wiki/%C3     ←間違えてるってば
...

と、GET すべき URL を間違え続けるため、無限にサーバへのアクセスを繰り返すことになってしまいます。

内部で使用されている to_absolute_uri というメソッドの処理に誤りがあるため、0.7.5ではこうなります。

Link オブジェクトを click するときは to_absolute_uri 使ってたんでしたっけ?

require "rubygems"; require "mechanize"; require 'kconv'
require 'logger'
logger = Logger.new($stderr); logger.level = Logger::INFO
agent = WWW::Mechanize.new
agent.log = logger
agent.user_agent_alias='Windows Mozilla'
agent.get(URI.parse("http://d.hatena.ne.jp/keyword/%C6%FC%CB%DC%B8%EC"))
link = agent.page.links.text(/#{'はてな検索で検索'.toeuc}/e)
p link.uri.to_s
link.click

結果

I, [2008-04-10T16:42:39.307822 #15031]  INFO -- : Net::HTTP::Get: /keyword/%C6%FC%CB%DC%B8%EC
I, [2008-04-10T16:42:39.723080 #15031]  INFO -- : status: 200
"http://search.hatena.ne.jp/search?word=%c6%fc%cb%dc%b8%ec"      ← 抽出したURL
I, [2008-04-10T16:42:40.282121 #15031]  INFO -- : Net::HTTP::Get: /search?word=%c6     ←間違えてる
I, [2008-04-10T16:42:40.906072 #15031]  INFO -- : status: 200

HTML に書かれた URL へのアクセスでも間違えるんじゃそもそも駄目じゃん…

で、問題の個所の mechanize.rb のソースが、たとえば 0.7.5 だと

# at mechanize.rb
def to_absolute_uri(url, cur_page=current_page())
  unless url.is_a? URI
    url = url.to_s.strip.gsub(/[^#{0.chr}-#{125.chr}]/) { |match|
      sprintf('%%%X', match.unpack($KCODE == 'UTF8' ? 'U' : 'c')[0])
    }

    url = URI.parse(
            Mechanize.html_unescape(
              url.split(/%[0-9A-Fa-f]{2}|#/).zip(
                url.scan(/%[0-9A-Fa-f]{2}|#/)
              ).map { |x,y|
                "#{URI.escape(x)}#{y}"
              }.join('')
            )
          )
  end

  url.path = '/' if url.path.length == 0

  # construct an absolute uri
  if url.relative?
    raise 'no history. please specify an absolute URL' unless cur_page.uri
    base = cur_page.respond_to?(:bases) ? cur_page.bases.last : nil
    url = ((base && base.uri && base.uri.absolute?) ?
            base.uri :
            cur_page.uri) + url
    url = cur_page.uri + url
    # Strip initial "/.." bits from the path
    url.path.sub!(/^(\/\.\.)+(?=\/)/, '')
  end

  return url
end

def html_unescape(s)
  return s unless s
  s.gsub(/&(\w+|#[0-9]+);/) { |match|
    number = case match
    when /&(\w+);/
      Hpricot::NamedCharacters[$1]
    when /&#([0-9]+);/
      $1.to_i
    end

    number ? ([number].pack('U') rescue match) : match
  }
end

うおー何やってるかわからん。
Ruby 付属の URI モジュールの挙動の差異を吸収する意味でもあるんですかね。

仕方がないので、to_absolute_uri 自体をスクリプトの最初で上書きすることにします。

コメント欄で指摘受けましたので、その部分だけピンポイントで書き換えてみます。

require "rubygems"
require "mechanize"
# WWW::Mechanize#to_absolute_uri を上書き
# split と scan の正規表現を /%[0-9A-Fa-f]{2}|#/ から変更
module WWW
  class Mechanize
    def to_absolute_uri(url, cur_page=current_page())
      unless url.is_a? URI
        url = url.to_s.strip.gsub(/[^#{0.chr}-#{125.chr}]/) { |match|
          sprintf('%%%X', match.unpack($KCODE == 'UTF8' ? 'U' : 'c')[0])
        }

        url = URI.parse(
          Mechanize.html_unescape(
            url.split(/(?:%[0-9A-Fa-f]{2})+|#/).zip(
              url.scan(/(?:%[0-9A-Fa-f]{2})+|#/)
            ).map { |x,y|
              "#{URI.escape(x)}#{y}"
            }.join('')
          )
        )
      end
      url.path = '/' if url.path.length == 0

      # construct an absolute uri
      if url.relative?
        raise 'no history. please specify an absolute URL' unless cur_page.uri
        base = cur_page.respond_to?(:bases) ? cur_page.bases.last : nil
        url = ((base && base.uri && base.uri.absolute?) ?
               base.uri :
                 cur_page.uri) + url
        url = cur_page.uri + url
        # Strip initial "/.." bits from the path
        url.path.sub!(/^(\/\.\.)+(?=\/)/, '')
      end

      return url
    end
  end
end

# ここから普通
require 'kconv'
require 'logger'
logger = Logger.new($stderr); logger.level = Logger::INFO
agent = WWW::Mechanize.new
agent.log = logger
agent.user_agent_alias='Windows Mozilla'
agent.get(URI.parse("http://d.hatena.ne.jp/keyword/%C6%FC%CB%DC%B8%EC"))
link = agent.page.links.text(/#{'はてな検索で検索'.toeuc}/e)
p link.uri.to_s
link.click

結果

I, [2008-04-18T18:01:00.707866 #913]  INFO -- : Net::HTTP::Get: /keyword/%C6%FC%CB%DC%B8%EC
I, [2008-04-18T18:01:01.101886 #913]  INFO -- : status: 200
"http://search.hatena.ne.jp/search?word=%c6%fc%cb%dc%b8%ec"
I, [2008-04-18T18:01:01.619172 #913]  INFO -- : Net::HTTP::Get: /search?word=%c6%fc%cb%dc%b8%ec
I, [2008-04-18T18:01:02.393165 #913]  INFO -- : status: 200

わー動いた。

コメント寄せて頂いた tance_tk さんありがとうございます。

Mechanize 0.7.6

問題の部分は0.7.5と同じなのでたぶん動く、たぶん…

で、GmailでMechanize使えなかったよ記録 にもあるんですが、実はもうひとつ困った動作があって

uri = URI.parse('?param=foo')
agent.page.uri = URI.parse('http://www.example.com/dir/')
puts to_absolute_uri(uri).request_uri 

という環境だと

http://www.example.com/?param=foo

になってしまうんです。パスのない相対 URI オブジェクトを渡すと、現在のページの URI の path 部分の dir/ が消えてしまうんですね。

という話なんですが、検索しても困ってる人がいない感じで、ちょっと不安です。

raise 'Mechanize 0.7.6 only' unless WWW::Mechanize::VERSION == '0.7.6'
module WWW
  class Mechanize
    def to_absolute_uri(url, cur_page=current_page())
      ## 1) original: to_absolute_uri('http://ex/%xx%yy%zz') returns URI(http://ex/%xx)
      ## 1) fixed:    to_absolute_uri('http://ex/%xx%yy%zz') returns URI(http://ex/%xx%yy%zz)
      ## 2) original: to_absolute_uri('?param=foo',Page(http://ex/a/)) returns URI(http://ex/?param=foo)
      ## 2) fixed:    to_absolute_uri('?param=foo',Page(http://ex/a/)) returns URI(http://ex/a/?param=foo)

      unless url.is_a? URI
        url = url.to_s.strip.gsub(/[^#{0.chr}-#{126.chr}]/) { |match|
          sprintf('%%%X', match.unpack($KCODE == 'UTF8' ? 'U' : 'c')[0])
        }

        url = URI.parse(
            Mechanize.html_unescape(
#               url.split(/%[0-9A-Fa-f]{2}|#/).zip( # (1)
                url.split(/(?:%[0-9A-Fa-f]{2})+|#/).zip(
#                   url.scan(/%[0-9A-Fa-f]{2}|#/) # (1)
                    url.scan(/(?:%[0-9A-Fa-f]{2})+|#/)
                        ).map { |x,y|
                          "#{URI.escape(x)}#{y}"
                        }.join('')
                )
            )
      end

      url = @scheme_handlers[url.relative? ? 'relative' : url.scheme.downcase].call(url, cur_page)
#     url.path = '/' if url.path.length == 0 # (2)

      # construct an absolute uri
      if url.relative?
        raise 'no history. please specify an absolute URL' unless cur_page.uri
        base = cur_page.respond_to?(:bases) ? cur_page.bases.last : nil
        url = ((base && base.uri && base.uri.absolute?) ?
               base.uri :
                 cur_page.uri) + url
        url = cur_page.uri + url
        # Strip initial "/.." bits from the path
        url.path.sub!(/^(\/\.\.)+(?=\/)/, '')
      end

      url.normalize! # (2)
      return url
    end
  end
end

tance_tktance_tk 2008/04/18 12:02 一応解決されているようですが,件のメソッドはバグっているので修正して使うほうがよろしいかと.
to_absolute_uri の /%[0-9A-Fa-f]{2}|#/ という正規表現二つを /(?:%[0-9A-Fa-f]{2})+|#/ に置き換えれば良いと思われます.

kitamomongakitamomonga 2008/04/18 18:17 おー、ありがとうございます。

はい、URI.parse への単純書き換え版で「切り捨ててしまったこと」は多いはずなんですよね。

def to_absolute_uri(url, cur_page=current_page())
unless url.is_a?(URI) then
url = URI.parse(Mechanize.html_unescape(url.to_s.strip))
end
url.path = ’/’ if url.path.length == 0

「自分が使わないシチュエーション」での動作にまでは気が回らないのでした。ごめんなさいです。

で、%xx にひとつだけマッチしてるとこだけがよろしくないので、
全部マッチさせるようにすれば元のメソッドの意図通り動作するだろうってことですよね。
書き換えてみましたが、どんなもんでしょう?

rubikitchrubikitch 2008/06/09 01:26 バグレポ送りました?
開発者にパッチを送るのが筋だと思います。
# たとえ英語が苦手であってもコードで示せばよい。

kitamomongakitamomonga 2008/06/10 21:49 真っ先にそれは考えたんですが、何ヶ月もだーれも「気づいてない」または「気にしてない」ことのほうが気になりました。
実は誰も使ってない機能なのか、あるいは自分の環境依存の現象かなにかというアリガチなことなんだろうと思って放置中です。

rubikitchrubikitch 2008/06/11 13:29 こっちでも再現できたのでパッチ送るべきでしょう。改善されればみんなが幸せになる。

kitamomongakitamomonga 2008/06/11 23:04 わお、では、…今のところはどこのページに何を書けばどこにどうなってどう適切に連絡になるのかさっぱりわかんないので、
前向きに気が向いたらそのうち調べて何かしようと思います。
書き換えのやつは私が書いたコードではないのでパッチの形では送れませんが…

littlestarlinglittlestarling 2008/07/23 14:52 0.7.7で修正されたようです。

= Mechanize CHANGELOG

=== 0.7.7

* New Features:
* Page#form_with takes a +criteria+ hash.
* Page#form is changed to Page#form_with
* Mechanize#get takes custom http headers. Thanks Mike Dalessio!
* Form#click_button submits a form defaulting to the current button.
* Form#set_fields now takes a hash. Thanks Tobi!
* Mechanize#redirection_limit= for setting the max number of redirects.

* Bug Fixes:
* Added more examples. Thanks Robert Jackson.
* #20480 Making sure the Host header is set.
* #20672 Making sure cookies with weird semicolons work.
* Fixed bug with percent signs in urls.
http://d.hatena.ne.jp/kitamomonga/20080410/ruby_mechanize_percent_url_bug
* #21132 Not checking for EOF errors on redirect
* Fixed a weird gzipping error.

kitamomongakitamomonga 2008/07/23 15:24 > * Fixed bug with percent signs in urls.
> http://d.hatena.ne.jp/kitamomonga/20080410/ruby_mechanize_percent_url_bug
Σ(゜Д゜)

…。

イエーイ見てる?(AA略

…直ってよかったです。はい。
関わっていただいた人たちに感謝。

情報ありがとうございました。
(どこに書いてあるんだろう、と思い、新しいのってCVSかなあ、と思ってやたらクリックしてみると7月6日に修正しましたって書かれてた。がーん)

投稿したコメントは管理者が承認するまで公開されません。

スパム対策のためのダミーです。もし見えても何も入力しないでください
ゲスト


画像認証