browserctl 0.2.0 → 0.3.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 +39 -0
- data/README.md +7 -1
- data/bin/browserctl +73 -32
- data/bin/browserd +5 -3
- data/examples/cloudflare_hitl.rb +65 -0
- data/lib/browserctl/client.rb +99 -2
- data/lib/browserctl/commands/cli_output.rb +15 -0
- data/lib/browserctl/commands/click.rb +13 -7
- data/lib/browserctl/commands/fill.rb +23 -9
- data/lib/browserctl/commands/init.rb +29 -0
- data/lib/browserctl/commands/inspect.rb +21 -0
- data/lib/browserctl/commands/open_page.rb +10 -5
- data/lib/browserctl/commands/pause_resume.rb +32 -0
- data/lib/browserctl/commands/record.rb +13 -18
- data/lib/browserctl/commands/screenshot.rb +10 -4
- data/lib/browserctl/commands/snapshot.rb +19 -8
- data/lib/browserctl/commands/watch.rb +13 -3
- data/lib/browserctl/recording.rb +3 -1
- data/lib/browserctl/runner.rb +19 -0
- data/lib/browserctl/server/command_dispatcher.rb +171 -53
- data/lib/browserctl/server/page_session.rb +21 -0
- data/lib/browserctl/server.rb +3 -3
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +7 -0
- data/lib/browserctl.rb +8 -0
- metadata +21 -2
- data/lib/browserctl/commands/flag_extractor.rb +0 -23
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cli_output"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
module PauseResume
|
|
8
|
+
extend CliOutput
|
|
9
|
+
|
|
10
|
+
def self.pause(client, args)
|
|
11
|
+
name = args.shift or abort "usage: browserctl pause <page>"
|
|
12
|
+
res = client.pause(name)
|
|
13
|
+
if res[:error]
|
|
14
|
+
warn "Error: #{res[:error]}"
|
|
15
|
+
exit 1
|
|
16
|
+
end
|
|
17
|
+
puts "Page '#{name}' paused. Browser is live — interact freely."
|
|
18
|
+
puts "When done: browserctl resume #{name}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.resume(client, args)
|
|
22
|
+
name = args.shift or abort "usage: browserctl resume <page>"
|
|
23
|
+
res = client.resume(name)
|
|
24
|
+
if res[:error]
|
|
25
|
+
warn "Error: #{res[:error]}"
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
puts "Page '#{name}' resumed."
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "optimist"
|
|
4
5
|
require "browserctl/recording"
|
|
5
6
|
|
|
6
7
|
module Browserctl
|
|
7
8
|
module Commands
|
|
8
9
|
class Record
|
|
9
|
-
USAGE = "
|
|
10
|
+
USAGE = "Usage: browserctl record start <name> | stop [--out PATH] | status"
|
|
10
11
|
|
|
11
12
|
def self.run(args)
|
|
12
13
|
subcmd = args.shift
|
|
@@ -14,7 +15,8 @@ module Browserctl
|
|
|
14
15
|
when "start" then run_start(args)
|
|
15
16
|
when "stop" then run_stop(args)
|
|
16
17
|
when "status" then run_status
|
|
17
|
-
else
|
|
18
|
+
else
|
|
19
|
+
abort "#{USAGE}\nRun 'browserctl record <subcommand> --help' for details."
|
|
18
20
|
end
|
|
19
21
|
end
|
|
20
22
|
|
|
@@ -22,6 +24,7 @@ module Browserctl
|
|
|
22
24
|
private
|
|
23
25
|
|
|
24
26
|
def run_start(args)
|
|
27
|
+
Optimist.options(args) { banner "Usage: browserctl record start <name>" }
|
|
25
28
|
name = args.shift or abort "usage: browserctl record start <name>"
|
|
26
29
|
Recording.start(name)
|
|
27
30
|
puts "Recording started: #{name}"
|
|
@@ -29,23 +32,15 @@ module Browserctl
|
|
|
29
32
|
end
|
|
30
33
|
|
|
31
34
|
def run_stop(args)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
args.delete_at(idx)
|
|
36
|
-
end
|
|
37
|
-
name = Recording.stop
|
|
38
|
-
if out
|
|
39
|
-
FileUtils.mkdir_p(File.dirname(out))
|
|
40
|
-
Recording.generate_workflow(name, output_path: out)
|
|
41
|
-
puts "Workflow saved: #{out}"
|
|
42
|
-
else
|
|
43
|
-
dest_dir = ".browserctl/workflows"
|
|
44
|
-
dest_file = File.join(dest_dir, "#{name}.rb")
|
|
45
|
-
FileUtils.mkdir_p(dest_dir)
|
|
46
|
-
Recording.generate_workflow(name, output_path: dest_file)
|
|
47
|
-
puts "Workflow saved: #{dest_file}"
|
|
35
|
+
opts = Optimist.options(args) do
|
|
36
|
+
banner "Usage: browserctl record stop [--out PATH]"
|
|
37
|
+
opt :out, "Output path for workflow file", type: :string, short: "-o"
|
|
48
38
|
end
|
|
39
|
+
name = Recording.stop
|
|
40
|
+
out = opts[:out] || File.join(".browserctl/workflows", "#{name}.rb")
|
|
41
|
+
FileUtils.mkdir_p(File.dirname(out))
|
|
42
|
+
Recording.generate_workflow(name, output_path: out)
|
|
43
|
+
puts "Workflow saved: #{out}"
|
|
49
44
|
puts "Run with: browserctl run #{name}"
|
|
50
45
|
end
|
|
51
46
|
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require "optimist"
|
|
4
|
+
require_relative "cli_output"
|
|
4
5
|
|
|
5
6
|
module Browserctl
|
|
6
7
|
module Commands
|
|
7
8
|
class Screenshot
|
|
9
|
+
extend CliOutput
|
|
10
|
+
|
|
8
11
|
def self.run(client, args)
|
|
12
|
+
opts = Optimist.options(args) do
|
|
13
|
+
banner "Usage: browserctl shot <page> [--out PATH] [--full]"
|
|
14
|
+
opt :out, "Output file path", type: :string, short: "-o"
|
|
15
|
+
opt :full, "Capture full page", default: false, short: "-f"
|
|
16
|
+
end
|
|
9
17
|
name = args.shift or abort "usage: browserctl shot <page> [--out PATH] [--full]"
|
|
10
|
-
|
|
11
|
-
full = args.delete("--full") ? true : false
|
|
12
|
-
puts client.screenshot(name, path: path, full: full).to_json
|
|
18
|
+
print_result(client.screenshot(name, path: opts[:out], full: opts[:full]))
|
|
13
19
|
end
|
|
14
20
|
end
|
|
15
21
|
end
|
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
|
|
4
|
+
require "optimist"
|
|
5
5
|
|
|
6
6
|
module Browserctl
|
|
7
7
|
module Commands
|
|
8
8
|
class Snapshot
|
|
9
|
+
VALID_FORMATS = %w[ai html].freeze
|
|
10
|
+
|
|
9
11
|
def self.run(client, args)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
opts = Optimist.options(args) do
|
|
13
|
+
banner "Usage: browserctl snap <page> [--format ai|html] [--diff]"
|
|
14
|
+
opt :format, "Output format: ai or html", default: "ai", short: "-f"
|
|
15
|
+
opt :diff, "Return only changed elements", default: false, short: "-d"
|
|
16
|
+
end
|
|
17
|
+
name = args.shift or abort "usage: browserctl snap <page> [--format ai|html] [--diff]"
|
|
18
|
+
unless VALID_FORMATS.include?(opts[:format])
|
|
19
|
+
warn "Error: --format must be one of: #{VALID_FORMATS.join(', ')}"
|
|
20
|
+
exit 1
|
|
21
|
+
end
|
|
22
|
+
res = client.snapshot(name, format: opts[:format], diff: opts[:diff])
|
|
23
|
+
output_snapshot(res, opts[:format])
|
|
15
24
|
end
|
|
16
25
|
|
|
17
26
|
class << self
|
|
18
27
|
private
|
|
19
28
|
|
|
20
29
|
def output_snapshot(res, format)
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
if res[:error]
|
|
31
|
+
warn "Error: #{res[:error]}"
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
23
34
|
puts(format == "ai" ? JSON.pretty_generate(res[:snapshot]) : res[:html])
|
|
24
35
|
end
|
|
25
36
|
end
|
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require "optimist"
|
|
4
|
+
require_relative "cli_output"
|
|
4
5
|
|
|
5
6
|
module Browserctl
|
|
6
7
|
module Commands
|
|
7
8
|
class Watch
|
|
9
|
+
extend CliOutput
|
|
10
|
+
|
|
8
11
|
def self.run(client, args)
|
|
12
|
+
opts = Optimist.options(args) do
|
|
13
|
+
banner "Usage: browserctl watch <page> <selector> [--timeout N]"
|
|
14
|
+
opt :timeout, "Seconds to wait (default: 30)", default: 30.0, short: "-t"
|
|
15
|
+
end
|
|
9
16
|
name = args.shift
|
|
10
17
|
selector = args.shift
|
|
11
|
-
timeout = (FlagExtractor.extract_opt(args, "--timeout") || 30).to_f
|
|
12
18
|
abort "usage: browserctl watch <page> <selector> [--timeout N]" unless name && selector
|
|
13
|
-
|
|
19
|
+
unless opts[:timeout].positive?
|
|
20
|
+
warn "Error: --timeout must be a positive number"
|
|
21
|
+
exit 1
|
|
22
|
+
end
|
|
23
|
+
print_result(client.watch(name, selector, timeout: opts[:timeout]))
|
|
14
24
|
end
|
|
15
25
|
end
|
|
16
26
|
end
|
data/lib/browserctl/recording.rb
CHANGED
|
@@ -39,6 +39,8 @@ module Browserctl
|
|
|
39
39
|
# ref-based interactions have no replayable selector — skip them
|
|
40
40
|
return if %w[click fill].include?(cmd.to_s) && attrs[:selector].nil?
|
|
41
41
|
|
|
42
|
+
attrs = attrs.except(:value) if cmd.to_s == "fill"
|
|
43
|
+
|
|
42
44
|
File.open(log_path(name), "a") do |f|
|
|
43
45
|
f.puts JSON.generate({ cmd: cmd.to_s }.merge(attrs.transform_keys(&:to_s)))
|
|
44
46
|
end
|
|
@@ -90,7 +92,7 @@ module Browserctl
|
|
|
90
92
|
["goto #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
|
|
91
93
|
when "fill"
|
|
92
94
|
["fill #{cmd[:selector]} on #{page}",
|
|
93
|
-
"page(:#{page}).fill(#{cmd[:selector].inspect},
|
|
95
|
+
"page(:#{page}).fill(#{cmd[:selector].inspect}, params[:fill_value])"]
|
|
94
96
|
when "click"
|
|
95
97
|
["click #{cmd[:selector]} on #{page}",
|
|
96
98
|
"page(:#{page}).click(#{cmd[:selector].inspect})"]
|
data/lib/browserctl/runner.rb
CHANGED
|
@@ -10,6 +10,11 @@ module Browserctl
|
|
|
10
10
|
File.expand_path("~/.browserctl/workflows")
|
|
11
11
|
].freeze
|
|
12
12
|
|
|
13
|
+
# Runs a named workflow with the given parameters.
|
|
14
|
+
# @param name [String] workflow name (must match /\A[a-zA-Z0-9_-]+\z/)
|
|
15
|
+
# @param params [Hash] keyword arguments passed to the workflow
|
|
16
|
+
# @return [Boolean] true if all steps succeeded
|
|
17
|
+
# @raise [WorkflowError] if the name is invalid or a step fails
|
|
13
18
|
def run_workflow(name, **params)
|
|
14
19
|
defn = fetch_workflow(name)
|
|
15
20
|
results = defn.call(params, Client.new)
|
|
@@ -17,19 +22,33 @@ module Browserctl
|
|
|
17
22
|
results.all?(&:ok)
|
|
18
23
|
end
|
|
19
24
|
|
|
25
|
+
# Lists all registered workflows from the standard search paths.
|
|
26
|
+
# @return [Array<Hash>] array of `{ name:, desc: }` hashes
|
|
20
27
|
def list_workflows
|
|
21
28
|
load_all_workflows
|
|
22
29
|
REGISTRY.map { |name, defn| { name: name, desc: defn.description } }
|
|
23
30
|
end
|
|
24
31
|
|
|
32
|
+
# Returns detailed information about a workflow.
|
|
33
|
+
# @param name [String] workflow name
|
|
34
|
+
# @return [Hash] `{ name:, desc:, params:, steps: }`
|
|
25
35
|
def describe_workflow(name)
|
|
26
36
|
defn = fetch_workflow(name)
|
|
27
37
|
{ name: defn.name, desc: defn.description, params: format_params(defn), steps: defn.steps.map(&:label) }
|
|
28
38
|
end
|
|
29
39
|
|
|
40
|
+
SAFE_WORKFLOW_NAME = /\A[a-zA-Z0-9_-]+\z/
|
|
41
|
+
|
|
30
42
|
private
|
|
31
43
|
|
|
44
|
+
def validate_name!(name)
|
|
45
|
+
return if SAFE_WORKFLOW_NAME.match?(name.to_s)
|
|
46
|
+
|
|
47
|
+
raise Browserctl::WorkflowError, "invalid workflow name: #{name.inspect} — use letters, digits, _ and - only"
|
|
48
|
+
end
|
|
49
|
+
|
|
32
50
|
def fetch_workflow(name)
|
|
51
|
+
validate_name!(name)
|
|
33
52
|
load_workflow_file(name)
|
|
34
53
|
REGISTRY[name.to_s] || raise("workflow '#{name}' not found")
|
|
35
54
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "snapshot_builder"
|
|
4
|
+
require_relative "page_session"
|
|
4
5
|
|
|
5
6
|
module Browserctl
|
|
6
7
|
class CommandDispatcher
|
|
@@ -18,68 +19,92 @@ module Browserctl
|
|
|
18
19
|
"watch" => :cmd_watch,
|
|
19
20
|
"url" => :cmd_url,
|
|
20
21
|
"ping" => :cmd_ping,
|
|
21
|
-
"shutdown" => :cmd_shutdown
|
|
22
|
+
"shutdown" => :cmd_shutdown,
|
|
23
|
+
"pause" => :cmd_pause,
|
|
24
|
+
"resume" => :cmd_resume,
|
|
25
|
+
"inspect" => :cmd_inspect,
|
|
26
|
+
"cookies" => :cmd_cookies,
|
|
27
|
+
"set_cookie" => :cmd_set_cookie,
|
|
28
|
+
"clear_cookies" => :cmd_clear_cookies
|
|
22
29
|
}.freeze
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
SCREENSHOT_DIR = File.expand_path("~/.browserctl/screenshots").freeze
|
|
32
|
+
SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
|
|
33
|
+
CLOUDFLARE_SIGNALS = [
|
|
34
|
+
"cf-challenge-running",
|
|
35
|
+
"cf_chl_opt",
|
|
36
|
+
"__cf_chl_f_tk",
|
|
37
|
+
"Just a moment..."
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
def initialize(pages, browser, snapshot_builder = SnapshotBuilder.new, global_mutex: Mutex.new)
|
|
41
|
+
@pages = pages
|
|
42
|
+
@browser = browser
|
|
43
|
+
@snapshot_builder = snapshot_builder
|
|
44
|
+
@global_mutex = global_mutex
|
|
31
45
|
end
|
|
32
46
|
|
|
33
47
|
def dispatch(req)
|
|
34
48
|
handler = COMMAND_MAP[req[:cmd]]
|
|
35
|
-
|
|
49
|
+
if handler
|
|
50
|
+
Browserctl.logger.debug("#{req[:cmd]} #{req[:name]}")
|
|
51
|
+
return send(handler, req)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if (plugin = Browserctl::PLUGIN_COMMANDS[req[:cmd]])
|
|
55
|
+
Browserctl.logger.debug("plugin:#{req[:cmd]} #{req[:name]}")
|
|
56
|
+
session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
|
|
57
|
+
return plugin.call(session, req)
|
|
58
|
+
end
|
|
36
59
|
|
|
37
|
-
|
|
38
|
-
send(handler, req)
|
|
60
|
+
{ error: "unknown command: #{req[:cmd]}" }
|
|
39
61
|
end
|
|
40
62
|
|
|
41
63
|
private
|
|
42
64
|
|
|
43
65
|
def cmd_open_page(req)
|
|
44
|
-
|
|
45
|
-
|
|
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]
|
|
46
70
|
{ ok: true, name: req[:name] }
|
|
47
71
|
end
|
|
48
72
|
|
|
49
73
|
def cmd_close_page(req)
|
|
50
|
-
@
|
|
74
|
+
session = @global_mutex.synchronize { @pages.delete(req[:name]) }
|
|
75
|
+
session&.page&.close
|
|
51
76
|
{ ok: true }
|
|
52
77
|
end
|
|
53
78
|
|
|
54
79
|
def cmd_list_pages(_req)
|
|
55
|
-
{ pages: @
|
|
80
|
+
{ pages: @global_mutex.synchronize { @pages.keys } }
|
|
56
81
|
end
|
|
57
82
|
|
|
58
83
|
def cmd_goto(req)
|
|
59
|
-
with_page(req[:name]) do |
|
|
60
|
-
|
|
61
|
-
{ ok: true, url:
|
|
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) }
|
|
62
87
|
end
|
|
63
88
|
end
|
|
64
89
|
|
|
65
90
|
def cmd_snapshot(req)
|
|
66
|
-
with_page(req[:name]) { |
|
|
91
|
+
with_page(req[:name]) { |session| take_snapshot(session, req[:format], req[:diff]) }
|
|
67
92
|
end
|
|
68
93
|
|
|
69
|
-
def take_snapshot(
|
|
70
|
-
|
|
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"
|
|
71
98
|
|
|
72
|
-
snapshot = @
|
|
99
|
+
snapshot = @snapshot_builder.call(session.page)
|
|
73
100
|
registry = snapshot.to_h { |el| [el[:ref], el[:selector]] }
|
|
74
101
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
diff && prev ? compute_diff(prev, snapshot) : snapshot
|
|
80
|
-
end
|
|
102
|
+
prev = session.prev_snapshot
|
|
103
|
+
session.ref_registry = registry
|
|
104
|
+
session.prev_snapshot = snapshot
|
|
105
|
+
result = diff && prev ? compute_diff(prev, snapshot) : snapshot
|
|
81
106
|
|
|
82
|
-
{ ok: true, snapshot: result }
|
|
107
|
+
{ ok: true, snapshot: result, challenge: challenge }
|
|
83
108
|
end
|
|
84
109
|
|
|
85
110
|
def compute_diff(prev, current)
|
|
@@ -91,14 +116,16 @@ module Browserctl
|
|
|
91
116
|
end
|
|
92
117
|
|
|
93
118
|
def cmd_evaluate(req)
|
|
94
|
-
with_page(req[:name]) { |
|
|
119
|
+
with_page(req[:name]) { |session| { ok: true, result: session.page.evaluate(req[:expression]) } }
|
|
95
120
|
end
|
|
96
121
|
|
|
97
122
|
def cmd_fill(req)
|
|
98
|
-
|
|
99
|
-
|
|
123
|
+
with_page(req[:name]) do |session|
|
|
124
|
+
sel = resolve_selector_from(session, req)
|
|
125
|
+
return sel if sel.is_a?(Hash)
|
|
100
126
|
|
|
101
|
-
|
|
127
|
+
type_into(session.page, sel, req[:value])
|
|
128
|
+
end
|
|
102
129
|
end
|
|
103
130
|
|
|
104
131
|
def type_into(page, selector, value)
|
|
@@ -111,10 +138,12 @@ module Browserctl
|
|
|
111
138
|
end
|
|
112
139
|
|
|
113
140
|
def cmd_click(req)
|
|
114
|
-
|
|
115
|
-
|
|
141
|
+
with_page(req[:name]) do |session|
|
|
142
|
+
sel = resolve_selector_from(session, req)
|
|
143
|
+
return sel if sel.is_a?(Hash)
|
|
116
144
|
|
|
117
|
-
|
|
145
|
+
click_element(session.page, sel)
|
|
146
|
+
end
|
|
118
147
|
end
|
|
119
148
|
|
|
120
149
|
def click_element(page, selector)
|
|
@@ -126,56 +155,145 @@ module Browserctl
|
|
|
126
155
|
end
|
|
127
156
|
|
|
128
157
|
def cmd_screenshot(req)
|
|
129
|
-
with_page(req[:name]) do |
|
|
130
|
-
path = req[:path]
|
|
131
|
-
|
|
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))
|
|
132
164
|
{ ok: true, path: path }
|
|
133
165
|
end
|
|
134
166
|
end
|
|
135
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
|
+
|
|
136
183
|
def cmd_wait_for(req)
|
|
137
|
-
with_page(req[:name]) { |
|
|
184
|
+
with_page(req[:name]) { |session| wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 10).to_f) }
|
|
138
185
|
end
|
|
139
186
|
|
|
140
187
|
def cmd_watch(req)
|
|
141
|
-
with_page(req[:name]) do |
|
|
142
|
-
result = wait_for_selector(
|
|
188
|
+
with_page(req[:name]) do |session|
|
|
189
|
+
result = wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 30).to_f)
|
|
143
190
|
result[:error] ? result : { ok: true, selector: req[:selector] }
|
|
144
191
|
end
|
|
145
192
|
end
|
|
146
193
|
|
|
147
194
|
def wait_for_selector(page, selector, timeout)
|
|
148
195
|
deadline = Time.now + timeout
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
151
203
|
end
|
|
152
204
|
|
|
153
205
|
def cmd_url(req)
|
|
154
|
-
with_page(req[:name]) { |
|
|
206
|
+
with_page(req[:name]) { |session| { ok: true, url: session.page.current_url } }
|
|
155
207
|
end
|
|
156
208
|
|
|
157
|
-
def
|
|
158
|
-
|
|
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 }
|
|
159
228
|
end
|
|
160
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
|
+
|
|
161
270
|
def cmd_shutdown(_req)
|
|
162
271
|
Process.kill("INT", Process.pid)
|
|
163
272
|
{ ok: true }
|
|
164
273
|
end
|
|
165
274
|
|
|
166
275
|
def with_page(name)
|
|
167
|
-
|
|
168
|
-
return { error: "no page named '#{name}'" } unless
|
|
276
|
+
session = @global_mutex.synchronize { @pages[name] }
|
|
277
|
+
return { error: "no page named '#{name}'" } unless session
|
|
278
|
+
|
|
279
|
+
session.mutex.synchronize do
|
|
280
|
+
session.pause_cv.wait(session.mutex) while session.paused?
|
|
281
|
+
yield session
|
|
282
|
+
end
|
|
283
|
+
end
|
|
169
284
|
|
|
170
|
-
|
|
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) }
|
|
171
290
|
end
|
|
172
291
|
|
|
173
|
-
def
|
|
292
|
+
def resolve_selector_from(session, req)
|
|
174
293
|
return req[:selector] if req[:selector]
|
|
175
294
|
return { error: "selector or ref required" } unless req[:ref]
|
|
176
295
|
|
|
177
|
-
|
|
178
|
-
sel || { error: "ref '#{req[:ref]}' not found — run snap first" }
|
|
296
|
+
session.ref_registry[req[:ref]] || { error: "ref '#{req[:ref]}' not found — run snap first" }
|
|
179
297
|
end
|
|
180
298
|
end
|
|
181
299
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
class PageSession
|
|
5
|
+
attr_reader :page, :mutex, :pause_cv
|
|
6
|
+
attr_accessor :ref_registry, :prev_snapshot
|
|
7
|
+
|
|
8
|
+
def initialize(page)
|
|
9
|
+
@page = page
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
@pause_cv = ConditionVariable.new
|
|
12
|
+
@ref_registry = {}
|
|
13
|
+
@prev_snapshot = nil
|
|
14
|
+
@paused = false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def paused? = @paused
|
|
18
|
+
def pause! = (@paused = true)
|
|
19
|
+
def resume! = (@paused = false)
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/browserctl/server.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "constants"
|
|
|
9
9
|
require_relative "logger"
|
|
10
10
|
require_relative "server/command_dispatcher"
|
|
11
11
|
require_relative "server/idle_watcher"
|
|
12
|
+
require_relative "server/page_session"
|
|
12
13
|
|
|
13
14
|
module Browserctl
|
|
14
15
|
class Server
|
|
@@ -16,7 +17,7 @@ module Browserctl
|
|
|
16
17
|
@socket_path = socket_path
|
|
17
18
|
@pid_path = pid_path
|
|
18
19
|
prepare_runtime(headless)
|
|
19
|
-
@dispatcher = CommandDispatcher.new(@pages, @browser,
|
|
20
|
+
@dispatcher = CommandDispatcher.new(@pages, @browser, global_mutex: @mutex)
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
def run
|
|
@@ -85,7 +86,6 @@ module Browserctl
|
|
|
85
86
|
def dispatch(socket, line)
|
|
86
87
|
return unless line
|
|
87
88
|
|
|
88
|
-
@mutex.synchronize { @last_used = Time.now }
|
|
89
89
|
socket.puts JSON.generate(process(line))
|
|
90
90
|
end
|
|
91
91
|
|
|
@@ -109,7 +109,7 @@ module Browserctl
|
|
|
109
109
|
|
|
110
110
|
def quietly
|
|
111
111
|
yield
|
|
112
|
-
rescue
|
|
112
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
113
113
|
nil
|
|
114
114
|
end
|
|
115
115
|
end
|
data/lib/browserctl/version.rb
CHANGED
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -112,6 +112,13 @@ module Browserctl
|
|
|
112
112
|
@steps << StepDef.new(label: label, block: block, retry_count: retry_count, timeout: timeout)
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
def compose(workflow_name)
|
|
116
|
+
source = REGISTRY[workflow_name.to_s]
|
|
117
|
+
raise WorkflowError, "workflow '#{workflow_name}' not found for composition" unless source
|
|
118
|
+
|
|
119
|
+
@steps.concat(source.steps)
|
|
120
|
+
end
|
|
121
|
+
|
|
115
122
|
def call(params, client)
|
|
116
123
|
ctx = WorkflowContext.new(resolve_params(params), client)
|
|
117
124
|
execute_steps(ctx)
|