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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +28 -0
- data/lib/ferrum.rb +25 -0
- data/lib/ferrum/browser.rb +145 -0
- data/lib/ferrum/browser/api.rb +14 -0
- data/lib/ferrum/browser/api/cookie.rb +46 -0
- data/lib/ferrum/browser/api/header.rb +32 -0
- data/lib/ferrum/browser/api/intercept.rb +32 -0
- data/lib/ferrum/browser/api/screenshot.rb +78 -0
- data/lib/ferrum/browser/client.rb +69 -0
- data/lib/ferrum/browser/process.rb +239 -0
- data/lib/ferrum/browser/subscriber.rb +26 -0
- data/lib/ferrum/browser/web_socket.rb +72 -0
- data/lib/ferrum/cookie.rb +47 -0
- data/lib/ferrum/errors.rb +94 -0
- data/lib/ferrum/network/error.rb +25 -0
- data/lib/ferrum/network/request.rb +33 -0
- data/lib/ferrum/network/response.rb +44 -0
- data/lib/ferrum/node.rb +175 -0
- data/lib/ferrum/page.rb +373 -0
- data/lib/ferrum/page/dom.rb +62 -0
- data/lib/ferrum/page/frame.rb +122 -0
- data/lib/ferrum/page/input.json +1341 -0
- data/lib/ferrum/page/input.rb +189 -0
- data/lib/ferrum/page/net.rb +92 -0
- data/lib/ferrum/page/runtime.rb +194 -0
- data/lib/ferrum/targets.rb +127 -0
- data/lib/ferrum/version.rb +5 -0
- metadata +245 -0
@@ -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
|