chromate-rb 0.0.2.pre → 0.0.3.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -0
  3. data/CHANGELOG.md +20 -2
  4. data/Rakefile +2 -2
  5. data/docker_root/app.rb +6 -11
  6. data/docs/BOT_BROWSER.md +74 -0
  7. data/docs/browser.md +20 -55
  8. data/docs/client.md +126 -0
  9. data/docs/element.md +77 -1
  10. data/docs/elements/checkbox.md +69 -0
  11. data/docs/elements/radio.md +57 -0
  12. data/lib/bot_browser/downloader.rb +38 -26
  13. data/lib/bot_browser/installer.rb +27 -9
  14. data/lib/bot_browser.rb +5 -1
  15. data/lib/chromate/actions/dom.rb +24 -35
  16. data/lib/chromate/actions/navigate.rb +3 -0
  17. data/lib/chromate/actions/screenshot.rb +52 -14
  18. data/lib/chromate/actions/stealth.rb +38 -23
  19. data/lib/chromate/binary.rb +83 -0
  20. data/lib/chromate/browser.rb +70 -26
  21. data/lib/chromate/c_logger.rb +1 -0
  22. data/lib/chromate/client.rb +25 -8
  23. data/lib/chromate/configuration.rb +2 -2
  24. data/lib/chromate/element.rb +62 -9
  25. data/lib/chromate/elements/checkbox.rb +40 -0
  26. data/lib/chromate/elements/option.rb +43 -0
  27. data/lib/chromate/elements/radio.rb +37 -0
  28. data/lib/chromate/elements/select.rb +10 -18
  29. data/lib/chromate/elements/tags.rb +29 -0
  30. data/lib/chromate/exceptions.rb +2 -0
  31. data/lib/chromate/files/agents.json +11 -0
  32. data/lib/chromate/files/stealth.js +199 -0
  33. data/lib/chromate/hardwares/keyboard_controller.rb +11 -0
  34. data/lib/chromate/hardwares/mouse_controller.rb +8 -0
  35. data/lib/chromate/hardwares.rb +4 -4
  36. data/lib/chromate/user_agent.rb +14 -12
  37. data/lib/chromate/version.rb +1 -1
  38. data/results/bot.png +0 -0
  39. data/results/brotector.png +0 -0
  40. data/results/cloudflare.png +0 -0
  41. data/results/pixelscan.png +0 -0
  42. metadata +27 -2
@@ -6,10 +6,15 @@ require 'securerandom'
6
6
  require 'net/http'
7
7
  require 'websocket-client-simple'
8
8
  require_relative 'helpers'
9
+ require_relative 'binary'
9
10
  require_relative 'client'
10
- require_relative 'element'
11
11
  require_relative 'hardwares'
12
+ require_relative 'element'
12
13
  require_relative 'elements/select'
14
+ require_relative 'elements/option'
15
+ require_relative 'elements/tags'
16
+ require_relative 'elements/radio'
17
+ require_relative 'elements/checkbox'
13
18
  require_relative 'user_agent'
14
19
  require_relative 'actions/navigate'
15
20
  require_relative 'actions/screenshot'
@@ -41,19 +46,13 @@ module Chromate
41
46
  @xfvb = @options.fetch(:xfvb)
42
47
  @native_control = @options.fetch(:native_control)
43
48
  @record = @options.fetch(:record, false)
44
- @process = nil
45
- @xfvb_process = nil
49
+ @binary = nil
46
50
  @record_process = nil
47
51
  @client = nil
48
- @args = [
49
- @chrome_path,
50
- "--user-data-dir=#{@user_data_dir}"
51
- ]
52
+ @args = []
52
53
 
53
54
  trap('INT') { stop_and_exit }
54
55
  trap('TERM') { stop_and_exit }
55
-
56
- at_exit { stop }
57
56
  end
58
57
 
59
58
  # @return [self]
@@ -72,25 +71,34 @@ module Chromate
72
71
 
73
72
  Hardwares::MouseController.reset_mouse_position
74
73
  Chromate::CLogger.log("Starting browser with args: #{@args}", level: :debug)
75
- @process = spawn(*@args, err: 'chrome_errors.log', out: 'chrome_output.log')
76
- sleep 2
74
+ @binary = Binary.new(@chrome_path, @args)
77
75
 
76
+ @binary.start
78
77
  @client.start
79
78
 
80
79
  start_video_recording if @record
81
80
 
82
81
  patch if config.patch?
83
82
 
83
+ update_config!
84
+
84
85
  self
85
86
  end
86
87
 
88
+ # @return [Boolean]
89
+ def started?
90
+ @binary&.started? || false
91
+ end
92
+
87
93
  # @return [self]
88
94
  def stop
89
- stop_process(@process) if @process
90
95
  stop_process(@record_process) if @record_process
91
- stop_process(@xfvb_process) if @xfvb_process
96
+ @binary.stop if started?
92
97
  @client&.stop
93
98
 
99
+ @binary = nil
100
+ @record_process = nil
101
+
94
102
  self
95
103
  end
96
104
 
@@ -104,30 +112,67 @@ module Chromate
104
112
  # @return [Integer]
105
113
  def start_video_recording
106
114
  outname = @record.is_a?(String) ? @record : "output_video_#{Time.now.to_i}.mp4"
107
- outfile = File.join(Dir.pwd, outname)
108
- # TODO: get screen resolution dynamically
109
- @record_process = spawn(
110
- "ffmpeg -f x11grab -draw_mouse 1 -r 30 -s 1920x1080 -i #{ENV.fetch("DISPLAY")} -c:v libx264 -preset ultrafast -pix_fmt yuv420p -y #{outfile}"
111
- )
115
+ outfile = File.join(Dir.pwd, outname).to_s
116
+ args = [
117
+ '-f',
118
+ 'x11grab',
119
+ '-draw_mouse',
120
+ '1',
121
+ '-r',
122
+ '30',
123
+ '-s',
124
+ '1920x1080',
125
+ '-i',
126
+ ENV.fetch('DISPLAY'),
127
+ '-c:v',
128
+ 'libx264',
129
+ '-preset',
130
+ 'ultrafast',
131
+ '-pix_fmt',
132
+ 'yuv420p',
133
+ '-y',
134
+ outfile
135
+ ]
136
+ binary = Binary.new('ffmpeg', args)
137
+ binary.start
138
+ @record_process = binary.pid
112
139
  end
113
140
 
114
141
  # @return [Array<String>]
115
142
  def build_args
116
143
  exclude_switches = config.exclude_switches || []
117
144
  exclude_switches += @options[:exclude_switches] if @options[:exclude_switches]
145
+ @user_agent = @options[:user_agent] || UserAgent.call
118
146
 
119
- if @options.dig(:options, :args)
120
- @args += @options[:options][:args]
121
- @args << "--exclude-switches=#{exclude_switches.join(",")}" if exclude_switches.any?
122
- return @args
123
- end
124
- @args += config.generate_arguments(**@options)
125
- @args << "--user-agent=#{@options[:user_agent] || UserAgent.call}"
147
+ @args = if @options.dig(:options, :args)
148
+ @options[:options][:args]
149
+ else
150
+ config.generate_arguments(**@options)
151
+ end
152
+
153
+ @args << "--user-agent=#{@user_agent}"
126
154
  @args << "--exclude-switches=#{exclude_switches.join(",")}" if exclude_switches.any?
155
+ @args << "--user-data-dir=#{@user_data_dir}"
127
156
 
128
157
  @args
129
158
  end
130
159
 
160
+ def set_hardwares
161
+ config.mouse_controller = Hardwares.mouse(client: @client, element: nil)
162
+ config.keyboard_controller = Hardwares.keyboard(client: @client, element: nil)
163
+ end
164
+
165
+ # @return [void]
166
+ def update_config!
167
+ config.args = @args
168
+ config.user_data_dir = @user_data_dir
169
+ config.headless = @headless
170
+ config.xfvb = @xfvb
171
+ config.native_control = @native_control
172
+
173
+ set_hardwares
174
+ end
175
+
131
176
  # @param pid [Integer] PID of the process to stop
132
177
  # @param timeout [Integer] Timeout in seconds to wait for the process to stop
133
178
  # @return [void]
@@ -144,7 +189,6 @@ module Chromate
144
189
  # If the process does not stop gracefully, send SIGKILL
145
190
  CLogger.log("Process #{pid} did not stop gracefully. Sending SIGKILL...", level: :debug)
146
191
  Process.kill('KILL', pid)
147
- Process.wait(pid)
148
192
  end
149
193
  rescue Errno::ESRCH
150
194
  # The process has already stopped
@@ -12,6 +12,7 @@ module Chromate
12
12
  self.formatter = proc do |severity, datetime, _progname, msg|
13
13
  "[Chromate] #{datetime.strftime("%Y-%m-%d %H:%M:%S")} #{severity}: #{msg}\n"
14
14
  end
15
+ self.level = ENV['CHROMATE_DEBUG'] ? :debug : :info
15
16
  end
16
17
 
17
18
  # @return [Chromate::CLogger]
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'websocket-client-simple'
4
4
  require 'chromate/helpers'
5
+ require 'chromate/exceptions'
5
6
 
6
7
  module Chromate
7
8
  class Client
@@ -117,16 +118,32 @@ module Chromate
117
118
 
118
119
  # @return [String]
119
120
  def fetch_websocket_debug_url
120
- uri = URI("http://localhost:#{@port}/json/list")
121
- response = Net::HTTP.get(uri)
122
- targets = JSON.parse(response)
121
+ retries = 0
122
+ max_retries = 5
123
+ base_delay = 0.5
123
124
 
124
- page_target = targets.find { |target| target['type'] == 'page' }
125
+ begin
126
+ uri = URI("http://localhost:#{@port}/json/list")
127
+ response = Net::HTTP.get(uri)
128
+ targets = JSON.parse(response)
129
+
130
+ page_target = targets.find { |target| target['type'] == 'page' }
131
+ websocket_url = if page_target
132
+ page_target['webSocketDebuggerUrl']
133
+ else
134
+ create_new_page_target
135
+ end
136
+ raise Exceptions::DebugURLError, 'Can\'t get WebSocket URL' if websocket_url.nil?
137
+
138
+ websocket_url
139
+ rescue StandardError => e
140
+ retries += 1
141
+ raise Exceptions::ConnectionTimeoutError, "Can't get WebSocket URL after #{max_retries} retries" if retries >= max_retries
125
142
 
126
- if page_target
127
- page_target['webSocketDebuggerUrl']
128
- else
129
- create_new_page_target
143
+ delay = base_delay * (2**retries) # Exponential delay: 0.5s, 1s, 2s, 4s, 8s
144
+ Chromate::CLogger.log("Attempting to reconnect in #{delay} seconds, #{e.message}", level: :debug)
145
+ sleep delay
146
+ retry
130
147
  end
131
148
  end
132
149
 
@@ -17,7 +17,6 @@ module Chromate
17
17
  '--no-sandbox', # Required for chrome devtools to work
18
18
  '--test-type', # Remove the not allowed message for --no-sandbox flag
19
19
  '--disable-dev-shm-usage', # Disable /dev/shm usage
20
- '--disable-gpu', # Disable the GPU
21
20
  '--disable-popup-blocking', # Disable popup blocking
22
21
  '--ignore-certificate-errors', # Ignore certificate errors
23
22
  '--window-size=1920,1080', # TODO: Make this automatic
@@ -48,7 +47,8 @@ module Chromate
48
47
  ].freeze
49
48
 
50
49
  attr_accessor :user_data_dir, :headless, :xfvb, :native_control, :startup_patch,
51
- :args, :headless_args, :xfvb_args, :exclude_switches, :proxy, :disable_features
50
+ :args, :headless_args, :xfvb_args, :exclude_switches, :proxy, :disable_features,
51
+ :mouse_controller, :keyboard_controller
52
52
 
53
53
  def initialize
54
54
  @user_data_dir = File.expand_path('~/.config/google-chrome/Default')
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'chromate/elements/tags'
4
+
3
5
  module Chromate
4
6
  class Element
7
+ include Elements::Tags
8
+
5
9
  class NotFoundError < StandardError
6
10
  def initialize(selector, root_id)
7
11
  super("Element not found with selector: #{selector} under root_id: #{root_id}")
@@ -13,7 +17,7 @@ module Chromate
13
17
  super("Unable to resolve element with selector: #{selector}")
14
18
  end
15
19
  end
16
- attr_reader :selector, :client
20
+ attr_reader :selector, :client, :root_id, :object_id, :node_id
17
21
 
18
22
  # @param [String] selector
19
23
  # @param [Chromate::Client] client
@@ -25,18 +29,18 @@ module Chromate
25
29
  @client = client
26
30
  @object_id = object_id
27
31
  @node_id = node_id
28
- @object_id, @node_id = find(selector, root_id) unless @object_id && @node_id
32
+ @object_id, @node_id = magick_find(selector, root_id) unless @object_id && @node_id
29
33
  @root_id = root_id || document['root']['nodeId']
30
34
  end
31
35
 
32
36
  # @return [Chromate::Hardwares::MouseController]
33
37
  def mouse
34
- @mouse ||= Hardwares.mouse(client: client, element: self)
38
+ Chromate.configuration.mouse_controller.set_element(self)
35
39
  end
36
40
 
37
41
  # @return [Chromate::Hardwares::KeyboardController]
38
42
  def keyboard
39
- @keyboard ||= Hardwares.keyboard(client: client, element: self)
43
+ Chromate.configuration.keyboard_controller.set_element(self)
40
44
  end
41
45
 
42
46
  # @return [String]
@@ -47,8 +51,12 @@ module Chromate
47
51
 
48
52
  # @return [String]
49
53
  def text
50
- result = client.send_message('Runtime.callFunctionOn', functionDeclaration: 'function() { return this.innerText; }', objectId: @object_id)
51
- result['result']['value']
54
+ evaluate_script('function() { return this.innerText; }')
55
+ end
56
+
57
+ # @return [String]
58
+ def value
59
+ evaluate_script('function() { return this.value; }')
52
60
  end
53
61
 
54
62
  # @return [String]
@@ -63,6 +71,11 @@ module Chromate
63
71
  Hash[*result['attributes']]
64
72
  end
65
73
 
74
+ # @return [String]
75
+ def tag_name
76
+ evaluate_script('function() { return this.tagName.toLowerCase(); }')
77
+ end
78
+
66
79
  # @param [String] name
67
80
  # @param [String] value
68
81
  # @return [self]
@@ -196,6 +209,19 @@ module Chromate
196
209
  end
197
210
  end
198
211
 
212
+ # @param [String] script
213
+ # @return [String]
214
+ def evaluate_script(script, options = {})
215
+ result = client.send_message(
216
+ 'Runtime.callFunctionOn',
217
+ functionDeclaration: script,
218
+ objectId: @object_id,
219
+ returnByValue: true,
220
+ **options
221
+ )
222
+ result['result']['value']
223
+ end
224
+
199
225
  private
200
226
 
201
227
  # @param [String] event
@@ -216,10 +242,39 @@ module Chromate
216
242
  [node_info['object']['objectId'], result['nodeId']]
217
243
  end
218
244
 
245
+ # @param [String] selector
246
+ # @option [Integer] root_id
247
+ # @return [Chromate::Element, nil]
248
+ def magick_find(selector, root_id = nil)
249
+ find(selector, root_id)
250
+ rescue NotFoundError, InvalidSelectorError
251
+ el = find_in_shadow_recursively(selector)
252
+ raise NotFoundError.new(selector, @root_id) unless el
253
+
254
+ el
255
+ end
256
+
257
+ # @param [String] selector
258
+ # @return [Chromate::Element, nil]
259
+ def find_in_shadow_recursively(selector)
260
+ shadow_children = find_shadow_children('*')
261
+ shadow_children.each do |child|
262
+ found_element = child.find_element(selector) || child.find_in_shadow_recursively(selector)
263
+ return found_element if found_element
264
+ end
265
+
266
+ nil
267
+ end
268
+
269
+ # @return [Hash]
219
270
  def document
220
271
  @document ||= client.send_message('DOM.getDocument')
221
272
  end
222
273
 
274
+ # Allows to submit the parent form of the element
275
+ # can be used to submit a form
276
+ #
277
+ # @return [void]
223
278
  def submit_parent_form
224
279
  script = <<~JAVASCRIPT
225
280
  function() {
@@ -236,9 +291,7 @@ module Chromate
236
291
  }
237
292
  JAVASCRIPT
238
293
 
239
- client.send_message('Runtime.callFunctionOn',
240
- functionDeclaration: script,
241
- objectId: @object_id)
294
+ evaluate_script(script)
242
295
  end
243
296
  end
244
297
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chromate/element'
4
+
5
+ module Chromate
6
+ module Elements
7
+ class Checkbox < Element
8
+ def initialize(selector, client, **options)
9
+ super
10
+ raise InvalidSelectorError, selector unless checkbox?
11
+ end
12
+
13
+ # @return [Boolean]
14
+ def checked?
15
+ attributes['checked'] == 'true'
16
+ end
17
+
18
+ # @return [self]
19
+ def check
20
+ click unless checked?
21
+
22
+ self
23
+ end
24
+
25
+ # @return [self]
26
+ def uncheck
27
+ click if checked?
28
+
29
+ self
30
+ end
31
+
32
+ # @return [self]
33
+ def toggle
34
+ click
35
+
36
+ self
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chromate/element'
4
+
5
+ module Chromate
6
+ module Elements
7
+ class Option < Element
8
+ attr_reader :value
9
+
10
+ # @param [String] value
11
+ def initialize(value, client, node_id: nil, object_id: nil, root_id: nil)
12
+ super("option[value='#{value}']", client, node_id: node_id, object_id: object_id, root_id: root_id)
13
+
14
+ @value = value
15
+ end
16
+
17
+ def bounding_box
18
+ script = <<~JAVASCRIPT
19
+ function() {
20
+ const select = this.closest('select');
21
+ const rect = select.getBoundingClientRect();
22
+ return {
23
+ x: rect.x,
24
+ y: rect.y,
25
+ width: rect.width,
26
+ height: rect.height
27
+ };
28
+ }
29
+ JAVASCRIPT
30
+
31
+ result = evaluate_script(script)
32
+ # TODO: fix this
33
+ # The offset is due to the fact that the option return the wrong coordinates
34
+ # can be fixed by mesuring an option and use the offset multiply by the index of the option
35
+ {
36
+ 'content' => [result['x'] + 100, result['y'] + 100],
37
+ 'width' => result['width'],
38
+ 'height' => result['height']
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chromate/element'
4
+
5
+ module Chromate
6
+ module Elements
7
+ class Radio < Element
8
+ def initialize(selector = nil, client = nil, **options)
9
+ if selector
10
+ super
11
+ raise InvalidSelectorError, selector unless radio?
12
+ else
13
+ super(**options)
14
+ end
15
+ end
16
+
17
+ # @return [Boolean]
18
+ def checked?
19
+ attributes['checked'] == 'true'
20
+ end
21
+
22
+ # @return [self]
23
+ def check
24
+ click unless checked?
25
+
26
+ self
27
+ end
28
+
29
+ # @return [self]
30
+ def uncheck
31
+ click if checked?
32
+
33
+ self
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'chromate/element'
4
+ require 'chromate/elements/option'
4
5
 
5
6
  module Chromate
6
7
  module Elements
@@ -8,39 +9,30 @@ module Chromate
8
9
  # @param [String] value
9
10
  # @return [self]
10
11
  def select_option(value)
11
- script = javascript
12
+ click
12
13
 
13
- client.send_message('Runtime.callFunctionOn',
14
- functionDeclaration: script,
15
- objectId: @object_id,
16
- arguments: [{ value: value }])
14
+ evaluate_script(javascript, arguments: [{ value: value }]) unless Chromate.configuration.native_control
17
15
 
18
- self
19
- rescue StandardError => e
20
- raise ArgumentError, "Option '#{value}' not found in select" if e.message.include?('Option')
16
+ Option.new(value, client).click
21
17
 
22
- raise e
18
+ self
23
19
  end
24
20
 
25
21
  # @return [String|nil]
26
22
  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')
23
+ evaluate_script('function() { return this.value; }')
31
24
  end
32
25
 
33
26
  # @return [String|nil]
34
27
  def selected_text
35
- result = client.send_message('Runtime.callFunctionOn',
36
- functionDeclaration: 'function() {
28
+ evaluate_script('function() {
37
29
  const option = this.options[this.selectedIndex];
38
30
  return option ? option.textContent.trim() : null;
39
- }',
40
- objectId: @object_id)
41
- result.dig('result', 'value')
31
+ }')
42
32
  end
43
33
 
34
+ private
35
+
44
36
  # @return [String]
45
37
  def javascript
46
38
  <<~JAVASCRIPT
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chromate/element'
4
+
5
+ module Chromate
6
+ module Elements
7
+ module Tags
8
+ def select?
9
+ tag_name == 'select'
10
+ end
11
+
12
+ def option?
13
+ tag_name == 'option'
14
+ end
15
+
16
+ def radio?
17
+ tag_name == 'input' && attributes['type'] == 'radio'
18
+ end
19
+
20
+ def checkbox?
21
+ tag_name == 'input' && attributes['type'] == 'checkbox'
22
+ end
23
+
24
+ def base?
25
+ !select? && !option? && !radio? && !checkbox?
26
+ end
27
+ end
28
+ end
29
+ end
@@ -5,5 +5,7 @@ module Chromate
5
5
  class ChromateError < StandardError; end
6
6
  class InvalidBrowserError < ChromateError; end
7
7
  class InvalidPlatformError < ChromateError; end
8
+ class ConnectionTimeoutError < StandardError; end
9
+ class DebugURLError < StandardError; end
8
10
  end
9
11
  end
@@ -0,0 +1,11 @@
1
+ {
2
+ "windows": [
3
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
4
+ ],
5
+ "mac": [
6
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
7
+ ],
8
+ "linux": [
9
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
10
+ ]
11
+ }