ferrum-mcp 1.0.0

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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/.env.example +90 -0
  3. data/CHANGELOG.md +229 -0
  4. data/CONTRIBUTING.md +469 -0
  5. data/LICENSE +21 -0
  6. data/README.md +334 -0
  7. data/SECURITY.md +286 -0
  8. data/bin/ferrum-mcp +66 -0
  9. data/bin/lint +10 -0
  10. data/bin/serve +3 -0
  11. data/bin/test +4 -0
  12. data/docs/API_REFERENCE.md +1410 -0
  13. data/docs/CONFIGURATION.md +254 -0
  14. data/docs/DEPLOYMENT.md +846 -0
  15. data/docs/DOCKER.md +836 -0
  16. data/docs/DOCKER_BOTBROWSER.md +455 -0
  17. data/docs/GETTING_STARTED.md +249 -0
  18. data/docs/TROUBLESHOOTING.md +677 -0
  19. data/lib/ferrum_mcp/browser_manager.rb +101 -0
  20. data/lib/ferrum_mcp/cli/command_handler.rb +99 -0
  21. data/lib/ferrum_mcp/cli/server_runner.rb +166 -0
  22. data/lib/ferrum_mcp/configuration.rb +229 -0
  23. data/lib/ferrum_mcp/resource_manager.rb +223 -0
  24. data/lib/ferrum_mcp/server.rb +254 -0
  25. data/lib/ferrum_mcp/session.rb +227 -0
  26. data/lib/ferrum_mcp/session_manager.rb +183 -0
  27. data/lib/ferrum_mcp/tools/accept_cookies_tool.rb +458 -0
  28. data/lib/ferrum_mcp/tools/base_tool.rb +114 -0
  29. data/lib/ferrum_mcp/tools/clear_cookies_tool.rb +66 -0
  30. data/lib/ferrum_mcp/tools/click_tool.rb +218 -0
  31. data/lib/ferrum_mcp/tools/close_session_tool.rb +49 -0
  32. data/lib/ferrum_mcp/tools/create_session_tool.rb +146 -0
  33. data/lib/ferrum_mcp/tools/drag_and_drop_tool.rb +171 -0
  34. data/lib/ferrum_mcp/tools/evaluate_js_tool.rb +46 -0
  35. data/lib/ferrum_mcp/tools/execute_script_tool.rb +48 -0
  36. data/lib/ferrum_mcp/tools/fill_form_tool.rb +78 -0
  37. data/lib/ferrum_mcp/tools/find_by_text_tool.rb +153 -0
  38. data/lib/ferrum_mcp/tools/get_attribute_tool.rb +56 -0
  39. data/lib/ferrum_mcp/tools/get_cookies_tool.rb +70 -0
  40. data/lib/ferrum_mcp/tools/get_html_tool.rb +52 -0
  41. data/lib/ferrum_mcp/tools/get_session_info_tool.rb +40 -0
  42. data/lib/ferrum_mcp/tools/get_text_tool.rb +67 -0
  43. data/lib/ferrum_mcp/tools/get_title_tool.rb +42 -0
  44. data/lib/ferrum_mcp/tools/get_url_tool.rb +39 -0
  45. data/lib/ferrum_mcp/tools/go_back_tool.rb +49 -0
  46. data/lib/ferrum_mcp/tools/go_forward_tool.rb +49 -0
  47. data/lib/ferrum_mcp/tools/hover_tool.rb +76 -0
  48. data/lib/ferrum_mcp/tools/list_sessions_tool.rb +33 -0
  49. data/lib/ferrum_mcp/tools/navigate_tool.rb +59 -0
  50. data/lib/ferrum_mcp/tools/press_key_tool.rb +91 -0
  51. data/lib/ferrum_mcp/tools/query_shadow_dom_tool.rb +225 -0
  52. data/lib/ferrum_mcp/tools/refresh_tool.rb +49 -0
  53. data/lib/ferrum_mcp/tools/screenshot_tool.rb +121 -0
  54. data/lib/ferrum_mcp/tools/session_tool.rb +37 -0
  55. data/lib/ferrum_mcp/tools/set_cookie_tool.rb +77 -0
  56. data/lib/ferrum_mcp/tools/solve_captcha_tool.rb +528 -0
  57. data/lib/ferrum_mcp/transport/http_server.rb +93 -0
  58. data/lib/ferrum_mcp/transport/rate_limiter.rb +79 -0
  59. data/lib/ferrum_mcp/transport/stdio_server.rb +63 -0
  60. data/lib/ferrum_mcp/version.rb +5 -0
  61. data/lib/ferrum_mcp/whisper_service.rb +222 -0
  62. data/lib/ferrum_mcp.rb +35 -0
  63. metadata +248 -0
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # Base class for all MCP tools
6
+ class BaseTool
7
+ attr_reader :browser, :logger
8
+
9
+ def initialize(browser_manager)
10
+ @browser_manager = browser_manager
11
+ @browser = browser_manager.browser
12
+ @logger = browser_manager.logger
13
+ end
14
+
15
+ def execute(params)
16
+ raise NotImplementedError, 'Subclasses must implement #execute'
17
+ end
18
+
19
+ def self.tool_name
20
+ raise NotImplementedError, 'Subclasses must implement .tool_name'
21
+ end
22
+
23
+ def self.description
24
+ raise NotImplementedError, 'Subclasses must implement .description'
25
+ end
26
+
27
+ def self.input_schema
28
+ raise NotImplementedError, 'Subclasses must implement .input_schema'
29
+ end
30
+
31
+ protected
32
+
33
+ def ensure_browser_active
34
+ raise BrowserError, 'Browser is not active' unless @browser_manager.active?
35
+
36
+ @browser = @browser_manager.browser
37
+ end
38
+
39
+ # Helper to access params consistently (supports both string and symbol keys)
40
+ def param(params, key)
41
+ params[key.to_s] || params[key.to_sym]
42
+ end
43
+
44
+ # Find element with improved timeout handling
45
+ # Uses shorter polling intervals for better responsiveness
46
+ def find_element(selector, timeout: 5)
47
+ ensure_browser_active
48
+ deadline = Time.now + timeout
49
+
50
+ loop do
51
+ element = browser.at_css(selector)
52
+ return element if element
53
+
54
+ raise ToolError, "Element not found: #{selector}" if Time.now > deadline
55
+
56
+ # Use shorter sleep for better responsiveness (0.1s instead of 0.5s)
57
+ sleep 0.1
58
+ end
59
+ rescue Ferrum::NodeNotFoundError
60
+ raise ToolError, "Element not found: #{selector}"
61
+ end
62
+
63
+ # Retry logic for handling stale/moving elements
64
+ def with_retry(retries: 3)
65
+ attempts = 0
66
+ begin
67
+ attempts += 1
68
+ yield
69
+ rescue Ferrum::NodeMovingError => e
70
+ raise ToolError, "Element became stale after #{retries} retries" unless attempts < retries
71
+
72
+ logger.debug "Retry #{attempts}/#{retries} due to: #{e.class}"
73
+ sleep 0.1
74
+ retry
75
+ end
76
+ end
77
+
78
+ # Check if element is actually visible (has dimensions and not hidden)
79
+ def element_visible?(element)
80
+ return false unless element
81
+
82
+ # Check both CSS visibility and actual rendered dimensions
83
+ script = <<~JS
84
+ (function(el) {
85
+ if (!el) return false;
86
+ const rect = el.getBoundingClientRect();
87
+ const style = window.getComputedStyle(el);
88
+ return rect.width > 0 &&
89
+ rect.height > 0 &&
90
+ style.visibility !== 'hidden' &&
91
+ style.display !== 'none';
92
+ })(arguments[0])
93
+ JS
94
+
95
+ browser.evaluate(script, element)
96
+ rescue StandardError => e
97
+ logger.debug "Error checking element visibility: #{e.message}"
98
+ false
99
+ end
100
+
101
+ def success_response(data = {})
102
+ { success: true, data: data }
103
+ end
104
+
105
+ def image_response(base64_data, mime_type = 'image/png')
106
+ { success: true, type: 'image', data: base64_data, mime_type: mime_type }
107
+ end
108
+
109
+ def error_response(message)
110
+ { success: false, error: message }
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # Tool to clear cookies
6
+ class ClearCookiesTool < BaseTool
7
+ def self.tool_name
8
+ 'clear_cookies'
9
+ end
10
+
11
+ def self.description
12
+ 'Clear all cookies or cookies for a specific domain'
13
+ end
14
+
15
+ def self.input_schema
16
+ {
17
+ type: 'object',
18
+ properties: {
19
+ domain: {
20
+ type: 'string',
21
+ description: 'Optional: Clear cookies only for this domain'
22
+ },
23
+ session_id: {
24
+ type: 'string',
25
+ description: 'Session ID to use for this operation'
26
+ }
27
+ },
28
+ required: ['session_id']
29
+ }
30
+ end
31
+
32
+ def execute(params)
33
+ ensure_browser_active
34
+ domain = params['domain'] || params[:domain]
35
+
36
+ if domain
37
+ logger.info "Clearing cookies for: #{domain}"
38
+ # cookies.all returns a hash where keys are cookie names and values can be Cookie objects or strings
39
+ all_cookies = browser.cookies.all
40
+ cookies_to_remove = all_cookies.select do |_name, cookie|
41
+ cookie_str = cookie.is_a?(String) ? cookie : cookie.to_s
42
+ cookie_str&.match(/Domain=([^;]+)/i) &&
43
+ Regexp.last_match(1).include?(domain)
44
+ end
45
+ # Remove each cookie by name with domain
46
+ cookies_to_remove.each do |cookie_name, cookie|
47
+ # Extract domain from cookie string
48
+ cookie_str = cookie.is_a?(String) ? cookie : cookie.to_s
49
+ if cookie_str =~ /Domain=([^;]+)/i
50
+ cookie_domain = Regexp.last_match(1).strip
51
+ browser.cookies.remove(name: cookie_name, domain: cookie_domain)
52
+ end
53
+ end
54
+ success_response(message: "Cleared #{cookies_to_remove.length} cookies for #{domain}")
55
+ else
56
+ logger.info 'Clearing all cookies'
57
+ browser.cookies.clear
58
+ success_response(message: 'All cookies cleared')
59
+ end
60
+ rescue StandardError => e
61
+ logger.error "Clear cookies failed: #{e.message}"
62
+ error_response("Failed to clear cookies: #{e.message}")
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # Tool to click on an element
6
+ class ClickTool < BaseTool
7
+ def self.tool_name
8
+ 'click'
9
+ end
10
+
11
+ def self.description
12
+ 'Click on an element using a CSS selector or XPath'
13
+ end
14
+
15
+ def self.input_schema
16
+ {
17
+ type: 'object',
18
+ properties: {
19
+ selector: {
20
+ type: 'string',
21
+ description: 'CSS selector or XPath of the element to click (use xpath: prefix for XPath)'
22
+ },
23
+ wait: {
24
+ type: 'number',
25
+ description: 'Seconds to wait for element (default: 5)',
26
+ default: 5
27
+ },
28
+ force: {
29
+ type: 'boolean',
30
+ description: 'Force click even if element is hidden or not visible (default: false)',
31
+ default: false
32
+ },
33
+ session_id: {
34
+ type: 'string',
35
+ description: 'Session ID to use for this operation'
36
+ }
37
+ },
38
+ required: %w[selector session_id]
39
+ }
40
+ end
41
+
42
+ def execute(params)
43
+ selector = param(params, :selector)
44
+ wait_time = param(params, :wait) || 5
45
+ force = param(params, :force) || false
46
+
47
+ logger.info "Clicking element: #{selector} (force: #{force})"
48
+
49
+ # Use retry logic for stale elements
50
+ with_retry do
51
+ element = find_element_robust(selector, wait_time)
52
+
53
+ # Scroll element into view before clicking
54
+ element.scroll_into_view if element.respond_to?(:scroll_into_view)
55
+
56
+ # Use native Ferrum click
57
+ element.click
58
+ end
59
+
60
+ success_response(message: "Clicked on #{selector}")
61
+ rescue Ferrum::NodeNotFoundError, Ferrum::CoordinatesNotFoundError, Ferrum::NodeMovingError => e
62
+ # If native click fails and force is enabled, try with JavaScript
63
+ if force
64
+ logger.warn "Native click failed, retrying with JavaScript: #{e.message}"
65
+ click_with_javascript(selector)
66
+ success_response(message: "Clicked on #{selector} (forced)")
67
+ else
68
+ logger.error "Click failed: #{e.message}"
69
+ error_response("Failed to click: #{e.message}. Try with force: true")
70
+ end
71
+ rescue StandardError => e
72
+ # Handle "Node does not have a layout object" and similar errors
73
+ if e.message.include?('layout object') || e.message.include?('not visible')
74
+ if force
75
+ logger.warn "Element not visible, forcing click with JavaScript: #{e.message}"
76
+ click_with_javascript(selector)
77
+ success_response(message: "Clicked on #{selector} (forced)")
78
+ else
79
+ logger.error "Click failed: #{e.message}"
80
+ error_response("Failed to click: #{e.message}. Try with force: true")
81
+ end
82
+ else
83
+ logger.error "Click failed: #{e.message}"
84
+ error_response("Failed to click: #{e.message}")
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def find_element_robust(selector, timeout)
91
+ deadline = Time.now + timeout
92
+
93
+ # Support both CSS and XPath selectors
94
+ if selector.start_with?('xpath:', '//')
95
+ xpath = selector.sub(/^xpath:/, '')
96
+ logger.debug "Using XPath: #{xpath}"
97
+
98
+ # Retry XPath search until timeout
99
+ elements = []
100
+ loop do
101
+ elements = browser.xpath(xpath)
102
+ break unless elements.empty?
103
+
104
+ raise ToolError, "Element not found with XPath: #{xpath}" if Time.now > deadline
105
+
106
+ sleep 0.2
107
+ end
108
+
109
+ # Prefer visible element
110
+ element = elements.find { |el| element_visible?(el) } || elements.first
111
+ visibility = element_visible?(element) ? 'visible' : 'first'
112
+ logger.debug "Found #{elements.length} XPath matches, using #{visibility} one"
113
+ else
114
+ # For CSS selectors, use the base find_element with timeout
115
+ element = find_element(selector, timeout: timeout)
116
+ end
117
+
118
+ element
119
+ end
120
+
121
+ def click_with_javascript(selector) # rubocop:disable Metrics/MethodLength
122
+ logger.info "Using JavaScript click for: #{selector}"
123
+
124
+ # Build JavaScript to find and click element, even if hidden
125
+ if selector.start_with?('xpath:', '//')
126
+ xpath = selector.sub(/^xpath:/, '')
127
+ script = <<~JAVASCRIPT
128
+ const xpath = #{xpath.inspect};
129
+ const result = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
130
+ const elements = [];
131
+ for (let i = 0; i < result.snapshotLength; i++) {
132
+ elements.push(result.snapshotItem(i));
133
+ }
134
+
135
+ if (elements.length === 0) {
136
+ throw new Error('No element found with XPath: ' + xpath);
137
+ }
138
+
139
+ // Find first visible element, or use first element if force clicking
140
+ const visible = elements.find(el => el.offsetWidth > 0 && el.offsetHeight > 0);
141
+ const target = visible || elements[0];
142
+
143
+ // For hidden elements, temporarily show them, click, then hide again
144
+ const wasHidden = target.offsetWidth === 0 && target.offsetHeight === 0;
145
+ const originalDisplay = target.style.display;
146
+ const originalVisibility = target.style.visibility;
147
+
148
+ if (wasHidden) {
149
+ target.style.display = 'block';
150
+ target.style.visibility = 'visible';
151
+ }
152
+
153
+ try {
154
+ target.scrollIntoView({ behavior: 'instant', block: 'center' });
155
+ target.click();
156
+ } finally {
157
+ if (wasHidden) {
158
+ target.style.display = originalDisplay;
159
+ target.style.visibility = originalVisibility;
160
+ }
161
+ }
162
+
163
+ return true;
164
+ JAVASCRIPT
165
+ else
166
+ script = <<~JAVASCRIPT
167
+ const elements = Array.from(document.querySelectorAll(#{selector.inspect}));
168
+
169
+ if (elements.length === 0) {
170
+ throw new Error('No elements found with selector: #{selector}');
171
+ }
172
+
173
+ // Find first visible element, or use first element if force clicking
174
+ const visible = elements.find(el => el.offsetWidth > 0 && el.offsetHeight > 0);
175
+ const target = visible || elements[0];
176
+
177
+ // For hidden elements, temporarily show them, click, then hide again
178
+ const wasHidden = target.offsetWidth === 0 && target.offsetHeight === 0;
179
+ const originalDisplay = target.style.display;
180
+ const originalVisibility = target.style.visibility;
181
+
182
+ if (wasHidden) {
183
+ target.style.display = 'block';
184
+ target.style.visibility = 'visible';
185
+ }
186
+
187
+ try {
188
+ target.scrollIntoView({ behavior: 'instant', block: 'center' });
189
+ target.click();
190
+ } finally {
191
+ if (wasHidden) {
192
+ target.style.display = originalDisplay;
193
+ target.style.visibility = originalVisibility;
194
+ }
195
+ }
196
+
197
+ return true;
198
+ JAVASCRIPT
199
+ end
200
+
201
+ browser.execute(script)
202
+ logger.debug 'JavaScript click executed successfully'
203
+ end
204
+
205
+ def element_visible?(element)
206
+ return false unless element
207
+
208
+ # Use property instead of evaluate to avoid "Node does not have a layout object" errors
209
+ offset_width = element.property('offsetWidth')
210
+ offset_height = element.property('offsetHeight')
211
+ offset_width&.positive? && offset_height&.positive?
212
+ rescue StandardError => e
213
+ logger.debug "Cannot check visibility: #{e.message}"
214
+ false
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # Close a specific session
6
+ class CloseSessionTool < SessionTool
7
+ def self.tool_name
8
+ 'close_session'
9
+ end
10
+
11
+ def self.description
12
+ 'Close a specific browser session by its ID. The browser will be stopped and the session will be removed.'
13
+ end
14
+
15
+ def self.input_schema
16
+ {
17
+ type: 'object',
18
+ properties: {
19
+ session_id: {
20
+ type: 'string',
21
+ description: 'The ID of the session to close'
22
+ }
23
+ },
24
+ required: ['session_id']
25
+ }
26
+ end
27
+
28
+ def execute(params)
29
+ session_id = params[:session_id] || params['session_id']
30
+
31
+ return error_response('session_id is required') unless session_id
32
+
33
+ success = session_manager.close_session(session_id)
34
+
35
+ if success
36
+ success_response(
37
+ session_id: session_id,
38
+ message: 'Session closed successfully'
39
+ )
40
+ else
41
+ error_response("Session not found: #{session_id}")
42
+ end
43
+ rescue StandardError => e
44
+ logger.error "Failed to close session: #{e.message}"
45
+ error_response("Failed to close session: #{e.message}")
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ class CreateSessionTool < SessionTool
6
+ def self.tool_name
7
+ 'create_session'
8
+ end
9
+
10
+ def self.description
11
+ <<~DESC
12
+ Create a new browser session with custom options.
13
+ Supports multiple browsers in parallel (Chrome, BotBrowser).
14
+ Returns a session_id to use with other tools.
15
+
16
+ Note: When running in Docker (DOCKER=true), headless mode is mandatory.
17
+ Attempting to create a non-headless session will result in an error.
18
+ DESC
19
+ end
20
+
21
+ def self.input_schema
22
+ {
23
+ type: 'object',
24
+ properties: {
25
+ browser_id: {
26
+ type: 'string',
27
+ description: 'Optional: Browser ID to use (from ferrum://browsers resource)'
28
+ },
29
+ user_profile_id: {
30
+ type: 'string',
31
+ description: 'Optional: User profile ID to use (from ferrum://user-profiles resource)'
32
+ },
33
+ bot_profile_id: {
34
+ type: 'string',
35
+ description: 'Optional: BotBrowser profile ID to use (from ferrum://bot-profiles resource)'
36
+ },
37
+ browser_path: {
38
+ type: 'string',
39
+ description: 'Optional: Path to browser executable (legacy, prefer browser_id)'
40
+ },
41
+ botbrowser_profile: {
42
+ type: 'string',
43
+ description: 'Optional: Path to BotBrowser profile (legacy, prefer bot_profile_id)'
44
+ },
45
+ headless: {
46
+ type: 'boolean',
47
+ description: 'Optional: Run browser in headless mode (default: false). ' \
48
+ 'REQUIRED to be true when running in Docker.'
49
+ },
50
+ timeout: {
51
+ type: 'number',
52
+ description: 'Optional: Browser timeout in seconds (default: 60)'
53
+ },
54
+ browser_options: {
55
+ type: 'object',
56
+ description: 'Optional: Additional browser command-line options (e.g., {"--window-size": "1920,1080"})',
57
+ additionalProperties: { type: 'string' }
58
+ },
59
+ metadata: {
60
+ type: 'object',
61
+ description: 'Optional: Custom metadata for this session (e.g., {"user": "john", "project": "scraping"})',
62
+ additionalProperties: true
63
+ }
64
+ }
65
+ }
66
+ end
67
+
68
+ def execute(params)
69
+ logger.info 'Creating new browser session'
70
+
71
+ options = build_options(params)
72
+ validate_docker_headless!(options)
73
+
74
+ session_id = session_manager.create_session(options)
75
+
76
+ success_response(
77
+ session_id: session_id,
78
+ message: 'Session created successfully',
79
+ options: options.except(:metadata)
80
+ )
81
+ rescue StandardError => e
82
+ logger.error "Failed to create session: #{e.message}"
83
+ error_response("Failed to create session: #{e.message}")
84
+ end
85
+
86
+ private
87
+
88
+ def validate_docker_headless!(options)
89
+ # Check if running in Docker environment
90
+ return unless ENV['DOCKER'] == 'true'
91
+
92
+ # In Docker, headless mode is mandatory
93
+ # Check if headless is explicitly set to false
94
+ if options.key?(:headless) && options[:headless] == false
95
+ raise 'Headless mode is required when running in Docker. ' \
96
+ 'Cannot create a non-headless session in a containerized environment.'
97
+ end
98
+
99
+ # Force headless to true in Docker if not explicitly set
100
+ options[:headless] = true unless options.key?(:headless)
101
+ end
102
+
103
+ def build_options(params)
104
+ options = {}
105
+
106
+ # New resource-based parameters (preferred)
107
+ if params[:browser_id] || params['browser_id']
108
+ options[:browser_id] =
109
+ params[:browser_id] || params['browser_id']
110
+ end
111
+ if params[:user_profile_id] || params['user_profile_id']
112
+ options[:user_profile_id] =
113
+ params[:user_profile_id] || params['user_profile_id']
114
+ end
115
+ if params[:bot_profile_id] || params['bot_profile_id']
116
+ options[:bot_profile_id] =
117
+ params[:bot_profile_id] || params['bot_profile_id']
118
+ end
119
+
120
+ # Legacy parameters (for backward compatibility)
121
+ if params[:browser_path] || params['browser_path']
122
+ options[:browser_path] =
123
+ params[:browser_path] || params['browser_path']
124
+ end
125
+ if params[:botbrowser_profile] || params['botbrowser_profile']
126
+ options[:botbrowser_profile] =
127
+ params[:botbrowser_profile] || params['botbrowser_profile']
128
+ end
129
+
130
+ # Other options
131
+ if params.key?(:headless) || params.key?('headless')
132
+ # Use fetch to handle false values correctly
133
+ options[:headless] = params.key?(:headless) ? params[:headless] : params['headless']
134
+ end
135
+ options[:timeout] = params[:timeout] || params['timeout'] if params[:timeout] || params['timeout']
136
+ if params[:browser_options] || params['browser_options']
137
+ options[:browser_options] =
138
+ params[:browser_options] || params['browser_options']
139
+ end
140
+ options[:metadata] = params[:metadata] || params['metadata'] if params[:metadata] || params['metadata']
141
+
142
+ options
143
+ end
144
+ end
145
+ end
146
+ end