obxcura 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f2f8eab2a1520c5f96b85a50ddafee3b7938cd88014e1b288bb4d7a2839454ba
4
+ data.tar.gz: 2d81b467a60b702cfeea5d90792d184f2362f9f2bf96f9a26fdfce68929be12d
5
+ SHA512:
6
+ metadata.gz: 43e149575bda036ff64b42670f8b86147999142538cc89f459bace265ac3cef2f1feccbea1dda40adec6dbed151958d8ae8d2f40941f40155093226e8cf8232e
7
+ data.tar.gz: ea2770f6a9799e45ab8d1dbbb7c79a7b62c621305831a319f182d7cae3fdc2f257f0a49be55d1757e387065bc6e52ae4d596d0165599d76f501d8ae6ebcaec99
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.1] - 2026-07-14
4
+
5
+ ### Fixed
6
+
7
+ - Corrected gemspec repository URLs (`homepage`, `source_code_uri`,
8
+ `changelog_uri`) to point at the actual GitHub repository.
9
+
10
+ ## [0.1.0] - 2026-07-09
11
+
12
+ Initial release. A Ruby client that drives the Obscura headless browser over
13
+ the Chrome DevTools Protocol.
14
+
15
+ ### Added
16
+
17
+ - `Browser` — target lifecycle over a single WebSocket, cookie management
18
+ (`cookies`, `clear_cookies`), and `quit`.
19
+ - `Page` — navigation with load waiting, `evaluate`, DOM access, form filling,
20
+ JSON/form XHR POSTs, and network logging. Each page is a CDP target with its
21
+ own attached session, multiplexed over the shared socket.
22
+ - `Client` — WebSocket transport built directly on `websocket-driver`, with a
23
+ single reader thread routing command replies by `id` and events by
24
+ `sessionId`.
25
+ - `Frame` (with `Runtime` and `DOM` mixins) and `Node` helpers for scripting
26
+ and DOM queries.
27
+ - Chunked reads (`read_string`) so large-string getters like `Page#html`
28
+ work around Obscura's ~500–700 KB single-message ceiling.
29
+ - Optional `nokogiri` integration in `Page#dom`, lazily required.
30
+ - Integration test suite that boots one `obscura serve` plus a local WEBrick
31
+ site, tagged `:obscura` and skipped when no browser binary is available.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Guillermo Moreno
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # Obxcura
2
+
3
+ A small Ruby client for the [Obscura](https://github.com/h4ckf0r0day/obscura)
4
+ headless browser, driven over the Chrome DevTools Protocol.
5
+
6
+ A `Browser` owns one WebSocket connection; each `Page` is a CDP target with its
7
+ own attached session. One connection, many pages.
8
+
9
+ > **Obxcura vs Obscura.** This gem is `Obxcura`. The browser it drives is
10
+ > `obscura` — a separate binary you run yourself. The `x` keeps them apart.
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ bundle add obxcura
16
+ ```
17
+
18
+ You also need the `obscura` binary on your `PATH`
19
+ ([releases](https://github.com/h4ckf0r0day/obscura/releases)).
20
+
21
+ ## Usage
22
+
23
+ Start the browser first (defaults to port 9222):
24
+
25
+ ```bash
26
+ obscura serve
27
+ ```
28
+
29
+ Then:
30
+
31
+ ```ruby
32
+ require "obxcura"
33
+
34
+ browser = Obxcura::Browser.new # or Obxcura.start
35
+ page = browser.go_to("https://example.com")
36
+
37
+ page.html # rendered DOM, post-JS
38
+ page.title # "Example Domain"
39
+ page.current_url # "https://example.com/"
40
+ page.evaluate("1 + 2") # => 3
41
+
42
+ browser.quit
43
+ ```
44
+
45
+ ### Querying the DOM
46
+
47
+ `#at_css` / `#css` return live `Obxcura::Node` handles backed by the real DOM:
48
+
49
+ ```ruby
50
+ page.at_css("h1").text # => "Example Domain"
51
+ page.at_css("a")["href"] # attribute value, or nil
52
+ page.at_css("h1").outer_html # "<h1>Example Domain</h1>"
53
+ page.css("p").map(&:text) # every <p>'s text
54
+ page.at_css("#missing") # => nil
55
+ ```
56
+
57
+ ### Running JavaScript
58
+
59
+ Pass Ruby values as arguments — they cross into the page as real values
60
+ (`arguments[0]`, `arguments[1]`, …), never string-interpolated into source:
61
+
62
+ ```ruby
63
+ page.evaluate("arguments[0] * 2", 21) # => 42
64
+
65
+ # `evaluate_func` calls a function declaration directly:
66
+ page.evaluate_func(<<~JS, "h1")
67
+ function(sel) { return document.querySelector(sel).textContent; }
68
+ JS
69
+ ```
70
+
71
+ ### POSTing from inside the page
72
+
73
+ Obscura routes `XMLHttpRequest` but not `fetch`, so `#xhr_post` runs the POST
74
+ from the page's context (reusing its cookies). Values cross as arguments:
75
+
76
+ ```ruby
77
+ result = page.xhr_post(
78
+ "https://example.com/api/login",
79
+ URI.encode_www_form(user: "me", pass: "secret"), # payload
80
+ "application/x-www-form-urlencoded", # content type
81
+ { "X-Requested-With" => "XMLHttpRequest" } # headers
82
+ )
83
+
84
+ result["status"] # => 200
85
+ result["ok"] # => true (2xx)
86
+ result["body"] # response text
87
+ ```
88
+
89
+ Any HTTP reply (including 4xx/5xx) comes back as `{ "status", "ok", "body" }`.
90
+ A transport failure — the request never reached the server (CORS, the
91
+ private-network SSRF guard, mixed origin, a dead host) — raises
92
+ `Obxcura::ConnectionError`. If the server accepts the connection but never
93
+ answers, the wait ends with `Obxcura::TimeoutError`; pass `timeout:` (seconds)
94
+ to bound it. Some anti-bot endpoints tarpit non-stealth clients — try
95
+ `obscura serve --stealth`.
96
+
97
+ ## API
98
+
99
+ `Obxcura.start(**opts)` is sugar for `Obxcura::Browser.new`.
100
+
101
+ - **`Obxcura::Browser`** — `.new(host:, port:, timeout:)`, `#create_page(url)`,
102
+ `#go_to`/`#goto`, `#targets`, `#version`, `#command`, `#close`/`#quit`.
103
+ Readers: `#client`, `#pages`, `#host`, `#port`.
104
+ - **`Obxcura::Page`** — `#goto`/`#go_to`, `#evaluate`, `#evaluate_func`,
105
+ `#html`/`#body`, `#title`, `#current_url`, `#at_css`, `#css`, `#xhr_post`,
106
+ `#cookies`, `#refresh`/`#reload`, `#command`, `#close`, `#close_connection`.
107
+ Readers: `#frame`, `#target_id`, `#session_id`, `#client`.
108
+ - **`Obxcura::Node`** (from `#at_css`/`#css`) — `#text`, `#[]` (attribute),
109
+ `#outer_html`, `#object_id`.
110
+ - **`Obxcura::Frame`** — the main frame behind a Page; carries the DOM/Runtime
111
+ methods Page delegates (`#evaluate`, `#at_css`, `#read_string`, …).
112
+ - **`Obxcura::Client`** — the CDP transport, if you need raw `#command`,
113
+ `#subscribe` / `#unsubscribe`, or `#close`.
114
+
115
+ Errors all descend from `Obxcura::Error`: `TimeoutError`, `ProtocolError`,
116
+ `ConnectionError`.
117
+
118
+ ## Obscura's quirks (and how Obxcura handles them)
119
+
120
+ These are properties of the **Obscura browser**, not of this gem. They shape the
121
+ API, so they're worth knowing:
122
+
123
+ - **No paint engine.** There is no screenshot API, and there never will be one
124
+ here. `Page.captureScreenshot` is unusable.
125
+ - **~500–700 KB message ceiling.** Obscura won't send a single CDP message
126
+ larger than that. `Page#html` works around it by snapshotting `outerHTML` into
127
+ a page global and pulling it back in 400 KB slices. Don't return giant values
128
+ from `#evaluate` directly.
129
+ - **DOM nodes don't serialize.** A node returned by value comes back as an
130
+ internal stub, so `#at_css` / `#css` resolve it (via `DOM.resolveNode`) to a
131
+ live handle and read it with `Runtime.callFunctionOn`.
132
+ - **XHR, not fetch.** Obscura routes `XMLHttpRequest` but not `fetch`, so
133
+ `#xhr_post` uses XHR from the page context. It also ignores
134
+ `XMLHttpRequest#timeout`, so the effective bound is the CDP-level `timeout:`.
135
+ - **Persistent cookies.** `obscura serve` is long-lived and its cookie jar
136
+ survives `#quit` (that only drops the WebSocket). Read them with
137
+ `page.cookies`; closing a page clears the browser's cookie jar.
138
+ - **Private networks are blocked by default.** To drive a local site, start the
139
+ browser with `obscura serve --allow-private-network`.
140
+
141
+ The transport is built directly on `websocket-driver` rather than
142
+ `websocket-client-simple`, which reads a byte at a time and wedges on large
143
+ frames.
144
+
145
+ ## Development
146
+
147
+ ```bash
148
+ bin/setup
149
+ bundle exec rake # rubocop + rspec
150
+ bundle exec rake doc # YARD docs into doc/
151
+ bin/console # IRB with the gem loaded
152
+ ```
153
+
154
+ The integration specs boot a real `obscura serve` against a local WEBrick test
155
+ site. Point them at your binary:
156
+
157
+ ```bash
158
+ OBSCURA_BIN=/path/to/obscura bundle exec rspec
159
+ ```
160
+
161
+ Without the binary those specs skip cleanly, so `bundle exec rspec` still passes.
162
+
163
+ ## Contributing
164
+
165
+ Bug reports and pull requests are welcome at https://github.com/memoxmrdl/obxcura.
166
+
167
+ ## License
168
+
169
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Obxcura
6
+ # The entry point. Owns one Client (one connection) and the pages opened on it.
7
+ #
8
+ # browser = Obxcura::Browser.new
9
+ # page = browser.create_page
10
+ # browser.quit
11
+ #
12
+ # Assumes a running `obscura serve` (default 127.0.0.1:9222). Pass host:/port:
13
+ # to point elsewhere.
14
+ class Browser
15
+ extend Forwardable
16
+
17
+ # @return [String] default host for `obscura serve`.
18
+ DEFAULT_HOST = "127.0.0.1"
19
+ # @return [Integer] default CDP port for `obscura serve`.
20
+ DEFAULT_PORT = 9222
21
+
22
+ # @return [Obxcura::Client] the underlying CDP transport.
23
+ # @return [Array<Obxcura::Page>] the pages currently open.
24
+ # @return [String] the browser host.
25
+ # @return [Integer] the browser port.
26
+ attr_reader :client, :pages, :host, :port
27
+
28
+ delegate %i[command] => :client
29
+
30
+ # Connect to a running `obscura serve`.
31
+ #
32
+ # @param host [String] host the browser listens on.
33
+ # @param port [Integer] CDP port the browser listens on.
34
+ # @param timeout [Integer] default seconds to wait for CDP replies.
35
+ # @raise [Obxcura::ConnectionError] if the browser can't be reached.
36
+ def initialize(host: DEFAULT_HOST, port: DEFAULT_PORT, timeout: Client::DEFAULT_TIMEOUT)
37
+ @host = host
38
+ @port = port
39
+ @timeout = timeout
40
+ @pages = []
41
+ @client = Client.new(browser_ws_url, timeout: @timeout)
42
+ end
43
+
44
+ # Open a fresh page (a CDP target) and attach to it.
45
+ #
46
+ # @param url [String] URL to open the target at (defaults to a blank page).
47
+ # @return [Obxcura::Page] the new, tracked page.
48
+ def create_page(url = "about:blank")
49
+ target_id = command("Target.createTarget", { url: url })["targetId"]
50
+ session_id = command("Target.attachToTarget", { targetId: target_id, flatten: true })["sessionId"]
51
+
52
+ page = Page.new(self, target_id: target_id, session_id: session_id)
53
+ @pages << page
54
+ page
55
+ end
56
+
57
+ # Open a page and navigate to `url` in one call.
58
+ #
59
+ # Navigates from a blank target rather than passing the URL straight to
60
+ # Target.createTarget: creating two URL-loaded targets and then evaluating
61
+ # crashes `obscura serve` (connection closed: end of file reached).
62
+ #
63
+ # @param url [String] URL to navigate to.
64
+ # @return [Obxcura::Page] the navigated page (load event fired).
65
+ def go_to(url)
66
+ create_page.goto(url)
67
+ end
68
+ alias goto go_to
69
+
70
+ # Every target the browser knows about (pages, workers, ...).
71
+ #
72
+ # @return [Array<Hash>] raw CDP `TargetInfo` hashes.
73
+ def targets
74
+ command("Target.getTargets")["targetInfos"]
75
+ end
76
+
77
+ # The browser's `/json/version` metadata (product, protocol, ws endpoint).
78
+ #
79
+ # @return [Hash] the decoded JSON version document.
80
+ def version
81
+ uri = URI("http://#{@host}:#{@port}/json/version")
82
+ JSON.parse(Net::HTTP.get(uri))
83
+ end
84
+
85
+ # Stop tracking a page. Called by {Obxcura::Page#close}.
86
+ #
87
+ # @param page [Obxcura::Page] the page to forget.
88
+ # @return [Obxcura::Page, nil] the removed page, or nil if unknown.
89
+ def remove_page(page)
90
+ @pages.delete(page)
91
+ end
92
+
93
+ # Close every page, then drop the connection. Aliased as `quit`.
94
+ #
95
+ # @return [void]
96
+ def close
97
+ @pages.dup.each(&:close)
98
+ client.close
99
+ end
100
+ alias quit close
101
+
102
+ private
103
+
104
+ def browser_ws_url
105
+ info = version
106
+ info["webSocketDebuggerUrl"] ||
107
+ raise(ConnectionError, "No browser endpoint at #{@host}:#{@port}")
108
+ rescue SystemCallError, SocketError => e
109
+ raise ConnectionError,
110
+ "Could not reach Obscura at #{@host}:#{@port} (#{e.message}). Is it running? Start it with: obscura serve"
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "openssl"
5
+ require "websocket/driver"
6
+
7
+ module Obxcura
8
+ # The CDP transport: a single WebSocket to the browser endpoint.
9
+ #
10
+ # Built directly on websocket-driver, because the higher-level
11
+ # websocket-client-simple reads the socket one byte at a time (`@socket.getc`)
12
+ # — a ~1MB CDP frame there takes seconds and can wedge the read loop. Here a
13
+ # single reader thread pumps raw bytes through the driver (`readpartial` +
14
+ # `driver.parse`), which parses frames efficiently.
15
+ #
16
+ # This object *is* the driver's I/O adapter: websocket-driver calls #url for the
17
+ # handshake and #write to put bytes on the wire.
18
+ #
19
+ # Everything on the wire is multiplexed over this one socket. Command replies
20
+ # come back keyed by the `id` we assigned (globally unique across the
21
+ # connection), so we resolve those regardless of session. Events instead carry
22
+ # a `sessionId`, so we fan them out to whichever Page subscribed for that
23
+ # session. Browser-level events (no sessionId) go to the `:browser` slot.
24
+ class Client
25
+ # @return [Integer] default seconds to wait for a command reply.
26
+ DEFAULT_TIMEOUT = 30
27
+
28
+ # Obscura frames stay well under this; it's just a guard against a runaway
29
+ # allocation — a sane cap on the receive size.
30
+ #
31
+ # @return [Integer] largest CDP frame (bytes) the driver will assemble.
32
+ MAX_MESSAGE_SIZE = 64 * 1024 * 1024
33
+
34
+ # Bytes pulled per read syscall. Small is fine — the driver reassembles
35
+ # frames across reads; this only bounds how much we buffer at once.
36
+ #
37
+ # @return [Integer]
38
+ READ_CHUNK = 512
39
+
40
+ # @return [String] the WebSocket URL of the browser endpoint.
41
+ attr_reader :url
42
+
43
+ # Open the WebSocket to the browser and block until the handshake completes.
44
+ #
45
+ # @param url [String] the `ws://`/`wss://` browser endpoint.
46
+ # @param timeout [Integer] default seconds to wait for command replies.
47
+ # @raise [Obxcura::TimeoutError] if the connection can't be established.
48
+ def initialize(url, timeout: DEFAULT_TIMEOUT)
49
+ @url = url
50
+ @timeout = timeout
51
+ @command_id = 0
52
+ @mutex = Mutex.new
53
+ @write_mutex = Mutex.new
54
+ @pending = {}
55
+ @subscribers = {}
56
+ @open = Queue.new
57
+ connect
58
+ end
59
+
60
+ # Send a CDP command and block until its reply arrives.
61
+ #
62
+ # @param method [String] the CDP method name, e.g. "Runtime.evaluate".
63
+ # @param params [Hash] the method parameters.
64
+ # @param session_id [String, nil] nil sends a browser-level command (target
65
+ # management); a session id routes it to a specific page.
66
+ # @param timeout [Integer] seconds to wait for the reply.
67
+ # @return [Hash] the command's `result` object.
68
+ # @raise [Obxcura::TimeoutError] if no reply arrives in time.
69
+ # @raise [Obxcura::ProtocolError] if the browser returns an error.
70
+ def command(method, params = {}, session_id: nil, timeout: @timeout)
71
+ id = next_id
72
+ queue = Queue.new
73
+ @mutex.synchronize { @pending[id] = queue }
74
+
75
+ frame = { id: id, method: method, params: params }
76
+ frame[:sessionId] = session_id if session_id
77
+ @driver.text(JSON.generate(frame))
78
+
79
+ message = Timeout.timeout(timeout, TimeoutError, "#{method} timed out after #{timeout}s") { queue.pop }
80
+ raise ProtocolError, message["error"]["message"] if message["error"]
81
+
82
+ message["result"]
83
+ ensure
84
+ @mutex.synchronize { @pending.delete(id) }
85
+ end
86
+
87
+ # Register a handler for events on a given session.
88
+ #
89
+ # @param session_id [String, Symbol] the page session id, or `:browser` for
90
+ # target-level (session-less) events.
91
+ # @yieldparam method [String] the CDP event name.
92
+ # @yieldparam params [Hash] the event parameters.
93
+ # @return [Proc] the stored handler.
94
+ def subscribe(session_id, &block)
95
+ @subscribers[session_id] = block
96
+ end
97
+
98
+ # Remove the handler previously registered for a session.
99
+ #
100
+ # @param session_id [String, Symbol] the session to stop listening on.
101
+ # @return [Proc, nil] the removed handler, if any.
102
+ def unsubscribe(session_id)
103
+ @subscribers.delete(session_id)
104
+ end
105
+
106
+ # Close the driver, socket and reader thread. Safe to call more than once.
107
+ #
108
+ # @return [void]
109
+ def close
110
+ @closing = true
111
+ @driver&.close
112
+ @socket&.close
113
+ @reader&.kill
114
+ rescue IOError, SystemCallError
115
+ nil
116
+ end
117
+
118
+ # @return [Boolean] whether the connection is being (or has been) torn down.
119
+ def closing?
120
+ @closing == true
121
+ end
122
+
123
+ # I/O adapter for websocket-driver: puts outgoing bytes on the wire. Writes
124
+ # are serialized so command threads and the driver's own control frames
125
+ # (ping/pong/close) never interleave and corrupt the stream.
126
+ #
127
+ # @param data [String] raw bytes to write.
128
+ # @return [void]
129
+ def write(data)
130
+ @write_mutex.synchronize { @socket.write(data) }
131
+ rescue IOError, SystemCallError => e
132
+ warn "[Obxcura] websocket write error: #{e}" unless closing?
133
+ end
134
+
135
+ private
136
+
137
+ def connect
138
+ @socket = open_socket
139
+ @driver = ::WebSocket::Driver.client(self, max_length: MAX_MESSAGE_SIZE)
140
+
141
+ @driver.on(:open) { @open.push(true) }
142
+ @driver.on(:message) { |event| handle_message(event.data) }
143
+ @driver.on(:close) { |event| handle_disconnect(event) }
144
+ @driver.on(:error) { |event| warn "[Obxcura] websocket error: #{event.message}" unless closing? }
145
+
146
+ start_reader
147
+ @driver.start
148
+
149
+ Timeout.timeout(@timeout, TimeoutError, "Could not connect to #{@url}") { @open.pop }
150
+ end
151
+
152
+ def open_socket
153
+ uri = URI(@url)
154
+ tcp = TCPSocket.new(uri.host, uri.port || (uri.scheme == "wss" ? 443 : 80))
155
+ return tcp unless uri.scheme == "wss"
156
+
157
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, OpenSSL::SSL::SSLContext.new)
158
+ ssl.sync_close = true
159
+ ssl.hostname = uri.host
160
+ ssl.connect
161
+ ssl
162
+ end
163
+
164
+ # One reader thread feeds raw bytes to the driver, which emits :message as
165
+ # frames complete. A raised parse is logged (not fatal); a dead socket ends
166
+ # the loop and wakes any in-flight commands so they don't hang to timeout.
167
+ def start_reader
168
+ @reader = Thread.new do
169
+ until closing?
170
+ begin
171
+ @driver.parse(@socket.readpartial(READ_CHUNK))
172
+ rescue EOFError, IOError, SystemCallError => e
173
+ handle_disconnect(e) unless closing?
174
+ break
175
+ rescue StandardError => e
176
+ warn "[Obxcura] reader error: #{e}" unless closing?
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ def handle_message(data)
183
+ message = JSON.parse(data)
184
+
185
+ if message["id"]
186
+ queue = @mutex.synchronize { @pending[message["id"]] }
187
+ queue&.push(message)
188
+ elsif message["method"]
189
+ handler = @subscribers[message["sessionId"] || :browser]
190
+ handler&.call(message["method"], message["params"])
191
+ end
192
+ rescue JSON::ParserError => e
193
+ warn "[Obxcura] dropped unparseable message: #{e}" unless closing?
194
+ end
195
+
196
+ # The socket dropped (or the browser closed us). Fail every waiting command
197
+ # so callers get a prompt error instead of a 30s timeout.
198
+ def handle_disconnect(reason)
199
+ description = reason.respond_to?(:message) ? reason.message : reason.to_s
200
+ @mutex.synchronize do
201
+ @pending.each_value { |queue| queue.push({ "error" => { "message" => "connection closed: #{description}" } }) }
202
+ end
203
+ end
204
+
205
+ def next_id
206
+ @mutex.synchronize { @command_id += 1 }
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Obxcura
4
+ class Frame
5
+ # DOM: high-level reads of the rendered page, all built on Runtime.
6
+ #
7
+ # Selector queries return live Nodes. Obscura serializes a DOM node over
8
+ # returnByValue as an internal stub carrying an `_nid` — useless on its own,
9
+ # but the `_nid` resolves (DOM.resolveNode) to a real remote object handle we
10
+ # wrap in a Node and drive with Runtime#call_on.
11
+ module DOM
12
+ # @return [String] the current top-window URL.
13
+ def current_url
14
+ evaluate("window.location.href")
15
+ end
16
+
17
+ # @return [String] the current document title.
18
+ def title
19
+ evaluate("document.title")
20
+ end
21
+
22
+ # The live, post-JS HTML. Retrieved in chunks (see {Runtime#read_string})
23
+ # because a full page's outerHTML routinely exceeds Obscura's message limit.
24
+ # Aliased as `html`.
25
+ #
26
+ # @return [String] the rendered document's outer HTML.
27
+ def body
28
+ read_string("document.documentElement.outerHTML")
29
+ end
30
+ alias_method :html, :body
31
+
32
+ # First element matching a CSS selector.
33
+ #
34
+ # @param selector [String] a CSS selector.
35
+ # @return [Obxcura::Node, nil] the matching node, or nil if none.
36
+ def at_css(selector)
37
+ to_node(evaluate_func("function(s) { return document.querySelector(s); }", selector))
38
+ end
39
+
40
+ # All elements matching a CSS selector.
41
+ #
42
+ # @param selector [String] a CSS selector.
43
+ # @return [Array<Obxcura::Node>] the matching nodes (empty if none).
44
+ def css(selector)
45
+ Array(evaluate_func("function(s) { return Array.from(document.querySelectorAll(s)); }", selector))
46
+ .filter_map { |stub| to_node(stub) }
47
+ end
48
+
49
+ private
50
+
51
+ # Resolve Obscura's serialized node stub ({"_nid"=>N,...}) into a Node.
52
+ #
53
+ # @param stub [Hash, nil] the serialized node from a querySelector call.
54
+ # @return [Obxcura::Node, nil] a live node handle, or nil for a blank match.
55
+ def to_node(stub)
56
+ return nil unless stub.is_a?(Hash) && stub["_nid"]
57
+
58
+ object_id = command("DOM.resolveNode", { nodeId: stub["_nid"] }).dig("object", "objectId")
59
+
60
+ Node.new(self, object_id)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Obxcura
4
+ class Frame
5
+ # Runtime: executing JavaScript in the frame's context. This is the side that
6
+ # reaches the Client and puts CDP commands on the wire; everything in DOM is
7
+ # built on top of #evaluate.
8
+ #
9
+ # Ruby values cross into JS as real arguments (Runtime.callFunctionOn), not by
10
+ # being string-interpolated into source. Node-handle resolution, cyclic-node
11
+ # detection and a retry loop are deliberately left out.
12
+ module Runtime
13
+ # Obscura won't send a single CDP message larger than ~500-700KB, so we pull
14
+ # large strings back in slices this size and stitch them together. Well under
15
+ # the ceiling, and each slice is fast over the websocket-driver transport.
16
+ #
17
+ # @return [Integer] slice size (bytes) used by {#read_string}.
18
+ EVALUATE_CHUNK = 400_000
19
+
20
+ # Evaluate a JS expression and return its value (awaits promises).
21
+ #
22
+ # Extra args are passed to the page as real values, reachable in the
23
+ # expression as `arguments[0]`, `arguments[1]`, ... Without args it's a
24
+ # single Runtime.evaluate (the hot path); with args it goes through
25
+ # Runtime.callFunctionOn so nothing is interpolated into source.
26
+ #
27
+ # @param expression [String] the JS expression to evaluate.
28
+ # @param args [Array] values passed to the page as `arguments[...]`.
29
+ # @return [Object, nil] the evaluated value (JSON-decoded).
30
+ # @raise [Obxcura::ProtocolError] if the JS throws.
31
+ # @example
32
+ # frame.evaluate("arguments[0] + arguments[1]", 2, 3) # => 5
33
+ def evaluate(expression, *args)
34
+ return handle_result(evaluate_command(expression)) if args.empty?
35
+
36
+ evaluate_func("function() { return (#{expression}); }", *args)
37
+ end
38
+
39
+ # Call a JS function declaration with the given arguments, bound to the live
40
+ # global object. Unlike {#evaluate}, `expression` is the function itself.
41
+ #
42
+ # @param expression [String] a JS function declaration.
43
+ # @param args [Array] values passed to the function.
44
+ # @param timeout [Integer, nil] override for the client's reply timeout.
45
+ # @return [Object, nil] the function's return value (JSON-decoded).
46
+ # @raise [Obxcura::ProtocolError] if the JS throws.
47
+ # @example
48
+ # frame.evaluate_func("function(sel){ return document.querySelector(sel) }", "#id")
49
+ def evaluate_func(expression, *args, timeout: nil)
50
+ call_on(global_object_id, expression, args, timeout: timeout)
51
+ end
52
+
53
+ # Call a function declaration bound to a specific remote object (its `this`).
54
+ # Used by {#evaluate_func} (bound to globalThis) and by {Node} reads (bound
55
+ # to the node's handle).
56
+ #
57
+ # @param object_id [String] the CDP objectId to bind as `this`.
58
+ # @param function_declaration [String] a JS function declaration.
59
+ # @param args [Array] values passed to the function.
60
+ # @param timeout [Integer, nil] override for the client's reply timeout,
61
+ # e.g. for a call that awaits a slow in-page POST.
62
+ # @return [Object, nil] the function's return value (JSON-decoded).
63
+ # @raise [Obxcura::ProtocolError] if the JS throws.
64
+ def call_on(object_id, function_declaration, args = [], timeout: nil)
65
+ handle_result(command(
66
+ "Runtime.callFunctionOn",
67
+ {
68
+ functionDeclaration: function_declaration,
69
+ objectId: object_id,
70
+ arguments: args.map { |value| { value: value } },
71
+ awaitPromise: true,
72
+ returnByValue: true
73
+ },
74
+ timeout: timeout
75
+ ))
76
+ end
77
+
78
+ # Pull a possibly-large JS string back in {EVALUATE_CHUNK}-sized slices. The
79
+ # expression is snapshotted into a page global once, so it's evaluated a
80
+ # single time no matter how big the result is; we then slice that global.
81
+ #
82
+ # @param js_expression [String] a JS expression producing a string.
83
+ # @return [String] the full string, reassembled from slices.
84
+ def read_string(js_expression)
85
+ length = evaluate("(window.__obxcura_read = String(#{js_expression})).length").to_i
86
+ return "" if length.zero?
87
+
88
+ buffer = String.new(capacity: length)
89
+ offset = 0
90
+ while offset < length
91
+ buffer << evaluate("window.__obxcura_read.slice(#{offset}, #{offset + EVALUATE_CHUNK})")
92
+ offset += EVALUATE_CHUNK
93
+ end
94
+ buffer
95
+ end
96
+
97
+ private
98
+
99
+ # objectId of the live global object, fetched fresh so it can't go stale
100
+ # across navigations — no execution-context tracking needed.
101
+ def global_object_id
102
+ evaluate_command("globalThis", by_value: false).dig("result", "objectId")
103
+ end
104
+
105
+ def evaluate_command(expression, by_value: true)
106
+ command("Runtime.evaluate", { expression: expression, awaitPromise: true, returnByValue: by_value })
107
+ end
108
+
109
+ def handle_result(result)
110
+ if result["exceptionDetails"]
111
+ raise ProtocolError, result.dig("exceptionDetails", "text") || "JS execution error"
112
+ end
113
+
114
+ result.dig("result", "value")
115
+ end
116
+
117
+ def command(method, params = {}, timeout: nil)
118
+ options = { session_id: page.session_id }
119
+ options[:timeout] = timeout if timeout
120
+ page.client.command(method, params, **options)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "frame/runtime"
4
+ require_relative "frame/dom"
5
+
6
+ module Obxcura
7
+ # A browsing context inside a Page. It ties together Runtime (executing JS via
8
+ # the Client) and DOM (reads built on that), and reaches the transport through
9
+ # its owning Page — so a Frame never touches the Client directly except via
10
+ # `page.client`.
11
+ #
12
+ # Obxcura drives a single main frame per page today (id == the page's
13
+ # target_id); there is deliberately no iframe tree.
14
+ class Frame
15
+ include Runtime
16
+ include DOM
17
+
18
+ # @return [String] the frame's unique id (the page's CDP target_id).
19
+ attr_accessor :id
20
+
21
+ # @return [Obxcura::Page] the page that owns this frame.
22
+ attr_reader :page
23
+
24
+ # @param id [String] the frame id (its page's target_id).
25
+ # @param page [Obxcura::Page] the owning page, used to reach the Client.
26
+ def initialize(id, page)
27
+ @id = id
28
+ @page = page
29
+ end
30
+
31
+ # @return [String] the frame's current URL.
32
+ def url
33
+ evaluate("window.location.href")
34
+ end
35
+
36
+ # @return [String] the frame's current document title.
37
+ def title
38
+ evaluate("document.title")
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Obxcura
4
+ # A live handle to a DOM node inside a Frame. Backed by a CDP remote object, so
5
+ # reads run as function calls bound to that object and reflect the current DOM.
6
+ # Returned by Frame::DOM#at_css / #css.
7
+ class Node
8
+ # @return [String] the CDP remote objectId backing this node.
9
+ attr_reader :object_id
10
+
11
+ # @param frame [Obxcura::Frame] the frame the node lives in.
12
+ # @param object_id [String] the CDP objectId of the node.
13
+ def initialize(frame, object_id)
14
+ @frame = frame
15
+ @object_id = object_id
16
+ end
17
+
18
+ # @return [String] the visible text of the node and its descendants.
19
+ def text
20
+ @frame.call_on(@object_id, "function() { return this.textContent; }")
21
+ end
22
+
23
+ # Read an attribute value.
24
+ #
25
+ # @param name [String] the attribute name.
26
+ # @return [String, nil] the attribute value, or nil if absent.
27
+ def [](name)
28
+ @frame.call_on(@object_id, "function(name) { return this.getAttribute(name); }", [ name ])
29
+ end
30
+
31
+ # @return [String] the node's serialized outer HTML.
32
+ def outer_html
33
+ @frame.call_on(@object_id, "function() { return this.outerHTML; }")
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Obxcura
6
+ # A single page/tab: a CDP target with its own attached session. Created via
7
+ # {Obxcura::Browser#create_page} — you rarely instantiate this directly.
8
+ #
9
+ # Page owns the CDP session, navigation, in-page POSTs and lifecycle. Reading
10
+ # the DOM and executing JS belong to its main {Frame} (see {Frame::DOM} and
11
+ # {Frame::Runtime}), and Page delegates those (`#evaluate`, `#html`, `#title`,
12
+ # `#at_css`, ...).
13
+ #
14
+ # Obscura has no paint engine, so there is deliberately no screenshot API.
15
+ #
16
+ # @example
17
+ # page = browser.create_page
18
+ # page.goto("https://example.com")
19
+ # page.html # rendered DOM after JS
20
+ # page.title # "Example Domain"
21
+ # page.at_css("h1").text # => "Example Domain"
22
+ # page.close
23
+ class Page
24
+ extend Forwardable
25
+
26
+ # @return [String] the CDP target id backing this page.
27
+ # @return [String] the CDP session id attached to the target.
28
+ # @return [Obxcura::Client] the shared CDP transport.
29
+ # @return [Obxcura::Frame] the page's main frame.
30
+ attr_reader :target_id, :session_id, :client, :frame
31
+
32
+ # Delegated to the main {Frame}: `#evaluate`, `#evaluate_func`,
33
+ # `#current_url`, `#title`, `#html`, `#body`, `#at_css`, `#css`.
34
+ def_delegators :frame, :evaluate, :evaluate_func, :current_url, :title, :html, :body, :at_css, :css
35
+
36
+ # @param browser [Obxcura::Browser] the owning browser.
37
+ # @param target_id [String] the CDP target id.
38
+ # @param session_id [String] the CDP session attached to the target.
39
+ def initialize(browser, target_id:, session_id:)
40
+ @browser = browser
41
+ @client = browser.client
42
+ @target_id = target_id
43
+ @session_id = session_id
44
+ @frame = Frame.new(target_id, self)
45
+ @load_queue = Queue.new
46
+ @network_log = []
47
+ @network_mutex = Mutex.new
48
+
49
+ @client.subscribe(@session_id) { |method, params| dispatch_event(method, params) }
50
+ end
51
+
52
+ # Navigate to `url` and block until the page's load event fires. Aliased as
53
+ # `go_to`.
54
+ #
55
+ # @param url [String] the URL to navigate to.
56
+ # @return [self]
57
+ def goto(url)
58
+ @load_queue = Queue.new
59
+ command("Page.navigate", url: url)
60
+ wait_for_load
61
+ self
62
+ end
63
+ alias_method :go_to, :goto
64
+
65
+ # Close this page's target and stop listening for its events.
66
+ #
67
+ # @return [Obxcura::Page, nil] the page, removed from the browser.
68
+ def close
69
+ @client.unsubscribe(@session_id)
70
+ @client.command("Network.clearBrowserCookies", { targetId: @target_id })
71
+ @client.command("Target.closeTarget", { targetId: @target_id })
72
+ rescue ProtocolError
73
+ # Target already gone — nothing to do.
74
+ ensure
75
+ @browser.remove_page(self)
76
+ end
77
+
78
+ # Drop the underlying WebSocket connection (affects the whole browser).
79
+ #
80
+ # @return [void]
81
+ def close_connection
82
+ @client.close
83
+ end
84
+
85
+ # Reload the page and block until it loads again. Aliased as `reload`.
86
+ #
87
+ # @return [Hash] the CDP `Page.reload` result.
88
+ def refresh
89
+ command("Page.reload")
90
+ wait_for_load
91
+ end
92
+ alias_method :reload, :refresh
93
+
94
+ # @return [Array<Hash>] the page's cookies as CDP cookie hashes.
95
+ def cookies
96
+ command("Storage.getCookies")["cookies"]
97
+ end
98
+
99
+ # Send a CDP command scoped to this page's session.
100
+ #
101
+ # @param method [String] the CDP method name.
102
+ # @param params [Hash] the method parameters.
103
+ # @return [Hash] the command's `result` object.
104
+ def command(method, params = {})
105
+ @client.command(method, params, session_id: @session_id)
106
+ end
107
+
108
+ # POST via XMLHttpRequest from the page context. Obscura routes XHR but not
109
+ # fetch, so this is the reliable POST path. All values cross as arguments,
110
+ # never interpolated into the JS. Returns { status, ok, body } on any HTTP
111
+ # reply (including 4xx/5xx). A transport failure — the request never reached
112
+ # the server (blocked by CORS / private-network SSRF guard, mixed origin, or
113
+ # a dead host) — raises ConnectionError instead of silently returning nil.
114
+ #
115
+ # `timeout` (seconds) bounds how long we wait for the reply. If the server
116
+ # accepts the connection but never answers the XHR (some anti-bot endpoints
117
+ # tarpit non-stealth clients), the wait ends with a TimeoutError that points
118
+ # at the likely cause. Note: Obscura ignores XMLHttpRequest#timeout, so the
119
+ # effective bound is this CDP-level one.
120
+ #
121
+ # @param url [String] the URL to POST to.
122
+ # @param payload [String] the raw request body.
123
+ # @param content_type [String] the Content-Type header value.
124
+ # @param headers [Hash{String=>String}] extra request headers.
125
+ # @param timeout [Integer, nil] seconds to wait for the reply.
126
+ # @return [Hash] `{ "status" => Integer, "ok" => Boolean, "body" => String }`.
127
+ # @raise [Obxcura::ConnectionError] if the request never reached the server.
128
+ # @raise [Obxcura::TimeoutError] if the server accepts but never answers.
129
+ def xhr_post(url, payload, content_type, headers, timeout: nil)
130
+ result = evaluate_func(<<~JS, url, payload, content_type, headers, timeout:)
131
+ function(url, payload, contentType, headers) {
132
+ return new Promise((resolve) => {
133
+ const x = new XMLHttpRequest();
134
+ x.onreadystatechange = () => {
135
+ if (x.readyState === 4) {
136
+ resolve({ status: x.status, ok: x.status >= 200 && x.status < 300, body: x.responseText });
137
+ }
138
+ };
139
+ x.onerror = () => resolve({ error: "network error or request blocked (CORS / private network / mixed origin)" });
140
+ try {
141
+ x.open("POST", url, true);
142
+ x.setRequestHeader("Content-Type", contentType);
143
+ Object.keys(headers).forEach((k) => x.setRequestHeader(k, headers[k]));
144
+ x.send(payload);
145
+ } catch (e) {
146
+ resolve({ error: String(e) });
147
+ }
148
+ });
149
+ }
150
+ JS
151
+
152
+ if result.nil? || result["error"]
153
+ reason = result&.dig("error") || "no response (request blocked or never settled)"
154
+ raise ConnectionError, "POST #{url} failed: #{reason}"
155
+ end
156
+
157
+ result
158
+ rescue TimeoutError
159
+ raise TimeoutError,
160
+ "POST #{url} did not complete in time. The server accepted the connection " \
161
+ "but never answered the XHR — likely anti-bot tarpitting. Try submitting the " \
162
+ "real form with #type/#submit, run `obscura serve --stealth`, or pass a larger timeout:."
163
+ end
164
+
165
+ private
166
+
167
+ def dispatch_event(method, params)
168
+ case method
169
+ when "Page.loadEventFired"
170
+ @load_queue.push(true)
171
+ when "Network.responseReceived"
172
+ @network_mutex.synchronize do
173
+ @network_log << { url: params.dig("response", "url"), request_id: params["requestId"], finished: false }
174
+ end
175
+ when "Network.loadingFinished"
176
+ @network_mutex.synchronize do
177
+ entry = @network_log.find { |e| e[:request_id] == params["requestId"] }
178
+ entry[:finished] = true if entry
179
+ end
180
+ end
181
+ end
182
+
183
+ def wait_for_load
184
+ Timeout.timeout(Client::DEFAULT_TIMEOUT, TimeoutError, "Page load timed out") { @load_queue.pop }
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Obxcura
4
+ # The gem's semantic version string.
5
+ #
6
+ # @return [String]
7
+ VERSION = "0.1.1"
8
+ end
data/lib/obxcura.rb ADDED
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Obxcura — a small Ruby API for driving an Obscura browser
4
+ # (h4ckf0r0day/obscura) over the Chrome DevTools Protocol.
5
+ #
6
+ # Obscura speaks CDP over a WebSocket, just like headless Chrome. The shape is
7
+ # simple: a Browser owns one connection, and each Page is a target with its own
8
+ # attached session. One connection, many pages.
9
+ #
10
+ # require "obxcura"
11
+ #
12
+ # browser = Obxcura::Browser.new # connects to a running `obscura serve`
13
+ # page = browser.create_page
14
+ # page.goto("https://www.google.com")
15
+ # puts page.html # rendered DOM (post-JS)
16
+ # browser.quit
17
+ #
18
+ # Start the browser first (defaults to port 9222):
19
+ # obscura serve
20
+ #
21
+ # nokogiri is optional — only needed for page.dom / at_css / css.
22
+
23
+ require "json"
24
+ require "net/http"
25
+ require "uri"
26
+ require "timeout"
27
+
28
+ require_relative "obxcura/version"
29
+
30
+ # Top-level namespace for the gem: a small Ruby client that drives an Obscura
31
+ # headless browser over the Chrome DevTools Protocol. See the file header above
32
+ # for the big picture; start from {Obxcura.start} or {Obxcura::Browser}.
33
+ module Obxcura
34
+ # Base class for every error the gem raises, so callers can rescue the whole
35
+ # family with `rescue Obxcura::Error`.
36
+ class Error < StandardError; end
37
+
38
+ # Raised when a CDP command (or the initial connect) does not answer in time.
39
+ class TimeoutError < Error; end
40
+
41
+ # Raised when Obscura returns a protocol-level error, or JS throws during evaluate.
42
+ class ProtocolError < Error; end
43
+
44
+ # Raised when the browser endpoint can't be reached at all (nothing listening, etc).
45
+ class ConnectionError < Error; end
46
+
47
+ # Connect to a running `obscura serve` and return a Browser.
48
+ #
49
+ # @param options [Hash] forwarded to {Obxcura::Browser#initialize}
50
+ # (`:host`, `:port`, `:timeout`).
51
+ # @return [Obxcura::Browser] a connected browser.
52
+ # @raise [Obxcura::ConnectionError] if no browser is listening.
53
+ # @example
54
+ # browser = Obxcura.start(port: 9222)
55
+ # page = browser.go_to("https://example.com")
56
+ # browser.quit
57
+ def self.start(**options)
58
+ Browser.new(**options)
59
+ end
60
+ end
61
+
62
+ require_relative "obxcura/client"
63
+ require_relative "obxcura/node"
64
+ require_relative "obxcura/frame"
65
+ require_relative "obxcura/browser"
66
+ require_relative "obxcura/page"
metadata ADDED
@@ -0,0 +1,158 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: obxcura
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - memoxmrdl
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: websocket-driver
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.7'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: nokogiri
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.16'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.16'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.13'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.13'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop-rails-omakase
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.1'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.1'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '13.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '13.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: webrick
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.8'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.8'
97
+ - !ruby/object:Gem::Dependency
98
+ name: yard
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.9'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.9'
111
+ description: High-level Browser/Page API for driving h4ckf0r0day/obscura via the Chrome
112
+ DevTools Protocol.
113
+ email:
114
+ - jmemox@gmail.com
115
+ executables: []
116
+ extensions: []
117
+ extra_rdoc_files: []
118
+ files:
119
+ - CHANGELOG.md
120
+ - LICENSE.txt
121
+ - README.md
122
+ - lib/obxcura.rb
123
+ - lib/obxcura/browser.rb
124
+ - lib/obxcura/client.rb
125
+ - lib/obxcura/frame.rb
126
+ - lib/obxcura/frame/dom.rb
127
+ - lib/obxcura/frame/runtime.rb
128
+ - lib/obxcura/node.rb
129
+ - lib/obxcura/page.rb
130
+ - lib/obxcura/version.rb
131
+ homepage: https://github.com/consultasimple/obxcura
132
+ licenses:
133
+ - MIT
134
+ metadata:
135
+ homepage_uri: https://github.com/consultasimple/obxcura
136
+ source_code_uri: https://github.com/consultasimple/obxcura
137
+ changelog_uri: https://github.com/consultasimple/obxcura/blob/main/CHANGELOG.md
138
+ rubygems_mfa_required: 'true'
139
+ post_install_message:
140
+ rdoc_options: []
141
+ require_paths:
142
+ - lib
143
+ required_ruby_version: !ruby/object:Gem::Requirement
144
+ requirements:
145
+ - - ">="
146
+ - !ruby/object:Gem::Version
147
+ version: '3.1'
148
+ required_rubygems_version: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ requirements: []
154
+ rubygems_version: 3.5.11
155
+ signing_key:
156
+ specification_version: 4
157
+ summary: A small Ruby client for the Obscura headless browser over CDP.
158
+ test_files: []