ferrum 0.11 → 0.13
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/LICENSE +1 -1
- data/README.md +174 -30
- data/lib/ferrum/browser/binary.rb +46 -0
- data/lib/ferrum/browser/client.rb +17 -16
- data/lib/ferrum/browser/command.rb +10 -12
- data/lib/ferrum/browser/options/base.rb +2 -11
- data/lib/ferrum/browser/options/chrome.rb +29 -18
- data/lib/ferrum/browser/options/firefox.rb +13 -9
- data/lib/ferrum/browser/options.rb +84 -0
- data/lib/ferrum/browser/process.rb +45 -40
- data/lib/ferrum/browser/subscriber.rb +1 -3
- data/lib/ferrum/browser/version_info.rb +71 -0
- data/lib/ferrum/browser/web_socket.rb +9 -12
- data/lib/ferrum/browser/xvfb.rb +4 -8
- data/lib/ferrum/browser.rb +193 -47
- data/lib/ferrum/context.rb +9 -4
- data/lib/ferrum/contexts.rb +12 -10
- data/lib/ferrum/cookies/cookie.rb +126 -0
- data/lib/ferrum/cookies.rb +93 -55
- data/lib/ferrum/dialog.rb +30 -0
- data/lib/ferrum/errors.rb +115 -0
- data/lib/ferrum/frame/dom.rb +177 -0
- data/lib/ferrum/frame/runtime.rb +58 -75
- data/lib/ferrum/frame.rb +118 -23
- data/lib/ferrum/headers.rb +30 -2
- data/lib/ferrum/keyboard.rb +56 -13
- data/lib/ferrum/mouse.rb +92 -7
- data/lib/ferrum/network/auth_request.rb +7 -2
- data/lib/ferrum/network/exchange.rb +97 -12
- data/lib/ferrum/network/intercepted_request.rb +10 -8
- data/lib/ferrum/network/request.rb +69 -0
- data/lib/ferrum/network/response.rb +85 -3
- data/lib/ferrum/network.rb +285 -36
- data/lib/ferrum/node.rb +69 -23
- data/lib/ferrum/page/animation.rb +16 -1
- data/lib/ferrum/page/frames.rb +111 -30
- data/lib/ferrum/page/screenshot.rb +142 -65
- data/lib/ferrum/page/stream.rb +38 -0
- data/lib/ferrum/page/tracing.rb +97 -0
- data/lib/ferrum/page.rb +224 -60
- data/lib/ferrum/proxy.rb +147 -0
- data/lib/ferrum/{rbga.rb → rgba.rb} +4 -2
- data/lib/ferrum/target.rb +7 -4
- data/lib/ferrum/utils/attempt.rb +20 -0
- data/lib/ferrum/utils/elapsed_time.rb +27 -0
- data/lib/ferrum/utils/platform.rb +28 -0
- data/lib/ferrum/version.rb +1 -1
- data/lib/ferrum.rb +4 -146
- metadata +63 -51
@@ -2,28 +2,32 @@
|
|
2
2
|
|
3
3
|
module Ferrum
|
4
4
|
class Browser
|
5
|
-
|
5
|
+
class Options
|
6
6
|
class Firefox < Base
|
7
7
|
DEFAULT_OPTIONS = {
|
8
|
-
"headless" => nil
|
8
|
+
"headless" => nil
|
9
9
|
}.freeze
|
10
10
|
|
11
11
|
MAC_BIN_PATH = [
|
12
12
|
"/Applications/Firefox.app/Contents/MacOS/firefox-bin"
|
13
13
|
].freeze
|
14
14
|
LINUX_BIN_PATH = %w[firefox].freeze
|
15
|
+
WINDOWS_BIN_PATH = [
|
16
|
+
"C:/Program Files/Firefox Developer Edition/firefox.exe",
|
17
|
+
"C:/Program Files/Mozilla Firefox/firefox.exe"
|
18
|
+
].freeze
|
19
|
+
PLATFORM_PATH = {
|
20
|
+
mac: MAC_BIN_PATH,
|
21
|
+
windows: WINDOWS_BIN_PATH,
|
22
|
+
linux: LINUX_BIN_PATH
|
23
|
+
}.freeze
|
15
24
|
|
16
25
|
def merge_required(flags, options, user_data_dir)
|
17
|
-
|
18
|
-
host = options.fetch(:host, BROWSER_HOST)
|
19
|
-
flags.merge("remote-debugger" => "#{host}:#{port}",
|
20
|
-
"profile" => user_data_dir)
|
26
|
+
flags.merge("remote-debugger" => "#{options.host}:#{options.port}", "profile" => user_data_dir)
|
21
27
|
end
|
22
28
|
|
23
29
|
def merge_default(flags, options)
|
24
|
-
unless options.
|
25
|
-
defaults = except("headless")
|
26
|
-
end
|
30
|
+
defaults = except("headless") unless options.headless
|
27
31
|
|
28
32
|
defaults ||= DEFAULT_OPTIONS
|
29
33
|
defaults.merge(flags)
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ferrum
|
4
|
+
class Browser
|
5
|
+
class Options
|
6
|
+
HEADLESS = true
|
7
|
+
BROWSER_PORT = "0"
|
8
|
+
BROWSER_HOST = "127.0.0.1"
|
9
|
+
WINDOW_SIZE = [1024, 768].freeze
|
10
|
+
BASE_URL_SCHEMA = %w[http https].freeze
|
11
|
+
DEFAULT_TIMEOUT = ENV.fetch("FERRUM_DEFAULT_TIMEOUT", 5).to_i
|
12
|
+
PROCESS_TIMEOUT = ENV.fetch("FERRUM_PROCESS_TIMEOUT", 10).to_i
|
13
|
+
DEBUG_MODE = !ENV.fetch("FERRUM_DEBUG", nil).nil?
|
14
|
+
|
15
|
+
attr_reader :window_size, :timeout, :logger, :ws_max_receive_size,
|
16
|
+
:js_errors, :base_url, :slowmo, :pending_connection_errors,
|
17
|
+
:url, :env, :process_timeout, :browser_name, :browser_path,
|
18
|
+
:save_path, :extensions, :proxy, :port, :host, :headless,
|
19
|
+
:ignore_default_browser_options, :browser_options, :xvfb
|
20
|
+
|
21
|
+
def initialize(options = nil)
|
22
|
+
@options = Hash(options&.dup)
|
23
|
+
@port = @options.fetch(:port, BROWSER_PORT)
|
24
|
+
@host = @options.fetch(:host, BROWSER_HOST)
|
25
|
+
@timeout = @options.fetch(:timeout, DEFAULT_TIMEOUT)
|
26
|
+
@window_size = @options.fetch(:window_size, WINDOW_SIZE)
|
27
|
+
@js_errors = @options.fetch(:js_errors, false)
|
28
|
+
@headless = @options.fetch(:headless, HEADLESS)
|
29
|
+
@pending_connection_errors = @options.fetch(:pending_connection_errors, true)
|
30
|
+
@process_timeout = @options.fetch(:process_timeout, PROCESS_TIMEOUT)
|
31
|
+
@browser_options = @options.fetch(:browser_options, {})
|
32
|
+
@slowmo = @options[:slowmo].to_f
|
33
|
+
|
34
|
+
@ws_max_receive_size, @env, @browser_name, @browser_path,
|
35
|
+
@save_path, @extensions, @ignore_default_browser_options, @xvfb = @options.values_at(
|
36
|
+
:ws_max_receive_size, :env, :browser_name, :browser_path, :save_path, :extensions,
|
37
|
+
:ignore_default_browser_options, :xvfb
|
38
|
+
)
|
39
|
+
|
40
|
+
@options[:window_size] = @window_size
|
41
|
+
@proxy = parse_proxy(@options[:proxy])
|
42
|
+
@logger = parse_logger(@options[:logger])
|
43
|
+
@base_url = parse_base_url(@options[:base_url]) if @options[:base_url]
|
44
|
+
@url = @options[:url].to_s if @options[:url]
|
45
|
+
|
46
|
+
@options.freeze
|
47
|
+
@browser_options.freeze
|
48
|
+
end
|
49
|
+
|
50
|
+
def to_h
|
51
|
+
@options
|
52
|
+
end
|
53
|
+
|
54
|
+
def parse_base_url(value)
|
55
|
+
parsed = Addressable::URI.parse(value)
|
56
|
+
unless BASE_URL_SCHEMA.include?(parsed&.normalized_scheme)
|
57
|
+
raise ArgumentError, "`base_url` should be absolute and include schema: #{BASE_URL_SCHEMA.join(' | ')}"
|
58
|
+
end
|
59
|
+
|
60
|
+
parsed
|
61
|
+
end
|
62
|
+
|
63
|
+
def parse_proxy(options)
|
64
|
+
return unless options
|
65
|
+
|
66
|
+
raise ArgumentError, "proxy options must be a Hash" unless options.is_a?(Hash)
|
67
|
+
|
68
|
+
if options[:host].nil? && options[:port].nil?
|
69
|
+
raise ArgumentError, "proxy options must be a Hash with at least :host | :port"
|
70
|
+
end
|
71
|
+
|
72
|
+
options
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def parse_logger(logger)
|
78
|
+
return logger if logger
|
79
|
+
|
80
|
+
!logger && DEBUG_MODE ? $stdout.tap { |s| s.sync = true } : logger
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -1,6 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "cliver"
|
4
3
|
require "net/http"
|
5
4
|
require "json"
|
6
5
|
require "addressable"
|
@@ -16,13 +15,11 @@ module Ferrum
|
|
16
15
|
class Process
|
17
16
|
KILL_TIMEOUT = 2
|
18
17
|
WAIT_KILLED = 0.05
|
19
|
-
PROCESS_TIMEOUT = ENV.fetch("FERRUM_PROCESS_TIMEOUT", 10).to_i
|
20
18
|
|
21
19
|
attr_reader :host, :port, :ws_url, :pid, :command,
|
22
20
|
:default_user_agent, :browser_version, :protocol_version,
|
23
21
|
:v8_version, :webkit_version, :xvfb
|
24
22
|
|
25
|
-
|
26
23
|
extend Forwardable
|
27
24
|
delegate path: :command
|
28
25
|
|
@@ -32,41 +29,50 @@ module Ferrum
|
|
32
29
|
|
33
30
|
def self.process_killer(pid)
|
34
31
|
proc do
|
35
|
-
|
36
|
-
|
32
|
+
if Utils::Platform.windows?
|
33
|
+
# Process.kill is unreliable on Windows
|
34
|
+
::Process.kill("KILL", pid) unless system("taskkill /f /t /pid #{pid} >NUL 2>NUL")
|
35
|
+
else
|
36
|
+
::Process.kill("USR1", pid)
|
37
|
+
start = Utils::ElapsedTime.monotonic_time
|
38
|
+
while ::Process.wait(pid, ::Process::WNOHANG).nil?
|
39
|
+
sleep(WAIT_KILLED)
|
40
|
+
next unless Utils::ElapsedTime.timeout?(start, KILL_TIMEOUT)
|
41
|
+
|
37
42
|
::Process.kill("KILL", pid)
|
38
|
-
|
39
|
-
|
40
|
-
start = Ferrum.monotonic_time
|
41
|
-
while ::Process.wait(pid, ::Process::WNOHANG).nil?
|
42
|
-
sleep(WAIT_KILLED)
|
43
|
-
next unless Ferrum.timeout?(start, KILL_TIMEOUT)
|
44
|
-
::Process.kill("KILL", pid)
|
45
|
-
::Process.wait(pid)
|
46
|
-
break
|
47
|
-
end
|
43
|
+
::Process.wait(pid)
|
44
|
+
break
|
48
45
|
end
|
49
|
-
rescue Errno::ESRCH, Errno::ECHILD
|
50
46
|
end
|
47
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
48
|
+
# nop
|
51
49
|
end
|
52
50
|
end
|
53
51
|
|
54
52
|
def self.directory_remover(path)
|
55
|
-
proc {
|
53
|
+
proc {
|
54
|
+
begin
|
55
|
+
FileUtils.remove_entry(path)
|
56
|
+
rescue StandardError
|
57
|
+
Errno::ENOENT
|
58
|
+
end
|
59
|
+
}
|
56
60
|
end
|
57
61
|
|
58
62
|
def initialize(options)
|
59
|
-
|
60
|
-
|
63
|
+
@pid = @xvfb = @user_data_dir = nil
|
64
|
+
|
65
|
+
if options.url
|
66
|
+
url = URI.join(options.url, "/json/version")
|
61
67
|
response = JSON.parse(::Net::HTTP.get(url))
|
62
|
-
|
68
|
+
self.ws_url = response["webSocketDebuggerUrl"]
|
63
69
|
parse_browser_versions
|
64
70
|
return
|
65
71
|
end
|
66
72
|
|
67
|
-
@
|
68
|
-
@
|
69
|
-
@
|
73
|
+
@logger = options.logger
|
74
|
+
@process_timeout = options.process_timeout
|
75
|
+
@env = Hash(options.env)
|
70
76
|
|
71
77
|
tmpdir = Dir.mktmpdir("ferrum_user_data_dir_")
|
72
78
|
ObjectSpace.define_finalizer(self, self.class.directory_remover(tmpdir))
|
@@ -81,7 +87,7 @@ module Ferrum
|
|
81
87
|
begin
|
82
88
|
read_io, write_io = IO.pipe
|
83
89
|
process_options = { in: File::NULL }
|
84
|
-
process_options[:pgroup] = true unless
|
90
|
+
process_options[:pgroup] = true unless Utils::Platform.windows?
|
85
91
|
process_options[:out] = process_options[:err] = write_io
|
86
92
|
|
87
93
|
if @command.xvfb?
|
@@ -89,7 +95,8 @@ module Ferrum
|
|
89
95
|
ObjectSpace.define_finalizer(self, self.class.process_killer(@xvfb.pid))
|
90
96
|
end
|
91
97
|
|
92
|
-
|
98
|
+
env = Hash(@xvfb&.to_env).merge(@env)
|
99
|
+
@pid = ::Process.spawn(env, *@command.to_a, process_options)
|
93
100
|
ObjectSpace.define_finalizer(self, self.class.process_killer(@pid))
|
94
101
|
|
95
102
|
parse_ws_url(read_io, @process_timeout)
|
@@ -128,29 +135,29 @@ module Ferrum
|
|
128
135
|
|
129
136
|
def parse_ws_url(read_io, timeout)
|
130
137
|
output = ""
|
131
|
-
start =
|
138
|
+
start = Utils::ElapsedTime.monotonic_time
|
132
139
|
max_time = start + timeout
|
133
|
-
regexp =
|
134
|
-
while (now =
|
140
|
+
regexp = %r{DevTools listening on (ws://.*)}
|
141
|
+
while (now = Utils::ElapsedTime.monotonic_time) < max_time
|
135
142
|
begin
|
136
143
|
output += read_io.read_nonblock(512)
|
137
144
|
rescue IO::WaitReadable
|
138
|
-
|
145
|
+
read_io.wait_readable(max_time - now)
|
139
146
|
else
|
140
147
|
if output.match(regexp)
|
141
|
-
|
148
|
+
self.ws_url = output.match(regexp)[1].strip
|
142
149
|
break
|
143
150
|
end
|
144
151
|
end
|
145
152
|
end
|
146
153
|
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
154
|
+
return if ws_url
|
155
|
+
|
156
|
+
@logger&.puts(output)
|
157
|
+
raise ProcessTimeoutError.new(timeout, output)
|
151
158
|
end
|
152
159
|
|
153
|
-
def
|
160
|
+
def ws_url=(url)
|
154
161
|
@ws_url = Addressable::URI.parse(url)
|
155
162
|
@host = @ws_url.host
|
156
163
|
@port = @ws_url.port
|
@@ -171,11 +178,9 @@ module Ferrum
|
|
171
178
|
|
172
179
|
def close_io(*ios)
|
173
180
|
ios.each do |io|
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
raise unless RUBY_ENGINE == "jruby"
|
178
|
-
end
|
181
|
+
io.close unless io.closed?
|
182
|
+
rescue IOError
|
183
|
+
raise unless RUBY_ENGINE == "jruby"
|
179
184
|
end
|
180
185
|
end
|
181
186
|
end
|
@@ -1,7 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "concurrent-ruby"
|
4
|
-
|
5
3
|
module Ferrum
|
6
4
|
class Browser
|
7
5
|
class Subscriber
|
@@ -29,7 +27,7 @@ module Ferrum
|
|
29
27
|
method, params = message.values_at("method", "params")
|
30
28
|
total = @on[method].size
|
31
29
|
@on[method].each_with_index do |block, index|
|
32
|
-
#
|
30
|
+
# In case of multiple callbacks we provide current index and total
|
33
31
|
block.call(params, index, total)
|
34
32
|
end
|
35
33
|
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ferrum
|
4
|
+
class Browser
|
5
|
+
#
|
6
|
+
# The browser's version information returned by [Browser.getVersion].
|
7
|
+
#
|
8
|
+
# [Browser.getVersion]: https://chromedevtools.github.io/devtools-protocol/1-3/Browser/#method-getVersion
|
9
|
+
#
|
10
|
+
# @since 0.13
|
11
|
+
#
|
12
|
+
class VersionInfo
|
13
|
+
#
|
14
|
+
# Initializes the browser's version information.
|
15
|
+
#
|
16
|
+
# @param [Hash{String => Object}] properties
|
17
|
+
# The object properties returned by [Browser.getVersion](https://chromedevtools.github.io/devtools-protocol/1-3/Browser/#method-getVersion).
|
18
|
+
#
|
19
|
+
# @api private
|
20
|
+
#
|
21
|
+
def initialize(properties)
|
22
|
+
@properties = properties
|
23
|
+
end
|
24
|
+
|
25
|
+
#
|
26
|
+
# The Chrome DevTools protocol version.
|
27
|
+
#
|
28
|
+
# @return [String]
|
29
|
+
#
|
30
|
+
def protocol_version
|
31
|
+
@properties["protocolVersion"]
|
32
|
+
end
|
33
|
+
|
34
|
+
#
|
35
|
+
# The Chrome version.
|
36
|
+
#
|
37
|
+
# @return [String]
|
38
|
+
#
|
39
|
+
def product
|
40
|
+
@properties["product"]
|
41
|
+
end
|
42
|
+
|
43
|
+
#
|
44
|
+
# The Chrome revision properties.
|
45
|
+
#
|
46
|
+
# @return [String]
|
47
|
+
#
|
48
|
+
def revision
|
49
|
+
@properties["revision"]
|
50
|
+
end
|
51
|
+
|
52
|
+
#
|
53
|
+
# The Chrome `User-Agent` string.
|
54
|
+
#
|
55
|
+
# @return [String]
|
56
|
+
#
|
57
|
+
def user_agent
|
58
|
+
@properties["userAgent"]
|
59
|
+
end
|
60
|
+
|
61
|
+
#
|
62
|
+
# The JavaScript engine version.
|
63
|
+
#
|
64
|
+
# @return [String]
|
65
|
+
#
|
66
|
+
def js_version
|
67
|
+
@properties["jsVersion"]
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -21,9 +21,7 @@ module Ferrum
|
|
21
21
|
@driver = ::WebSocket::Driver.client(self, max_length: max_receive_size)
|
22
22
|
@messages = Queue.new
|
23
23
|
|
24
|
-
if SKIP_LOGGING_SCREENSHOTS
|
25
|
-
@screenshot_commands = Concurrent::Hash.new
|
26
|
-
end
|
24
|
+
@screenshot_commands = Concurrent::Hash.new if SKIP_LOGGING_SCREENSHOTS
|
27
25
|
|
28
26
|
@driver.on(:open, &method(:on_open))
|
29
27
|
@driver.on(:message, &method(:on_message))
|
@@ -31,12 +29,13 @@ module Ferrum
|
|
31
29
|
|
32
30
|
@thread = Thread.new do
|
33
31
|
Thread.current.abort_on_exception = true
|
34
|
-
if Thread.current.respond_to?(:report_on_exception=)
|
35
|
-
Thread.current.report_on_exception = true
|
36
|
-
end
|
32
|
+
Thread.current.report_on_exception = true if Thread.current.respond_to?(:report_on_exception=)
|
37
33
|
|
38
34
|
begin
|
39
|
-
|
35
|
+
loop do
|
36
|
+
data = @sock.readpartial(512)
|
37
|
+
break unless data
|
38
|
+
|
40
39
|
@driver.parse(data)
|
41
40
|
end
|
42
41
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE
|
@@ -62,7 +61,7 @@ module Ferrum
|
|
62
61
|
output.sub!(/{"data":"(.*)"}/, %("Set FERRUM_LOGGING_SCREENSHOTS=true to see screenshots in Base64"))
|
63
62
|
end
|
64
63
|
|
65
|
-
@logger&.puts(" ◀ #{
|
64
|
+
@logger&.puts(" ◀ #{Utils::ElapsedTime.elapsed_time} #{output}\n")
|
66
65
|
end
|
67
66
|
|
68
67
|
def on_close(_event)
|
@@ -71,13 +70,11 @@ module Ferrum
|
|
71
70
|
end
|
72
71
|
|
73
72
|
def send_message(data)
|
74
|
-
if SKIP_LOGGING_SCREENSHOTS
|
75
|
-
@screenshot_commands[data[:id]] = true
|
76
|
-
end
|
73
|
+
@screenshot_commands[data[:id]] = true if SKIP_LOGGING_SCREENSHOTS
|
77
74
|
|
78
75
|
json = data.to_json
|
79
76
|
@driver.text(json)
|
80
|
-
@logger&.puts("\n\n▶ #{
|
77
|
+
@logger&.puts("\n\n▶ #{Utils::ElapsedTime.elapsed_time} #{json}")
|
81
78
|
end
|
82
79
|
|
83
80
|
def write(data)
|
data/lib/ferrum/browser/xvfb.rb
CHANGED
@@ -4,23 +4,19 @@ module Ferrum
|
|
4
4
|
class Browser
|
5
5
|
class Xvfb
|
6
6
|
NOT_FOUND = "Could not find an executable for the Xvfb. Try to install " \
|
7
|
-
"it with your package manager"
|
7
|
+
"it with your package manager"
|
8
8
|
|
9
9
|
def self.start(*args)
|
10
10
|
new(*args).tap(&:start)
|
11
11
|
end
|
12
12
|
|
13
|
-
def self.xvfb_path
|
14
|
-
Cliver.detect("Xvfb")
|
15
|
-
end
|
16
|
-
|
17
13
|
attr_reader :screen_size, :display_id, :pid
|
18
14
|
|
19
15
|
def initialize(options)
|
20
|
-
@path =
|
21
|
-
raise
|
16
|
+
@path = Binary.find("Xvfb")
|
17
|
+
raise BinaryNotFoundError, NOT_FOUND unless @path
|
22
18
|
|
23
|
-
@screen_size = options.
|
19
|
+
@screen_size = "#{options.window_size.join('x')}x24"
|
24
20
|
@display_id = (Time.now.to_f * 1000).to_i % 100_000_000
|
25
21
|
end
|
26
22
|
|