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.
@@ -15,20 +15,32 @@ module Ferrum
15
15
  evaluate("document.documentElement.outerHTML")
16
16
  end
17
17
 
18
- def property(node, name)
19
- evaluate_on(node: node, expression: %Q(this["#{name}"]))
20
- end
21
-
22
- def select_file(node, value)
23
- command("DOM.setFileInputFiles", nodeId: node.node_id, files: Array(value))
24
- end
25
-
26
18
  def at_xpath(selector, within: nil)
27
- raise NotImplemented
19
+ xpath(selector, within: within).first
28
20
  end
29
21
 
22
+ # FIXME: Check within
30
23
  def xpath(selector, within: nil)
31
- raise NotImplemented
24
+ evaluate_async(%(
25
+ try {
26
+ let selector = arguments[0];
27
+ let within = arguments[1] || document;
28
+ let results = [];
29
+
30
+ let xpath = document.evaluate(selector, within, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
31
+ for (let i = 0; i < xpath.snapshotLength; i++) {
32
+ results.push(xpath.snapshotItem(i));
33
+ }
34
+
35
+ arguments[2](results);
36
+ } catch (error) {
37
+ // DOMException.INVALID_EXPRESSION_ERR is undefined, using pure code
38
+ if (error.code == DOMException.SYNTAX_ERR || error.code == 51) {
39
+ throw "Invalid Selector";
40
+ } else {
41
+ throw error;
42
+ }
43
+ }), timeout, selector, within)
32
44
  end
33
45
 
34
46
  def css(selector, within: nil)
@@ -54,8 +66,6 @@ module Ferrum
54
66
  def build_node(node_id)
55
67
  description = command("DOM.describeNode", nodeId: node_id)
56
68
  Node.new(self, target_id, node_id, description["node"])
57
- rescue BrowserError => e
58
- node_id.zero? ? raise(NodeError.new(nil, e.response)) : raise
59
69
  end
60
70
  end
61
71
  end
@@ -5,9 +5,9 @@ module Ferrum
5
5
  module Frame
6
6
  def execution_context_id
7
7
  context_id = current_execution_context_id
8
- raise NoExecutionContext unless context_id
8
+ raise NoExecutionContextError unless context_id
9
9
  context_id
10
- rescue NoExecutionContext
10
+ rescue NoExecutionContextError
11
11
  @event.reset
12
12
  @event.wait(timeout) ? retry : raise
13
13
  end
@@ -24,16 +24,17 @@ module Ferrum
24
24
  evaluate("document.title")
25
25
  end
26
26
 
27
- def switch_to_frame(handle)
28
- case handle
29
- when :parent
30
- @frame_stack.pop
31
- when :top
32
- @frame_stack = []
33
- else
34
- @frame_stack << handle
35
- inject_extensions
27
+ def within_frame(frame)
28
+ unless frame.is_a?(Node)
29
+ raise ArgumentError, "Node is expected, but #{frame.class} is given"
36
30
  end
31
+
32
+ frame_id = frame.description["frameId"]
33
+ @frame_stack << frame_id
34
+ inject_extensions
35
+ yield
36
+ ensure
37
+ @frame_stack.pop
37
38
  end
38
39
 
39
40
  private
@@ -53,7 +54,7 @@ module Ferrum
53
54
  @client.on("Page.frameNavigated") do |params|
54
55
  id = params["frame"]["id"]
55
56
  if frame = @frames[id]
56
- frame.merge!(params["frame"].select { |k, v| k == "name" || k == "url" })
57
+ frame.merge!(params["frame"].select { |k, _| k == "name" || k == "url" })
57
58
  end
58
59
  end
59
60
 
@@ -5,154 +5,11 @@ require "json"
5
5
  module Ferrum
6
6
  class Page
7
7
  module Input
8
- KEYS = JSON.parse(File.read(File.expand_path("../input.json", __FILE__)))
9
- MODIFIERS = { "alt" => 1, "ctrl" => 2, "control" => 2, "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 click(node, keys = [], offset = {})
30
- x, y, modifiers = prepare_before_click(__method__, node, keys, offset)
31
- command("Input.dispatchMouseEvent", type: "mousePressed", modifiers: modifiers, button: "left", x: x, y: y, clickCount: 1)
32
- # Potential wait because if network event is triggered then we have to wait until it's over.
33
- command("Input.dispatchMouseEvent", timeout: 0.05, type: "mouseReleased", modifiers: modifiers, button: "left", x: x, y: y, clickCount: 1)
34
- end
35
-
36
- def right_click(node, keys = [], offset = {})
37
- x, y, modifiers = prepare_before_click(__method__, node, keys, offset)
38
- command("Input.dispatchMouseEvent", type: "mousePressed", modifiers: modifiers, button: "right", x: x, y: y, clickCount: 1)
39
- command("Input.dispatchMouseEvent", type: "mouseReleased", modifiers: modifiers, button: "right", x: x, y: y, clickCount: 1)
40
- end
41
-
42
- def double_click(node, keys = [], offset = {})
43
- x, y, modifiers = prepare_before_click(__method__, node, keys, offset)
44
- command("Input.dispatchMouseEvent", type: "mousePressed", modifiers: modifiers, button: "left", x: x, y: y, clickCount: 2)
45
- command("Input.dispatchMouseEvent", type: "mouseReleased", modifiers: modifiers, button: "left", x: x, y: y, clickCount: 2)
46
- end
47
-
48
- def click_coordinates(x, y)
49
- command("Input.dispatchMouseEvent", type: "mousePressed", button: "left", x: x, y: y, clickCount: 1)
50
- # Potential wait because if network event is triggered then we have to wait until it's over.
51
- command("Input.dispatchMouseEvent", timeout: 0.05, type: "mouseReleased", button: "left", x: x, y: y, clickCount: 1)
52
- end
53
-
54
- def focus(node)
55
- command("DOM.focus", nodeId: node.node_id)
56
- end
57
-
58
- def hover(node)
59
- raise NotImplemented
60
- end
61
-
62
- def set(node, value)
63
- raise NotImplemented
64
- end
65
-
66
- def select(node, value)
67
- raise NotImplemented
68
- end
69
-
70
- def trigger(node, event)
71
- raise NotImplemented
72
- end
73
-
74
8
  def scroll_to(top, left)
75
9
  execute("window.scrollTo(#{top}, #{left})")
76
10
  end
77
11
 
78
- def send_keys(node, keys)
79
- # click(node)
80
- # focus(node)
81
-
82
- keys = normalize_keys(Array(keys))
83
-
84
- keys.each do |key|
85
- type = key[:text] ? "keyDown" : "rawKeyDown"
86
- command("Input.dispatchKeyEvent", type: type, **key)
87
- command("Input.dispatchKeyEvent", type: "keyUp", **key)
88
- end
89
- end
90
-
91
- def normalize_keys(keys, pressed_keys = [], memo = [])
92
- case keys
93
- when Array
94
- pressed_keys.push([])
95
- memo += combine_strings(keys).map { |k| normalize_keys(k, pressed_keys, memo) }
96
- pressed_keys.pop
97
- memo.flatten.compact
98
- when Symbol
99
- key = keys.to_s.downcase
100
-
101
- if MODIFIERS.keys.include?(key)
102
- pressed_keys.last.push(key)
103
- nil
104
- else
105
- _key = KEYS.fetch(KEYS_MAPPING[key.to_sym] || key.to_sym)
106
- _key[:modifiers] = pressed_keys.flatten.map { |k| MODIFIERS[k] }.reduce(0, :|)
107
- to_options(_key)
108
- end
109
- when String
110
- pressed = pressed_keys.flatten
111
- keys.each_char.map do |char|
112
- if pressed.empty?
113
- key = KEYS[char] || {}
114
- key = key.merge(text: char, unmodifiedText: char)
115
- [to_options(key)]
116
- else
117
- key = KEYS[char] || {}
118
- text = pressed == ["shift"] ? char.upcase : char
119
- key = key.merge(
120
- text: text,
121
- unmodifiedText: text,
122
- isKeypad: key["location"] == 3,
123
- modifiers: pressed.map { |k| MODIFIERS[k] }.reduce(0, :|),
124
- )
125
-
126
- modifiers = pressed.map { |k| to_options(KEYS.fetch(KEYS_MAPPING[k.to_sym])) }
127
- modifiers + [to_options(key)]
128
- end.flatten
129
- end
130
- end
131
- end
132
-
133
- def combine_strings(keys)
134
- keys
135
- .chunk { |k| k.is_a?(String) }
136
- .map { |s, k| s ? [k.reduce(&:+)] : k }
137
- .reduce(&:+)
138
- end
139
-
140
- private
141
-
142
- def prepare_before_click(name, node, keys, offset)
143
- # FIXME: scrollIntoViewport
144
- # evaluate_on(node: node, expression: "this.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'})")
145
-
146
- x, y = calculate_quads(node, offset[:x], offset[:y])
147
-
148
- modifiers = keys.map { |k| MODIFIERS[k.to_s] }.compact.reduce(0, :|)
149
-
150
- command("Input.dispatchMouseEvent", type: "mouseMoved", x: x, y: y)
151
-
152
- [x, y, modifiers]
153
- end
154
-
155
- def calculate_quads(node, offset_x = nil, offset_y = nil)
12
+ def find_position(node, offset_x = nil, offset_y = nil)
156
13
  quads = get_content_quads(node)
157
14
  offset_x, offset_y = offset_x.to_i, offset_y.to_i
158
15
 
@@ -168,6 +25,8 @@ module Ferrum
168
25
  end
169
26
  end
170
27
 
28
+ private
29
+
171
30
  def get_content_quads(node)
172
31
  result = command("DOM.getContentQuads", nodeId: node.node_id)
173
32
  raise "Node is either not visible or not an HTMLElement" if result["quads"].size == 0
@@ -180,10 +39,6 @@ module Ferrum
180
39
  {x: quad[6], y: quad[7]}]
181
40
  end.first
182
41
  end
183
-
184
- def to_options(hash)
185
- hash.inject({}) { |memo, (k, v)| memo.merge(k.to_sym => v) }
186
- end
187
42
  end
188
43
  end
189
44
  end
@@ -3,27 +3,75 @@
3
3
  module Ferrum
4
4
  class Page
5
5
  module Net
6
+ RESOURCE_TYPES = %w[Document Stylesheet Image Media Font Script TextTrack
7
+ XHR Fetch EventSource WebSocket Manifest
8
+ SignedExchange Ping CSPViolationReport Other]
9
+
6
10
  def proxy_authorize(user, password)
11
+ @proxy_authorized_ids ||= []
12
+
7
13
  if user && password
8
- @proxy_username, @proxy_password = user, password
9
- intercept_request("*")
14
+ intercept_request do |request, index, total|
15
+ if request.auth_challenge?(:proxy)
16
+ response = authorized_response(@proxy_authorized_ids,
17
+ request.interception_id,
18
+ user, password)
19
+ @proxy_authorized_ids << request.interception_id
20
+ request.continue(authChallengeResponse: response)
21
+ elsif index + 1 < total
22
+ next # There are other callbacks that can handle this, skip
23
+ else
24
+ request.continue
25
+ end
26
+ end
10
27
  end
11
28
  end
12
29
 
13
30
  def authorize(user, password)
14
- @username, @password = user, password
15
- intercept_request("*")
31
+ @authorized_ids ||= []
32
+
33
+ intercept_request do |request, index, total|
34
+ if request.auth_challenge?(:server)
35
+ response = authorized_response(@authorized_ids,
36
+ request.interception_id,
37
+ user, password)
38
+
39
+ @authorized_ids << request.interception_id
40
+ request.continue(authChallengeResponse: response)
41
+ elsif index + 1 < total
42
+ next # There are other callbacks that can handle this, skip
43
+ else
44
+ request.continue
45
+ end
46
+ end
47
+ end
48
+
49
+ def intercept_request(pattern: "*", resource_type: nil, &block)
50
+ pattern = { urlPattern: pattern }
51
+ if resource_type && RESOURCE_TYPES.include?(resource_type.to_s)
52
+ pattern[:resourceType] = resource_type
53
+ end
54
+
55
+ command("Network.setRequestInterception", patterns: [pattern])
56
+
57
+ on_request_intercepted(&block) if block_given?
16
58
  end
17
59
 
18
- def intercept_request(patterns)
19
- patterns = Array(patterns).map { |p| { urlPattern: p } }
20
- @client.command("Network.setRequestInterception", patterns: patterns)
60
+ def on_request_intercepted(&block)
61
+ @client.on("Network.requestIntercepted") do |params, index, total|
62
+ request = Network::InterceptedRequest.new(self, params)
63
+ block.call(request, index, total)
64
+ end
21
65
  end
22
66
 
23
67
  def continue_request(interception_id, options = nil)
24
68
  options ||= {}
25
69
  options = options.merge(interceptionId: interception_id)
26
- @client.command("Network.continueInterceptedRequest", **options)
70
+ command("Network.continueInterceptedRequest", **options)
71
+ end
72
+
73
+ def abort_request(interception_id)
74
+ continue_request(interception_id, errorReason: "Aborted")
27
75
  end
28
76
 
29
77
  private
@@ -38,53 +86,17 @@ module Ferrum
38
86
  @document_id = get_document_id
39
87
  end
40
88
  end
89
+ end
41
90
 
42
- @client.on("Network.requestIntercepted") do |params|
43
- @authorized_ids ||= []
44
- @proxy_authorized_ids ||= []
45
- url = params.dig("request", "url")
46
- interception_id = params["interceptionId"]
47
-
48
- if params["authChallenge"]
49
- response = if params.dig("authChallenge", "source") == "Proxy"
50
- if @proxy_authorized_ids.include?(interception_id)
51
- { response: "CancelAuth" }
52
- elsif @proxy_username && @proxy_password
53
- { response: "ProvideCredentials",
54
- username: @proxy_username,
55
- password: @proxy_password }
56
- else
57
- { response: "CancelAuth" }
58
- end
59
- else
60
- if @authorized_ids.include?(interception_id)
61
- { response: "CancelAuth" }
62
- elsif @username && @password
63
- { response: "ProvideCredentials",
64
- username: @username,
65
- password: @password }
66
- else
67
- { response: "CancelAuth" }
68
- end
69
- end
70
-
71
- @authorized_ids << interception_id
72
- continue_request(interception_id, authChallengeResponse: response)
73
- elsif @browser.url_blacklist && !@browser.url_blacklist.empty?
74
- if @browser.url_blacklist.any? { |r| r.match(url) }
75
- continue_request(interception_id, errorReason: "Aborted")
76
- else
77
- continue_request(interception_id)
78
- end
79
- elsif @browser.url_whitelist && !@browser.url_whitelist.empty?
80
- if @browser.url_whitelist.any? { |r| r.match(url) }
81
- continue_request(interception_id)
82
- else
83
- continue_request(interception_id, errorReason: "Aborted")
84
- end
85
- else
86
- continue_request(interception_id)
87
- end
91
+ def authorized_response(ids, interception_id, username, password)
92
+ if ids.include?(interception_id)
93
+ { response: "CancelAuth" }
94
+ elsif username && password
95
+ { response: "ProvideCredentials",
96
+ username: username,
97
+ password: password }
98
+ else
99
+ { response: "CancelAuth" }
88
100
  end
89
101
  end
90
102
  end
@@ -5,14 +5,14 @@ module Ferrum
5
5
  module Runtime
6
6
  EXECUTE_OPTIONS = {
7
7
  returnByValue: true,
8
- functionDeclaration: %Q(function() { %s })
8
+ functionDeclaration: %(function() { %s })
9
9
  }.freeze
10
10
  DEFAULT_OPTIONS = {
11
- functionDeclaration: %Q(function() { return %s })
11
+ functionDeclaration: %(function() { return %s })
12
12
  }.freeze
13
13
  EVALUATE_ASYNC_OPTIONS = {
14
14
  awaitPromise: true,
15
- functionDeclaration: %Q(
15
+ functionDeclaration: %(
16
16
  function() {
17
17
  return new Promise((__resolve, __reject) => {
18
18
  try {
@@ -42,7 +42,11 @@ module Ferrum
42
42
  end
43
43
 
44
44
  def evaluate_on(node:, expression:, by_value: true, timeout: 0)
45
- rescue_intermittent_error do
45
+ errors = [NodeNotFoundError, NoExecutionContextError]
46
+ max = ENV.fetch("FERRUM_INTERMITTENT_ATTEMPTS", 6).to_i
47
+ wait = ENV.fetch("FERRUM_INTERMITTENT_SLEEP", 0.1).to_f
48
+
49
+ Ferrum.with_attempts(errors: errors, max: max, wait: wait) do
46
50
  response = command("DOM.resolveNode", nodeId: node.node_id)
47
51
  object_id = response.dig("object", "objectId")
48
52
  options = DEFAULT_OPTIONS.merge(objectId: object_id)
@@ -60,7 +64,11 @@ module Ferrum
60
64
  private
61
65
 
62
66
  def call(*args, expression:, wait_time: nil, handle: true, **options)
63
- rescue_intermittent_error do
67
+ errors = [NodeNotFoundError, NoExecutionContextError]
68
+ max = ENV.fetch("FERRUM_INTERMITTENT_ATTEMPTS", 6).to_i
69
+ wait = ENV.fetch("FERRUM_INTERMITTENT_SLEEP", 0.1).to_f
70
+
71
+ Ferrum.with_attempts(errors: errors, max: max, wait: wait) do
64
72
  arguments = prepare_args(args)
65
73
  params = DEFAULT_OPTIONS.merge(options)
66
74
  expression = [wait_time, expression] if wait_time
@@ -174,20 +182,6 @@ module Ferrum
174
182
  JS
175
183
  )
176
184
  end
177
-
178
- def rescue_intermittent_error(max = 6)
179
- attempts ||= 0
180
- yield
181
- rescue BrowserError => e
182
- case e.message
183
- when "No node with given id found", # Node has disappeared while we were trying to get it
184
- "Could not find node with given id",
185
- "Cannot find context with specified id" # Context is lost, page is reloading
186
- sleep 0.1
187
- attempts += 1
188
- attempts < max ? retry : raise
189
- end
190
- end
191
185
  end
192
186
  end
193
187
  end