browserctl 0.2.1 → 0.3.1
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 +37 -0
- data/README.md +7 -1
- data/bin/browserctl +19 -0
- data/examples/cloudflare_hitl.rb +65 -0
- data/lib/browserctl/client.rb +99 -2
- data/lib/browserctl/commands/init.rb +29 -0
- data/lib/browserctl/commands/inspect.rb +21 -0
- data/lib/browserctl/commands/pause_resume.rb +32 -0
- data/lib/browserctl/recording.rb +3 -1
- data/lib/browserctl/runner.rb +21 -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 +20 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1626673b3046c133aa7e1d63ee7d1886ed3071970227c184ab412a8f3cf48a4f
|
|
4
|
+
data.tar.gz: 4773002a9052247ec8afe259abd0d4c38cc3aa308be81bbeca756626bb206d44
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8e241d9fc064c419285b2e83b5e5154c6ccb639aeffb2179ace9b6ef3ec180069b76772cf5f6485af3cea4ef4616265a87dbbc43d7879cd0a71eec946e022ce8
|
|
7
|
+
data.tar.gz: b396184b74f6804e3b84105c4b75a89ef5a63751ad5577e2dc8008ed1c3111614f2d579ac9c3a12f7943968b3c4cab59fab69dfe392f4af51ba59fff0e8684ba
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.1](https://github.com/patrick204nqh/browserctl/compare/v0.3.0...v0.3.1) (2026-04-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* allow pre-registered workflows with slash names (e.g. the_internet/login) ([e755a4a](https://github.com/patrick204nqh/browserctl/commit/e755a4aa20608fb11ce425c81ed8c0a43f3b13fe))
|
|
14
|
+
* remove unused client variable in runner spec ([94e1a31](https://github.com/patrick204nqh/browserctl/commit/94e1a310acc1d5ddb5e538860907040d3504f2c9))
|
|
15
|
+
|
|
16
|
+
## [0.3.0](https://github.com/patrick204nqh/browserctl/compare/v0.2.2...v0.3.0) (2026-04-20)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
* add cookies, set_cookie, clear_cookies commands for CF clearance replay ([dd38f2d](https://github.com/patrick204nqh/browserctl/commit/dd38f2d587d12f789072326310a1f490e7f993b7))
|
|
22
|
+
* add cookies, set_cookie, clear_cookies commands for CF clearance replay ([c773fe2](https://github.com/patrick204nqh/browserctl/commit/c773fe25e73660cbe5c7e0e3d698fbf7709df51d))
|
|
23
|
+
* browserctl v0.3 — HITL, Cloudflare detection, init, compose, plugins, inspect, RBS, YARD ([ec24c85](https://github.com/patrick204nqh/browserctl/commit/ec24c85a7772b508c477ef4413b0b4a64be8dfa2))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
* rubocop offenses — bump ClassLength max to 240, fix hash alignment ([52669fd](https://github.com/patrick204nqh/browserctl/commit/52669fd21051e1fef581d1c6acd3189d7d805dfb))
|
|
29
|
+
* rubocop offenses — move CLOUDFLARE_SIGNALS above private, autocorrect style ([0ad3d38](https://github.com/patrick204nqh/browserctl/commit/0ad3d38127671284c935809e189478c7160294f6))
|
|
30
|
+
|
|
31
|
+
## [0.2.2](https://github.com/patrick204nqh/browserctl/compare/v0.2.1...v0.2.2) (2026-04-20)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
### Bug Fixes
|
|
35
|
+
|
|
36
|
+
* never record plaintext fill values in session recordings ([a0ea9a1](https://github.com/patrick204nqh/browserctl/commit/a0ea9a15f4dbdc29a0a6aaa8186fa313e601c691))
|
|
37
|
+
* rescue Timeout::Error in quietly; remove duplicate [@last](https://github.com/last)_used update ([d0b1430](https://github.com/patrick204nqh/browserctl/commit/d0b143051a4a6adcdc80620388390bd649ee0bce))
|
|
38
|
+
* restrict screenshot path to ~/.browserctl/screenshots ([0a1303f](https://github.com/patrick204nqh/browserctl/commit/0a1303f95b1df3135a81c98e531ac31c99e2e275))
|
|
39
|
+
* rubocop offenses — constant scope and hash alignment ([cc11bc5](https://github.com/patrick204nqh/browserctl/commit/cc11bc546746c7f341573ffff43b02fdc94578d8))
|
|
40
|
+
* security patches and PageSession architecture refactor (review fixes) ([a01d0f7](https://github.com/patrick204nqh/browserctl/commit/a01d0f7bd3f58b51d21fc534a67dda25c15b029b))
|
|
41
|
+
* trigger release on tag push; revert release-please to default token ([011d4dc](https://github.com/patrick204nqh/browserctl/commit/011d4dcddd872f0ed88548ee9c2e8b19d64ad5c2))
|
|
42
|
+
* update CI workflows to use actions/checkout@v4 and set timeout for jobs ([15ab5d4](https://github.com/patrick204nqh/browserctl/commit/15ab5d471952ff9759eff7ff77ce2df30eea5e1f))
|
|
43
|
+
* validate workflow name to prevent path traversal ([1d0f221](https://github.com/patrick204nqh/browserctl/commit/1d0f221b35a855c1991bee5177b7e742ecb16e86))
|
|
44
|
+
|
|
8
45
|
## [0.2.1](https://github.com/patrick204nqh/browserctl/compare/v0.2.0...v0.2.1) (2026-04-20)
|
|
9
46
|
|
|
10
47
|
|
data/README.md
CHANGED
|
@@ -138,6 +138,12 @@ browserctl shutdown
|
|
|
138
138
|
| `shot <page> [--out PATH] [--full]` | Take a screenshot |
|
|
139
139
|
| `url <page>` | Print current URL |
|
|
140
140
|
| `eval <page> <expression>` | Evaluate a JS expression |
|
|
141
|
+
| `pause <page>` | Pause automation — browser stays live for manual interaction |
|
|
142
|
+
| `resume <page>` | Resume automation after manual action |
|
|
143
|
+
| `inspect <page>` | Open Chrome DevTools for a named page |
|
|
144
|
+
| `cookies <page>` | List all cookies as JSON |
|
|
145
|
+
| `set_cookie <page> <name> <value> <domain>` | Set a cookie (path defaults to `/`) |
|
|
146
|
+
| `clear_cookies <page>` | Clear all cookies for a page |
|
|
141
147
|
| `record start <name>` | Begin recording commands as a replayable workflow |
|
|
142
148
|
| `record stop [--out path]` | End recording; saves to `.browserctl/workflows/` or custom path |
|
|
143
149
|
| `record status` | Show whether a recording is active |
|
|
@@ -262,7 +268,7 @@ browserctl run smoke_login --email me@example.com --password s3cr3t
|
|
|
262
268
|
|
|
263
269
|
### PageProxy methods
|
|
264
270
|
|
|
265
|
-
`goto(url)` · `fill(selector, value)` · `click(selector)` · `snapshot(**opts)` · `screenshot(**opts)` · `wait_for(selector, timeout: 10)` · `url` · `evaluate(expression)`
|
|
271
|
+
`goto(url)` · `fill(selector, value)` · `click(selector)` · `snapshot(**opts)` · `screenshot(**opts)` · `wait_for(selector, timeout: 10)` · `url` · `evaluate(expression)` · `pause` · `resume` · `inspect_page` · `cookies` · `set_cookie(name, value, domain, path: "/")` · `clear_cookies`
|
|
266
272
|
|
|
267
273
|
---
|
|
268
274
|
|
data/bin/browserctl
CHANGED
|
@@ -21,6 +21,9 @@ require "browserctl/commands/snapshot"
|
|
|
21
21
|
require "browserctl/commands/screenshot"
|
|
22
22
|
require "browserctl/commands/watch"
|
|
23
23
|
require "browserctl/commands/record"
|
|
24
|
+
require "browserctl/commands/pause_resume"
|
|
25
|
+
require "browserctl/commands/init"
|
|
26
|
+
require "browserctl/commands/inspect"
|
|
24
27
|
|
|
25
28
|
def print_result(res)
|
|
26
29
|
if res.is_a?(Hash) && res[:error]
|
|
@@ -35,6 +38,9 @@ def usage
|
|
|
35
38
|
puts <<~USAGE
|
|
36
39
|
Usage: browserctl <command> [args]
|
|
37
40
|
|
|
41
|
+
Setup:
|
|
42
|
+
init Scaffold .browserctl/ in this project
|
|
43
|
+
|
|
38
44
|
Browser commands (require browserd running):
|
|
39
45
|
open <page> [--url URL] Open or focus a named page
|
|
40
46
|
close <page> Close a named page
|
|
@@ -49,6 +55,12 @@ def usage
|
|
|
49
55
|
url <page> Print current URL
|
|
50
56
|
eval <page> <expression> Evaluate JS expression
|
|
51
57
|
watch <page> <selector> [--timeout N] Wait for a selector to appear
|
|
58
|
+
pause <page> Pause automation — browser stays live
|
|
59
|
+
resume <page> Resume automation after manual action
|
|
60
|
+
inspect <page> Open Chrome DevTools for a named page
|
|
61
|
+
cookies <page> List all cookies as JSON
|
|
62
|
+
set_cookie <page> <name> <value> <domain> Set a cookie (path defaults to /)
|
|
63
|
+
clear_cookies <page> Clear all cookies for a page
|
|
52
64
|
|
|
53
65
|
Recording commands:
|
|
54
66
|
record start <name> Start recording browser commands
|
|
@@ -110,6 +122,7 @@ when "describe"
|
|
|
110
122
|
puts JSON.pretty_generate(runner.describe_workflow(name))
|
|
111
123
|
|
|
112
124
|
when "record" then Browserctl::Commands::Record.run(args)
|
|
125
|
+
when "init" then Browserctl::Commands::Init.run(args)
|
|
113
126
|
|
|
114
127
|
else
|
|
115
128
|
client = Browserctl::Client.new(Browserctl.socket_path(daemon_name))
|
|
@@ -126,6 +139,12 @@ else
|
|
|
126
139
|
when "url" then print_result(client.url(args[0]))
|
|
127
140
|
when "eval" then print_result(client.evaluate(args[0], args[1]))
|
|
128
141
|
when "watch" then Browserctl::Commands::Watch.run(client, args)
|
|
142
|
+
when "pause" then Browserctl::Commands::PauseResume.pause(client, args)
|
|
143
|
+
when "resume" then Browserctl::Commands::PauseResume.resume(client, args)
|
|
144
|
+
when "inspect" then Browserctl::Commands::Inspect.run(client, args)
|
|
145
|
+
when "cookies" then print_result(client.cookies(args[0]))
|
|
146
|
+
when "set_cookie" then print_result(client.set_cookie(args[0], args[1], args[2], args[3]))
|
|
147
|
+
when "clear_cookies" then print_result(client.clear_cookies(args[0]))
|
|
129
148
|
when "ping" then print_result(client.ping)
|
|
130
149
|
when "shutdown" then print_result(client.shutdown)
|
|
131
150
|
else
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Demonstrates the Cloudflare HITL (Human-in-the-Loop) pattern.
|
|
4
|
+
#
|
|
5
|
+
# When an agent navigates to a Cloudflare-protected page and hits a bot challenge,
|
|
6
|
+
# it cannot interact with the real page content. This workflow detects the challenge,
|
|
7
|
+
# pauses automation so a human can solve it in the live browser, then resumes.
|
|
8
|
+
#
|
|
9
|
+
# Run:
|
|
10
|
+
# browserctl run examples/cloudflare_hitl.rb --url https://example.com
|
|
11
|
+
#
|
|
12
|
+
# Note: modern Cloudflare often passes a real headed Chrome without challenge.
|
|
13
|
+
# The pause/resume branch fires only when the challenge page is actually served
|
|
14
|
+
# (typically against headless or CDP-fingerprinted browsers, or stricter zones).
|
|
15
|
+
# When paused, the terminal prints instructions. Solve the challenge in the open
|
|
16
|
+
# browser window, then run: browserctl resume main
|
|
17
|
+
|
|
18
|
+
Browserctl.workflow "cloudflare_hitl" do
|
|
19
|
+
desc "Navigate a Cloudflare-protected URL with human-assisted challenge bypass"
|
|
20
|
+
|
|
21
|
+
param :url, required: true
|
|
22
|
+
param :selector, default: "body"
|
|
23
|
+
|
|
24
|
+
step "open page" do
|
|
25
|
+
client.open_page("main")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
step "navigate to target URL" do
|
|
29
|
+
res = client.goto("main", url)
|
|
30
|
+
|
|
31
|
+
if res[:challenge]
|
|
32
|
+
$stdout.puts ""
|
|
33
|
+
$stdout.puts " ⚠ Cloudflare challenge detected on #{url}"
|
|
34
|
+
$stdout.puts " → Pausing automation. Solve the challenge in the browser, then run:"
|
|
35
|
+
$stdout.puts " browserctl resume main"
|
|
36
|
+
$stdout.puts ""
|
|
37
|
+
|
|
38
|
+
client.pause("main")
|
|
39
|
+
|
|
40
|
+
# Block here until the human calls `browserctl resume main`.
|
|
41
|
+
# The pause command marks the page; resume unblocks and signals the CV.
|
|
42
|
+
# In a workflow context, we poll the server until the page is unpaused
|
|
43
|
+
# by attempting a lightweight snapshot — once it succeeds, we're through.
|
|
44
|
+
loop do
|
|
45
|
+
snap = client.snapshot("main", format: "html")
|
|
46
|
+
break unless snap[:challenge]
|
|
47
|
+
|
|
48
|
+
$stdout.puts " … still on challenge page, waiting 3s"
|
|
49
|
+
sleep 3
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
$stdout.puts " ✓ Challenge cleared — resuming automation"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
step "wait for content and snapshot" do
|
|
57
|
+
page(:main).wait_for(selector, timeout: 15)
|
|
58
|
+
result = page(:main).snapshot(format: "ai")
|
|
59
|
+
$stdout.puts " Snapshot: #{result[:snapshot]&.length || 0} elements captured"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
step "close page" do
|
|
63
|
+
client.close_page("main")
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/browserctl/client.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "constants"
|
|
|
6
6
|
require_relative "recording"
|
|
7
7
|
|
|
8
8
|
module Browserctl
|
|
9
|
+
# Thin IPC client that wraps each browserd command as a Ruby method call.
|
|
9
10
|
class Client
|
|
10
11
|
def initialize(socket_path = Browserctl.socket_path)
|
|
11
12
|
@socket_path = socket_path
|
|
@@ -19,42 +20,138 @@ module Browserctl
|
|
|
19
20
|
raise "browserd is not running — start it with: browserd"
|
|
20
21
|
end
|
|
21
22
|
|
|
22
|
-
#
|
|
23
|
-
|
|
23
|
+
# Opens or focuses a named browser page.
|
|
24
|
+
# @param name [String] logical page name
|
|
25
|
+
# @param url [String, nil] optional URL to navigate to after opening
|
|
26
|
+
# @return [Hash] `{ ok: true, name: }` or `{ error: }`
|
|
24
27
|
def open_page(name, url: nil) = call("open_page", name: name, url: url)
|
|
28
|
+
|
|
29
|
+
# Closes a named page and removes it from the session.
|
|
30
|
+
# @param name [String] logical page name
|
|
31
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
25
32
|
def close_page(name) = call("close_page", name: name)
|
|
33
|
+
|
|
34
|
+
# Lists all open page names.
|
|
35
|
+
# @return [Hash] `{ pages: [String] }`
|
|
26
36
|
def list_pages = call("list_pages")
|
|
37
|
+
|
|
38
|
+
# Navigates a page to a URL. Returns `challenge: true` when Cloudflare is detected.
|
|
39
|
+
# @param name [String] logical page name
|
|
40
|
+
# @param url [String] destination URL
|
|
41
|
+
# @return [Hash] `{ ok: true, url:, challenge: }` or `{ error: }`
|
|
27
42
|
def goto(name, url) = call("goto", name: name, url: url)
|
|
28
43
|
|
|
44
|
+
# Clicks an element identified by CSS selector or snapshot ref.
|
|
45
|
+
# @param name [String] logical page name
|
|
46
|
+
# @param selector [String, nil] CSS selector
|
|
47
|
+
# @param ref [String, nil] snapshot ref (e.g. "e3")
|
|
48
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
29
49
|
def click(name, selector = nil, ref: nil)
|
|
30
50
|
raise ArgumentError, "click: provide selector or ref:" unless selector || ref
|
|
31
51
|
|
|
32
52
|
call("click", name: name, selector: selector, ref: ref)
|
|
33
53
|
end
|
|
34
54
|
|
|
55
|
+
# Fills an input element with a value.
|
|
56
|
+
# @param name [String] logical page name
|
|
57
|
+
# @param selector [String, nil] CSS selector
|
|
58
|
+
# @param value [String, nil] text to type
|
|
59
|
+
# @param ref [String, nil] snapshot ref
|
|
60
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
35
61
|
def fill(name, selector = nil, value = nil, ref: nil)
|
|
36
62
|
raise ArgumentError, "fill: provide selector or ref:" unless selector || ref
|
|
37
63
|
|
|
38
64
|
call("fill", name: name, selector: selector, ref: ref, value: value)
|
|
39
65
|
end
|
|
40
66
|
|
|
67
|
+
# Takes a screenshot of a named page.
|
|
68
|
+
# @param name [String] logical page name
|
|
69
|
+
# @param path [String, nil] output path (default: ~/.browserctl/screenshots/)
|
|
70
|
+
# @param full [Boolean] capture full page (default: false)
|
|
71
|
+
# @return [Hash] `{ ok: true, path: }` or `{ error: }`
|
|
41
72
|
def screenshot(name, path: nil, full: false) = call("screenshot", name: name, path: path, full: full)
|
|
42
73
|
|
|
74
|
+
# Takes a DOM snapshot. Returns `challenge: true` when Cloudflare is detected.
|
|
75
|
+
# @param name [String] logical page name
|
|
76
|
+
# @param format [String] "ai" (token-efficient JSON) or "html" (raw HTML)
|
|
77
|
+
# @param diff [Boolean] return only elements changed since last snapshot
|
|
78
|
+
# @return [Hash] `{ ok: true, snapshot:, challenge: }` or `{ ok: true, html:, challenge: }` or `{ error: }`
|
|
43
79
|
def snapshot(name, format: "ai", diff: false)
|
|
44
80
|
call("snapshot", name: name, format: format, diff: diff)
|
|
45
81
|
end
|
|
46
82
|
|
|
83
|
+
# Waits for a CSS selector to appear (short timeout).
|
|
84
|
+
# @param name [String] logical page name
|
|
85
|
+
# @param selector [String] CSS selector to wait for
|
|
86
|
+
# @param timeout [Numeric] seconds before giving up (default: 10)
|
|
87
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
47
88
|
def wait_for(name, selector, timeout: 10) = call("wait_for", name: name, selector: selector, timeout: timeout)
|
|
48
89
|
|
|
90
|
+
# Polls for a CSS selector with a longer timeout (suitable for async operations).
|
|
91
|
+
# @param name [String] logical page name
|
|
92
|
+
# @param selector [String] CSS selector to poll for
|
|
93
|
+
# @param timeout [Numeric] seconds before giving up (default: 30)
|
|
94
|
+
# @return [Hash] `{ ok: true, selector: }` or `{ error: }`
|
|
49
95
|
def watch(name, selector, timeout: 30)
|
|
50
96
|
call("watch", name: name, selector: selector, timeout: timeout)
|
|
51
97
|
end
|
|
52
98
|
|
|
99
|
+
# Returns the current URL of a named page.
|
|
100
|
+
# @param name [String] logical page name
|
|
101
|
+
# @return [Hash] `{ ok: true, url: }` or `{ error: }`
|
|
53
102
|
def url(name) = call("url", name: name)
|
|
103
|
+
|
|
104
|
+
# Evaluates a JavaScript expression and returns the result.
|
|
105
|
+
# @param name [String] logical page name
|
|
106
|
+
# @param expression [String] JavaScript expression
|
|
107
|
+
# @return [Hash] `{ ok: true, result: }` or `{ error: }`
|
|
54
108
|
def evaluate(name, expression) = call("evaluate", name: name, expression: expression)
|
|
109
|
+
|
|
110
|
+
# Checks if browserd is alive.
|
|
111
|
+
# @return [Hash] `{ ok: true, pid: }` or raises if daemon is not running
|
|
55
112
|
def ping = call("ping")
|
|
113
|
+
|
|
114
|
+
# Shuts down browserd gracefully.
|
|
115
|
+
# @return [Hash] `{ ok: true }`
|
|
56
116
|
def shutdown = call("shutdown")
|
|
57
117
|
|
|
118
|
+
# Pauses automation on a page so a human can interact directly.
|
|
119
|
+
# @param name [String] logical page name
|
|
120
|
+
# @return [Hash] `{ ok: true, paused: true }` or `{ error: }`
|
|
121
|
+
def pause(name) = call("pause", name: name)
|
|
122
|
+
|
|
123
|
+
# Resumes automation on a paused page.
|
|
124
|
+
# @param name [String] logical page name
|
|
125
|
+
# @return [Hash] `{ ok: true, paused: false }` or `{ error: }`
|
|
126
|
+
def resume(name) = call("resume", name: name)
|
|
127
|
+
|
|
128
|
+
# Returns the Chrome DevTools URL for a named page.
|
|
129
|
+
# @param name [String] logical page name
|
|
130
|
+
# @return [Hash] `{ ok: true, devtools_url: }` or `{ error: }`
|
|
131
|
+
def inspect_page(name) = call("inspect", name: name)
|
|
132
|
+
|
|
133
|
+
# Returns all cookies for a named page.
|
|
134
|
+
# @param name [String] logical page name
|
|
135
|
+
# @return [Hash] `{ ok: true, cookies: [Hash] }` or `{ error: }`
|
|
136
|
+
def cookies(name) = call("cookies", name: name)
|
|
137
|
+
|
|
138
|
+
# Sets a cookie on a named page.
|
|
139
|
+
# @param name [String] logical page name
|
|
140
|
+
# @param cookie_name [String] cookie name (e.g. "cf_clearance")
|
|
141
|
+
# @param value [String] cookie value
|
|
142
|
+
# @param domain [String] cookie domain (e.g. ".example.com")
|
|
143
|
+
# @param path [String] cookie path (default: "/")
|
|
144
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
145
|
+
def set_cookie(name, cookie_name, value, domain, path: "/")
|
|
146
|
+
call("set_cookie", name: name, cookie_name: cookie_name,
|
|
147
|
+
value: value, domain: domain, path: path)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Clears all cookies for a named page.
|
|
151
|
+
# @param name [String] logical page name
|
|
152
|
+
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
153
|
+
def clear_cookies(name) = call("clear_cookies", name: name)
|
|
154
|
+
|
|
58
155
|
private
|
|
59
156
|
|
|
60
157
|
def communicate(payload)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
class Init
|
|
8
|
+
CONFIG_TEMPLATE = <<~YAML
|
|
9
|
+
# browserctl project configuration
|
|
10
|
+
# Uncomment and edit to customise.
|
|
11
|
+
|
|
12
|
+
# daemon: default # named daemon instance (see browserd --name)
|
|
13
|
+
# workflows_dir: .browserctl/workflows
|
|
14
|
+
YAML
|
|
15
|
+
|
|
16
|
+
def self.run(_args)
|
|
17
|
+
FileUtils.mkdir_p(".browserctl/workflows")
|
|
18
|
+
FileUtils.touch(".browserctl/workflows/.keep")
|
|
19
|
+
|
|
20
|
+
config_path = ".browserctl/config.yml"
|
|
21
|
+
File.write(config_path, CONFIG_TEMPLATE) unless File.exist?(config_path)
|
|
22
|
+
|
|
23
|
+
puts "Initialised browserctl project:"
|
|
24
|
+
puts " .browserctl/workflows/ (place workflow .rb files here)"
|
|
25
|
+
puts " .browserctl/config.yml (project settings)"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Commands
|
|
5
|
+
class Inspect
|
|
6
|
+
def self.run(client, args)
|
|
7
|
+
name = args.shift or abort "usage: browserctl inspect <page>"
|
|
8
|
+
res = client.inspect_page(name)
|
|
9
|
+
if res[:error]
|
|
10
|
+
warn "Error: #{res[:error]}"
|
|
11
|
+
exit 1
|
|
12
|
+
end
|
|
13
|
+
url = res[:devtools_url]
|
|
14
|
+
puts "Opening DevTools for '#{name}':"
|
|
15
|
+
puts " #{url}"
|
|
16
|
+
opener = RUBY_PLATFORM =~ /darwin/ ? "open" : "xdg-open"
|
|
17
|
+
system(opener, url)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -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
|
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,35 @@ 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
|
+
return REGISTRY[name.to_s] if REGISTRY.key?(name.to_s)
|
|
52
|
+
|
|
53
|
+
validate_name!(name)
|
|
33
54
|
load_workflow_file(name)
|
|
34
55
|
REGISTRY[name.to_s] || raise("workflow '#{name}' not found")
|
|
35
56
|
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)
|
data/lib/browserctl.rb
CHANGED
|
@@ -5,3 +5,11 @@ require_relative "browserctl/constants"
|
|
|
5
5
|
require_relative "browserctl/workflow"
|
|
6
6
|
require_relative "browserctl/runner"
|
|
7
7
|
require_relative "browserctl/client"
|
|
8
|
+
|
|
9
|
+
module Browserctl
|
|
10
|
+
PLUGIN_COMMANDS = {} # rubocop:disable Style/MutableConstant
|
|
11
|
+
|
|
12
|
+
def self.register_command(name, &block)
|
|
13
|
+
PLUGIN_COMMANDS[name.to_s] = block
|
|
14
|
+
end
|
|
15
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: browserctl
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -80,6 +80,20 @@ dependencies:
|
|
|
80
80
|
- - "~>"
|
|
81
81
|
- !ruby/object:Gem::Version
|
|
82
82
|
version: '1.65'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: yard
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0.9'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0.9'
|
|
83
97
|
description: Named browser sessions, Ruby workflow DSL, and a token-efficient DOM
|
|
84
98
|
snapshot format. Built on Ferrum (Chrome DevTools Protocol).
|
|
85
99
|
email:
|
|
@@ -96,6 +110,7 @@ files:
|
|
|
96
110
|
- bin/browserctl
|
|
97
111
|
- bin/browserd
|
|
98
112
|
- bin/setup
|
|
113
|
+
- examples/cloudflare_hitl.rb
|
|
99
114
|
- examples/the_internet/add_remove_elements.rb
|
|
100
115
|
- examples/the_internet/checkboxes.rb
|
|
101
116
|
- examples/the_internet/dropdown.rb
|
|
@@ -106,7 +121,10 @@ files:
|
|
|
106
121
|
- lib/browserctl/commands/cli_output.rb
|
|
107
122
|
- lib/browserctl/commands/click.rb
|
|
108
123
|
- lib/browserctl/commands/fill.rb
|
|
124
|
+
- lib/browserctl/commands/init.rb
|
|
125
|
+
- lib/browserctl/commands/inspect.rb
|
|
109
126
|
- lib/browserctl/commands/open_page.rb
|
|
127
|
+
- lib/browserctl/commands/pause_resume.rb
|
|
110
128
|
- lib/browserctl/commands/record.rb
|
|
111
129
|
- lib/browserctl/commands/screenshot.rb
|
|
112
130
|
- lib/browserctl/commands/snapshot.rb
|
|
@@ -118,6 +136,7 @@ files:
|
|
|
118
136
|
- lib/browserctl/server.rb
|
|
119
137
|
- lib/browserctl/server/command_dispatcher.rb
|
|
120
138
|
- lib/browserctl/server/idle_watcher.rb
|
|
139
|
+
- lib/browserctl/server/page_session.rb
|
|
121
140
|
- lib/browserctl/server/snapshot_builder.rb
|
|
122
141
|
- lib/browserctl/version.rb
|
|
123
142
|
- lib/browserctl/workflow.rb
|