browserctl 0.3.1 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +120 -214
- data/bin/browserctl +35 -13
- data/bin/browserd +7 -1
- data/bin/setup +7 -3
- data/examples/cloudflare_hitl.rb +1 -1
- data/examples/smoke/params_file.rb +35 -0
- data/examples/smoke/store_fetch.rb +39 -0
- data/examples/the_internet/add_remove_elements.rb +3 -3
- data/examples/the_internet/checkboxes.rb +3 -3
- data/examples/the_internet/dropdown.rb +3 -3
- data/examples/the_internet/dynamic_loading.rb +3 -3
- data/examples/the_internet/login.rb +5 -5
- data/lib/browserctl/client.rb +38 -2
- data/lib/browserctl/commands/export_cookies.rb +18 -0
- data/lib/browserctl/commands/import_cookies.rb +23 -0
- data/lib/browserctl/commands/init.rb +11 -0
- data/lib/browserctl/commands/{pause_resume.rb → pause.rb} +2 -12
- data/lib/browserctl/commands/record.rb +2 -0
- data/lib/browserctl/commands/resume.rb +21 -0
- data/lib/browserctl/commands/snapshot.rb +5 -5
- data/lib/browserctl/commands/status.rb +30 -0
- data/lib/browserctl/constants.rb +9 -2
- data/lib/browserctl/detectors.rb +23 -0
- data/lib/browserctl/errors.rb +25 -0
- data/lib/browserctl/logger.rb +40 -5
- data/lib/browserctl/policy.rb +36 -0
- data/lib/browserctl/recording.rb +81 -15
- data/lib/browserctl/runner.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +31 -234
- data/lib/browserctl/server/handlers/cookies.rb +57 -0
- data/lib/browserctl/server/handlers/daemon_control.rb +29 -0
- data/lib/browserctl/server/handlers/devtools.rb +22 -0
- data/lib/browserctl/server/handlers/hitl.rb +30 -0
- data/lib/browserctl/server/handlers/navigation.rb +72 -0
- data/lib/browserctl/server/handlers/observation.rb +113 -0
- data/lib/browserctl/server/handlers/page_lifecycle.rb +29 -0
- data/lib/browserctl/server.rb +18 -2
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +41 -3
- data/lib/browserctl.rb +12 -2
- metadata +48 -4
data/lib/browserctl/recording.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "date"
|
|
5
5
|
require "fileutils"
|
|
6
6
|
require "tmpdir"
|
|
7
|
+
require "uri"
|
|
7
8
|
|
|
8
9
|
module Browserctl
|
|
9
10
|
class Recording
|
|
@@ -12,11 +13,15 @@ module Browserctl
|
|
|
12
13
|
|
|
13
14
|
RECORDABLE = %w[open_page goto fill click screenshot evaluate].freeze
|
|
14
15
|
|
|
16
|
+
SENSITIVE_PARAM_PATTERN = /\A(token|key|secret|auth|code|access_token|api_key|client_secret|state)\z/ix
|
|
17
|
+
|
|
15
18
|
def self.start(name)
|
|
16
|
-
FileUtils.mkdir_p(RECORDINGS_DIR)
|
|
19
|
+
FileUtils.mkdir_p(RECORDINGS_DIR, mode: 0o700)
|
|
17
20
|
FileUtils.mkdir_p(File.dirname(STATE_FILE))
|
|
18
21
|
File.write(STATE_FILE, name)
|
|
19
22
|
FileUtils.rm_f(log_path(name))
|
|
23
|
+
FileUtils.touch(log_path(name))
|
|
24
|
+
File.chmod(0o600, log_path(name))
|
|
20
25
|
name
|
|
21
26
|
end
|
|
22
27
|
|
|
@@ -36,10 +41,13 @@ module Browserctl
|
|
|
36
41
|
name = active
|
|
37
42
|
return unless name
|
|
38
43
|
return unless RECORDABLE.include?(cmd.to_s)
|
|
39
|
-
# ref-based interactions have no replayable selector — skip them
|
|
40
|
-
return if %w[click fill].include?(cmd.to_s) && attrs[:selector].nil?
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
if %w[click fill].include?(cmd.to_s) && attrs[:selector].nil?
|
|
46
|
+
record_ref_interaction(name, cmd.to_s, attrs)
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
attrs = prepare_attrs(cmd.to_s, attrs)
|
|
43
51
|
|
|
44
52
|
File.open(log_path(name), "a") do |f|
|
|
45
53
|
f.puts JSON.generate({ cmd: cmd.to_s }.merge(attrs.transform_keys(&:to_s)))
|
|
@@ -53,6 +61,13 @@ module Browserctl
|
|
|
53
61
|
lines = File.readlines(log).map { |l| JSON.parse(l, symbolize_names: true) }
|
|
54
62
|
ruby = build_workflow_ruby(name, lines)
|
|
55
63
|
File.write(output_path, ruby) if output_path
|
|
64
|
+
|
|
65
|
+
ref_count = lines.count { |l| l[:cmd] == "_ref_interaction" }
|
|
66
|
+
if ref_count.positive?
|
|
67
|
+
warn "Warning: #{ref_count} ref-based interaction(s) were captured but cannot be replayed by ref."
|
|
68
|
+
warn "Search the generated workflow for 'TODO: ref-based' and replace with stable CSS selectors."
|
|
69
|
+
end
|
|
70
|
+
|
|
56
71
|
ruby
|
|
57
72
|
ensure
|
|
58
73
|
FileUtils.rm_f(log) if log
|
|
@@ -65,6 +80,13 @@ module Browserctl
|
|
|
65
80
|
File.join(RECORDINGS_DIR, "#{name}.jsonl")
|
|
66
81
|
end
|
|
67
82
|
|
|
83
|
+
def record_ref_interaction(recording_name, cmd, attrs)
|
|
84
|
+
entry = { cmd: "_ref_interaction", action: cmd, ref: attrs[:ref], name: attrs[:name] }
|
|
85
|
+
File.open(log_path(recording_name), "a") do |f|
|
|
86
|
+
f.puts JSON.generate(entry)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
68
90
|
def build_workflow_ruby(name, commands)
|
|
69
91
|
steps = commands.map { |c| build_step(c) }.join("\n\n")
|
|
70
92
|
<<~RUBY
|
|
@@ -80,30 +102,74 @@ module Browserctl
|
|
|
80
102
|
|
|
81
103
|
def build_step(cmd)
|
|
82
104
|
label, body = step_parts(cmd)
|
|
83
|
-
|
|
105
|
+
|
|
106
|
+
if body.nil?
|
|
107
|
+
page_sym = cmd[:name].to_s.gsub(/[^a-zA-Z0-9_]/, "_")
|
|
108
|
+
action = cmd[:action].to_s.gsub(/[^a-z_]/, "")
|
|
109
|
+
return "# TODO: ref-based #{action} on #{cmd[:name].inspect} (ref: #{cmd[:ref].inspect}) — " \
|
|
110
|
+
"replace with a stable CSS selector\n" \
|
|
111
|
+
"# step #{label.inspect} do\n" \
|
|
112
|
+
"# page(:#{page_sym}).#{action}(\"YOUR_SELECTOR_HERE\")\n" \
|
|
113
|
+
"# end"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
url = cmd[:url].to_s
|
|
117
|
+
if url.include?("[REDACTED]")
|
|
118
|
+
"# NOTE: sensitive query params were redacted during recording\nstep #{label.inspect} do\n #{body}\nend"
|
|
119
|
+
else
|
|
120
|
+
"step #{label.inspect} do\n #{body}\nend"
|
|
121
|
+
end
|
|
84
122
|
end
|
|
85
123
|
|
|
86
124
|
def step_parts(cmd)
|
|
125
|
+
return ref_interaction_parts(cmd) if cmd[:cmd] == "_ref_interaction"
|
|
126
|
+
return selector_parts(cmd) if %w[fill click].include?(cmd[:cmd])
|
|
127
|
+
|
|
128
|
+
page = cmd[:name]
|
|
129
|
+
case cmd[:cmd]
|
|
130
|
+
when "open_page" then ["open #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
|
|
131
|
+
when "goto" then ["goto #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
|
|
132
|
+
when "screenshot" then ["screenshot #{page}", "page(:#{page}).screenshot"]
|
|
133
|
+
when "evaluate" then ["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"]
|
|
134
|
+
else ["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def ref_interaction_parts(cmd)
|
|
139
|
+
["TODO: ref-based #{cmd[:action]} on #{cmd[:name]} (ref: #{cmd[:ref]})", nil]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def selector_parts(cmd)
|
|
87
143
|
page = cmd[:name]
|
|
88
144
|
case cmd[:cmd]
|
|
89
|
-
when "open_page"
|
|
90
|
-
["open #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
|
|
91
|
-
when "goto"
|
|
92
|
-
["goto #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
|
|
93
145
|
when "fill"
|
|
94
146
|
["fill #{cmd[:selector]} on #{page}",
|
|
95
147
|
"page(:#{page}).fill(#{cmd[:selector].inspect}, params[:fill_value])"]
|
|
96
148
|
when "click"
|
|
97
149
|
["click #{cmd[:selector]} on #{page}",
|
|
98
150
|
"page(:#{page}).click(#{cmd[:selector].inspect})"]
|
|
99
|
-
when "screenshot"
|
|
100
|
-
["screenshot #{page}", "page(:#{page}).screenshot"]
|
|
101
|
-
when "evaluate"
|
|
102
|
-
["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"]
|
|
103
|
-
else
|
|
104
|
-
["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"]
|
|
105
151
|
end
|
|
106
152
|
end
|
|
153
|
+
|
|
154
|
+
def prepare_attrs(cmd, attrs)
|
|
155
|
+
attrs = attrs.except(:value) if cmd == "fill"
|
|
156
|
+
attrs[:url] = redact_url(attrs[:url]) if %w[goto open_page].include?(cmd) && attrs[:url]
|
|
157
|
+
attrs
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def redact_url(url)
|
|
161
|
+
uri = URI.parse(url)
|
|
162
|
+
return url if uri.query.nil?
|
|
163
|
+
|
|
164
|
+
uri.query = uri.query.gsub(/([^&=]+)=([^&]*)/) do |full_match|
|
|
165
|
+
raw_key = ::Regexp.last_match(1)
|
|
166
|
+
key = URI.decode_www_form_component(raw_key)
|
|
167
|
+
key =~ SENSITIVE_PARAM_PATTERN ? "#{raw_key}=[REDACTED]" : full_match
|
|
168
|
+
end
|
|
169
|
+
uri.to_s
|
|
170
|
+
rescue URI::InvalidURIError
|
|
171
|
+
url
|
|
172
|
+
end
|
|
107
173
|
end
|
|
108
174
|
end
|
|
109
175
|
end
|
data/lib/browserctl/runner.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require_relative "workflow"
|
|
4
5
|
require_relative "client"
|
|
5
6
|
|
|
@@ -26,7 +27,7 @@ module Browserctl
|
|
|
26
27
|
# @return [Array<Hash>] array of `{ name:, desc: }` hashes
|
|
27
28
|
def list_workflows
|
|
28
29
|
load_all_workflows
|
|
29
|
-
|
|
30
|
+
Browserctl.registry_snapshot.map { |name, defn| { name: name, desc: defn.description } }
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
# Returns detailed information about a workflow.
|
|
@@ -39,6 +40,24 @@ module Browserctl
|
|
|
39
40
|
|
|
40
41
|
SAFE_WORKFLOW_NAME = /\A[a-zA-Z0-9_-]+\z/
|
|
41
42
|
|
|
43
|
+
def self.load_params_file(path)
|
|
44
|
+
raise "params file not found: #{path}" unless File.exist?(path)
|
|
45
|
+
|
|
46
|
+
case File.extname(path).downcase
|
|
47
|
+
when ".yml", ".yaml"
|
|
48
|
+
require "yaml"
|
|
49
|
+
YAML.safe_load_file(path, symbolize_names: true)
|
|
50
|
+
when ".json"
|
|
51
|
+
JSON.parse(File.read(path), symbolize_names: true)
|
|
52
|
+
else
|
|
53
|
+
raise "unsupported params file format: #{path} (use .yml, .yaml, or .json)"
|
|
54
|
+
end
|
|
55
|
+
rescue Psych::SyntaxError => e
|
|
56
|
+
raise "invalid YAML in #{path}: #{e.message}"
|
|
57
|
+
rescue JSON::ParserError => e
|
|
58
|
+
raise "invalid JSON in #{path}: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
42
61
|
private
|
|
43
62
|
|
|
44
63
|
def validate_name!(name)
|
|
@@ -48,15 +67,15 @@ module Browserctl
|
|
|
48
67
|
end
|
|
49
68
|
|
|
50
69
|
def fetch_workflow(name)
|
|
51
|
-
return
|
|
70
|
+
return Browserctl.lookup_workflow(name.to_s) if Browserctl.lookup_workflow(name.to_s)
|
|
52
71
|
|
|
53
72
|
validate_name!(name)
|
|
54
73
|
load_workflow_file(name)
|
|
55
|
-
|
|
74
|
+
Browserctl.lookup_workflow(name.to_s) || raise("workflow '#{name}' not found")
|
|
56
75
|
end
|
|
57
76
|
|
|
58
77
|
def load_workflow_file(name)
|
|
59
|
-
return if
|
|
78
|
+
return if Browserctl.lookup_workflow(name.to_s)
|
|
60
79
|
|
|
61
80
|
path = workflow_path(name)
|
|
62
81
|
load path if path
|
|
@@ -2,9 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "snapshot_builder"
|
|
4
4
|
require_relative "page_session"
|
|
5
|
+
require_relative "handlers/page_lifecycle"
|
|
6
|
+
require_relative "handlers/navigation"
|
|
7
|
+
require_relative "handlers/observation"
|
|
8
|
+
require_relative "handlers/cookies"
|
|
9
|
+
require_relative "handlers/hitl"
|
|
10
|
+
require_relative "handlers/devtools"
|
|
11
|
+
require_relative "handlers/daemon_control"
|
|
12
|
+
require_relative "../detectors"
|
|
13
|
+
require_relative "../policy"
|
|
5
14
|
|
|
6
15
|
module Browserctl
|
|
7
16
|
class CommandDispatcher
|
|
17
|
+
include Handlers::PageLifecycle
|
|
18
|
+
include Handlers::Navigation
|
|
19
|
+
include Handlers::Observation
|
|
20
|
+
include Handlers::Cookies
|
|
21
|
+
include Handlers::Hitl
|
|
22
|
+
include Handlers::DevTools
|
|
23
|
+
include Handlers::DaemonControl
|
|
24
|
+
|
|
8
25
|
COMMAND_MAP = {
|
|
9
26
|
"open_page" => :cmd_open_page,
|
|
10
27
|
"close_page" => :cmd_close_page,
|
|
@@ -25,25 +42,29 @@ module Browserctl
|
|
|
25
42
|
"inspect" => :cmd_inspect,
|
|
26
43
|
"cookies" => :cmd_cookies,
|
|
27
44
|
"set_cookie" => :cmd_set_cookie,
|
|
28
|
-
"clear_cookies" => :cmd_clear_cookies
|
|
45
|
+
"clear_cookies" => :cmd_clear_cookies,
|
|
46
|
+
"import_cookies" => :cmd_import_cookies,
|
|
47
|
+
"store" => :cmd_store,
|
|
48
|
+
"fetch" => :cmd_fetch
|
|
29
49
|
}.freeze
|
|
30
50
|
|
|
31
|
-
SCREENSHOT_DIR
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"cf-challenge-running",
|
|
35
|
-
"cf_chl_opt",
|
|
36
|
-
"__cf_chl_f_tk",
|
|
37
|
-
"Just a moment..."
|
|
38
|
-
].freeze
|
|
51
|
+
SCREENSHOT_DIR = File.expand_path("~/.browserctl/screenshots").freeze
|
|
52
|
+
SCREENSHOT_ROOTS = [SCREENSHOT_DIR, File.expand_path(".")].freeze
|
|
53
|
+
SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
|
|
39
54
|
|
|
40
55
|
def initialize(pages, browser, snapshot_builder = SnapshotBuilder.new, global_mutex: Mutex.new)
|
|
41
56
|
@pages = pages
|
|
42
57
|
@browser = browser
|
|
43
58
|
@snapshot_builder = snapshot_builder
|
|
44
59
|
@global_mutex = global_mutex
|
|
60
|
+
@kv_store = {}
|
|
61
|
+
@kv_mutex = Mutex.new
|
|
45
62
|
end
|
|
46
63
|
|
|
64
|
+
# Dispatches a parsed request to the appropriate handler.
|
|
65
|
+
# Returns `{ error: String, code: String }` for unknown commands.
|
|
66
|
+
# @param req [Hash{Symbol => Object}] parsed request; must include `:cmd`
|
|
67
|
+
# @return [Hash{Symbol => Object}] response; always includes `:ok` or `:error`
|
|
47
68
|
def dispatch(req)
|
|
48
69
|
handler = COMMAND_MAP[req[:cmd]]
|
|
49
70
|
if handler
|
|
@@ -51,7 +72,7 @@ module Browserctl
|
|
|
51
72
|
return send(handler, req)
|
|
52
73
|
end
|
|
53
74
|
|
|
54
|
-
if (plugin = Browserctl
|
|
75
|
+
if (plugin = Browserctl.lookup_plugin_command(req[:cmd]))
|
|
55
76
|
Browserctl.logger.debug("plugin:#{req[:cmd]} #{req[:name]}")
|
|
56
77
|
session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
|
|
57
78
|
return plugin.call(session, req)
|
|
@@ -62,216 +83,6 @@ module Browserctl
|
|
|
62
83
|
|
|
63
84
|
private
|
|
64
85
|
|
|
65
|
-
def cmd_open_page(req)
|
|
66
|
-
session = @global_mutex.synchronize do
|
|
67
|
-
@pages[req[:name]] ||= PageSession.new(@browser.create_page)
|
|
68
|
-
end
|
|
69
|
-
session.page.go_to(req[:url]) if req[:url]
|
|
70
|
-
{ ok: true, name: req[:name] }
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def cmd_close_page(req)
|
|
74
|
-
session = @global_mutex.synchronize { @pages.delete(req[:name]) }
|
|
75
|
-
session&.page&.close
|
|
76
|
-
{ ok: true }
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def cmd_list_pages(_req)
|
|
80
|
-
{ pages: @global_mutex.synchronize { @pages.keys } }
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def cmd_goto(req)
|
|
84
|
-
with_page(req[:name]) do |session|
|
|
85
|
-
session.page.go_to(req[:url])
|
|
86
|
-
{ ok: true, url: session.page.current_url, challenge: cloudflare_challenge?(session.page) }
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def cmd_snapshot(req)
|
|
91
|
-
with_page(req[:name]) { |session| take_snapshot(session, req[:format], req[:diff]) }
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def take_snapshot(session, format, diff)
|
|
95
|
-
challenge = cloudflare_challenge?(session.page)
|
|
96
|
-
|
|
97
|
-
return { ok: true, html: session.page.body, challenge: challenge } unless format == "ai"
|
|
98
|
-
|
|
99
|
-
snapshot = @snapshot_builder.call(session.page)
|
|
100
|
-
registry = snapshot.to_h { |el| [el[:ref], el[:selector]] }
|
|
101
|
-
|
|
102
|
-
prev = session.prev_snapshot
|
|
103
|
-
session.ref_registry = registry
|
|
104
|
-
session.prev_snapshot = snapshot
|
|
105
|
-
result = diff && prev ? compute_diff(prev, snapshot) : snapshot
|
|
106
|
-
|
|
107
|
-
{ ok: true, snapshot: result, challenge: challenge }
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def compute_diff(prev, current)
|
|
111
|
-
prev_by_sel = prev.to_h { |el| [el[:selector], el] }
|
|
112
|
-
current.reject do |el|
|
|
113
|
-
old = prev_by_sel[el[:selector]]
|
|
114
|
-
old && old.slice(:text, :attrs) == el.slice(:text, :attrs)
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def cmd_evaluate(req)
|
|
119
|
-
with_page(req[:name]) { |session| { ok: true, result: session.page.evaluate(req[:expression]) } }
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def cmd_fill(req)
|
|
123
|
-
with_page(req[:name]) do |session|
|
|
124
|
-
sel = resolve_selector_from(session, req)
|
|
125
|
-
return sel if sel.is_a?(Hash)
|
|
126
|
-
|
|
127
|
-
type_into(session.page, sel, req[:value])
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def type_into(page, selector, value)
|
|
132
|
-
el = page.at_css(selector)
|
|
133
|
-
return { error: "selector not found: #{selector}" } unless el
|
|
134
|
-
|
|
135
|
-
el.focus
|
|
136
|
-
el.type(value)
|
|
137
|
-
{ ok: true }
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def cmd_click(req)
|
|
141
|
-
with_page(req[:name]) do |session|
|
|
142
|
-
sel = resolve_selector_from(session, req)
|
|
143
|
-
return sel if sel.is_a?(Hash)
|
|
144
|
-
|
|
145
|
-
click_element(session.page, sel)
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def click_element(page, selector)
|
|
150
|
-
el = page.at_css(selector)
|
|
151
|
-
return { error: "selector not found: #{selector}" } unless el
|
|
152
|
-
|
|
153
|
-
el.click
|
|
154
|
-
{ ok: true }
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def cmd_screenshot(req)
|
|
158
|
-
with_page(req[:name]) do |session|
|
|
159
|
-
path = safe_screenshot_path(req[:path], req[:name])
|
|
160
|
-
return path if path.is_a?(Hash)
|
|
161
|
-
|
|
162
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
163
|
-
session.page.screenshot(path: path, full: req.fetch(:full, false))
|
|
164
|
-
{ ok: true, path: path }
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def safe_screenshot_path(requested, page_name)
|
|
169
|
-
if requested
|
|
170
|
-
expanded = File.expand_path(requested)
|
|
171
|
-
return { error: "path outside allowed directory (#{SCREENSHOT_DIR})" } \
|
|
172
|
-
unless expanded.start_with?(SCREENSHOT_DIR)
|
|
173
|
-
return { error: "invalid extension — use .png, .jpg, or .jpeg" } \
|
|
174
|
-
unless SCREENSHOT_EXTS.include?(File.extname(expanded).downcase)
|
|
175
|
-
|
|
176
|
-
expanded
|
|
177
|
-
else
|
|
178
|
-
name_safe = page_name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
|
|
179
|
-
File.join(SCREENSHOT_DIR, "browserctl_shot_#{name_safe}_#{Time.now.to_i}.png")
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def cmd_wait_for(req)
|
|
184
|
-
with_page(req[:name]) { |session| wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 10).to_f) }
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def cmd_watch(req)
|
|
188
|
-
with_page(req[:name]) do |session|
|
|
189
|
-
result = wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 30).to_f)
|
|
190
|
-
result[:error] ? result : { ok: true, selector: req[:selector] }
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def wait_for_selector(page, selector, timeout)
|
|
195
|
-
deadline = Time.now + timeout
|
|
196
|
-
loop do
|
|
197
|
-
found = page.at_css(selector)
|
|
198
|
-
break { ok: true } if found
|
|
199
|
-
break { error: "wait_for timeout: selector '#{selector}' not found after #{timeout}s" } if Time.now >= deadline
|
|
200
|
-
|
|
201
|
-
sleep 0.2
|
|
202
|
-
end
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def cmd_url(req)
|
|
206
|
-
with_page(req[:name]) { |session| { ok: true, url: session.page.current_url } }
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def cmd_cookies(req)
|
|
210
|
-
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
211
|
-
return { error: "no page named '#{req[:name]}'" } unless session
|
|
212
|
-
|
|
213
|
-
all = session.page.cookies.all
|
|
214
|
-
{ ok: true, cookies: all.values.map(&:to_h) }
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def cmd_set_cookie(req)
|
|
218
|
-
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
219
|
-
return { error: "no page named '#{req[:name]}'" } unless session
|
|
220
|
-
|
|
221
|
-
session.page.cookies.set(
|
|
222
|
-
name: req[:cookie_name],
|
|
223
|
-
value: req[:value],
|
|
224
|
-
domain: req[:domain],
|
|
225
|
-
path: req.fetch(:path, "/")
|
|
226
|
-
)
|
|
227
|
-
{ ok: true }
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def cmd_clear_cookies(req)
|
|
231
|
-
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
232
|
-
return { error: "no page named '#{req[:name]}'" } unless session
|
|
233
|
-
|
|
234
|
-
session.page.cookies.clear
|
|
235
|
-
{ ok: true }
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def cmd_inspect(req)
|
|
239
|
-
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
240
|
-
return { error: "no page named '#{req[:name]}'" } unless session
|
|
241
|
-
|
|
242
|
-
port = @browser.process.port
|
|
243
|
-
target_id = session.page.target_id
|
|
244
|
-
devtools_url = "http://127.0.0.1:#{port}/devtools/inspector.html" \
|
|
245
|
-
"?ws=127.0.0.1:#{port}/devtools/page/#{target_id}"
|
|
246
|
-
{ ok: true, devtools_url: devtools_url }
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
def cmd_pause(req)
|
|
250
|
-
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
251
|
-
return { error: "no page named '#{req[:name]}'" } unless session
|
|
252
|
-
|
|
253
|
-
session.mutex.synchronize { session.pause! }
|
|
254
|
-
{ ok: true, paused: true }
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
def cmd_resume(req)
|
|
258
|
-
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
259
|
-
return { error: "no page named '#{req[:name]}'" } unless session
|
|
260
|
-
|
|
261
|
-
session.mutex.synchronize do
|
|
262
|
-
session.resume!
|
|
263
|
-
session.pause_cv.signal
|
|
264
|
-
end
|
|
265
|
-
{ ok: true, paused: false }
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
def cmd_ping(_req) = { ok: true, pid: Process.pid }
|
|
269
|
-
|
|
270
|
-
def cmd_shutdown(_req)
|
|
271
|
-
Process.kill("INT", Process.pid)
|
|
272
|
-
{ ok: true }
|
|
273
|
-
end
|
|
274
|
-
|
|
275
86
|
def with_page(name)
|
|
276
87
|
session = @global_mutex.synchronize { @pages[name] }
|
|
277
88
|
return { error: "no page named '#{name}'" } unless session
|
|
@@ -281,19 +92,5 @@ module Browserctl
|
|
|
281
92
|
yield session
|
|
282
93
|
end
|
|
283
94
|
end
|
|
284
|
-
|
|
285
|
-
def cloudflare_challenge?(page)
|
|
286
|
-
url = page.current_url.to_s
|
|
287
|
-
body = page.body.to_s
|
|
288
|
-
url.include?("challenge-platform") ||
|
|
289
|
-
CLOUDFLARE_SIGNALS.any? { |sig| body.include?(sig) }
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
def resolve_selector_from(session, req)
|
|
293
|
-
return req[:selector] if req[:selector]
|
|
294
|
-
return { error: "selector or ref required" } unless req[:ref]
|
|
295
|
-
|
|
296
|
-
session.ref_registry[req[:ref]] || { error: "ref '#{req[:ref]}' not found — run snap first" }
|
|
297
|
-
end
|
|
298
95
|
end
|
|
299
96
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
class CommandDispatcher
|
|
5
|
+
module Handlers
|
|
6
|
+
module Cookies
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def cmd_cookies(req)
|
|
10
|
+
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
11
|
+
return { error: "no page named '#{req[:name]}'" } unless session
|
|
12
|
+
|
|
13
|
+
all = session.page.cookies.all
|
|
14
|
+
{ ok: true, cookies: all.values.map(&:to_h) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cmd_set_cookie(req)
|
|
18
|
+
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
19
|
+
return { error: "no page named '#{req[:name]}'" } unless session
|
|
20
|
+
|
|
21
|
+
session.page.cookies.set(
|
|
22
|
+
name: req[:cookie_name],
|
|
23
|
+
value: req[:value],
|
|
24
|
+
domain: req[:domain],
|
|
25
|
+
path: req.fetch(:path, "/")
|
|
26
|
+
)
|
|
27
|
+
{ ok: true }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def cmd_clear_cookies(req)
|
|
31
|
+
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
32
|
+
return { error: "no page named '#{req[:name]}'" } unless session
|
|
33
|
+
|
|
34
|
+
session.page.cookies.clear
|
|
35
|
+
{ ok: true }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cmd_import_cookies(req)
|
|
39
|
+
with_page(req[:name]) do |session|
|
|
40
|
+
req[:cookies].each do |c|
|
|
41
|
+
session.page.cookies.set(
|
|
42
|
+
name: c[:name],
|
|
43
|
+
value: c[:value],
|
|
44
|
+
domain: c[:domain],
|
|
45
|
+
path: c.fetch(:path, "/"),
|
|
46
|
+
httponly: c[:httpOnly] == true,
|
|
47
|
+
secure: c[:secure] == true,
|
|
48
|
+
expires: c[:expires] ? Time.at(c[:expires].to_i) : nil
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
{ ok: true, count: req[:cookies].length }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
class CommandDispatcher
|
|
5
|
+
module Handlers
|
|
6
|
+
module DaemonControl
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def cmd_ping(_req) = { ok: true, pid: Process.pid, protocol_version: PROTOCOL_VERSION }
|
|
10
|
+
|
|
11
|
+
def cmd_shutdown(_req)
|
|
12
|
+
Process.kill("INT", Process.pid)
|
|
13
|
+
{ ok: true }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def cmd_store(req)
|
|
17
|
+
@kv_mutex.synchronize { @kv_store[req[:key].to_s] = req[:value] }
|
|
18
|
+
{ ok: true }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def cmd_fetch(req)
|
|
22
|
+
key = req[:key].to_s
|
|
23
|
+
found = @kv_mutex.synchronize { @kv_store.key?(key) ? { ok: true, value: @kv_store[key] } : nil }
|
|
24
|
+
found || { error: "key '#{key}' not found", code: "key_not_found" }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
class CommandDispatcher
|
|
5
|
+
module Handlers
|
|
6
|
+
module DevTools
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def cmd_inspect(req)
|
|
10
|
+
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
11
|
+
return { error: "no page named '#{req[:name]}'" } unless session
|
|
12
|
+
|
|
13
|
+
port = @browser.process.port
|
|
14
|
+
target_id = session.page.target_id
|
|
15
|
+
devtools_url = "http://127.0.0.1:#{port}/devtools/inspector.html" \
|
|
16
|
+
"?ws=127.0.0.1:#{port}/devtools/page/#{target_id}"
|
|
17
|
+
{ ok: true, devtools_url: devtools_url }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
class CommandDispatcher
|
|
5
|
+
module Handlers
|
|
6
|
+
module Hitl
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def cmd_pause(req)
|
|
10
|
+
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
11
|
+
return { error: "no page named '#{req[:name]}'" } unless session
|
|
12
|
+
|
|
13
|
+
session.mutex.synchronize { session.pause! }
|
|
14
|
+
{ ok: true, paused: true }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cmd_resume(req)
|
|
18
|
+
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
19
|
+
return { error: "no page named '#{req[:name]}'" } unless session
|
|
20
|
+
|
|
21
|
+
session.mutex.synchronize do
|
|
22
|
+
session.resume!
|
|
23
|
+
session.pause_cv.signal
|
|
24
|
+
end
|
|
25
|
+
{ ok: true, paused: false }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|