crucible 0.1.2
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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +102 -0
- data/Gemfile +10 -0
- data/LICENSE +21 -0
- data/README.md +366 -0
- data/Rakefile +23 -0
- data/TESTING.md +319 -0
- data/config.sample.yml +48 -0
- data/crucible.gemspec +48 -0
- data/exe/crucible +122 -0
- data/lib/crucible/configuration.rb +212 -0
- data/lib/crucible/server.rb +123 -0
- data/lib/crucible/session_manager.rb +209 -0
- data/lib/crucible/stealth/evasions/chrome_app.js +75 -0
- data/lib/crucible/stealth/evasions/chrome_csi.js +33 -0
- data/lib/crucible/stealth/evasions/chrome_load_times.js +44 -0
- data/lib/crucible/stealth/evasions/chrome_runtime.js +190 -0
- data/lib/crucible/stealth/evasions/iframe_content_window.js +101 -0
- data/lib/crucible/stealth/evasions/media_codecs.js +65 -0
- data/lib/crucible/stealth/evasions/navigator_hardware_concurrency.js +18 -0
- data/lib/crucible/stealth/evasions/navigator_languages.js +18 -0
- data/lib/crucible/stealth/evasions/navigator_permissions.js +53 -0
- data/lib/crucible/stealth/evasions/navigator_plugins.js +261 -0
- data/lib/crucible/stealth/evasions/navigator_vendor.js +18 -0
- data/lib/crucible/stealth/evasions/navigator_webdriver.js +16 -0
- data/lib/crucible/stealth/evasions/webgl_vendor.js +43 -0
- data/lib/crucible/stealth/evasions/window_outerdimensions.js +18 -0
- data/lib/crucible/stealth/utils.js +266 -0
- data/lib/crucible/stealth.rb +213 -0
- data/lib/crucible/tools/cookies.rb +206 -0
- data/lib/crucible/tools/downloads.rb +273 -0
- data/lib/crucible/tools/extraction.rb +335 -0
- data/lib/crucible/tools/helpers.rb +46 -0
- data/lib/crucible/tools/interaction.rb +355 -0
- data/lib/crucible/tools/navigation.rb +181 -0
- data/lib/crucible/tools/sessions.rb +85 -0
- data/lib/crucible/tools/stealth.rb +167 -0
- data/lib/crucible/tools.rb +42 -0
- data/lib/crucible/version.rb +5 -0
- data/lib/crucible.rb +60 -0
- metadata +201 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Crucible
|
|
6
|
+
# Configuration for the Crucible server
|
|
7
|
+
#
|
|
8
|
+
# Supports both programmatic configuration and YAML config files.
|
|
9
|
+
#
|
|
10
|
+
# @example Programmatic configuration
|
|
11
|
+
# config = Crucible::Configuration.new(headless: false, timeout: 60)
|
|
12
|
+
# config.viewport_width = 1920
|
|
13
|
+
#
|
|
14
|
+
# @example From YAML file
|
|
15
|
+
# config = Crucible::Configuration.from_file("~/.config/crucible/config.yml")
|
|
16
|
+
#
|
|
17
|
+
class Configuration
|
|
18
|
+
DEFAULT_CONFIG_PATHS = [
|
|
19
|
+
'~/.config/crucible/config.yml',
|
|
20
|
+
'~/.crucible.yml',
|
|
21
|
+
'.crucible.yml'
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
DEFAULTS = {
|
|
25
|
+
headless: true,
|
|
26
|
+
viewport_width: 1280,
|
|
27
|
+
viewport_height: 720,
|
|
28
|
+
chrome_path: nil,
|
|
29
|
+
timeout: 30,
|
|
30
|
+
error_level: :warn,
|
|
31
|
+
screenshot_format: :png,
|
|
32
|
+
content_format: :html,
|
|
33
|
+
# Stealth settings
|
|
34
|
+
stealth_enabled: true,
|
|
35
|
+
stealth_profile: :moderate,
|
|
36
|
+
stealth_locale: 'en-US,en',
|
|
37
|
+
# Mode settings
|
|
38
|
+
mode: nil,
|
|
39
|
+
log_file: nil
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
VALID_ERROR_LEVELS = %i[debug info warn error].freeze
|
|
43
|
+
VALID_SCREENSHOT_FORMATS = %i[png jpeg base64].freeze
|
|
44
|
+
VALID_CONTENT_FORMATS = %i[html text].freeze
|
|
45
|
+
VALID_STEALTH_PROFILES = %i[minimal moderate maximum].freeze
|
|
46
|
+
|
|
47
|
+
attr_accessor(*DEFAULTS.keys, :modes)
|
|
48
|
+
|
|
49
|
+
# Load configuration from a YAML file
|
|
50
|
+
# @param path [String] path to the YAML config file
|
|
51
|
+
# @return [Configuration]
|
|
52
|
+
def self.from_file(path)
|
|
53
|
+
expanded_path = File.expand_path(path)
|
|
54
|
+
raise Error, "Config file not found: #{expanded_path}" unless File.exist?(expanded_path)
|
|
55
|
+
|
|
56
|
+
yaml = YAML.safe_load_file(expanded_path, symbolize_names: true)
|
|
57
|
+
from_yaml_hash(yaml)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Load configuration from default locations
|
|
61
|
+
# @return [Configuration] config from first found file, or defaults
|
|
62
|
+
def self.from_defaults
|
|
63
|
+
DEFAULT_CONFIG_PATHS.each do |path|
|
|
64
|
+
expanded = File.expand_path(path)
|
|
65
|
+
return from_file(expanded) if File.exist?(expanded)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
new
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Create configuration from a parsed YAML hash
|
|
72
|
+
# @param yaml [Hash] parsed YAML configuration
|
|
73
|
+
# @return [Configuration]
|
|
74
|
+
def self.from_yaml_hash(yaml)
|
|
75
|
+
options = {}
|
|
76
|
+
|
|
77
|
+
# Browser settings
|
|
78
|
+
if yaml[:browser]
|
|
79
|
+
options[:headless] = yaml[:browser][:headless] if yaml[:browser].key?(:headless)
|
|
80
|
+
if yaml[:browser][:window_size].is_a?(Array)
|
|
81
|
+
options[:viewport_width] = yaml[:browser][:window_size][0]
|
|
82
|
+
options[:viewport_height] = yaml[:browser][:window_size][1]
|
|
83
|
+
end
|
|
84
|
+
options[:chrome_path] = yaml[:browser][:chrome_path] if yaml[:browser][:chrome_path]
|
|
85
|
+
options[:timeout] = yaml[:browser][:timeout] if yaml[:browser][:timeout]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Stealth settings
|
|
89
|
+
if yaml[:stealth]
|
|
90
|
+
options[:stealth_enabled] = yaml[:stealth][:enabled] if yaml[:stealth].key?(:enabled)
|
|
91
|
+
options[:stealth_profile] = yaml[:stealth][:profile]&.to_sym if yaml[:stealth][:profile]
|
|
92
|
+
options[:stealth_locale] = yaml[:stealth][:locale] if yaml[:stealth][:locale]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Server settings
|
|
96
|
+
if yaml[:server]
|
|
97
|
+
options[:error_level] = yaml[:server][:log_level]&.to_sym if yaml[:server][:log_level]
|
|
98
|
+
options[:log_file] = yaml[:server][:logfile] if yaml[:server][:logfile]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
config = new(options)
|
|
102
|
+
|
|
103
|
+
# Store modes for runtime switching
|
|
104
|
+
config.modes = yaml[:modes] if yaml[:modes]
|
|
105
|
+
|
|
106
|
+
config
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param options [Hash] configuration options
|
|
110
|
+
def initialize(options = {})
|
|
111
|
+
DEFAULTS.each do |key, default|
|
|
112
|
+
value = options.fetch(key, default)
|
|
113
|
+
# Symbolize string keys for enums
|
|
114
|
+
value = value.to_sym if value.is_a?(String) && enum_key?(key)
|
|
115
|
+
instance_variable_set(:"@#{key}", value)
|
|
116
|
+
end
|
|
117
|
+
@modes = {}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Creates a new Configuration with merged options
|
|
121
|
+
# @param options [Hash] options to merge
|
|
122
|
+
# @return [Configuration]
|
|
123
|
+
def merge(options)
|
|
124
|
+
self.class.new(to_h.merge(options))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Converts configuration to a hash
|
|
128
|
+
# @return [Hash]
|
|
129
|
+
def to_h
|
|
130
|
+
DEFAULTS.keys.to_h { |k| [k, instance_variable_get(:"@#{k}")] }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Returns options suitable for Ferrum::Browser.new
|
|
134
|
+
# @return [Hash]
|
|
135
|
+
def browser_options
|
|
136
|
+
opts = {
|
|
137
|
+
headless: headless,
|
|
138
|
+
window_size: [viewport_width, viewport_height],
|
|
139
|
+
timeout: timeout,
|
|
140
|
+
process_timeout: timeout * 2
|
|
141
|
+
}
|
|
142
|
+
opts[:browser_path] = chrome_path if chrome_path
|
|
143
|
+
opts
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Creates a Stealth instance based on configuration
|
|
147
|
+
# @return [Stealth]
|
|
148
|
+
def stealth
|
|
149
|
+
Stealth.new(
|
|
150
|
+
profile: stealth_profile,
|
|
151
|
+
enabled: stealth_enabled,
|
|
152
|
+
locale: stealth_locale
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Apply a named mode from the modes configuration
|
|
157
|
+
# @param mode_name [String, Symbol] the mode to apply
|
|
158
|
+
# @return [Configuration] self with mode settings applied
|
|
159
|
+
def apply_mode(mode_name)
|
|
160
|
+
mode_name = mode_name.to_sym
|
|
161
|
+
return self unless modes && modes[mode_name]
|
|
162
|
+
|
|
163
|
+
mode_config = modes[mode_name]
|
|
164
|
+
|
|
165
|
+
self.stealth_profile = mode_config[:stealth_profile]&.to_sym if mode_config[:stealth_profile]
|
|
166
|
+
self.screenshot_format = mode_config[:screenshot_format]&.to_sym if mode_config[:screenshot_format]
|
|
167
|
+
self.timeout = mode_config[:wait_timeout] / 1000 if mode_config[:wait_timeout]
|
|
168
|
+
|
|
169
|
+
@mode = mode_name
|
|
170
|
+
self
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Get list of available mode names
|
|
174
|
+
# @return [Array<Symbol>]
|
|
175
|
+
def available_modes
|
|
176
|
+
modes&.keys || []
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Validates the configuration
|
|
180
|
+
# @raise [Error] if configuration is invalid
|
|
181
|
+
def validate!
|
|
182
|
+
raise Error, 'Timeout must be positive' unless timeout.positive?
|
|
183
|
+
raise Error, 'Viewport width must be positive' unless viewport_width.positive?
|
|
184
|
+
raise Error, 'Viewport height must be positive' unless viewport_height.positive?
|
|
185
|
+
|
|
186
|
+
unless VALID_ERROR_LEVELS.include?(error_level)
|
|
187
|
+
raise Error, "Invalid error_level: #{error_level}. Must be one of: #{VALID_ERROR_LEVELS.join(', ')}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
unless VALID_SCREENSHOT_FORMATS.include?(screenshot_format)
|
|
191
|
+
raise Error,
|
|
192
|
+
"Invalid screenshot_format: #{screenshot_format}. Must be one of: #{VALID_SCREENSHOT_FORMATS.join(', ')}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
unless VALID_CONTENT_FORMATS.include?(content_format)
|
|
196
|
+
raise Error, "Invalid content_format: #{content_format}. Must be one of: #{VALID_CONTENT_FORMATS.join(', ')}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
unless VALID_STEALTH_PROFILES.include?(stealth_profile)
|
|
200
|
+
raise Error, "Invalid stealth_profile: #{stealth_profile}. Must be one of: #{VALID_STEALTH_PROFILES.join(', ')}"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
true
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
def enum_key?(key)
|
|
209
|
+
%i[error_level screenshot_format content_format stealth_profile].include?(key)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mcp'
|
|
4
|
+
|
|
5
|
+
module Crucible
|
|
6
|
+
# MCP Server for browser automation
|
|
7
|
+
#
|
|
8
|
+
# Provides browser automation tools via the Model Context Protocol.
|
|
9
|
+
# Uses stdio transport for communication with AI agents.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# config = Crucible::Configuration.new(headless: true)
|
|
13
|
+
# server = Crucible::Server.new(config)
|
|
14
|
+
# server.run
|
|
15
|
+
#
|
|
16
|
+
class Server
|
|
17
|
+
# @param config [Configuration, Hash] server configuration
|
|
18
|
+
def initialize(config)
|
|
19
|
+
@config = config.is_a?(Configuration) ? config : Configuration.new(config)
|
|
20
|
+
@config.validate!
|
|
21
|
+
@session_manager = SessionManager.new(@config)
|
|
22
|
+
@running = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Starts the MCP server (blocking)
|
|
26
|
+
def run
|
|
27
|
+
@running = true
|
|
28
|
+
setup_signal_handlers
|
|
29
|
+
|
|
30
|
+
server = create_mcp_server
|
|
31
|
+
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
32
|
+
|
|
33
|
+
log(:info, "Crucible server starting (headless: #{@config.headless})")
|
|
34
|
+
transport.open
|
|
35
|
+
rescue Interrupt
|
|
36
|
+
log(:info, 'Shutting down...')
|
|
37
|
+
ensure
|
|
38
|
+
shutdown
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Gracefully shuts down the server
|
|
42
|
+
def shutdown
|
|
43
|
+
return unless @running
|
|
44
|
+
|
|
45
|
+
@running = false
|
|
46
|
+
@session_manager.close_all
|
|
47
|
+
log(:info, 'Server stopped')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def create_mcp_server
|
|
53
|
+
MCP::Server.new(
|
|
54
|
+
name: 'crucible',
|
|
55
|
+
version: VERSION,
|
|
56
|
+
instructions: instructions_text,
|
|
57
|
+
tools: Tools.all(@session_manager, @config)
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def instructions_text
|
|
62
|
+
<<~INSTRUCTIONS
|
|
63
|
+
Browser automation server powered by Ferrum and headless Chrome.
|
|
64
|
+
|
|
65
|
+
## Sessions
|
|
66
|
+
Use the `session` parameter to manage multiple independent browser instances.
|
|
67
|
+
Default session is "default". Sessions persist until explicitly closed.
|
|
68
|
+
|
|
69
|
+
## Common Workflows
|
|
70
|
+
|
|
71
|
+
### Basic Navigation
|
|
72
|
+
1. navigate(url: "https://example.com")
|
|
73
|
+
2. wait_for(selector: ".content")
|
|
74
|
+
3. get_content(format: "text")
|
|
75
|
+
|
|
76
|
+
### Form Interaction
|
|
77
|
+
1. navigate(url: "https://example.com/login")
|
|
78
|
+
2. type(selector: "#email", text: "user@example.com")
|
|
79
|
+
3. type(selector: "#password", text: "secret")
|
|
80
|
+
4. click(selector: "button[type=submit]")
|
|
81
|
+
|
|
82
|
+
### Screenshots & PDFs
|
|
83
|
+
- screenshot() - viewport screenshot
|
|
84
|
+
- screenshot(full_page: true) - full page
|
|
85
|
+
- screenshot(selector: ".element") - specific element
|
|
86
|
+
- pdf(format: "A4", landscape: true)
|
|
87
|
+
|
|
88
|
+
### JavaScript Execution
|
|
89
|
+
- evaluate(expression: "document.title")
|
|
90
|
+
- evaluate(expression: "window.scrollTo(0, 0)")
|
|
91
|
+
|
|
92
|
+
## Session Management
|
|
93
|
+
- list_sessions() - see all active sessions
|
|
94
|
+
- close_session(session: "name") - close specific session
|
|
95
|
+
- close_session(all: true) - close all sessions
|
|
96
|
+
INSTRUCTIONS
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def setup_signal_handlers
|
|
100
|
+
%w[INT TERM].each do |signal|
|
|
101
|
+
Signal.trap(signal) do
|
|
102
|
+
# Can't call complex code (mutex) from trap context
|
|
103
|
+
# Just raise Interrupt to let the main thread handle cleanup
|
|
104
|
+
Thread.main.raise(Interrupt)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def log(level, message)
|
|
110
|
+
return if should_suppress_log?(level)
|
|
111
|
+
|
|
112
|
+
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
|
|
113
|
+
warn "[#{timestamp}] [#{level.upcase}] #{message}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def should_suppress_log?(level)
|
|
117
|
+
levels = %i[debug info warn error]
|
|
118
|
+
current_level_index = levels.index(@config.error_level) || 2
|
|
119
|
+
message_level_index = levels.index(level) || 0
|
|
120
|
+
message_level_index < current_level_index
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ferrum'
|
|
4
|
+
|
|
5
|
+
module Crucible
|
|
6
|
+
# Manages multiple named browser sessions
|
|
7
|
+
#
|
|
8
|
+
# Thread-safe session management with lazy browser initialization.
|
|
9
|
+
# Each session maintains its own Ferrum::Browser instance.
|
|
10
|
+
# Supports per-session stealth mode settings.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# manager = SessionManager.new(config)
|
|
14
|
+
# page = manager.page("my-session")
|
|
15
|
+
# page.go_to("https://example.com")
|
|
16
|
+
# manager.close("my-session")
|
|
17
|
+
#
|
|
18
|
+
class SessionManager
|
|
19
|
+
# Session metadata
|
|
20
|
+
SessionInfo = Struct.new(:browser, :stealth, :stealth_enabled, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
# @param config [Configuration] the server configuration
|
|
23
|
+
def initialize(config)
|
|
24
|
+
@config = config
|
|
25
|
+
@sessions = {}
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Gets or creates a session by name, returning the browser
|
|
30
|
+
# @param name [String] session name (default: "default")
|
|
31
|
+
# @return [Ferrum::Browser]
|
|
32
|
+
def get_or_create(name = 'default')
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
@sessions[name] ||= create_session
|
|
35
|
+
@sessions[name].browser
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Gets an existing session by name
|
|
40
|
+
# @param name [String] session name
|
|
41
|
+
# @return [Ferrum::Browser]
|
|
42
|
+
# @raise [SessionNotFoundError] if session doesn't exist
|
|
43
|
+
def get(name)
|
|
44
|
+
info = @mutex.synchronize { @sessions[name] }
|
|
45
|
+
raise SessionNotFoundError, "Session '#{name}' not found. Use get_or_create or navigate first." unless info
|
|
46
|
+
|
|
47
|
+
info.browser
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Convenience method to get the page for a session
|
|
51
|
+
# @param name [String] session name (default: "default")
|
|
52
|
+
# @return [Ferrum::Page]
|
|
53
|
+
def page(name = 'default')
|
|
54
|
+
browser = get_or_create(name)
|
|
55
|
+
browser.page || browser.create_page
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Creates a new named session
|
|
59
|
+
# @param name [String] session name
|
|
60
|
+
# @return [Ferrum::Browser]
|
|
61
|
+
# @raise [Error] if session already exists
|
|
62
|
+
def create(name)
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
raise Error, "Session '#{name}' already exists" if @sessions.key?(name)
|
|
65
|
+
|
|
66
|
+
@sessions[name] = create_session
|
|
67
|
+
@sessions[name].browser
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Closes a session and quits its browser
|
|
72
|
+
# @param name [String] session name
|
|
73
|
+
# @return [Boolean] true if session was closed, false if not found
|
|
74
|
+
def close(name)
|
|
75
|
+
info = @mutex.synchronize { @sessions.delete(name) }
|
|
76
|
+
return false unless info
|
|
77
|
+
|
|
78
|
+
info.browser.quit
|
|
79
|
+
true
|
|
80
|
+
rescue Ferrum::Error
|
|
81
|
+
# Browser may already be dead
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Closes all sessions
|
|
86
|
+
def close_all
|
|
87
|
+
sessions = @mutex.synchronize do
|
|
88
|
+
result = @sessions.values
|
|
89
|
+
@sessions.clear
|
|
90
|
+
result
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
sessions.each do |info|
|
|
94
|
+
info.browser.quit
|
|
95
|
+
rescue Ferrum::Error
|
|
96
|
+
# Ignore errors during shutdown
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Lists all active session names
|
|
101
|
+
# @return [Array<String>]
|
|
102
|
+
def list
|
|
103
|
+
@mutex.synchronize { @sessions.keys.dup }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Checks if a session exists
|
|
107
|
+
# @param name [String] session name
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
def exists?(name)
|
|
110
|
+
@mutex.synchronize { @sessions.key?(name) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Returns the count of active sessions
|
|
114
|
+
# @return [Integer]
|
|
115
|
+
def count
|
|
116
|
+
@mutex.synchronize { @sessions.size }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Enable stealth mode for a session
|
|
120
|
+
# @param name [String] session name
|
|
121
|
+
# @param profile [Symbol] stealth profile (:minimal, :moderate, :maximum)
|
|
122
|
+
# @return [Boolean] true if stealth was enabled
|
|
123
|
+
def enable_stealth(name, profile: nil)
|
|
124
|
+
info = @mutex.synchronize { @sessions[name] }
|
|
125
|
+
raise SessionNotFoundError, "Session '#{name}' not found" unless info
|
|
126
|
+
|
|
127
|
+
profile ||= @config.stealth_profile
|
|
128
|
+
stealth = Stealth.new(profile: profile, enabled: true, locale: @config.stealth_locale)
|
|
129
|
+
|
|
130
|
+
# Apply stealth to the browser
|
|
131
|
+
stealth.apply(info.browser)
|
|
132
|
+
|
|
133
|
+
# Update session metadata
|
|
134
|
+
@mutex.synchronize do
|
|
135
|
+
@sessions[name] = SessionInfo.new(
|
|
136
|
+
browser: info.browser,
|
|
137
|
+
stealth: stealth,
|
|
138
|
+
stealth_enabled: true
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
true
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Disable stealth mode for a session
|
|
146
|
+
# Note: This won't undo already-applied evasions, but prevents new ones
|
|
147
|
+
# @param name [String] session name
|
|
148
|
+
# @return [Boolean] true if stealth was disabled
|
|
149
|
+
def disable_stealth(name)
|
|
150
|
+
info = @mutex.synchronize { @sessions[name] }
|
|
151
|
+
raise SessionNotFoundError, "Session '#{name}' not found" unless info
|
|
152
|
+
|
|
153
|
+
@mutex.synchronize do
|
|
154
|
+
@sessions[name] = SessionInfo.new(
|
|
155
|
+
browser: info.browser,
|
|
156
|
+
stealth: info.stealth,
|
|
157
|
+
stealth_enabled: false
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
true
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Check if stealth is enabled for a session
|
|
165
|
+
# @param name [String] session name
|
|
166
|
+
# @return [Boolean]
|
|
167
|
+
def stealth_enabled?(name)
|
|
168
|
+
info = @mutex.synchronize { @sessions[name] }
|
|
169
|
+
return false unless info
|
|
170
|
+
|
|
171
|
+
info.stealth_enabled
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Get stealth info for a session
|
|
175
|
+
# @param name [String] session name
|
|
176
|
+
# @return [Hash] stealth status and profile
|
|
177
|
+
def stealth_info(name)
|
|
178
|
+
info = @mutex.synchronize { @sessions[name] }
|
|
179
|
+
raise SessionNotFoundError, "Session '#{name}' not found" unless info
|
|
180
|
+
|
|
181
|
+
{
|
|
182
|
+
enabled: info.stealth_enabled,
|
|
183
|
+
profile: info.stealth&.profile&.to_s
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def create_session
|
|
190
|
+
browser = create_browser
|
|
191
|
+
stealth = @config.stealth
|
|
192
|
+
|
|
193
|
+
# Apply stealth if enabled in config
|
|
194
|
+
stealth.apply(browser) if @config.stealth_enabled
|
|
195
|
+
|
|
196
|
+
SessionInfo.new(
|
|
197
|
+
browser: browser,
|
|
198
|
+
stealth: stealth,
|
|
199
|
+
stealth_enabled: @config.stealth_enabled
|
|
200
|
+
)
|
|
201
|
+
rescue Ferrum::Error => e
|
|
202
|
+
raise BrowserError, "Failed to create browser: #{e.message}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def create_browser
|
|
206
|
+
Ferrum::Browser.new(@config.browser_options)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evasion: chrome.app
|
|
3
|
+
* Mock the chrome.app object that exists in headed Chrome.
|
|
4
|
+
*/
|
|
5
|
+
(function() {
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
if (!window.chrome) {
|
|
9
|
+
Object.defineProperty(window, 'chrome', {
|
|
10
|
+
writable: true,
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: false,
|
|
13
|
+
value: {}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if ('app' in window.chrome) {
|
|
18
|
+
return; // Already exists
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const makeError = {
|
|
22
|
+
ErrorInInvocation: fn => {
|
|
23
|
+
const err = new TypeError(`Error in invocation of app.${fn}()`);
|
|
24
|
+
return err;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const STATIC_DATA = {
|
|
29
|
+
isInstalled: false,
|
|
30
|
+
InstallState: {
|
|
31
|
+
DISABLED: 'disabled',
|
|
32
|
+
INSTALLED: 'installed',
|
|
33
|
+
NOT_INSTALLED: 'not_installed'
|
|
34
|
+
},
|
|
35
|
+
RunningState: {
|
|
36
|
+
CANNOT_RUN: 'cannot_run',
|
|
37
|
+
READY_TO_RUN: 'ready_to_run',
|
|
38
|
+
RUNNING: 'running'
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
window.chrome.app = {
|
|
43
|
+
...STATIC_DATA,
|
|
44
|
+
|
|
45
|
+
get isInstalled() {
|
|
46
|
+
return false;
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
getDetails: function getDetails() {
|
|
50
|
+
if (arguments.length) {
|
|
51
|
+
throw makeError.ErrorInInvocation('getDetails');
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
getIsInstalled: function getIsInstalled() {
|
|
57
|
+
if (arguments.length) {
|
|
58
|
+
throw makeError.ErrorInInvocation('getIsInstalled');
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
runningState: function runningState() {
|
|
64
|
+
if (arguments.length) {
|
|
65
|
+
throw makeError.ErrorInInvocation('runningState');
|
|
66
|
+
}
|
|
67
|
+
return 'cannot_run';
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Patch toString for native appearance
|
|
72
|
+
if (window._stealthUtils) {
|
|
73
|
+
window._stealthUtils.patchToStringNested(window.chrome.app);
|
|
74
|
+
}
|
|
75
|
+
})();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evasion: chrome.csi
|
|
3
|
+
* Mock the chrome.csi function that exists in headed Chrome.
|
|
4
|
+
*/
|
|
5
|
+
(function() {
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
if (!window.chrome) {
|
|
9
|
+
Object.defineProperty(window, 'chrome', {
|
|
10
|
+
writable: true,
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: false,
|
|
13
|
+
value: {}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if ('csi' in window.chrome) {
|
|
18
|
+
return; // Already exists
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
window.chrome.csi = function csi() {
|
|
22
|
+
return {
|
|
23
|
+
onloadT: Date.now(),
|
|
24
|
+
startE: Date.now(),
|
|
25
|
+
pageT: Math.random() * 1000 + 100,
|
|
26
|
+
tran: 15
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
if (window._stealthUtils) {
|
|
31
|
+
window._stealthUtils.patchToString(window.chrome.csi);
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evasion: chrome.loadTimes
|
|
3
|
+
* Mock the chrome.loadTimes function that exists in headed Chrome.
|
|
4
|
+
*/
|
|
5
|
+
(function() {
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
if (!window.chrome) {
|
|
9
|
+
Object.defineProperty(window, 'chrome', {
|
|
10
|
+
writable: true,
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: false,
|
|
13
|
+
value: {}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if ('loadTimes' in window.chrome) {
|
|
18
|
+
return; // Already exists
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
window.chrome.loadTimes = function loadTimes() {
|
|
22
|
+
const now = Date.now() / 1000;
|
|
23
|
+
const rand = Math.random();
|
|
24
|
+
return {
|
|
25
|
+
commitLoadTime: now - rand,
|
|
26
|
+
connectionInfo: 'http/1.1',
|
|
27
|
+
finishDocumentLoadTime: now - rand + 0.1,
|
|
28
|
+
finishLoadTime: now - rand + 0.2,
|
|
29
|
+
firstPaintAfterLoadTime: 0,
|
|
30
|
+
firstPaintTime: now - rand + 0.05,
|
|
31
|
+
navigationType: 'Other',
|
|
32
|
+
npnNegotiatedProtocol: 'unknown',
|
|
33
|
+
requestTime: now - rand - 0.1,
|
|
34
|
+
startLoadTime: now - rand,
|
|
35
|
+
wasAlternateProtocolAvailable: false,
|
|
36
|
+
wasFetchedViaSpdy: false,
|
|
37
|
+
wasNpnNegotiated: false
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
if (window._stealthUtils) {
|
|
42
|
+
window._stealthUtils.patchToString(window.chrome.loadTimes);
|
|
43
|
+
}
|
|
44
|
+
})();
|