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,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chromate
4
+ module Actions
5
+ module Dom
6
+ def find_element(selector)
7
+ Chromate::Element.new(selector, @client)
8
+ end
9
+
10
+ def click_element(selector)
11
+ find_element(selector).click
12
+ end
13
+
14
+ def hover_element(selector)
15
+ find_element(selector).hover
16
+ end
17
+
18
+ def type_text(selector, text)
19
+ find_element(selector).type(text)
20
+ end
21
+
22
+ def get_text(selector)
23
+ find_element(selector).text
24
+ end
25
+
26
+ def get_property(selector, property)
27
+ find_element(selector).attributes[property]
28
+ end
29
+
30
+ def select_option(selector, option)
31
+ Chromate::Elements::Select.new(selector, @client).select_option(option)
32
+ end
33
+
34
+ def evaluate_script(script)
35
+ @client.send_message('Runtime.evaluate', expression: script)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chromate
4
+ module Actions
5
+ module Navigate
6
+ # @param [String] url
7
+ # @return [self]
8
+ def navigate_to(url)
9
+ @client.send_message('Page.enable')
10
+ @client.send_message('Page.navigate', url: url)
11
+ wait_for_page_load
12
+ end
13
+
14
+ # @return [self]
15
+ def wait_for_page_load # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
16
+ page_loaded = false
17
+ dom_content_loaded = false
18
+ frame_stopped_loading = false
19
+
20
+ # Utiliser un Mutex pour la synchronisation
21
+ mutex = Mutex.new
22
+ condition = ConditionVariable.new
23
+
24
+ # S'abonner aux messages WebSocket
25
+ listener = proc do |message|
26
+ mutex.synchronize do
27
+ case message['method']
28
+ when 'Page.domContentEventFired'
29
+ dom_content_loaded = true
30
+ condition.signal if dom_content_loaded && page_loaded && frame_stopped_loading
31
+ when 'Page.loadEventFired'
32
+ page_loaded = true
33
+ condition.signal if dom_content_loaded && page_loaded && frame_stopped_loading
34
+ when 'Page.frameStoppedLoading'
35
+ frame_stopped_loading = true
36
+ condition.signal if dom_content_loaded && page_loaded && frame_stopped_loading
37
+ end
38
+ end
39
+ end
40
+
41
+ @client.on_message(&listener)
42
+
43
+ # Attendre les trois événements (DOMContent, Load et FrameStoppedLoading) avec un timeout
44
+ Timeout.timeout(15) do
45
+ mutex.synchronize do
46
+ condition.wait(mutex) until dom_content_loaded && page_loaded && frame_stopped_loading
47
+ end
48
+ end
49
+
50
+ # Nettoyer l'écouteur WebSocket
51
+ @client.on_message { |msg| } # Supprime tous les anciens écouteurs en ajoutant un listener vide
52
+
53
+ self
54
+ end
55
+
56
+ # @return [self]
57
+ def refresh
58
+ @client.send_message('Page.reload')
59
+ wait_for_page_load
60
+ self
61
+ end
62
+
63
+ # @return [self]
64
+ def go_back
65
+ @client.send_message('Page.goBack')
66
+ wait_for_page_load
67
+ self
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chromate
4
+ module Actions
5
+ module Screenshot
6
+ def screenshot_to_file(file_path, options = {})
7
+ image_data = screenshot(options)
8
+ File.binwrite(file_path, image_data)
9
+ true
10
+ end
11
+
12
+ def screenshot_full_page(file_path, options = {})
13
+ metrics = @client.send_message('Page.getLayoutMetrics')
14
+
15
+ content_size = metrics['contentSize']
16
+ width = content_size['width'].ceil
17
+ height = content_size['height'].ceil
18
+
19
+ @client.send_message('Emulation.setDeviceMetricsOverride', {
20
+ mobile: false,
21
+ width: width,
22
+ height: height,
23
+ deviceScaleFactor: 1
24
+ })
25
+
26
+ screenshot_to_file(file_path, options)
27
+
28
+ @client.send_message('Emulation.clearDeviceMetricsOverride')
29
+ true
30
+ end
31
+
32
+ def xvfb_screenshot(file_path)
33
+ display = ENV['DISPLAY'] || ':99'
34
+ system("xwd -root -display #{display} | convert xwd:- #{file_path}")
35
+ end
36
+
37
+ private
38
+
39
+ def screenshot(options = {})
40
+ default_options = {
41
+ format: 'png',
42
+ fromSurface: true
43
+ }
44
+
45
+ params = default_options.merge(options)
46
+
47
+ @client.send_message('Page.enable')
48
+
49
+ result = @client.send_message('Page.captureScreenshot', params)
50
+
51
+ image_data = result['data']
52
+ Base64.decode64(image_data)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+ require 'securerandom'
6
+ require 'net/http'
7
+ require 'websocket-client-simple'
8
+ require_relative 'helpers'
9
+ require_relative 'client'
10
+ require_relative 'element'
11
+ require_relative 'hardwares'
12
+ require_relative 'elements/select'
13
+ require_relative 'user_agent'
14
+ require_relative 'actions/navigate'
15
+ require_relative 'actions/screenshot'
16
+ require_relative 'actions/dom'
17
+
18
+ module Chromate
19
+ class Browser
20
+ attr_reader :client, :options
21
+
22
+ include Helpers
23
+ include Actions::Navigate
24
+ include Actions::Screenshot
25
+ include Actions::Dom
26
+
27
+ def initialize(options = {})
28
+ @options = config.options.merge(options)
29
+ @chrome_path = @options.fetch(:chrome_path)
30
+ @user_data_dir = @options.fetch(:user_data_dir, "/tmp/chromate_#{SecureRandom.hex}")
31
+ @headless = @options.fetch(:headless)
32
+ @xfvb = @options.fetch(:xfvb)
33
+ @native_control = @options.fetch(:native_control)
34
+ @record = @options.fetch(:record, false)
35
+ @process = nil
36
+ @xfvb_process = nil
37
+ @record_process = nil
38
+ @client = nil
39
+ @args = [
40
+ @chrome_path,
41
+ "--user-data-dir=#{@user_data_dir}",
42
+ "--lang=#{@options[:lang] || "fr-FR"}"
43
+ ]
44
+
45
+ trap('INT') { stop_and_exit }
46
+ trap('TERM') { stop_and_exit }
47
+
48
+ at_exit { stop }
49
+ end
50
+
51
+ def start
52
+ build_args
53
+ @client = Client.new(self)
54
+ @args << "--remote-debugging-port=#{@client.port}"
55
+
56
+ start_video_recording if @record
57
+
58
+ if @xfvb
59
+ if ENV['DISPLAY'].nil?
60
+ ENV['DISPLAY'] = ':0' if mac? # XQuartz generally uses :0 on Mac
61
+ ENV['DISPLAY'] = ':99' if linux? # Xvfb generally uses :99 on Linux
62
+ end
63
+ @args << "--display=#{ENV.fetch("DISPLAY", nil)}"
64
+ end
65
+
66
+ @process = spawn(*@args, err: 'chrome_errors.log', out: 'chrome_output.log')
67
+ sleep 2
68
+
69
+ @client.start
70
+ self
71
+ end
72
+
73
+ def stop
74
+ Process.kill('TERM', @process) if @process
75
+ Process.kill('TERM', @record_process) if @record_process
76
+ Process.kill('TERM', @xfvb_process) if @xfvb_process
77
+ @client&.stop
78
+ end
79
+
80
+ def native_control?
81
+ @native_control
82
+ end
83
+
84
+ private
85
+
86
+ def start_video_recording
87
+ outfile = File.join(Dir.pwd, "output_video_#{Time.now.to_i}.mp4")
88
+ @record_process = spawn("ffmpeg -f x11grab -r 25 -s 1920x1080 -i #{ENV.fetch("DISPLAY", ":99")} -pix_fmt yuv420p -y #{outfile}")
89
+ end
90
+
91
+ def build_args
92
+ exclude_switches = config.exclude_switches || []
93
+ exclude_switches += @options[:exclude_switches] if @options[:exclude_switches]
94
+
95
+ @args += config.generate_arguments(**@options)
96
+ @args += @options[:options][:args] if @options.dig(:options, :args)
97
+ @args << "--user-agent=#{@options[:user_agent] || UserAgent.call}"
98
+ @args << "--exclude-switches=#{exclude_switches.join(",")}" if exclude_switches.any?
99
+
100
+ @args
101
+ end
102
+
103
+ def stop_and_exit
104
+ puts 'Stopping browser...'
105
+ stop
106
+ exit
107
+ end
108
+
109
+ def config
110
+ Chromate.configuration
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module Chromate
6
+ class CLogger < Logger
7
+ def initialize(logdev, shift_age: 0, shift_size: 1_048_576)
8
+ super(logdev, shift_age, shift_size)
9
+ self.formatter = proc do |severity, datetime, _progname, msg|
10
+ "[Chromate] #{datetime.strftime("%Y-%m-%d %H:%M:%S")} #{severity}: #{msg}\n"
11
+ end
12
+ end
13
+
14
+ def self.logger
15
+ @logger ||= new($stdout)
16
+ end
17
+
18
+ def self.log(message, level: :info)
19
+ logger.send(level, message)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'websocket-client-simple'
4
+ require 'chromate/helpers'
5
+
6
+ module Chromate
7
+ class Client
8
+ include Helpers
9
+
10
+ def self.listeners
11
+ @@listeners ||= [] # rubocop:disable Style/ClassVars
12
+ end
13
+
14
+ attr_reader :port, :ws, :browser
15
+
16
+ def initialize(browser)
17
+ @browser = browser
18
+ options = browser.options
19
+ @port = options[:port] || find_available_port
20
+ end
21
+
22
+ def start
23
+ @ws_url = fetch_websocket_debug_url
24
+ @ws = WebSocket::Client::Simple.connect(@ws_url)
25
+ @id = 0
26
+ @callbacks = {}
27
+
28
+ client_self = self
29
+
30
+ @ws.on :message do |msg|
31
+ message = JSON.parse(msg.data)
32
+ client_self.handle_message(message)
33
+
34
+ Client.listeners.each do |listener|
35
+ listener.call(message)
36
+ end
37
+ end
38
+
39
+ @ws.on :open do
40
+ puts "Connexion WebSocket établie avec #{@ws_url}"
41
+ end
42
+
43
+ @ws.on :error do |e|
44
+ puts "Erreur WebSocket : #{e.message}"
45
+ end
46
+
47
+ @ws.on :close do |_e|
48
+ puts 'Connexion WebSocket fermée'
49
+ end
50
+
51
+ sleep 0.2
52
+ client_self.send_message('Target.setDiscoverTargets', { discover: true })
53
+ client_self
54
+ end
55
+
56
+ def stop
57
+ @ws&.close
58
+ end
59
+
60
+ def send_message(method, params = {})
61
+ @id += 1
62
+ message = { id: @id, method: method, params: params }
63
+ puts "Envoi du message : #{message}"
64
+
65
+ begin
66
+ @ws.send(message.to_json)
67
+ @callbacks[@id] = Queue.new
68
+ result = @callbacks[@id].pop
69
+ puts "Réponse reçue pour le message #{message[:id]} : #{result}"
70
+ result
71
+ rescue StandardError => e
72
+ puts "Erreur WebSocket lors de l'envoi du message : #{e.message}"
73
+ reconnect
74
+ retry
75
+ end
76
+ end
77
+
78
+ def reconnect
79
+ @ws_url = fetch_websocket_debug_url
80
+ @ws = WebSocket::Client::Simple.connect(@ws_url)
81
+ puts 'Reconnexion WebSocket réussie'
82
+ end
83
+
84
+ def handle_message(message)
85
+ puts "Message reçu : #{message}"
86
+ return unless message['id'] && @callbacks[message['id']]
87
+
88
+ @callbacks[message['id']].push(message['result'])
89
+ @callbacks.delete(message['id'])
90
+ end
91
+
92
+ # Allowing different parts to subscribe to WebSocket messages
93
+ def on_message(&block)
94
+ Client.listeners << block
95
+ end
96
+
97
+ def fetch_websocket_debug_url
98
+ uri = URI("http://localhost:#{@port}/json/list")
99
+ response = Net::HTTP.get(uri)
100
+ targets = JSON.parse(response)
101
+
102
+ page_target = targets.find { |target| target['type'] == 'page' }
103
+
104
+ if page_target
105
+ page_target['webSocketDebuggerUrl']
106
+ else
107
+ create_new_page_target
108
+ end
109
+ end
110
+
111
+ def create_new_page_target
112
+ # Créer une nouvelle page
113
+ uri = URI("http://localhost:#{@port}/json/new")
114
+ response = Net::HTTP.get(uri)
115
+ new_target = JSON.parse(response)
116
+
117
+ new_target['webSocketDebuggerUrl']
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helpers'
4
+ require_relative 'exceptions'
5
+ require_relative 'c_logger'
6
+
7
+ module Chromate
8
+ class Configuration
9
+ include Helpers
10
+ include Exceptions
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
+ '--disable-popup-blocking',
19
+ '--ignore-certificate-errors',
20
+ '--disable-gpu',
21
+ '--disable-dev-shm-usage',
22
+ '--window-size=1920,1080', # TODO: Make this automatic
23
+ '--hide-crash-restore-bubble'
24
+ ].freeze
25
+ HEADLESS_ARGS = [
26
+ '--headless=new',
27
+ '--window-position=2400,2400'
28
+ ].freeze
29
+ XVFB_ARGS = [
30
+ '--window-position=0,0'
31
+ ].freeze
32
+ DISABLED_FEATURES = %w[
33
+ Translate
34
+ OptimizationHints
35
+ MediaRouter
36
+ DialMediaRouteProvider
37
+ CalculateNativeWinOcclusion
38
+ InterestFeedContentSuggestions
39
+ CertificateTransparencyComponentUpdater
40
+ AutofillServerCommunication
41
+ PrivacySandboxSettings4
42
+ AutomationControlled
43
+ ].freeze
44
+ EXCLUDE_SWITCHES = %w[
45
+ enable-automation
46
+ ].freeze
47
+
48
+ attr_accessor :user_data_dir, :headless, :xfvb, :native_control, :args, :headless_args, :xfvb_args, :exclude_switches, :proxy,
49
+ :disable_features
50
+
51
+ def initialize
52
+ @user_data_dir = File.expand_path('~/.config/google-chrome/Default')
53
+ @headless = true
54
+ @xfvb = false
55
+ @native_control = false
56
+ @proxy = nil
57
+ @args = [] + DEFAULT_ARGS
58
+ @headless_args = [] + HEADLESS_ARGS
59
+ @xfvb_args = [] + XVFB_ARGS
60
+ @disable_features = [] + DISABLED_FEATURES
61
+ @exclude_switches = [] + EXCLUDE_SWITCHES
62
+
63
+ @args << '--use-angle=metal' if mac?
64
+ end
65
+
66
+ def self.config
67
+ @config ||= Configuration.new
68
+ end
69
+
70
+ def self.configure
71
+ yield(config)
72
+ end
73
+
74
+ def config
75
+ self.class.config
76
+ end
77
+
78
+ def chrome_path
79
+ return ENV['CHROME_BIN'] if ENV['CHROME_BIN']
80
+
81
+ if mac?
82
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
83
+ elsif linux?
84
+ '/usr/bin/google-chrome-stable'
85
+ elsif windows?
86
+ 'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe'
87
+ else
88
+ raise Exceptions::InvalidPlatformError, 'Unsupported platform'
89
+ end
90
+ end
91
+
92
+ def generate_arguments(headless: @headless, xfvb: @xfvb, proxy: @proxy, disable_features: @disable_features, **_args)
93
+ dynamic_args = []
94
+
95
+ dynamic_args += @headless_args if headless
96
+ dynamic_args += @xfvb_args if xfvb
97
+ dynamic_args << "--proxy-server=#{proxy[:host]}:#{proxy[:port]}" if proxy && proxy[:host] && proxy[:port]
98
+ dynamic_args << "--disable-features=#{disable_features.join(",")}" unless disable_features.empty?
99
+
100
+ @args + dynamic_args
101
+ end
102
+
103
+ def options
104
+ {
105
+ chrome_path: chrome_path,
106
+ user_data_dir: @user_data_dir,
107
+ headless: @headless,
108
+ xfvb: @xfvb,
109
+ native_control: @native_control,
110
+ args: @args,
111
+ headless_args: @headless_args,
112
+ xfvb_args: @xfvb_args,
113
+ exclude_switches: @exclude_switches,
114
+ proxy: @proxy,
115
+ disable_features: @disable_features
116
+ }
117
+ end
118
+ end
119
+ end