chromate-rb 0.0.1.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.
@@ -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