chromate-rb 0.0.1.pre → 0.0.2.pre
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/.rubocop.yml +1 -0
- data/CHANGELOG.md +54 -3
- data/README.md +33 -6
- data/Rakefile +48 -16
- data/docker_root/Gemfile +4 -0
- data/docker_root/Gemfile.lock +28 -0
- data/docker_root/TestInDocker.gif +0 -0
- data/docker_root/app.rb +92 -0
- data/dockerfiles/Dockerfile +21 -7
- data/dockerfiles/README.md +49 -0
- data/docs/README.md +74 -0
- data/docs/browser.md +149 -92
- data/docs/element.md +289 -0
- data/lib/bot_browser/downloader.rb +52 -0
- data/lib/bot_browser/installer.rb +81 -0
- data/lib/bot_browser.rb +39 -0
- data/lib/chromate/actions/dom.rb +28 -9
- data/lib/chromate/actions/navigate.rb +4 -5
- data/lib/chromate/actions/screenshot.rb +30 -11
- data/lib/chromate/actions/stealth.rb +47 -0
- data/lib/chromate/browser.rb +64 -12
- data/lib/chromate/c_logger.rb +7 -0
- data/lib/chromate/client.rb +40 -18
- data/lib/chromate/configuration.rb +31 -14
- data/lib/chromate/element.rb +65 -15
- data/lib/chromate/elements/select.rb +59 -7
- data/lib/chromate/hardwares/keyboard_controller.rb +34 -0
- data/lib/chromate/hardwares/keyboards/virtual_controller.rb +65 -0
- data/lib/chromate/hardwares/mouse_controller.rb +47 -11
- data/lib/chromate/hardwares/mouses/linux_controller.rb +124 -21
- data/lib/chromate/hardwares/mouses/mac_os_controller.rb +6 -6
- data/lib/chromate/hardwares/mouses/virtual_controller.rb +95 -7
- data/lib/chromate/hardwares/mouses/x11.rb +36 -0
- data/lib/chromate/hardwares.rb +16 -0
- data/lib/chromate/helpers.rb +22 -15
- data/lib/chromate/user_agent.rb +39 -15
- data/lib/chromate/version.rb +1 -1
- data/lib/chromate.rb +2 -0
- data/logo.png +0 -0
- data/results/bot.png +0 -0
- data/results/brotector.png +0 -0
- data/results/cloudflare.png +0 -0
- data/results/headers.png +0 -0
- data/results/pixelscan.png +0 -0
- metadata +20 -2
@@ -9,25 +9,27 @@ module Chromate
|
|
9
9
|
include Helpers
|
10
10
|
include Exceptions
|
11
11
|
DEFAULT_ARGS = [
|
12
|
-
'--no-first-run',
|
13
|
-
'--no-default-browser-check',
|
14
|
-
'--disable-blink-features=AutomationControlled',
|
15
|
-
'--disable-extensions',
|
16
|
-
'--disable-infobars',
|
17
|
-
'--no-sandbox',
|
18
|
-
'--
|
19
|
-
'--
|
20
|
-
'--disable-gpu',
|
21
|
-
'--disable-
|
12
|
+
'--no-first-run', # Skip the first run wizard
|
13
|
+
'--no-default-browser-check', # Disable the default browser check
|
14
|
+
'--disable-blink-features=AutomationControlled', # Disable the AutomationControlled feature
|
15
|
+
'--disable-extensions', # Disable extensions
|
16
|
+
'--disable-infobars', # Disable the infobar that asks if you want to install Chrome
|
17
|
+
'--no-sandbox', # Required for chrome devtools to work
|
18
|
+
'--test-type', # Remove the not allowed message for --no-sandbox flag
|
19
|
+
'--disable-dev-shm-usage', # Disable /dev/shm usage
|
20
|
+
'--disable-gpu', # Disable the GPU
|
21
|
+
'--disable-popup-blocking', # Disable popup blocking
|
22
|
+
'--ignore-certificate-errors', # Ignore certificate errors
|
22
23
|
'--window-size=1920,1080', # TODO: Make this automatic
|
23
|
-
'--hide-crash-restore-bubble'
|
24
|
+
'--hide-crash-restore-bubble' # Hide the crash restore bubble
|
24
25
|
].freeze
|
25
26
|
HEADLESS_ARGS = [
|
26
27
|
'--headless=new',
|
27
28
|
'--window-position=2400,2400'
|
28
29
|
].freeze
|
29
30
|
XVFB_ARGS = [
|
30
|
-
'--window-position=0,0'
|
31
|
+
'--window-position=0,0',
|
32
|
+
'--start-fullscreen'
|
31
33
|
].freeze
|
32
34
|
DISABLED_FEATURES = %w[
|
33
35
|
Translate
|
@@ -45,14 +47,15 @@ module Chromate
|
|
45
47
|
enable-automation
|
46
48
|
].freeze
|
47
49
|
|
48
|
-
attr_accessor :user_data_dir, :headless, :xfvb, :native_control, :
|
49
|
-
:disable_features
|
50
|
+
attr_accessor :user_data_dir, :headless, :xfvb, :native_control, :startup_patch,
|
51
|
+
:args, :headless_args, :xfvb_args, :exclude_switches, :proxy, :disable_features
|
50
52
|
|
51
53
|
def initialize
|
52
54
|
@user_data_dir = File.expand_path('~/.config/google-chrome/Default')
|
53
55
|
@headless = true
|
54
56
|
@xfvb = false
|
55
57
|
@native_control = false
|
58
|
+
@startup_patch = true
|
56
59
|
@proxy = nil
|
57
60
|
@args = [] + DEFAULT_ARGS
|
58
61
|
@headless_args = [] + HEADLESS_ARGS
|
@@ -63,18 +66,27 @@ module Chromate
|
|
63
66
|
@args << '--use-angle=metal' if mac?
|
64
67
|
end
|
65
68
|
|
69
|
+
# @return [Chromate::Configuration]
|
66
70
|
def self.config
|
67
71
|
@config ||= Configuration.new
|
68
72
|
end
|
69
73
|
|
74
|
+
# @yield [Chromate::Configuration]
|
70
75
|
def self.configure
|
71
76
|
yield(config)
|
72
77
|
end
|
73
78
|
|
79
|
+
# @return [Chromate::Configuration]
|
74
80
|
def config
|
75
81
|
self.class.config
|
76
82
|
end
|
77
83
|
|
84
|
+
# @return [Boolean]
|
85
|
+
def patch?
|
86
|
+
@startup_patch
|
87
|
+
end
|
88
|
+
|
89
|
+
# @return [String]
|
78
90
|
def chrome_path
|
79
91
|
return ENV['CHROME_BIN'] if ENV['CHROME_BIN']
|
80
92
|
|
@@ -89,6 +101,10 @@ module Chromate
|
|
89
101
|
end
|
90
102
|
end
|
91
103
|
|
104
|
+
# @option [Boolean] headless
|
105
|
+
# @option [Boolean] xfvb
|
106
|
+
# @option [Hash] proxy
|
107
|
+
# @option [Array<String>] disable_features
|
92
108
|
def generate_arguments(headless: @headless, xfvb: @xfvb, proxy: @proxy, disable_features: @disable_features, **_args)
|
93
109
|
dynamic_args = []
|
94
110
|
|
@@ -100,6 +116,7 @@ module Chromate
|
|
100
116
|
@args + dynamic_args
|
101
117
|
end
|
102
118
|
|
119
|
+
# @return [Hash]
|
103
120
|
def options
|
104
121
|
{
|
105
122
|
chrome_path: chrome_path,
|
data/lib/chromate/element.rb
CHANGED
@@ -13,7 +13,7 @@ module Chromate
|
|
13
13
|
super("Unable to resolve element with selector: #{selector}")
|
14
14
|
end
|
15
15
|
end
|
16
|
-
attr_reader :selector, :client
|
16
|
+
attr_reader :selector, :client
|
17
17
|
|
18
18
|
# @param [String] selector
|
19
19
|
# @param [Chromate::Client] client
|
@@ -26,36 +26,41 @@ module Chromate
|
|
26
26
|
@object_id = object_id
|
27
27
|
@node_id = node_id
|
28
28
|
@object_id, @node_id = find(selector, root_id) unless @object_id && @node_id
|
29
|
-
@root_id
|
30
|
-
|
29
|
+
@root_id = root_id || document['root']['nodeId']
|
30
|
+
end
|
31
|
+
|
32
|
+
# @return [Chromate::Hardwares::MouseController]
|
33
|
+
def mouse
|
34
|
+
@mouse ||= Hardwares.mouse(client: client, element: self)
|
31
35
|
end
|
32
36
|
|
37
|
+
# @return [Chromate::Hardwares::KeyboardController]
|
38
|
+
def keyboard
|
39
|
+
@keyboard ||= Hardwares.keyboard(client: client, element: self)
|
40
|
+
end
|
41
|
+
|
42
|
+
# @return [String]
|
33
43
|
def inspect
|
34
44
|
value = selector.length > 20 ? "#{selector[0..20]}..." : selector
|
35
45
|
"#<Chromate::Element:#{value}>"
|
36
46
|
end
|
37
47
|
|
48
|
+
# @return [String]
|
38
49
|
def text
|
39
|
-
return @text if @text
|
40
|
-
|
41
50
|
result = client.send_message('Runtime.callFunctionOn', functionDeclaration: 'function() { return this.innerText; }', objectId: @object_id)
|
42
|
-
|
51
|
+
result['result']['value']
|
43
52
|
end
|
44
53
|
|
45
54
|
# @return [String]
|
46
55
|
def html
|
47
|
-
|
48
|
-
|
49
|
-
@html = client.send_message('DOM.getOuterHTML', objectId: @object_id)
|
50
|
-
@html = @html['outerHTML']
|
56
|
+
html = client.send_message('DOM.getOuterHTML', objectId: @object_id)
|
57
|
+
html['outerHTML']
|
51
58
|
end
|
52
59
|
|
53
60
|
# @return [Hash]
|
54
61
|
def attributes
|
55
|
-
return @attributes if @attributes
|
56
|
-
|
57
62
|
result = client.send_message('DOM.getAttributes', nodeId: @node_id)
|
58
|
-
|
63
|
+
Hash[*result['attributes']]
|
59
64
|
end
|
60
65
|
|
61
66
|
# @param [String] name
|
@@ -94,6 +99,13 @@ module Chromate
|
|
94
99
|
bounding_box['height']
|
95
100
|
end
|
96
101
|
|
102
|
+
# @return [self]
|
103
|
+
def focus
|
104
|
+
client.send_message('DOM.focus', nodeId: @node_id)
|
105
|
+
|
106
|
+
self
|
107
|
+
end
|
108
|
+
|
97
109
|
# @return [self]
|
98
110
|
def click
|
99
111
|
mouse.click
|
@@ -109,9 +121,24 @@ module Chromate
|
|
109
121
|
end
|
110
122
|
|
111
123
|
# @param [String] text
|
124
|
+
# @return [self]
|
112
125
|
def type(text)
|
113
|
-
|
114
|
-
|
126
|
+
focus
|
127
|
+
keyboard.type(text)
|
128
|
+
|
129
|
+
self
|
130
|
+
end
|
131
|
+
|
132
|
+
# @return [self]
|
133
|
+
def press_enter
|
134
|
+
keyboard.press_key('Enter')
|
135
|
+
submit_parent_form
|
136
|
+
|
137
|
+
self
|
138
|
+
end
|
139
|
+
|
140
|
+
def drop_to(element)
|
141
|
+
mouse.drag_and_drop_to(element)
|
115
142
|
|
116
143
|
self
|
117
144
|
end
|
@@ -171,6 +198,8 @@ module Chromate
|
|
171
198
|
|
172
199
|
private
|
173
200
|
|
201
|
+
# @param [String] event
|
202
|
+
# @return [void]
|
174
203
|
def dispatch_event(event)
|
175
204
|
client.send_message('DOM.dispatchEvent', nodeId: @node_id, type: event)
|
176
205
|
end
|
@@ -190,5 +219,26 @@ module Chromate
|
|
190
219
|
def document
|
191
220
|
@document ||= client.send_message('DOM.getDocument')
|
192
221
|
end
|
222
|
+
|
223
|
+
def submit_parent_form
|
224
|
+
script = <<~JAVASCRIPT
|
225
|
+
function() {
|
226
|
+
const form = this.closest('form');
|
227
|
+
if (form) {
|
228
|
+
const submitEvent = new Event('submit', {
|
229
|
+
bubbles: true,
|
230
|
+
cancelable: true
|
231
|
+
});
|
232
|
+
if (form.dispatchEvent(submitEvent)) {
|
233
|
+
form.submit();
|
234
|
+
}
|
235
|
+
}
|
236
|
+
}
|
237
|
+
JAVASCRIPT
|
238
|
+
|
239
|
+
client.send_message('Runtime.callFunctionOn',
|
240
|
+
functionDeclaration: script,
|
241
|
+
objectId: @object_id)
|
242
|
+
end
|
193
243
|
end
|
194
244
|
end
|
@@ -5,14 +5,66 @@ require 'chromate/element'
|
|
5
5
|
module Chromate
|
6
6
|
module Elements
|
7
7
|
class Select < Element
|
8
|
-
# @param [String]
|
8
|
+
# @param [String] value
|
9
|
+
# @return [self]
|
9
10
|
def select_option(value)
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
11
|
+
script = javascript
|
12
|
+
|
13
|
+
client.send_message('Runtime.callFunctionOn',
|
14
|
+
functionDeclaration: script,
|
15
|
+
objectId: @object_id,
|
16
|
+
arguments: [{ value: value }])
|
17
|
+
|
18
|
+
self
|
19
|
+
rescue StandardError => e
|
20
|
+
raise ArgumentError, "Option '#{value}' not found in select" if e.message.include?('Option')
|
21
|
+
|
22
|
+
raise e
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [String|nil]
|
26
|
+
def selected_value
|
27
|
+
result = client.send_message('Runtime.callFunctionOn',
|
28
|
+
functionDeclaration: 'function() { return this.value; }',
|
29
|
+
objectId: @object_id)
|
30
|
+
result.dig('result', 'value')
|
31
|
+
end
|
32
|
+
|
33
|
+
# @return [String|nil]
|
34
|
+
def selected_text
|
35
|
+
result = client.send_message('Runtime.callFunctionOn',
|
36
|
+
functionDeclaration: 'function() {
|
37
|
+
const option = this.options[this.selectedIndex];
|
38
|
+
return option ? option.textContent.trim() : null;
|
39
|
+
}',
|
40
|
+
objectId: @object_id)
|
41
|
+
result.dig('result', 'value')
|
42
|
+
end
|
43
|
+
|
44
|
+
# @return [String]
|
45
|
+
def javascript
|
46
|
+
<<~JAVASCRIPT
|
47
|
+
function() {
|
48
|
+
this.focus();
|
49
|
+
this.dispatchEvent(new MouseEvent('mousedown'));
|
50
|
+
|
51
|
+
const options = Array.from(this.options);
|
52
|
+
const option = options.find(opt =>#{" "}
|
53
|
+
opt.value === arguments[0] || opt.textContent.trim() === arguments[0]
|
54
|
+
);
|
55
|
+
|
56
|
+
if (!option) {
|
57
|
+
throw new Error(`Option '${arguments[0]}' not found in select`);
|
58
|
+
}
|
59
|
+
|
60
|
+
this.value = option.value;
|
61
|
+
|
62
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
63
|
+
this.dispatchEvent(new Event('input', { bubbles: true }));
|
64
|
+
|
65
|
+
this.blur();
|
66
|
+
}
|
67
|
+
JAVASCRIPT
|
16
68
|
end
|
17
69
|
end
|
18
70
|
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Chromate
|
4
|
+
module Hardwares
|
5
|
+
class KeyboardController
|
6
|
+
attr_accessor :element, :client
|
7
|
+
|
8
|
+
# @param [Chromate::Element] element
|
9
|
+
# @param [Chromate::Client] client
|
10
|
+
def initialize(element: nil, client: nil)
|
11
|
+
@element = element
|
12
|
+
@client = client
|
13
|
+
@type_interval = rand(0.05..0.1)
|
14
|
+
end
|
15
|
+
|
16
|
+
# @param [String] key
|
17
|
+
# @return [self]
|
18
|
+
def press_key(_key)
|
19
|
+
raise NotImplementedError
|
20
|
+
end
|
21
|
+
|
22
|
+
# @param [String] text
|
23
|
+
# @return [self]
|
24
|
+
def type(text)
|
25
|
+
text.each_char do |char|
|
26
|
+
press_key(char)
|
27
|
+
sleep(@type_interval)
|
28
|
+
end
|
29
|
+
|
30
|
+
self
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Chromate
|
4
|
+
module Hardwares
|
5
|
+
module Keyboards
|
6
|
+
class VirtualController < Chromate::Hardwares::KeyboardController
|
7
|
+
def press_key(key = 'Enter')
|
8
|
+
params = {
|
9
|
+
key: key,
|
10
|
+
code: key_to_code(key),
|
11
|
+
windowsVirtualKeyCode: key_to_virtual_code(key)
|
12
|
+
}
|
13
|
+
|
14
|
+
params[:text] = key if key.length == 1
|
15
|
+
|
16
|
+
# Dispatch keyDown event
|
17
|
+
client.send_message('Input.dispatchKeyEvent', params.merge(type: 'keyDown'))
|
18
|
+
|
19
|
+
# Dispatch keyUp event
|
20
|
+
client.send_message('Input.dispatchKeyEvent', params.merge(type: 'keyUp'))
|
21
|
+
|
22
|
+
self
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
# @param [String] key
|
28
|
+
# @return [String]
|
29
|
+
def key_to_code(key)
|
30
|
+
case key
|
31
|
+
when 'Enter' then 'Enter'
|
32
|
+
when 'Tab' then 'Tab'
|
33
|
+
when 'Backspace' then 'Backspace'
|
34
|
+
when 'Delete' then 'Delete'
|
35
|
+
when 'Escape' then 'Escape'
|
36
|
+
when 'ArrowLeft' then 'ArrowLeft'
|
37
|
+
when 'ArrowRight' then 'ArrowRight'
|
38
|
+
when 'ArrowUp' then 'ArrowUp'
|
39
|
+
when 'ArrowDown' then 'ArrowDown'
|
40
|
+
else
|
41
|
+
"Key#{key.upcase}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# @param [String] key
|
46
|
+
# @return [Integer]
|
47
|
+
def key_to_virtual_code(key)
|
48
|
+
case key
|
49
|
+
when 'Enter' then 0x0D
|
50
|
+
when 'Tab' then 0x09
|
51
|
+
when 'Backspace' then 0x08
|
52
|
+
when 'Delete' then 0x2E
|
53
|
+
when 'Escape' then 0x1B
|
54
|
+
when 'ArrowLeft' then 0x25
|
55
|
+
when 'ArrowRight' then 0x27
|
56
|
+
when 'ArrowUp' then 0x26
|
57
|
+
when 'ArrowDown' then 0x28
|
58
|
+
else
|
59
|
+
key.upcase.ord
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -6,62 +6,98 @@ module Chromate
|
|
6
6
|
CLICK_DURATION_RANGE = (0.01..0.1)
|
7
7
|
DOUBLE_CLICK_DURATION_RANGE = (0.1..0.5)
|
8
8
|
|
9
|
-
|
9
|
+
def self.reset_mouse_position
|
10
|
+
@@mouse_position = { x: 0, y: 0 } # rubocop:disable Style/ClassVars
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_accessor :element, :client
|
10
14
|
|
11
15
|
# @param [Chromate::Element] element
|
12
16
|
# @param [Chromate::Client] client
|
13
17
|
def initialize(element: nil, client: nil)
|
14
18
|
@element = element
|
15
19
|
@client = client
|
16
|
-
@mouse_position = { x: 0, y: 0 }
|
17
20
|
end
|
18
21
|
|
22
|
+
# @return [Hash]
|
23
|
+
def mouse_position
|
24
|
+
@@mouse_position ||= { x: 0, y: 0 } # rubocop:disable Style/ClassVars
|
25
|
+
end
|
26
|
+
|
27
|
+
# @return [self]
|
19
28
|
def hover
|
20
29
|
raise NotImplementedError
|
21
30
|
end
|
22
31
|
|
32
|
+
# @return [self]
|
23
33
|
def click
|
24
34
|
raise NotImplementedError
|
25
35
|
end
|
26
36
|
|
37
|
+
# @return [self]
|
27
38
|
def double_click
|
28
39
|
raise NotImplementedError
|
29
40
|
end
|
30
41
|
|
42
|
+
# @return [self]
|
31
43
|
def right_click
|
32
44
|
raise NotImplementedError
|
33
45
|
end
|
34
46
|
|
47
|
+
# @params [Chromate::Element] element
|
48
|
+
# @return [self]
|
49
|
+
def drag_and_drop_to(element)
|
50
|
+
raise NotImplementedError
|
51
|
+
end
|
52
|
+
|
53
|
+
# @return [Integer]
|
35
54
|
def position_x
|
36
55
|
mouse_position[:x]
|
37
56
|
end
|
38
57
|
|
58
|
+
# @return [Integer]
|
39
59
|
def position_y
|
40
60
|
mouse_position[:y]
|
41
61
|
end
|
42
62
|
|
43
63
|
private
|
44
64
|
|
65
|
+
# @return [Integer]
|
45
66
|
def target_x
|
46
67
|
element.x + (element.width / 2)
|
47
68
|
end
|
48
69
|
|
70
|
+
# @return [Integer]
|
49
71
|
def target_y
|
50
72
|
element.y + (element.height / 2)
|
51
73
|
end
|
52
74
|
|
53
|
-
|
54
|
-
|
55
|
-
|
75
|
+
# @param [Integer] steps
|
76
|
+
# @return [Array<Hash>]
|
77
|
+
def bezier_curve(steps:, start_x: position_x, start_y: position_y, t_x: target_x, t_y: target_y) # rubocop:disable Metrics/AbcSize
|
78
|
+
# Points for the Bézier curve
|
79
|
+
control_x1 = start_x + (rand(50..150) * (t_x > start_x ? 1 : -1))
|
80
|
+
control_y1 = start_y + (rand(50..150) * (t_y > start_y ? 1 : -1))
|
81
|
+
control_x2 = t_x + (rand(50..150) * (t_x > start_x ? -1 : 1))
|
82
|
+
control_y2 = t_y + (rand(50..150) * (t_y > start_y ? -1 : 1))
|
56
83
|
|
57
|
-
(0..steps).map do |
|
58
|
-
t
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
{ x: new_x, y: new_y }
|
84
|
+
(0..steps).map do |i|
|
85
|
+
t = i.to_f / steps
|
86
|
+
x = (((1 - t)**3) * start_x) + (3 * ((1 - t)**2) * t * control_x1) + (3 * (1 - t) * (t**2) * control_x2) + ((t**3) * t_x)
|
87
|
+
y = (((1 - t)**3) * start_y) + (3 * ((1 - t)**2) * t * control_y1) + (3 * (1 - t) * (t**2) * control_y2) + ((t**3) * t_y)
|
88
|
+
{ x: x, y: y }
|
63
89
|
end
|
64
90
|
end
|
91
|
+
|
92
|
+
# @param [Integer] target_x
|
93
|
+
# @param [Integer] target_y
|
94
|
+
# @return [Hash]
|
95
|
+
def update_mouse_position(target_x, target_y)
|
96
|
+
@@mouse_position[:x] = target_x
|
97
|
+
@@mouse_position[:y] = target_y
|
98
|
+
|
99
|
+
mouse_position
|
100
|
+
end
|
65
101
|
end
|
66
102
|
end
|
67
103
|
end
|