ferrum 0.1.0

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.
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ferrum
6
+ class Page
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
+ def scroll_to(top, left)
75
+ execute("window.scrollTo(#{top}, #{left})")
76
+ end
77
+
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)
156
+ quads = get_content_quads(node)
157
+ offset_x, offset_y = offset_x.to_i, offset_y.to_i
158
+
159
+ if offset_x > 0 || offset_y > 0
160
+ point = quads.first
161
+ [point[:x] + offset_x, point[:y] + offset_y]
162
+ else
163
+ x, y = quads.inject([0, 0]) do |memo, point|
164
+ [memo[0] + point[:x],
165
+ memo[1] + point[:y]]
166
+ end
167
+ [x / 4, y / 4]
168
+ end
169
+ end
170
+
171
+ def get_content_quads(node)
172
+ result = command("DOM.getContentQuads", nodeId: node.node_id)
173
+ raise "Node is either not visible or not an HTMLElement" if result["quads"].size == 0
174
+
175
+ # FIXME: Case when a few quads returned
176
+ result["quads"].map do |quad|
177
+ [{x: quad[0], y: quad[1]},
178
+ {x: quad[2], y: quad[3]},
179
+ {x: quad[4], y: quad[5]},
180
+ {x: quad[6], y: quad[7]}]
181
+ end.first
182
+ end
183
+
184
+ def to_options(hash)
185
+ hash.inject({}) { |memo, (k, v)| memo.merge(k.to_sym => v) }
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Page
5
+ module Net
6
+ def proxy_authorize(user, password)
7
+ if user && password
8
+ @proxy_username, @proxy_password = user, password
9
+ intercept_request("*")
10
+ end
11
+ end
12
+
13
+ def authorize(user, password)
14
+ @username, @password = user, password
15
+ intercept_request("*")
16
+ end
17
+
18
+ def intercept_request(patterns)
19
+ patterns = Array(patterns).map { |p| { urlPattern: p } }
20
+ @client.command("Network.setRequestInterception", patterns: patterns)
21
+ end
22
+
23
+ def continue_request(interception_id, options = nil)
24
+ options ||= {}
25
+ options = options.merge(interceptionId: interception_id)
26
+ @client.command("Network.continueInterceptedRequest", **options)
27
+ end
28
+
29
+ private
30
+
31
+ def subscribe
32
+ super if defined?(super)
33
+
34
+ @client.on("Network.loadingFailed") do |params|
35
+ # Free mutex as we aborted main request we are waiting for
36
+ if params["requestId"] == @request_id && params["canceled"] == true
37
+ @event.set
38
+ @document_id = get_document_id
39
+ end
40
+ end
41
+
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
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Page
5
+ module Runtime
6
+ EXECUTE_OPTIONS = {
7
+ returnByValue: true,
8
+ functionDeclaration: %Q(function() { %s })
9
+ }.freeze
10
+ DEFAULT_OPTIONS = {
11
+ functionDeclaration: %Q(function() { return %s })
12
+ }.freeze
13
+ EVALUATE_ASYNC_OPTIONS = {
14
+ awaitPromise: true,
15
+ functionDeclaration: %Q(
16
+ function() {
17
+ return new Promise((__resolve, __reject) => {
18
+ try {
19
+ arguments[arguments.length] = r => __resolve(r);
20
+ arguments.length = arguments.length + 1;
21
+ setTimeout(() => __reject(new Error("timed out promise")), %s);
22
+ %s
23
+ } catch(error) {
24
+ __reject(error);
25
+ }
26
+ });
27
+ }
28
+ )
29
+ }.freeze
30
+
31
+ def evaluate(expression, *args)
32
+ response = call(expression, nil, nil, *args)
33
+ handle(response)
34
+ end
35
+
36
+ def evaluate_in(context_id, expression)
37
+ response = call(expression, nil, { executionContextId: context_id })
38
+ handle(response)
39
+ end
40
+
41
+ def evaluate_on(node:, expression:, by_value: true, timeout: 0)
42
+ object_id = command("DOM.resolveNode", nodeId: node.node_id).dig("object", "objectId")
43
+ options = DEFAULT_OPTIONS.merge(objectId: object_id)
44
+ options[:functionDeclaration] = options[:functionDeclaration] % expression
45
+ options.merge!(returnByValue: by_value)
46
+
47
+ response = command("Runtime.callFunctionOn", timeout: timeout, **options)
48
+ .dig("result").tap { |r| handle_error(r) }
49
+
50
+ by_value ? response.dig("value") : handle(response)
51
+ end
52
+
53
+ def evaluate_async(expression, wait_time, *args)
54
+ response = call(expression, wait_time * 1000, EVALUATE_ASYNC_OPTIONS, *args)
55
+ handle(response)
56
+ end
57
+
58
+ def execute(expression, *args)
59
+ call(expression, nil, EXECUTE_OPTIONS, *args)
60
+ true
61
+ end
62
+
63
+ private
64
+
65
+ def call(expression, wait_time, options = nil, *args)
66
+ options ||= {}
67
+ args = prepare_args(args)
68
+
69
+ options = DEFAULT_OPTIONS.merge(options)
70
+ expression = [wait_time, expression] if wait_time
71
+ options[:functionDeclaration] = options[:functionDeclaration] % expression
72
+ options = options.merge(arguments: args)
73
+ unless options[:executionContextId]
74
+ options = options.merge(executionContextId: execution_context_id)
75
+ end
76
+
77
+ begin
78
+ attempts ||= 1
79
+ response = command("Runtime.callFunctionOn", **options)
80
+ response.dig("result").tap { |r| handle_error(r) }
81
+ rescue BrowserError => e
82
+ case e.message
83
+ when "No node with given id found",
84
+ "Could not find node with given id",
85
+ "Cannot find context with specified id"
86
+ sleep 0.1
87
+ attempts += 1
88
+ options = options.merge(executionContextId: execution_context_id)
89
+ retry if attempts <= 3
90
+ end
91
+ end
92
+ end
93
+
94
+ # FIXME: We should have a central place to handle all type of errors
95
+ def handle_error(result)
96
+ return if result["subtype"] != "error"
97
+
98
+ case result["description"]
99
+ when /\AError: timed out promise/
100
+ raise ScriptTimeoutError
101
+ else
102
+ raise JavaScriptError.new(result)
103
+ end
104
+ end
105
+
106
+ def prepare_args(args)
107
+ args.map do |arg|
108
+ if arg.is_a?(Node)
109
+ resolved = command("DOM.resolveNode", nodeId: arg.node_id)
110
+ { objectId: resolved["object"]["objectId"] }
111
+ elsif arg.is_a?(Hash) && arg["objectId"]
112
+ { objectId: arg["objectId"] }
113
+ else
114
+ { value: arg }
115
+ end
116
+ end
117
+ end
118
+
119
+ def handle(response)
120
+ case response["type"]
121
+ when "boolean", "number", "string"
122
+ response["value"]
123
+ when "undefined"
124
+ nil
125
+ when "function"
126
+ {}
127
+ when "object"
128
+ object_id = response["objectId"]
129
+
130
+ case response["subtype"]
131
+ when "node"
132
+ begin
133
+ node_id = command("DOM.requestNode", objectId: object_id)["nodeId"]
134
+ desc = command("DOM.describeNode", nodeId: node_id)["node"]
135
+ Node.new(self, target_id, node_id, desc)
136
+ rescue BrowserError => e
137
+ # Node has disappeared while we were trying to get it
138
+ raise if e.message != "Could not find node with given id"
139
+ end
140
+ when "array"
141
+ reduce_props(object_id, []) do |memo, key, value|
142
+ next(memo) unless (Integer(key) rescue nil)
143
+ value = value["objectId"] ? handle(value) : value["value"]
144
+ memo.insert(key.to_i, value)
145
+ end.compact
146
+ when "date"
147
+ response["description"]
148
+ when "null"
149
+ nil
150
+ else
151
+ reduce_props(object_id, {}) do |memo, key, value|
152
+ value = value["objectId"] ? handle(value) : value["value"]
153
+ memo.merge(key => value)
154
+ end
155
+ end
156
+ end
157
+ end
158
+
159
+ def reduce_props(object_id, to)
160
+ if cyclic?(object_id).dig("result", "value")
161
+ return "(cyclic structure)"
162
+ else
163
+ props = command("Runtime.getProperties", objectId: object_id)
164
+ props["result"].reduce(to) do |memo, prop|
165
+ next(memo) unless prop["enumerable"]
166
+ yield(memo, prop["name"], prop["value"])
167
+ end
168
+ end
169
+ end
170
+
171
+ def cyclic?(object_id)
172
+ command("Runtime.callFunctionOn",
173
+ objectId: object_id,
174
+ returnByValue: true,
175
+ functionDeclaration: <<~JS
176
+ function() {
177
+ if (Array.isArray(this) &&
178
+ this.every(e => e instanceof Node)) {
179
+ return false;
180
+ }
181
+
182
+ try {
183
+ JSON.stringify(this);
184
+ return false;
185
+ } catch (e) {
186
+ return true;
187
+ }
188
+ }
189
+ JS
190
+ )
191
+ end
192
+ end
193
+ end
194
+ end