# # Copyright (c) 2005 by Michael Neumann (mneumann@ntecs.de) # # This is a quick hack, to get something like Perl's WWW::Mechanize. Sure, we # have Web::Unit, but, that does not work for me as expected, as it does not # set cookies (I might be wrong), does not automatically redirect and has # problems with some html documents. Version = "0.3.1" # required due to the missing get_fields method in Ruby 1.8.2 $LOAD_PATH.unshift File.join(File.dirname(__FILE__), "mechanize", "net-overrides") require 'net/http' require 'net/https' require 'web/htmltools/xmltree' # narf require 'mechanize/parsing' require 'uri' require 'logger' require 'webrick' module WWW class Field attr_accessor :name, :value def initialize(name, value) @name, @value = name, value end # Returns an array of Field objects # TODO: is this correct? def self.extract_all_from(root_node) fields = [] root_node.each_recursive {|node| if (node.name.downcase == 'input' and %w(text password hidden checkbox radio int).include?(node.attributes['type'].downcase)) or %w(textarea option).include?(node.name.downcase) fields << Field.new(node.attributes['name'], node.attributes['value']) end } return fields end end class FileUpload # value is the file-name, not the file-content attr_accessor :name attr_accessor :file_name, :file_data def initialize(name, file_name) @name, @file_name = name, file_name @file_data = nil end end class Button attr_accessor :name, :value def initialize(name, value) @name, @value = name, value end def add_to_query(query) query[@name] = @value || "" if @name end # Returns an array of Button objects def self.extract_all_from(root_node) buttons = [] root_node.each_recursive {|node| if node.name.downcase == 'input' and ['submit'].include?(node.attributes['type'].downcase) buttons << Button.new(node.attributes['name'], node.attributes['value']) end } return buttons end end class ImageButton < Button attr_accessor :x, :y def add_to_query(query) if @name query[@name] = @value || "" query[@name+".x"] = (@x || "0").to_s query[@name+".y"] = (@y || "0").to_s end end end class RadioButton attr_accessor :name, :value, :checked def initialize(name, value, checked) @name, @value, @checked = name, value, checked end end class CheckBox attr_accessor :name, :value, :checked def initialize(name, value, checked) @name, @value, @checked = name, value, checked end end class SelectList attr_accessor :name, :value, :options def initialize(name, node) @name = name @options = [] # parse node.each_recursive {|n| if n.name.downcase == 'option' value = n.attributes['value'] @options << value @value = value if n.attributes['selected'] end } end end # Class Form does not work in the case there is some invalid (unbalanced) html # involved, such as: # # #
# # # #
# # # GlobalForm takes two nodes, the node where the form tag is located # (form_node), and another node, from which to start looking for form elements # (elements_node) like buttons and the like. For class Form both fall together # into one and the same node. class GlobalForm attr_reader :form_node, :elements_node attr_accessor :method, :action, :name attr_reader :fields, :buttons, :file_uploads, :radiobuttons, :checkboxes def initialize(form_node, elements_node) @form_node, @elements_node = form_node, elements_node @method = (@form_node.attributes['method'] || 'POST').upcase @action = @form_node.attributes['action'] @name = @form_node.attributes['name'] parse end # In the case of malformed HTML, fields of multiple forms might occure in this forms' # field array. If the fields have the same name, posterior fields overwrite former fields. # To avoid this, this method rejects all posterior duplicate fields. def uniq_fields! names_in = {} fields.reject! {|f| if names_in.include?(f.name) true else names_in[f.name] = true false end } end def build_query query = {} fields().each do |f| query[f.name] = f.value || "" end checkboxes().each do |f| query[f.name] = f.value || "on" if f.checked end radio_groups = {} radiobuttons().each do |f| radio_groups[f.name] ||= [] radio_groups[f.name] << f end # take one radio button from each group radio_groups.each_value do |g| checked = g.select {|f| f.checked} if checked.size == 1 f = checked.first query[f.name] = f.value || "" elsif checked.size > 1 raise "multiple radiobuttons are checked in the same group!" end end query end def parse @fields = [] @buttons = [] @file_uploads = [] @radiobuttons = [] @checkboxes = [] @elements_node.each_recursive {|node| case node.name.downcase when 'input' case (node.attributes['type'] || '').downcase when 'text', 'password', 'hidden', 'int' @fields << Field.new(node.attributes['name'], node.attributes['value']) when 'radio' @radiobuttons << RadioButton.new(node.attributes['name'], node.attributes['value'], node.attributes.has_key?('checked')) when 'checkbox' @checkboxes << CheckBox.new(node.attributes['name'], node.attributes['value'], node.attributes.has_key?('checked')) when 'file' @file_uploads << FileUpload.new(node.attributes['name'], node.attributes['value']) when 'submit' @buttons << Button.new(node.attributes['name'], node.attributes['value']) when 'image' @buttons << ImageButton.new(node.attributes['name'], node.attributes['value']) end when 'textarea' @fields << Field.new(node.attributes['name'], node.all_text) when 'select' @fields << SelectList.new(node.attributes['name'], node) end } end end class Form < GlobalForm attr_reader :node def initialize(node) @node = node super(@node, @node) end end class Link attr_reader :node attr_reader :href def initialize(node) @node = node @href = node.attributes['href'] end end class Page attr_accessor :uri, :cookies, :response, :body, :code, :watch_for_set def initialize(uri=nil, cookies=[], response=nil, body=nil, code=nil) @uri, @cookies, @response, @body, @code = uri, cookies, response, body, code end def header @response.header end def content_type header['Content-Type'] end def forms parse_html() unless @forms @forms end def links parse_html() unless @links @links end def root parse_html() unless @root @root end def watches parse_html() unless @watches @watches end private def parse_html raise "no html" unless content_type() =~ /^text\/html/ # construct parser and feed with HTML parser = HTMLTree::XMLParser.new begin parser.feed(@body) rescue => ex if ex.message =~ /attempted adding second root element to document/ and # Put the whole document inside a single root element, which I simply name # , just to make the parser happy. It's no longer valid HTML, but # without a single root element, it's not valid HTML as well. # TODO: leave a possible doctype definition outside this element. parser = HTMLTree::XMLParser.new parser.feed("" + @body + "") else raise end end @root = parser.document @forms = [] @links = [] @watches = {} @root.each_recursive {|node| name = node.name.downcase case name when 'form' @forms << Form.new(node) when 'a' @links << Link.new(node) else if @watch_for_set and @watch_for_set.keys.include?( name ) @watches[name] = [] unless @watches[name] klass = @watch_for_set[name] @watches[name] << (klass ? klass.new(node) : node) end end } end end class Mechanize AGENT_ALIASES = { 'Windows IE 6' => 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)', 'Windows Mozilla' => 'Mozilla/5.0 (Windows; U; Windows NT 5.0; en-US; rv:1.4b) Gecko/20030516 Mozilla Firebird/0.6', 'Mac Safari' => 'Mozilla/5.0 (Macintosh; U; PPC Mac OS X; en-us) AppleWebKit/85 (KHTML, like Gecko) Safari/85', 'Mac Mozilla' => 'Mozilla/5.0 (Macintosh; U; PPC Mac OS X Mach-O; en-US; rv:1.4a) Gecko/20030401', 'Linux Mozilla' => 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.4) Gecko/20030624', 'Linux Konqueror' => 'Mozilla/5.0 (compatible; Konqueror/3; Linux)', } attr_accessor :log attr_accessor :user_agent attr_accessor :cookies attr_accessor :open_timeout, :read_timeout attr_accessor :watch_for_set attr_accessor :max_history def initialize @history = [] @cookies = [] @log = Logger.new(nil) yield self if block_given? end def user_agent_alias=(al) self.user_agent = AGENT_ALIASES[al] || raise("unknown agent alias") end def basic_authetication(user, password) @user = user @password = password end def get(url) cur_page = current_page() || Page.new # fetch the page page = fetch_page(to_absolute_uri(url, cur_page), :get, cur_page) add_to_history(page) page end def post(url, query={}) cur_page = current_page() || Page.new request_data = [build_query_string(query)] # this is called before the request is sent pre_request_hook = proc {|request| log.debug("query: #{ query.inspect }") request.add_header('Content-Type', 'application/x-www-form-urlencoded') request.add_header('Content-Length', request_data[0].size.to_s) } # fetch the page page = fetch_page(to_absolute_uri(url, cur_page), :post, cur_page, pre_request_hook, request_data) add_to_history(page) page end def click(link) uri = to_absolute_uri(link.href) get(uri) end def submit(form, button=nil) query = form.build_query button.add_to_query(query) if button uri = to_absolute_uri(form.action) case form.method.upcase when 'POST' post(uri, query) when 'GET' get(uri + "?" + build_query_string(query)) else raise 'unsupported method' end end def current_page @history.last end alias page current_page private def to_absolute_uri(url, cur_page=current_page()) if url.is_a?(URI) uri = url else uri = URI.parse(url) end # construct an absolute uri if uri.relative? if cur_page uri = cur_page.uri + url else raise 'no history. please specify an absolute URL' end end return uri end # uri is an absolute URI def fetch_page(uri, method=:get, cur_page=current_page(), pre_request_hook=nil, request_data=[]) raise "unsupported scheme" unless ['http', 'https'].include?(uri.scheme) log.info("#{ method.to_s.upcase }: #{ uri.to_s }") page = Page.new(uri) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true if uri.scheme == "https" http.start { case method when :get request = Net::HTTP::Get.new(uri.request_uri) when :post request = Net::HTTP::Post.new(uri.request_uri) else raise ArgumentError end unless @cookies.empty? cookie = @cookies.uniq.join("; ") log.debug("use cookie: #{ cookie }") request.add_header('Cookie', cookie) end # Add Referer header to request unless cur_page.uri.nil? request.add_header('Referer', cur_page.uri.to_s) end # Add User-Agent header to request request.add_header('User-Agent', @user_agent) if @user_agent request.basic_auth(@user, @password) if @user # Invoke pre-request-hook (use it to add custom headers or content) pre_request_hook.call(request) if pre_request_hook # Log specified headers for the request request.each_header do |k, v| log.debug("request-header: #{ k } => #{ v }") end # Specify timeouts if given http.open_timeout = @open_timeout if @open_timeout http.read_timeout = @read_timeout if @read_timeout # Send the request http.request(request, *request_data) {|response| # TODO: expire/validate cookies (response.get_fields('Set-Cookie')||[]).each do |cookie| log.debug("cookie received: #{ cookie }") @cookies << cookie.split(";").first.strip end response.each_header {|k,v| log.debug("header: #{ k } : #{ v }") } page.response = response page.code = response.code response.read_body page.body = response.body log.info("status: #{ page.code }") page.watch_for_set = @watch_for_set case page.code when "200" return page when "302" log.info("follow redirect to: #{ response.header['Location'] }") return fetch_page(to_absolute_uri(response.header['Location'], page), :get, page) else raise end } } end def build_query_string(hash) vals = [] hash.each_pair {|k,v| vals << [WEBrick::HTTPUtils.escape_form(k), WEBrick::HTTPUtils.escape_form(v)].join("=") } vals.join("&") end def add_to_history(page) @history.push(page) if @max_history and @history.size < @max_history # keep only the last @max_history entries @history = @history[@history.size - @max_history, @max_history] end end end end # module WWW