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.
- checksums.yaml +4 -4
- data/README.md +513 -10
- data/lib/ferrum.rb +85 -4
- data/lib/ferrum/browser.rb +16 -33
- data/lib/ferrum/browser/client.rb +18 -2
- data/lib/ferrum/browser/subscriber.rb +5 -1
- data/lib/ferrum/cookies.rb +97 -0
- data/lib/ferrum/headers.rb +50 -0
- data/lib/ferrum/{page/input.json → keyboard.json} +0 -0
- data/lib/ferrum/keyboard.rb +119 -0
- data/lib/ferrum/mouse.rb +53 -0
- data/lib/ferrum/network/intercepted_request.rb +53 -0
- data/lib/ferrum/node.rb +52 -115
- data/lib/ferrum/page.rb +30 -30
- data/lib/ferrum/page/dom.rb +22 -12
- data/lib/ferrum/page/frame.rb +13 -12
- data/lib/ferrum/page/input.rb +3 -148
- data/lib/ferrum/page/net.rb +66 -54
- data/lib/ferrum/page/runtime.rb +13 -19
- data/lib/ferrum/page/screenshot.rb +84 -0
- data/lib/ferrum/targets.rb +4 -8
- data/lib/ferrum/version.rb +1 -1
- metadata +9 -10
- data/lib/ferrum/browser/api.rb +0 -14
- data/lib/ferrum/browser/api/cookie.rb +0 -46
- data/lib/ferrum/browser/api/header.rb +0 -32
- data/lib/ferrum/browser/api/intercept.rb +0 -32
- data/lib/ferrum/browser/api/screenshot.rb +0 -78
- data/lib/ferrum/cookie.rb +0 -47
- data/lib/ferrum/errors.rb +0 -94
data/lib/ferrum.rb
CHANGED
@@ -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
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
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
|
data/lib/ferrum/browser.rb
CHANGED
@@ -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 :
|
18
|
+
attr_reader :window_size
|
21
19
|
|
22
20
|
delegate on: :@client
|
23
|
-
delegate %i
|
24
|
-
close_window within_window page
|
25
|
-
delegate %i
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
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
|
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
|
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
|
-
|
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].
|
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
|
File without changes
|
@@ -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
|