ferrum 0.1.2 → 0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,84 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ferrum/browser"
4
+ require "ferrum/node"
5
+
3
6
  Thread.abort_on_exception = true
4
7
  Thread.report_on_exception = true if Thread.respond_to?(:report_on_exception=)
5
8
 
6
9
  module Ferrum
7
- require "ferrum/browser"
8
- require "ferrum/node"
9
- require "ferrum/errors"
10
- require "ferrum/cookie"
10
+ class Error < StandardError; end
11
+ class NoSuchWindowError < Error; end
12
+ class ModalNotFoundError < Error; end
13
+ class NotImplementedError < Error; end
14
+
15
+ class EmptyTargetsError < Error
16
+ def initialize
17
+ super("There aren't targets available")
18
+ end
19
+ end
20
+
21
+ class StatusError < Error
22
+ def initialize(url)
23
+ super("Request to #{url} failed to reach server, check DNS and/or server status")
24
+ end
25
+ end
26
+
27
+ class TimeoutError < Error
28
+ def message
29
+ "Timed out waiting for response. It's possible that this happened " \
30
+ "because something took a very long time (for example a page load " \
31
+ "was slow). If so, setting the :timeout option to a higher value might " \
32
+ "help."
33
+ end
34
+ end
35
+
36
+ class ScriptTimeoutError < Error
37
+ def message
38
+ "Timed out waiting for evaluated script to return a value"
39
+ end
40
+ end
41
+
42
+ class DeadBrowserError < Error
43
+ def initialize(message = "Browser is dead")
44
+ super
45
+ end
46
+ end
47
+
48
+ class BrowserError < Error
49
+ attr_reader :response
50
+
51
+ def initialize(response)
52
+ @response = response
53
+ super(response["message"])
54
+ end
55
+
56
+ def code
57
+ response["code"]
58
+ end
59
+
60
+ def data
61
+ response["data"]
62
+ end
63
+ end
64
+
65
+ class NodeNotFoundError < BrowserError; end
66
+
67
+ class NoExecutionContextError < BrowserError
68
+ def initialize(response = nil)
69
+ response ||= { "message" => "There's no context available" }
70
+ super(response)
71
+ end
72
+ end
73
+
74
+ class JavaScriptError < BrowserError
75
+ attr_reader :class_name, :message
76
+
77
+ def initialize(response)
78
+ super
79
+ @class_name, @message = response.values_at("className", "description")
80
+ end
81
+ end
11
82
 
12
83
  class << self
13
84
  def windows?
@@ -21,5 +92,15 @@ module Ferrum
21
92
  def mri?
22
93
  defined?(RUBY_ENGINE) && RUBY_ENGINE == "ruby"
23
94
  end
95
+
96
+ def with_attempts(errors:, max:, wait:)
97
+ attempts ||= 1
98
+ yield
99
+ rescue *Array(errors)
100
+ raise if attempts >= max
101
+ attempts += 1
102
+ sleep(wait)
103
+ retry
104
+ end
24
105
  end
25
106
  end
@@ -4,7 +4,6 @@ require "base64"
4
4
  require "forwardable"
5
5
  require "ferrum/page"
6
6
  require "ferrum/targets"
7
- require "ferrum/browser/api"
8
7
  require "ferrum/browser/process"
9
8
  require "ferrum/browser/client"
10
9
 
@@ -14,26 +13,25 @@ module Ferrum
14
13
  WINDOW_SIZE = [1024, 768].freeze
15
14
  BASE_URL_SCHEMA = %w[http https].freeze
16
15
 
17
- include API
18
16
  extend Forwardable
19
17
 
20
- attr_reader :headers, :window_size
18
+ attr_reader :window_size
21
19
 
22
20
  delegate on: :@client
23
- delegate %i(window_handle window_handles switch_to_window open_new_window
24
- close_window within_window page) => :targets
25
- delegate %i(goto status body at_css at_xpath css xpath text property attributes attribute select_file
26
- value visible? disabled? network_traffic clear_network_traffic
27
- path response_headers refresh click right_click double_click
28
- hover set click_coordinates select trigger scroll_to send_keys
29
- evaluate evaluate_on evaluate_async execute frame_url
30
- frame_title switch_to_frame current_url title go_back
31
- go_forward find_modal accept_confirm dismiss_confirm
32
- accept_prompt dismiss_prompt reset_modals authorize
33
- proxy_authorize) => :page
34
-
35
- attr_reader :process, :logger, :js_errors, :slowmo, :base_url,
36
- :url_blacklist, :url_whitelist, :options
21
+ delegate %i[window_handle window_handles switch_to_window
22
+ open_new_window close_window within_window page] => :targets
23
+ delegate %i[goto back forward refresh status
24
+ at_css at_xpath css xpath current_url title body
25
+ headers cookies network_traffic clear_network_traffic response_headers
26
+ intercept_request on_request_intercepted continue_request abort_request
27
+ mouse keyboard scroll_to
28
+ screenshot pdf
29
+ evaluate evaluate_on evaluate_async execute
30
+ frame_url frame_title within_frame
31
+ find_modal accept_confirm dismiss_confirm accept_prompt dismiss_prompt reset_modals
32
+ authorize proxy_authorize] => :page
33
+
34
+ attr_reader :process, :logger, :js_errors, :slowmo, :base_url, :options
37
35
  attr_writer :timeout
38
36
 
39
37
  def initialize(options = nil)
@@ -52,9 +50,6 @@ module Ferrum
52
50
  self.base_url = @options[:base_url]
53
51
  end
54
52
 
55
- self.url_blacklist = @options[:url_blacklist]
56
- self.url_whitelist = @options[:url_whitelist]
57
-
58
53
  if ENV["FERRUM_DEBUG"] && !@logger
59
54
  STDOUT.sync = true
60
55
  @logger = STDOUT
@@ -85,27 +80,16 @@ module Ferrum
85
80
 
86
81
  def command(*args)
87
82
  @client.command(*args)
88
- rescue DeadBrowser
83
+ rescue DeadBrowserError
89
84
  restart
90
85
  raise
91
86
  end
92
87
 
93
- def set_overrides(user_agent: nil, accept_language: nil, platform: nil)
94
- options = Hash.new
95
- options[:userAgent] = user_agent if user_agent
96
- options[:acceptLanguage] = accept_language if accept_language
97
- options[:platform] if platform
98
-
99
- page.command("Network.setUserAgentOverride", **options) if !options.empty?
100
- end
101
-
102
88
  def clear_memory_cache
103
89
  page.command("Network.clearBrowserCache")
104
90
  end
105
91
 
106
92
  def reset
107
- @headers = {}
108
- @zoom_factor = nil
109
93
  @window_size = @original_window_size
110
94
  targets.reset
111
95
  end
@@ -137,7 +121,6 @@ module Ferrum
137
121
  private
138
122
 
139
123
  def start
140
- @headers = {}
141
124
  @process = Process.start(@options)
142
125
  @client = Client.new(self, @process.ws_url, 0, false)
143
126
  end
@@ -35,10 +35,10 @@ module Ferrum
35
35
  data = pending.value!(@browser.timeout)
36
36
  @pendings.delete(message[:id])
37
37
 
38
- raise DeadBrowser if data.nil? && @ws.messages.closed?
38
+ raise DeadBrowserError if data.nil? && @ws.messages.closed?
39
39
  raise TimeoutError unless data
40
40
  error, response = data.values_at("error", "result")
41
- raise BrowserError.new(error) if error
41
+ raise_browser_error(error) if error
42
42
  response
43
43
  end
44
44
 
@@ -64,6 +64,22 @@ module Ferrum
64
64
  def next_command_id
65
65
  @command_id += 1
66
66
  end
67
+
68
+ def raise_browser_error(error)
69
+ case error["message"]
70
+ # Node has disappeared while we were trying to get it
71
+ when "No node with given id found",
72
+ "Could not find node with given id"
73
+ raise NodeNotFoundError.new(error)
74
+ # Context is lost, page is reloading
75
+ when "Cannot find context with specified id"
76
+ raise NoExecutionContextError.new(error)
77
+ when "No target with given id found"
78
+ raise NoSuchWindowError
79
+ else
80
+ raise BrowserError.new(error)
81
+ end
82
+ end
67
83
  end
68
84
  end
69
85
  end
@@ -19,7 +19,11 @@ module Ferrum
19
19
 
20
20
  def call(message)
21
21
  method, params = message.values_at("method", "params")
22
- @on[method].each { |b| b.call(params) }
22
+ total = @on[method].size
23
+ @on[method].each_with_index do |block, index|
24
+ # If there are a few callback we provide current index and total
25
+ block.call(params, index, total)
26
+ end
23
27
  end
24
28
  end
25
29
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Cookies
5
+ class Cookie
6
+ def initialize(attributes)
7
+ @attributes = attributes
8
+ end
9
+
10
+ def name
11
+ @attributes["name"]
12
+ end
13
+
14
+ def value
15
+ @attributes["value"]
16
+ end
17
+
18
+ def domain
19
+ @attributes["domain"]
20
+ end
21
+
22
+ def path
23
+ @attributes["path"]
24
+ end
25
+
26
+ def size
27
+ @attributes["size"]
28
+ end
29
+
30
+ def secure?
31
+ @attributes["secure"]
32
+ end
33
+
34
+ def httponly?
35
+ @attributes["httpOnly"]
36
+ end
37
+
38
+ def session?
39
+ @attributes["session"]
40
+ end
41
+
42
+ def expires
43
+ if @attributes["expires"] > 0
44
+ Time.at(@attributes["expires"])
45
+ end
46
+ end
47
+ end
48
+
49
+ def initialize(page)
50
+ @page = page
51
+ end
52
+
53
+ def all
54
+ cookies = @page.command("Network.getAllCookies")["cookies"]
55
+ cookies.map { |c| [c["name"], Cookie.new(c)] }.to_h
56
+ end
57
+
58
+ def [](name)
59
+ all[name]
60
+ end
61
+
62
+ def set(name: nil, value: nil, **options)
63
+ cookie = options.dup
64
+ cookie[:name] ||= name
65
+ cookie[:value] ||= value
66
+ cookie[:domain] ||= default_domain
67
+
68
+ expires = cookie.delete(:expires).to_i
69
+ cookie[:expires] = expires if expires > 0
70
+
71
+ @page.command("Network.setCookie", **cookie)["success"]
72
+ end
73
+
74
+ # Supports :url, :domain and :path options
75
+ def remove(name:, **options)
76
+ raise "Specify :domain or :url option" if !options[:domain] && !options[:url] && !default_domain
77
+
78
+ options = options.merge(name: name)
79
+ options[:domain] ||= default_domain
80
+
81
+ @page.command("Network.deleteCookies", **options)
82
+
83
+ true
84
+ end
85
+
86
+ def clear
87
+ @page.command("Network.clearBrowserCookies")
88
+ true
89
+ end
90
+
91
+ private
92
+
93
+ def default_domain
94
+ URI.parse(@page.browser.base_url).host if @page.browser.base_url
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Headers
5
+ def initialize(page)
6
+ @page = page
7
+ @headers = {}
8
+ end
9
+
10
+ def get
11
+ @headers
12
+ end
13
+
14
+ def set(headers)
15
+ clear
16
+ add(headers)
17
+ end
18
+
19
+ def clear
20
+ @headers = {}
21
+ true
22
+ end
23
+
24
+ def add(headers, permanent: true)
25
+ if headers["Referer"]
26
+ @page.referrer = headers["Referer"]
27
+ headers.delete("Referer") unless permanent
28
+ end
29
+
30
+ @headers.merge!(headers)
31
+ user_agent = @headers["User-Agent"]
32
+ accept_language = @headers["Accept-Language"]
33
+
34
+ set_overrides(user_agent: user_agent, accept_language: accept_language)
35
+ @page.command("Network.setExtraHTTPHeaders", headers: @headers)
36
+ true
37
+ end
38
+
39
+ private
40
+
41
+ def set_overrides(user_agent: nil, accept_language: nil, platform: nil)
42
+ options = Hash.new
43
+ options[:userAgent] = user_agent if user_agent
44
+ options[:acceptLanguage] = accept_language if accept_language
45
+ options[:platform] if platform
46
+
47
+ @page.command("Network.setUserAgentOverride", **options) if !options.empty?
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ferrum
6
+ class Keyboard
7
+ KEYS = JSON.parse(File.read(File.expand_path("../keyboard.json", __FILE__)))
8
+ MODIFIERS = { "alt" => 1, "ctrl" => 2, "control" => 2,
9
+ "meta" => 4, "command" => 4, "shift" => 8 }
10
+ KEYS_MAPPING = {
11
+ cancel: "Cancel", help: "Help", backspace: "Backspace", tab: "Tab",
12
+ clear: "Clear", return: "Enter", enter: "Enter", shift: "Shift",
13
+ ctrl: "Control", control: "Control", alt: "Alt", pause: "Pause",
14
+ escape: "Escape", space: "Space", pageup: "PageUp", page_up: "PageUp",
15
+ pagedown: "PageDown", page_down: "PageDown", end: "End", home: "Home",
16
+ left: "ArrowLeft", up: "ArrowUp", right: "ArrowRight",
17
+ down: "ArrowDown", insert: "Insert", delete: "Delete",
18
+ semicolon: "Semicolon", equals: "Equal", numpad0: "Numpad0",
19
+ numpad1: "Numpad1", numpad2: "Numpad2", numpad3: "Numpad3",
20
+ numpad4: "Numpad4", numpad5: "Numpad5", numpad6: "Numpad6",
21
+ numpad7: "Numpad7", numpad8: "Numpad8", numpad9: "Numpad9",
22
+ multiply: "NumpadMultiply", add: "NumpadAdd",
23
+ separator: "NumpadDecimal", subtract: "NumpadSubtract",
24
+ decimal: "NumpadDecimal", divide: "NumpadDivide", f1: "F1", f2: "F2",
25
+ f3: "F3", f4: "F4", f5: "F5", f6: "F6", f7: "F7", f8: "F8", f9: "F9",
26
+ f10: "F10", f11: "F11", f12: "F12", meta: "Meta", command: "Meta",
27
+ }
28
+
29
+ def initialize(page)
30
+ @page = page
31
+ end
32
+
33
+ def down(key)
34
+ key = normalize_keys(Array(key))
35
+ type = key[:text] ? "keyDown" : "rawKeyDown"
36
+ @page.command("Input.dispatchKeyEvent", type: type, **key)
37
+ self
38
+ end
39
+
40
+ def up(key)
41
+ key = normalize_keys(Array(key))
42
+ @page.command("Input.dispatchKeyEvent", type: "keyUp", **key)
43
+ self
44
+ end
45
+
46
+ def type(*keys)
47
+ keys = normalize_keys(Array(keys))
48
+
49
+ keys.each do |key|
50
+ type = key[:text] ? "keyDown" : "rawKeyDown"
51
+ @page.command("Input.dispatchKeyEvent", type: type, **key)
52
+ @page.command("Input.dispatchKeyEvent", type: "keyUp", **key)
53
+ end
54
+
55
+ self
56
+ end
57
+
58
+ def modifiers(keys)
59
+ keys.map { |k| MODIFIERS[k.to_s] }.compact.reduce(0, :|)
60
+ end
61
+
62
+ private
63
+
64
+ def normalize_keys(keys, pressed_keys = [], memo = [])
65
+ case keys
66
+ when Array
67
+ pressed_keys.push([])
68
+ memo += combine_strings(keys).map do |key|
69
+ normalize_keys(key, pressed_keys, memo)
70
+ end
71
+ pressed_keys.pop
72
+ memo.flatten.compact
73
+ when Symbol
74
+ key = keys.to_s.downcase
75
+
76
+ if MODIFIERS.keys.include?(key)
77
+ pressed_keys.last.push(key)
78
+ nil
79
+ else
80
+ _key = KEYS.fetch(KEYS_MAPPING[key.to_sym] || key.to_sym)
81
+ _key[:modifiers] = pressed_keys.flatten.map { |k| MODIFIERS[k] }.reduce(0, :|)
82
+ to_options(_key)
83
+ end
84
+ when String
85
+ pressed = pressed_keys.flatten
86
+ keys.each_char.map do |char|
87
+ if pressed.empty?
88
+ key = KEYS[char] || {}
89
+ key = key.merge(text: char, unmodifiedText: char)
90
+ [to_options(key)]
91
+ else
92
+ key = KEYS[char] || {}
93
+ text = pressed == ["shift"] ? char.upcase : char
94
+ key = key.merge(
95
+ text: text,
96
+ unmodifiedText: text,
97
+ isKeypad: key["location"] == 3,
98
+ modifiers: pressed.map { |k| MODIFIERS[k] }.reduce(0, :|),
99
+ )
100
+
101
+ modifiers = pressed.map { |k| to_options(KEYS.fetch(KEYS_MAPPING[k.to_sym])) }
102
+ modifiers + [to_options(key)]
103
+ end.flatten
104
+ end
105
+ end
106
+ end
107
+
108
+ def combine_strings(keys)
109
+ keys
110
+ .chunk { |k| k.is_a?(String) }
111
+ .map { |s, k| s ? [k.reduce(&:+)] : k }
112
+ .reduce(&:+)
113
+ end
114
+
115
+ def to_options(hash)
116
+ hash.inject({}) { |memo, (k, v)| memo.merge(k.to_sym => v) }
117
+ end
118
+ end
119
+ end