chromate-rb 0.0.1.pre

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chromate
4
+ class Element
5
+ class NotFoundError < StandardError
6
+ def initialize(selector, root_id)
7
+ super("Element not found with selector: #{selector} under root_id: #{root_id}")
8
+ end
9
+ end
10
+
11
+ class InvalidSelectorError < StandardError
12
+ def initialize(selector)
13
+ super("Unable to resolve element with selector: #{selector}")
14
+ end
15
+ end
16
+ attr_reader :selector, :client, :mouse
17
+
18
+ # @param [String] selector
19
+ # @param [Chromate::Client] client
20
+ # @option [Integer] node_id
21
+ # @option [String] object_id
22
+ # @option [Integer] root_id
23
+ def initialize(selector, client, node_id: nil, object_id: nil, root_id: nil)
24
+ @selector = selector
25
+ @client = client
26
+ @object_id = object_id
27
+ @node_id = node_id
28
+ @object_id, @node_id = find(selector, root_id) unless @object_id && @node_id
29
+ @root_id = root_id || document['root']['nodeId']
30
+ @mouse = Hardwares.mouse(client: client, element: self)
31
+ end
32
+
33
+ def inspect
34
+ value = selector.length > 20 ? "#{selector[0..20]}..." : selector
35
+ "#<Chromate::Element:#{value}>"
36
+ end
37
+
38
+ def text
39
+ return @text if @text
40
+
41
+ result = client.send_message('Runtime.callFunctionOn', functionDeclaration: 'function() { return this.innerText; }', objectId: @object_id)
42
+ @text = result['result']['value']
43
+ end
44
+
45
+ # @return [String]
46
+ def html
47
+ return @html if @html
48
+
49
+ @html = client.send_message('DOM.getOuterHTML', objectId: @object_id)
50
+ @html = @html['outerHTML']
51
+ end
52
+
53
+ # @return [Hash]
54
+ def attributes
55
+ return @attributes if @attributes
56
+
57
+ result = client.send_message('DOM.getAttributes', nodeId: @node_id)
58
+ @attributes = Hash[*result['attributes']]
59
+ end
60
+
61
+ # @param [String] name
62
+ # @param [String] value
63
+ # @return [self]
64
+ def set_attribute(name, value)
65
+ client.send_message('DOM.setAttributeValue', nodeId: @node_id, name: name, value: value)
66
+ dispatch_event('change')
67
+ end
68
+
69
+ # @return [Hash]
70
+ def bounding_box
71
+ return @bounding_box if @bounding_box
72
+
73
+ result = client.send_message('DOM.getBoxModel', objectId: @object_id)
74
+ @bounding_box = result['model']
75
+ end
76
+
77
+ # @return [Integer]
78
+ def x
79
+ bounding_box['content'][0]
80
+ end
81
+
82
+ # @return [Integer]
83
+ def y
84
+ bounding_box['content'][1]
85
+ end
86
+
87
+ # @return [Integer]
88
+ def width
89
+ bounding_box['width']
90
+ end
91
+
92
+ # @return [Integer]
93
+ def height
94
+ bounding_box['height']
95
+ end
96
+
97
+ # @return [self]
98
+ def click
99
+ mouse.click
100
+
101
+ self
102
+ end
103
+
104
+ # @return [self]
105
+ def hover
106
+ mouse.hover
107
+
108
+ self
109
+ end
110
+
111
+ # @param [String] text
112
+ def type(text)
113
+ client.send_message('Runtime.callFunctionOn', functionDeclaration: "function(value) { this.value = value; this.dispatchEvent(new Event('input')); }",
114
+ objectId: @object_id, arguments: [{ value: text }])
115
+
116
+ self
117
+ end
118
+
119
+ # @param [String] selector
120
+ # @return [Chromate::Element]
121
+ def find_element(selector)
122
+ find_elements(selector, max: 1).first
123
+ end
124
+
125
+ # @param [String] selector
126
+ # @option [Integer] max
127
+ # @return [Array<Chromate::Element>]
128
+ def find_elements(selector, max: 0)
129
+ results = client.send_message('DOM.querySelectorAll', nodeId: @node_id, selector: selector)
130
+ results['nodeIds'].each_with_index.filter_map do |node_id, idx|
131
+ node_info = client.send_message('DOM.resolveNode', nodeId: node_id)
132
+ next unless node_info['object']
133
+ break if max.positive? && idx >= max
134
+
135
+ Element.new(selector, client, node_id: node_id, object_id: node_info['object']['objectId'], root_id: @node_id)
136
+ end
137
+ end
138
+
139
+ # @return [Integer]
140
+ def shadow_root_id
141
+ return @shadow_root_id if @shadow_root_id
142
+
143
+ node_info = client.send_message('DOM.describeNode', nodeId: @node_id)
144
+ @shadow_root_id = node_info.dig('node', 'shadowRoots', 0, 'nodeId')
145
+ end
146
+
147
+ # @return [Boolean]
148
+ def shadow_root?
149
+ !!shadow_root_id
150
+ end
151
+
152
+ # @param [String] selector
153
+ # @return [Chromate::Element|NilClass]
154
+ def find_shadow_child(selector)
155
+ find_shadow_children(selector).first
156
+ end
157
+
158
+ # @param [String] selector
159
+ # @return [Array<Chromate::Element>]
160
+ def find_shadow_children(selector)
161
+ return [] unless shadow_root?
162
+
163
+ results = client.send_message('DOM.querySelectorAll', nodeId: shadow_root_id, selector: selector)
164
+ (results&.dig('nodeIds') || []).map do |node_id|
165
+ node_info = client.send_message('DOM.resolveNode', nodeId: node_id)
166
+ next unless node_info['object']
167
+
168
+ Element.new(selector, client, node_id: node_id, object_id: node_info['object']['objectId'], root_id: shadow_root_id)
169
+ end
170
+ end
171
+
172
+ private
173
+
174
+ def dispatch_event(event)
175
+ client.send_message('DOM.dispatchEvent', nodeId: @node_id, type: event)
176
+ end
177
+
178
+ # @return [Array] [object_id, node_id]
179
+ def find(selector, root_id = nil)
180
+ @root_id = root_id || document['root']['nodeId']
181
+ result = client.send_message('DOM.querySelector', nodeId: @root_id, selector: selector)
182
+ raise NotFoundError.new(selector, @root_id) unless result['nodeId']
183
+
184
+ node_info = client.send_message('DOM.resolveNode', nodeId: result['nodeId'])
185
+ raise InvalidSelectorError, selector unless node_info['object']
186
+
187
+ [node_info['object']['objectId'], result['nodeId']]
188
+ end
189
+
190
+ def document
191
+ @document ||= client.send_message('DOM.getDocument')
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chromate/element'
4
+
5
+ module Chromate
6
+ module Elements
7
+ class Select < Element
8
+ # @param [String] selector
9
+ def select_option(value)
10
+ click
11
+ opt = find_elements('option').find do |option|
12
+ option.attributes['value'] == value
13
+ end
14
+ opt.set_attribute('selected', 'true')
15
+ click
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chromate
4
+ module Exceptions
5
+ class ChromateError < StandardError; end
6
+ class InvalidBrowserError < ChromateError; end
7
+ class InvalidPlatformError < ChromateError; end
8
+ end
9
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chromate
4
+ module Hardwares
5
+ class MouseController
6
+ CLICK_DURATION_RANGE = (0.01..0.1)
7
+ DOUBLE_CLICK_DURATION_RANGE = (0.1..0.5)
8
+
9
+ attr_accessor :element, :client, :mouse_position
10
+
11
+ # @param [Chromate::Element] element
12
+ # @param [Chromate::Client] client
13
+ def initialize(element: nil, client: nil)
14
+ @element = element
15
+ @client = client
16
+ @mouse_position = { x: 0, y: 0 }
17
+ end
18
+
19
+ def hover
20
+ raise NotImplementedError
21
+ end
22
+
23
+ def click
24
+ raise NotImplementedError
25
+ end
26
+
27
+ def double_click
28
+ raise NotImplementedError
29
+ end
30
+
31
+ def right_click
32
+ raise NotImplementedError
33
+ end
34
+
35
+ def position_x
36
+ mouse_position[:x]
37
+ end
38
+
39
+ def position_y
40
+ mouse_position[:y]
41
+ end
42
+
43
+ private
44
+
45
+ def target_x
46
+ element.x + (element.width / 2)
47
+ end
48
+
49
+ def target_y
50
+ element.y + (element.height / 2)
51
+ end
52
+
53
+ def bezier_curve(steps: 50) # rubocop:disable Metrics/AbcSize
54
+ control_x = (target_x / 2)
55
+ control_y = (target_y / 2)
56
+
57
+ (0..steps).map do |t|
58
+ t /= steps.to_f
59
+ # Compute the position on the quadratic Bezier curve
60
+ new_x = (((1 - t)**2) * position_x) + (2 * (1 - t) * t * control_x) + ((t**2) * target_x)
61
+ new_y = (((1 - t)**2) * position_y) + (2 * (1 - t) * t * control_y) + ((t**2) * target_y)
62
+ { x: new_x, y: new_y }
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chromate/helpers'
4
+ require 'open3'
5
+
6
+ module Chromate
7
+ module Hardwares
8
+ module Mouses
9
+ class LinuxController < MouseController
10
+ class InvalidPlatformError < StandardError; end
11
+ include Helpers
12
+
13
+ LEFT_BUTTON = 1
14
+ RIGHT_BUTTON = 3
15
+
16
+ def initialize(element: nil, client: nil)
17
+ raise InvalidPlatformError, 'MouseController is only supported on Linux' unless linux?
18
+
19
+ super
20
+ end
21
+
22
+ def hover
23
+ focus_chrome_window
24
+ # system("xdotool mousemove_relative --sync -- #{x} #{y}")
25
+ system("xdotool mousemove #{target_x} #{target_y}")
26
+ current_mouse_position
27
+ end
28
+
29
+ def click
30
+ hover
31
+ simulate_button_event(LEFT_BUTTON, true)
32
+ simulate_button_event(LEFT_BUTTON, false)
33
+ end
34
+
35
+ def right_click
36
+ simulate_button_event(RIGHT_BUTTON, true)
37
+ simulate_button_event(RIGHT_BUTTON, false)
38
+ end
39
+
40
+ def double_click
41
+ click
42
+ sleep(rand(DOUBLE_CLICK_DURATION_RANGE))
43
+ click
44
+ end
45
+
46
+ private
47
+
48
+ def focus_chrome_window
49
+ # Recherche de la fenêtre Chrome avec xdotool
50
+ chrome_window_id = `xdotool search --onlyvisible --name "Chrome"`.strip
51
+
52
+ if chrome_window_id.empty?
53
+ puts 'Aucune fenêtre Chrome trouvée'
54
+ else
55
+ # Active la fenêtre Chrome avec xdotool
56
+ system("xdotool windowactivate #{chrome_window_id}")
57
+ end
58
+ end
59
+
60
+ def current_mouse_position
61
+ x = nil
62
+ y = nil
63
+ Open3.popen3('xdotool getmouselocation --shell') do |_, stdout, _, _|
64
+ output = stdout.read
65
+ x = output.match(/X=(\d+)/)[1].to_i
66
+ y = output.match(/Y=(\d+)/)[1].to_i
67
+ end
68
+
69
+ { x: x, y: y }
70
+ end
71
+
72
+ def simulate_button_event(button, press)
73
+ action = press ? 'mousedown' : 'mouseup'
74
+ system("xdotool #{action} #{button}")
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+ require 'chromate/helpers'
5
+ module Chromate
6
+ module Hardwares
7
+ module Mouses
8
+ class MacOsController < MouseController
9
+ class InvalidPlatformError < StandardError; end
10
+ include Helpers
11
+ extend FFI::Library
12
+
13
+ ffi_lib '/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices'
14
+
15
+ class CGPoint < FFI::Struct
16
+ layout :x, :float,
17
+ :y, :float
18
+ end
19
+
20
+ attach_function :CGEventCreateMouseEvent, [:pointer, :uint32, CGPoint.by_value, :uint32], :pointer
21
+ attach_function :CGEventPost, %i[uint32 pointer], :void
22
+ attach_function :CGEventSetType, %i[pointer uint32], :void
23
+ attach_function :CFRelease, [:pointer], :void
24
+ attach_function :CGMainDisplayID, [], :uint32
25
+ attach_function :CGEventCreate, [:pointer], :pointer
26
+ attach_function :CGEventGetLocation, [:pointer], CGPoint.by_value
27
+ attach_function :CGDisplayPixelsHigh, [:uint32], :size_t
28
+
29
+ class CGSize < FFI::Struct
30
+ layout :width, :float,
31
+ :height, :float
32
+ end
33
+
34
+ LEFT_DOWN = 1
35
+ LEFT_UP = 2
36
+ RIGHT_DOWN = 3
37
+ RIGHT_UP = 4
38
+ MOUSE_MOVED = 5
39
+
40
+ def initialize(element: nil, client: nil)
41
+ raise InvalidPlatformError, 'MouseController is only supported on macOS' unless mac?
42
+
43
+ super
44
+ @main_display = CGMainDisplayID()
45
+ @display_height = CGDisplayPixelsHigh(@main_display).to_f
46
+ @scale_factor = determine_scale_factor
47
+ end
48
+
49
+ def hover
50
+ point = convert_coordinates(target_x, target_y)
51
+ create_and_post_event(MOUSE_MOVED, point)
52
+ current_mouse_position
53
+ end
54
+
55
+ def click
56
+ current_pos = current_mouse_position
57
+ create_and_post_event(LEFT_DOWN, current_pos)
58
+ create_and_post_event(LEFT_UP, current_pos)
59
+ end
60
+
61
+ def right_click
62
+ current_pos = current_mouse_position
63
+ create_and_post_event(RIGHT_DOWN, current_pos)
64
+ create_and_post_event(RIGHT_UP, current_pos)
65
+ end
66
+
67
+ def double_click
68
+ click
69
+ sleep(rand(DOUBLE_CLICK_DURATION_RANGE))
70
+ click
71
+ end
72
+
73
+ private
74
+
75
+ def create_and_post_event(event_type, point)
76
+ event = CGEventCreateMouseEvent(nil, event_type, point, 0)
77
+ CGEventPost(0, event)
78
+ CFRelease(event)
79
+ end
80
+
81
+ def current_mouse_position
82
+ event = CGEventCreate(nil)
83
+ return CGPoint.new if event.null?
84
+
85
+ system_point = CGEventGetLocation(event)
86
+ CFRelease(event)
87
+
88
+ # Convertir les coordonnées système en coordonnées navigateur
89
+ browser_x = system_point[:x] / @scale_factor
90
+ browser_y = (@display_height - system_point[:y]) / @scale_factor
91
+
92
+ @mouse_position = {
93
+ x: browser_x,
94
+ y: browser_y
95
+ }
96
+
97
+ # Retourner un nouveau CGPoint avec les coordonnées système
98
+ CGPoint.new.tap do |p|
99
+ p[:x] = system_point[:x]
100
+ p[:y] = system_point[:y]
101
+ end
102
+ end
103
+
104
+ def convert_coordinates(browser_x, browser_y)
105
+ # Convertir les coordonnées du navigateur en coordonnées système
106
+ system_x = browser_x * @scale_factor
107
+ system_y = @display_height - (browser_y * @scale_factor)
108
+
109
+ CGPoint.new.tap do |p|
110
+ p[:x] = system_x
111
+ p[:y] = system_y
112
+ end
113
+ end
114
+
115
+ def determine_scale_factor
116
+ # Obtenir le facteur d'échelle en comparant les dimensions logiques et physiques
117
+ # Par défaut, utiliser 2.0 pour les écrans Retina
118
+
119
+ `system_profiler SPDisplaysDataType | grep -i "retina"`.empty? ? 1.0 : 2.0
120
+ rescue StandardError
121
+ 2.0 # Par défaut pour les écrans Retina modernes
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ # Test
129
+ # require 'chromate/hardwares/mouses/mac_os_controller'
130
+ # require 'ostruct'
131
+ # element = OpenStruct.new(x: 500, y: 300, width: 100, height: 100)
132
+ # mouse = Chromate::Hardwares::Mouse::MacOsController.new(element: element)
133
+ # mouse.hover
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chromate
4
+ module Hardwares
5
+ module Mouses
6
+ class VirtualController < Chromate::Hardwares::MouseController
7
+ def hover
8
+ steps = rand(25..50)
9
+ points = bezier_curve(steps: steps)
10
+ duration = rand(0.1..0.3)
11
+
12
+ points.each do |point|
13
+ dispatch_mouse_event('mouseMoved', point[:x], point[:y])
14
+ sleep(duration / steps)
15
+ end
16
+
17
+ update_mouse_position(points.last[:x], points.last[:y])
18
+ end
19
+
20
+ def click
21
+ hover
22
+ click!
23
+ end
24
+
25
+ def double_click
26
+ click
27
+ sleep(rand(DOUBLE_CLICK_DURATION_RANGE))
28
+ click
29
+ end
30
+
31
+ def right_click
32
+ hover
33
+ dispatch_mouse_event('mousePressed', target_x, target_y, button: 'right', click_count: 1)
34
+ sleep(rand(CLICK_DURATION_RANGE))
35
+ dispatch_mouse_event('mouseReleased', target_x, target_y, button: 'right', click_count: 1)
36
+ end
37
+
38
+ private
39
+
40
+ def click!
41
+ dispatch_mouse_event('mousePressed', target_x, target_y, button: 'left', click_count: 1)
42
+ sleep(rand(CLICK_DURATION_RANGE))
43
+ dispatch_mouse_event('mouseReleased', target_x, target_y, button: 'left', click_count: 1)
44
+ end
45
+
46
+ # @param [String] type mouseMoved, mousePressed, mouseReleased
47
+ # @param [Integer] target_x
48
+ # @param [Integer] target_y
49
+ # @option [String] button
50
+ # @option [Integer] click_count
51
+ def dispatch_mouse_event(type, target_x, target_y, button: 'none', click_count: 0)
52
+ params = {
53
+ type: type,
54
+ x: target_x,
55
+ y: target_y,
56
+ button: button,
57
+ clickCount: click_count,
58
+ deltaX: 0,
59
+ deltaY: 0,
60
+ modifiers: 0,
61
+ timestamp: (Time.now.to_f * 1000).to_i
62
+ }
63
+
64
+ client.send_message('Input.dispatchMouseEvent', params)
65
+ end
66
+
67
+ def update_mouse_position(target_x, target_y)
68
+ @mouse_position[:x] = target_x
69
+ @mouse_position[:y] = target_y
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ # Test
77
+ # require 'chromate/hardwares/mouses/virtual_controller'
78
+ # require 'ostruct'
79
+ # element = OpenStruct.new(x: 500, y: 300, width: 100, height: 100)
80
+ # mouse = Chromate::Hardwares::Mouse::VirtualController.new(element: element)
81
+ # mouse.hover
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chromate/c_logger'
4
+ require 'chromate/hardwares/mouse_controller'
5
+ require 'chromate/hardwares/mouses/virtual_controller'
6
+ require 'chromate/helpers'
7
+
8
+ module Chromate
9
+ module Hardwares
10
+ extend Helpers
11
+
12
+ def mouse(**args)
13
+ browser = args[:client].browser
14
+ if browser.options[:native_control]
15
+ if mac?
16
+ Chromate::CLogger.log('🐁 Loading MacOs mouse controller')
17
+ require 'chromate/hardwares/mouses/mac_os_controller'
18
+ return Mouses::MacOsController.new(**args)
19
+ end
20
+ if linux?
21
+ Chromate::CLogger.log('🐁 Loading Linux mouse controller')
22
+ require 'chromate/hardwares/mouses/linux_controller'
23
+ return Mouses::LinuxController.new(**args)
24
+ end
25
+ raise 'Native mouse controller is not supported on Windows' if windows?
26
+ else
27
+ Chromate::CLogger.log('🐁 Loading Virtual mouse controller')
28
+ Mouses::VirtualController.new(**args)
29
+ end
30
+ end
31
+ module_function :mouse
32
+ end
33
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+ module Helpers
5
+ def linux?
6
+ RbConfig::CONFIG['host_os'] =~ /linux|bsd/i
7
+ end
8
+
9
+ def mac?
10
+ RbConfig::CONFIG['host_os'] =~ /darwin/i
11
+ end
12
+
13
+ def windows?
14
+ RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/i
15
+ end
16
+
17
+ def find_available_port
18
+ server = TCPServer.new('127.0.0.1', 0)
19
+ port = server.addr[1]
20
+ server.close
21
+ port
22
+ end
23
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chromate
4
+ class UserAgent
5
+ def self.call
6
+ new.call
7
+ end
8
+
9
+ attr_reader :os
10
+
11
+ def initialize
12
+ @os = find_os
13
+ end
14
+
15
+ def call
16
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36'
17
+ end
18
+
19
+ private
20
+
21
+ def find_os
22
+ case RUBY_PLATFORM
23
+ when /darwin/
24
+ 'Macintosh'
25
+ when /linux/
26
+ 'Linux'
27
+ when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
28
+ 'Windows'
29
+ else
30
+ 'Unknown'
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chromate
4
+ VERSION = '0.0.1.pre'
5
+ end
data/lib/chromate.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'chromate/version'
4
+ require_relative 'chromate/browser'
5
+ require_relative 'chromate/configuration'
6
+
7
+ module Chromate
8
+ class << self
9
+ def configure
10
+ yield configuration
11
+ end
12
+
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+ end
17
+ end