ferrum 0.10.1 → 0.12

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +261 -28
  4. data/lib/ferrum/browser/binary.rb +46 -0
  5. data/lib/ferrum/browser/client.rb +15 -12
  6. data/lib/ferrum/browser/command.rb +7 -8
  7. data/lib/ferrum/browser/options/base.rb +1 -7
  8. data/lib/ferrum/browser/options/chrome.rb +17 -10
  9. data/lib/ferrum/browser/options/firefox.rb +11 -4
  10. data/lib/ferrum/browser/process.rb +41 -35
  11. data/lib/ferrum/browser/subscriber.rb +1 -3
  12. data/lib/ferrum/browser/web_socket.rb +9 -12
  13. data/lib/ferrum/browser/xvfb.rb +4 -8
  14. data/lib/ferrum/browser.rb +49 -12
  15. data/lib/ferrum/context.rb +12 -4
  16. data/lib/ferrum/contexts.rb +13 -9
  17. data/lib/ferrum/cookies.rb +10 -9
  18. data/lib/ferrum/errors.rb +115 -0
  19. data/lib/ferrum/frame/runtime.rb +20 -17
  20. data/lib/ferrum/frame.rb +32 -24
  21. data/lib/ferrum/headers.rb +2 -2
  22. data/lib/ferrum/keyboard.rb +11 -11
  23. data/lib/ferrum/mouse.rb +8 -7
  24. data/lib/ferrum/network/auth_request.rb +7 -2
  25. data/lib/ferrum/network/exchange.rb +14 -10
  26. data/lib/ferrum/network/intercepted_request.rb +10 -8
  27. data/lib/ferrum/network/request.rb +5 -0
  28. data/lib/ferrum/network/response.rb +4 -4
  29. data/lib/ferrum/network.rb +124 -35
  30. data/lib/ferrum/node.rb +98 -40
  31. data/lib/ferrum/page/animation.rb +15 -0
  32. data/lib/ferrum/page/frames.rb +46 -15
  33. data/lib/ferrum/page/screenshot.rb +53 -67
  34. data/lib/ferrum/page/stream.rb +38 -0
  35. data/lib/ferrum/page/tracing.rb +71 -0
  36. data/lib/ferrum/page.rb +88 -34
  37. data/lib/ferrum/proxy.rb +58 -0
  38. data/lib/ferrum/{rbga.rb → rgba.rb} +4 -2
  39. data/lib/ferrum/target.rb +1 -0
  40. data/lib/ferrum/utils/attempt.rb +20 -0
  41. data/lib/ferrum/utils/elapsed_time.rb +27 -0
  42. data/lib/ferrum/utils/platform.rb +28 -0
  43. data/lib/ferrum/version.rb +1 -1
  44. data/lib/ferrum.rb +4 -140
  45. metadata +65 -50
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ferrum/rbga"
3
+ require "ferrum/rgba"
4
4
 
5
5
  module Ferrum
6
6
  class Page
@@ -12,22 +12,20 @@ module Ferrum
12
12
  scale: 1.0
13
13
  }.freeze
14
14
 
15
- PAPEP_FORMATS = {
16
- letter: { width: 8.50, height: 11.00 },
17
- legal: { width: 8.50, height: 14.00 },
18
- tabloid: { width: 11.00, height: 17.00 },
19
- ledger: { width: 17.00, height: 11.00 },
20
- A0: { width: 33.10, height: 46.80 },
21
- A1: { width: 23.40, height: 33.10 },
22
- A2: { width: 16.54, height: 23.40 },
23
- A3: { width: 11.70, height: 16.54 },
24
- A4: { width: 8.27, height: 11.70 },
25
- A5: { width: 5.83, height: 8.27 },
26
- A6: { width: 4.13, height: 5.83 },
15
+ PAPER_FORMATS = {
16
+ letter: { width: 8.50, height: 11.00 },
17
+ legal: { width: 8.50, height: 14.00 },
18
+ tabloid: { width: 11.00, height: 17.00 },
19
+ ledger: { width: 17.00, height: 11.00 },
20
+ A0: { width: 33.10, height: 46.80 },
21
+ A1: { width: 23.40, height: 33.10 },
22
+ A2: { width: 16.54, height: 23.40 },
23
+ A3: { width: 11.70, height: 16.54 },
24
+ A4: { width: 8.27, height: 11.70 },
25
+ A5: { width: 5.83, height: 8.27 },
26
+ A6: { width: 4.13, height: 5.83 }
27
27
  }.freeze
28
28
 
29
- STREAM_CHUNK = 128 * 1024
30
-
31
29
  def screenshot(**opts)
32
30
  path, encoding = common_options(**opts)
33
31
  options = screenshot_options(path, **opts)
@@ -42,17 +40,13 @@ module Ferrum
42
40
  path, encoding = common_options(**opts)
43
41
  options = pdf_options(**opts).merge(transferMode: "ReturnAsStream")
44
42
  handle = command("Page.printToPDF", **options).fetch("stream")
45
-
46
- if path
47
- stream_to_file(handle, path: path)
48
- else
49
- stream_to_memory(handle, encoding: encoding)
50
- end
43
+ stream_to(path: path, encoding: encoding, handle: handle)
51
44
  end
52
45
 
53
46
  def mhtml(path: nil)
54
47
  data = command("Page.captureSnapshot", format: :mhtml).fetch("data")
55
48
  return data if path.nil?
49
+
56
50
  save_file(path, data)
57
51
  end
58
52
 
@@ -73,30 +67,8 @@ module Ferrum
73
67
 
74
68
  def save_file(path, data)
75
69
  return data unless path
76
- File.open(path.to_s, "wb") { |f| f.write(data) }
77
- end
78
70
 
79
- def stream_to_file(handle, path:)
80
- File.open(path, "wb") { |f| stream_to(handle, f) }
81
- true
82
- end
83
-
84
- def stream_to_memory(handle, encoding:)
85
- data = String.new("") # Mutable string has << and compatible to File
86
- stream_to(handle, data)
87
- encoding == :base64 ? Base64.encode64(data) : data
88
- end
89
-
90
- def stream_to(handle, output)
91
- loop do
92
- result = command("IO.read", handle: handle, size: STREAM_CHUNK)
93
-
94
- data_chunk = result["data"]
95
- data_chunk = Base64.decode64(data_chunk) if result["base64Encoded"]
96
- output << data_chunk
97
-
98
- break if result["eof"]
99
- end
71
+ File.binwrite(path.to_s, data)
100
72
  end
101
73
 
102
74
  def common_options(encoding: :base64, path: nil, **_)
@@ -114,49 +86,62 @@ module Ferrum
114
86
  raise ArgumentError, "Specify :format or :paper_width, :paper_height"
115
87
  end
116
88
 
117
- dimension = PAPEP_FORMATS.fetch(format)
89
+ dimension = PAPER_FORMATS.fetch(format)
118
90
  options.merge!(paper_width: dimension[:width],
119
91
  paper_height: dimension[:height])
120
92
  end
121
93
 
122
- options.map { |k, v| [to_camel_case(k), v] }.to_h
94
+ options.transform_keys { |k| to_camel_case(k) }
123
95
  end
124
96
 
125
- def screenshot_options(path = nil, format: nil, scale: 1.0, **opts)
126
- options = {}
97
+ def screenshot_options(path = nil, format: nil, scale: 1.0, **options)
98
+ screenshot_options = {}
99
+
100
+ format, quality = format_options(format, path, options[:quality])
101
+ screenshot_options.merge!(quality: quality) if quality
102
+ screenshot_options.merge!(format: format)
103
+
104
+ clip = area_options(options[:full], options[:selector], scale)
105
+ screenshot_options.merge!(clip: clip) if clip
106
+
107
+ screenshot_options
108
+ end
127
109
 
110
+ def format_options(format, path, quality)
128
111
  format ||= path ? File.extname(path).delete(".") : "png"
129
112
  format = "jpeg" if format == "jpg"
130
113
  raise "Not supported options `:format` #{format}. jpeg | png" if format !~ /jpeg|png/i
131
- options.merge!(format: format)
132
114
 
133
- options.merge!(quality: opts[:quality] ? opts[:quality] : 75) if format == "jpeg"
115
+ quality ||= 75 if format == "jpeg"
134
116
 
135
- if !!opts[:full] && opts[:selector]
136
- warn "Ignoring :selector in #screenshot since full: true was given at #{caller(1..1).first}"
137
- end
117
+ [format, quality]
118
+ end
138
119
 
139
- if !!opts[:full]
140
- width, height = document_size
141
- options.merge!(clip: { x: 0, y: 0, width: width, height: height, scale: scale }) if width > 0 && height > 0
142
- elsif opts[:selector]
143
- options.merge!(clip: get_bounding_rect(opts[:selector]).merge(scale: scale))
144
- end
120
+ def area_options(full, selector, scale)
121
+ message = "Ignoring :selector in #screenshot since full: true was given at #{caller(1..1).first}"
122
+ warn(message) if full && selector
145
123
 
146
- if scale != 1.0
147
- if !options[:clip]
124
+ clip = if full
125
+ width, height = document_size
126
+ { x: 0, y: 0, width: width, height: height, scale: scale } if width.positive? && height.positive?
127
+ elsif selector
128
+ bounding_rect(selector).merge(scale: scale)
129
+ end
130
+
131
+ if scale != 1
132
+ unless clip
148
133
  width, height = viewport_size
149
- options[:clip] = { x: 0, y: 0, width: width, height: height }
134
+ clip = { x: 0, y: 0, width: width, height: height }
150
135
  end
151
136
 
152
- options[:clip].merge!(scale: scale)
137
+ clip.merge!(scale: scale)
153
138
  end
154
139
 
155
- options
140
+ clip
156
141
  end
157
142
 
158
- def get_bounding_rect(selector)
159
- rect = evaluate_async(%Q(
143
+ def bounding_rect(selector)
144
+ rect = evaluate_async(%(
160
145
  const rect = document
161
146
  .querySelector('#{selector}')
162
147
  .getBoundingClientRect();
@@ -169,7 +154,8 @@ module Ferrum
169
154
 
170
155
  def to_camel_case(option)
171
156
  return :preferCSSPageSize if option == :prefer_css_page_size
172
- option.to_s.gsub(/(?:_|(\/))([a-z\d]*)/) { "#{$1}#{$2.capitalize}" }.to_sym
157
+
158
+ option.to_s.gsub(%r{(?:_|(/))([a-z\d]*)}) { "#{Regexp.last_match(1)}#{Regexp.last_match(2).capitalize}" }.to_sym
173
159
  end
174
160
 
175
161
  def capture_screenshot(options, full, background_color)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Page
5
+ module Stream
6
+ STREAM_CHUNK = 128 * 1024
7
+
8
+ def stream_to(path:, encoding:, handle:)
9
+ if path.nil?
10
+ stream_to_memory(encoding: encoding, handle: handle)
11
+ else
12
+ stream_to_file(path: path, handle: handle)
13
+ end
14
+ end
15
+
16
+ def stream_to_file(path:, handle:)
17
+ File.open(path, "wb") { |f| stream(output: f, handle: handle) }
18
+ true
19
+ end
20
+
21
+ def stream_to_memory(encoding:, handle:)
22
+ data = String.new # Mutable string has << and compatible to File
23
+ stream(output: data, handle: handle)
24
+ encoding == :base64 ? Base64.encode64(data) : data
25
+ end
26
+
27
+ def stream(output:, handle:)
28
+ loop do
29
+ result = command("IO.read", handle: handle, size: STREAM_CHUNK)
30
+ chunk = result.fetch("data")
31
+ chunk = Base64.decode64(chunk) if result["base64Encoded"]
32
+ output << chunk
33
+ break if result["eof"]
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Page
5
+ class Tracing
6
+ EXCLUDED_CATEGORIES = %w[*].freeze
7
+ SCREENSHOT_CATEGORIES = %w[disabled-by-default-devtools.screenshot].freeze
8
+ INCLUDED_CATEGORIES = %w[devtools.timeline v8.execute disabled-by-default-devtools.timeline
9
+ disabled-by-default-devtools.timeline.frame toplevel blink.console
10
+ blink.user_timing latencyInfo disabled-by-default-devtools.timeline.stack
11
+ disabled-by-default-v8.cpu_profiler disabled-by-default-v8.cpu_profiler.hires].freeze
12
+ DEFAULT_TRACE_CONFIG = {
13
+ includedCategories: INCLUDED_CATEGORIES,
14
+ excludedCategories: EXCLUDED_CATEGORIES
15
+ }.freeze
16
+
17
+ def initialize(page)
18
+ @page = page
19
+ @subscribed_tracing_complete = false
20
+ end
21
+
22
+ def record(path: nil, encoding: :binary, timeout: nil, trace_config: nil, screenshots: false)
23
+ @path = path
24
+ @encoding = encoding
25
+ @pending = Concurrent::IVar.new
26
+ trace_config ||= DEFAULT_TRACE_CONFIG.dup
27
+
28
+ if screenshots
29
+ included = trace_config.fetch(:includedCategories, [])
30
+ trace_config.merge!(includedCategories: included | SCREENSHOT_CATEGORIES)
31
+ end
32
+
33
+ subscribe_tracing_complete
34
+
35
+ start(trace_config)
36
+ yield
37
+ stop
38
+
39
+ @pending.value!(timeout || @page.timeout)
40
+ end
41
+
42
+ private
43
+
44
+ def start(config)
45
+ @page.command("Tracing.start", transferMode: "ReturnAsStream", traceConfig: config)
46
+ end
47
+
48
+ def stop
49
+ @page.command("Tracing.end")
50
+ end
51
+
52
+ def subscribe_tracing_complete
53
+ return if @subscribed_tracing_complete
54
+
55
+ @page.on("Tracing.tracingComplete") do |event, index|
56
+ next if index.to_i != 0
57
+
58
+ @pending.set(stream_handle(event["stream"]))
59
+ rescue StandardError => e
60
+ @pending.fail(e)
61
+ end
62
+
63
+ @subscribed_tracing_complete = true
64
+ end
65
+
66
+ def stream_handle(handle)
67
+ @page.stream_to(path: @path, encoding: @encoding, handle: handle)
68
+ end
69
+ end
70
+ end
71
+ end
data/lib/ferrum/page.rb CHANGED
@@ -9,6 +9,9 @@ require "ferrum/dialog"
9
9
  require "ferrum/network"
10
10
  require "ferrum/page/frames"
11
11
  require "ferrum/page/screenshot"
12
+ require "ferrum/page/animation"
13
+ require "ferrum/page/tracing"
14
+ require "ferrum/page/stream"
12
15
  require "ferrum/browser/client"
13
16
 
14
17
  module Ferrum
@@ -31,21 +34,26 @@ module Ferrum
31
34
 
32
35
  extend Forwardable
33
36
  delegate %i[at_css at_xpath css xpath
34
- current_url current_title url title body doctype set_content
37
+ current_url current_title url title body doctype content=
35
38
  execution_id evaluate evaluate_on evaluate_async execute evaluate_func
36
39
  add_script_tag add_style_tag] => :main_frame
37
40
 
38
- include Frames, Screenshot
41
+ include Animation
42
+ include Screenshot
43
+ include Frames
44
+ include Stream
39
45
 
40
46
  attr_accessor :referrer
41
47
  attr_reader :target_id, :browser,
42
48
  :headers, :cookies, :network,
43
- :mouse, :keyboard, :event, :document_id
49
+ :mouse, :keyboard, :event,
50
+ :tracing
44
51
 
45
52
  def initialize(target_id, browser)
46
53
  @frames = {}
47
54
  @main_frame = Frame.new(nil, self)
48
- @target_id, @browser = target_id, browser
55
+ @browser = browser
56
+ @target_id = target_id
49
57
  @event = Event.new.tap(&:set)
50
58
 
51
59
  host = @browser.process.host
@@ -53,9 +61,12 @@ module Ferrum
53
61
  ws_url = "ws://#{host}:#{port}/devtools/page/#{@target_id}"
54
62
  @client = Browser::Client.new(browser, ws_url, id_starts_with: 1000)
55
63
 
56
- @mouse, @keyboard = Mouse.new(self), Keyboard.new(self)
57
- @headers, @cookies = Headers.new(self), Cookies.new(self)
64
+ @mouse = Mouse.new(self)
65
+ @keyboard = Keyboard.new(self)
66
+ @headers = Headers.new(self)
67
+ @cookies = Cookies.new(self)
58
68
  @network = Network.new(self)
69
+ @tracing = Tracing.new(self)
59
70
 
60
71
  subscribe
61
72
  prepare_page
@@ -65,6 +76,10 @@ module Ferrum
65
76
  @browser.timeout
66
77
  end
67
78
 
79
+ def context
80
+ @browser.contexts.find_by(target_id: target_id)
81
+ end
82
+
68
83
  def go_to(url = nil)
69
84
  options = { url: combine_url!(url) }
70
85
  options.merge!(referrer: referrer) if referrer
@@ -76,6 +91,7 @@ module Ferrum
76
91
  net::ERR_CONNECTION_TIMED_OUT].include?(response["errorText"])
77
92
  raise StatusError, options[:url]
78
93
  end
94
+
79
95
  response["frameId"]
80
96
  rescue TimeoutError
81
97
  if @browser.pending_connection_errors
@@ -84,6 +100,7 @@ module Ferrum
84
100
  end
85
101
  end
86
102
  alias goto go_to
103
+ alias go go_to
87
104
 
88
105
  def close
89
106
  @headers.clear
@@ -108,10 +125,20 @@ module Ferrum
108
125
  fitWindow: false)
109
126
  end
110
127
 
128
+ def position
129
+ @browser.command("Browser.getWindowBounds", windowId: window_id).fetch("bounds").values_at("left", "top")
130
+ end
131
+
132
+ def position=(options)
133
+ @browser.command("Browser.setWindowBounds",
134
+ windowId: window_id,
135
+ bounds: { left: options[:left], top: options[:top] })
136
+ end
137
+
111
138
  def refresh
112
139
  command("Page.reload", wait: timeout, slowmoable: true)
113
140
  end
114
- alias_method :reload, :refresh
141
+ alias reload refresh
115
142
 
116
143
  def stop
117
144
  command("Page.stopLoading", slowmoable: true)
@@ -131,8 +158,7 @@ module Ferrum
131
158
  @event.set
132
159
  end
133
160
 
134
- def bypass_csp(value = true)
135
- enabled = !!value
161
+ def bypass_csp(enabled: true)
136
162
  command("Page.setBypassCSP", enabled: enabled)
137
163
  enabled
138
164
  end
@@ -146,14 +172,15 @@ module Ferrum
146
172
  end
147
173
 
148
174
  def command(method, wait: 0, slowmoable: false, **params)
149
- iteration = @event.reset if wait > 0
150
- sleep(@browser.slowmo) if slowmoable && @browser.slowmo > 0
175
+ iteration = @event.reset if wait.positive?
176
+ sleep(@browser.slowmo) if slowmoable && @browser.slowmo.positive?
151
177
  result = @client.command(method, params)
152
178
 
153
- if wait > 0
154
- @event.wait(wait) # Wait a bit after command and check if iteration has
155
- # changed which means there was some network event for
156
- # the main frame and it started to load new content.
179
+ if wait.positive?
180
+ @event.wait(wait)
181
+ # Wait a bit after command and check if iteration has
182
+ # changed which means there was some network event for
183
+ # the main frame and it started to load new content.
157
184
  if iteration != @event.iteration
158
185
  set = @event.wait(@browser.timeout)
159
186
  raise TimeoutError unless set
@@ -205,8 +232,19 @@ module Ferrum
205
232
 
206
233
  if @browser.js_errors
207
234
  on("Runtime.exceptionThrown") do |params|
208
- # FIXME https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/
209
- Thread.main.raise JavaScriptError.new(params.dig("exceptionDetails", "exception"))
235
+ # FIXME: https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/
236
+ Thread.main.raise JavaScriptError.new(
237
+ params.dig("exceptionDetails", "exception"),
238
+ params.dig("exceptionDetails", "stackTrace")
239
+ )
240
+ end
241
+ end
242
+
243
+ on(:dialog) do |dialog, _index, total|
244
+ if total == 1
245
+ warn "Dialog was shown but you didn't provide `on(:dialog)` callback, accepting it by default. " \
246
+ "Please take a look at https://github.com/rubycdp/ferrum#dialog"
247
+ dialog.accept
210
248
  end
211
249
  end
212
250
  end
@@ -219,8 +257,22 @@ module Ferrum
219
257
  command("Log.enable")
220
258
  command("Network.enable")
221
259
 
260
+ if @browser.proxy_options && @browser.proxy_options[:user] && @browser.proxy_options[:password]
261
+ auth_options = @browser.proxy_options.slice(:user, :password)
262
+ network.authorize(type: :proxy, **auth_options) do |request, _index, _total|
263
+ request.continue
264
+ end
265
+ end
266
+
222
267
  if @browser.options[:save_path]
223
- command("Page.setDownloadBehavior", behavior: "allow", downloadPath: @browser.options[:save_path])
268
+ unless Pathname.new(@browser.options[:save_path]).absolute?
269
+ raise Error, "supply absolute path for `:save_path` option"
270
+ end
271
+
272
+ @browser.command("Browser.setDownloadBehavior",
273
+ browserContextId: context.id,
274
+ downloadPath: browser.options[:save_path],
275
+ behavior: "allow", eventsEnabled: true)
224
276
  end
225
277
 
226
278
  @browser.extensions.each do |extension|
@@ -233,14 +285,14 @@ module Ferrum
233
285
  resize(width: width, height: height)
234
286
 
235
287
  response = command("Page.getNavigationHistory")
236
- if response.dig("entries", 0, "transitionType") != "typed"
237
- # If we create page by clicking links, submiting forms and so on it
238
- # opens a new window for which `frameStoppedLoading` event never
239
- # occurs and thus search for nodes cannot be completed. Here we check
240
- # the history and if the transitionType for example `link` then
241
- # content is already loaded and we can try to get the document.
242
- get_document_id
243
- end
288
+ return unless response.dig("entries", 0, "transitionType") != "typed"
289
+
290
+ # If we create page by clicking links, submitting forms and so on it
291
+ # opens a new window for which `frameStoppedLoading` event never
292
+ # occurs and thus search for nodes cannot be completed. Here we check
293
+ # the history and if the transitionType for example `link` then
294
+ # content is already loaded and we can try to get the document.
295
+ document_node_id
244
296
  end
245
297
 
246
298
  def inject_extensions
@@ -259,13 +311,15 @@ module Ferrum
259
311
  def history_navigate(delta:)
260
312
  history = command("Page.getNavigationHistory")
261
313
  index, entries = history.values_at("currentIndex", "entries")
314
+ entry = entries[index + delta]
262
315
 
263
- if entry = entries[index + delta]
264
- # Potential wait because of network event
265
- command("Page.navigateToHistoryEntry", wait: Mouse::CLICK_WAIT,
266
- slowmoable: true,
267
- entryId: entry["id"])
268
- end
316
+ return unless entry
317
+
318
+ # Potential wait because of network event
319
+ command("Page.navigateToHistoryEntry",
320
+ wait: Mouse::CLICK_WAIT,
321
+ slowmoable: true,
322
+ entryId: entry["id"])
269
323
  end
270
324
 
271
325
  def combine_url!(url_or_path)
@@ -279,8 +333,8 @@ module Ferrum
279
333
  (nil_or_relative ? @browser.base_url.join(url.to_s) : url).to_s
280
334
  end
281
335
 
282
- def get_document_id
283
- @document_id = command("DOM.getDocument", depth: 0).dig("root", "nodeId")
336
+ def document_node_id
337
+ command("DOM.getDocument", depth: 0).dig("root", "nodeId")
284
338
  end
285
339
  end
286
340
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "webrick"
5
+ require "webrick/httpproxy"
6
+
7
+ module Ferrum
8
+ class Proxy
9
+ def self.start(**args)
10
+ new(**args).tap(&:start)
11
+ end
12
+
13
+ attr_reader :host, :port, :user, :password
14
+
15
+ def initialize(host: "127.0.0.1", port: 0, user: nil, password: nil)
16
+ @file = nil
17
+ @host = host
18
+ @port = port
19
+ @user = user
20
+ @password = password
21
+ at_exit { stop }
22
+ end
23
+
24
+ def start
25
+ options = {
26
+ ProxyURI: nil, ServerType: Thread,
27
+ Logger: Logger.new(IO::NULL), AccessLog: [],
28
+ BindAddress: host, Port: port
29
+ }
30
+
31
+ if user && password
32
+ @file = Tempfile.new("htpasswd")
33
+ htpasswd = WEBrick::HTTPAuth::Htpasswd.new(@file.path)
34
+ htpasswd.set_passwd "Proxy Realm", user, password
35
+ htpasswd.flush
36
+ authenticator = WEBrick::HTTPAuth::ProxyBasicAuth.new(Realm: "Proxy Realm",
37
+ UserDB: htpasswd,
38
+ Logger: Logger.new(IO::NULL))
39
+ options.merge!(ProxyAuthProc: authenticator.method(:authenticate).to_proc)
40
+ end
41
+
42
+ @server = WEBrick::HTTPProxyServer.new(**options)
43
+ @server.start
44
+ @port = @server.config[:Port]
45
+ end
46
+
47
+ def rotate(host:, port:, user: nil, password: nil)
48
+ credentials = "#{user}:#{password}@" if user && password
49
+ proxy_uri = "schema://#{credentials}#{host}:#{port}"
50
+ @server.config[:ProxyURI] = URI.parse(proxy_uri)
51
+ end
52
+
53
+ def stop
54
+ @file&.unlink
55
+ @server.shutdown
56
+ end
57
+ end
58
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Ferrum
2
4
  class RGBA
3
5
  def initialize(red, green, blue, alpha)
@@ -23,13 +25,13 @@ module Ferrum
23
25
  end
24
26
 
25
27
  def validate_color(value)
26
- return if value && value.is_a?(Integer) && Range.new(0, 255).include?(value)
28
+ return if value.is_a?(Integer) && Range.new(0, 255).include?(value)
27
29
 
28
30
  raise ArgumentError, "Wrong value of #{value} should be Integer from 0 to 255"
29
31
  end
30
32
 
31
33
  def validate_alpha
32
- return if alpha && alpha.is_a?(Float) && Range.new(0.0, 1.0).include?(alpha)
34
+ return if alpha.is_a?(Float) && Range.new(0.0, 1.0).include?(alpha)
33
35
 
34
36
  raise ArgumentError,
35
37
  "Wrong alpha value #{alpha} should be Float between 0.0 (fully transparent) and 1.0 (fully opaque)"
data/lib/ferrum/target.rb CHANGED
@@ -9,6 +9,7 @@ module Ferrum
9
9
  attr_writer :page
10
10
 
11
11
  def initialize(browser, params = nil)
12
+ @page = nil
12
13
  @browser = browser
13
14
  @params = params
14
15
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ module Utils
5
+ module Attempt
6
+ module_function
7
+
8
+ def with_retry(errors:, max:, wait:)
9
+ attempts ||= 1
10
+ yield
11
+ rescue *Array(errors)
12
+ raise if attempts >= max
13
+
14
+ attempts += 1
15
+ sleep(wait)
16
+ retry
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent-ruby"
4
+
5
+ module Ferrum
6
+ module Utils
7
+ module ElapsedTime
8
+ module_function
9
+
10
+ def start
11
+ @start ||= monotonic_time
12
+ end
13
+
14
+ def elapsed_time(start = nil)
15
+ monotonic_time - (start || @start)
16
+ end
17
+
18
+ def monotonic_time
19
+ Concurrent.monotonic_time
20
+ end
21
+
22
+ def timeout?(start, timeout)
23
+ elapsed_time(start) > timeout
24
+ end
25
+ end
26
+ end
27
+ end