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,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