Obxcura
A small Ruby client for the Obscura headless browser, driven over the Chrome DevTools Protocol.
A Browser owns one WebSocket connection; each Page is a CDP target with its
own attached session. One connection, many pages.
Obxcura vs Obscura. This gem is
Obxcura. The browser it drives isobscura— a separate binary you run yourself. Thexkeeps them apart.
Installation
bundle add obxcuraYou also need the obscura binary on your PATH
(releases).
Usage
Start the browser first (defaults to port 9222):
obscura serveThen:
require "obxcura"
browser = Obxcura::Browser.new # or Obxcura.start
page = browser.go_to("https://example.com")
page.html # rendered DOM, post-JS
page.title # "Example Domain"
page.current_url # "https://example.com/"
page.evaluate("1 + 2") # => 3
browser.quitQuerying the DOM
#at_css / #css return live Obxcura::Node handles backed by the real DOM:
page.at_css("h1").text # => "Example Domain"
page.at_css("a")["href"] # attribute value, or nil
page.at_css("h1").outer_html # "<h1>Example Domain</h1>"
page.css("p").map(&:text) # every <p>'s text
page.at_css("#missing") # => nilRunning JavaScript
Pass Ruby values as arguments — they cross into the page as real values
(arguments[0], arguments[1], …), never string-interpolated into source:
page.evaluate("arguments[0] * 2", 21) # => 42
# `evaluate_func` calls a function declaration directly:
page.evaluate_func(<<~JS, "h1")
function(sel) { return document.querySelector(sel).textContent; }
JSPOSTing from inside the page
#post runs the POST from the page's context with fetch, reusing its cookies.
Values cross as arguments:
result = page.post(
"https://example.com/api/login",
URI.encode_www_form(user: "me", pass: "secret"), # payload
"application/x-www-form-urlencoded", # content type
{ "X-Requested-With" => "XMLHttpRequest" } # headers
)
result["status"] # => 200
result["ok"] # => true (2xx)
result["body"] # response textAny HTTP reply (including 4xx/5xx) comes back as { "status", "ok", "body" }.
A transport failure — the request never reached the server (CORS, the
private-network SSRF guard, mixed origin, a dead host) — raises
Obxcura::ConnectionError. If the server accepts the connection but never
answers, the wait ends with Obxcura::TimeoutError; pass timeout: (seconds)
to bound it, and the request is dropped in the page at roughly that mark. Some
anti-bot endpoints tarpit non-stealth clients — try obscura serve --stealth.
Before Obscura 0.1.11 this had to use
XMLHttpRequest, becausefetchwasn't routed. The old name#xhr_poststill works as a deprecated alias.
API
Obxcura.start(**opts) is sugar for Obxcura::Browser.new.
-
Obxcura::Browser—.new(host:, port:, timeout:),#create_page(url),#go_to/#goto,#targets,#version,#clear_cookies,#command,#close/#quit. Readers:#client,#pages,#host,#port. -
Obxcura::Page—#goto/#go_to,#evaluate,#evaluate_func,#html/#body,#title,#current_url,#at_css,#css,#post,#cookies,#network_log,#refresh/#reload,#command,#close,#close_connection. Readers:#frame,#target_id,#session_id,#client. -
Obxcura::Node(from#at_css/#css) —#text,#value,#[](attribute),#at_css,#focus,#type,#submit,#outer_html. -
Obxcura::Frame— the main frame behind a Page; carries the DOM/Runtime methods Page delegates (#evaluate,#at_css,#call_on, …). -
Obxcura::Client— the CDP transport, if you need raw#command,#subscribe/#unsubscribe, or#close.
Errors all descend from Obxcura::Error: TimeoutError, ProtocolError,
ConnectionError.
Obscura's quirks (and how Obxcura handles them)
These are properties of the Obscura browser, not of this gem. They shape the API, so they're worth knowing:
-
No paint engine. There is no screenshot API, and there never will be one
here.
Page.captureScreenshotis unusable. -
DOM nodes don't serialize. A node returned by value comes back as an
internal stub, so
#at_css/#cssresolve it (viaDOM.resolveNode) to a live handle and read it withRuntime.callFunctionOn. -
In-page throws vanish. A JS
throwcomes back asundefinedrather than an exception, so the gem returns{ error: ... }sentinels and raises from Ruby. It's also why#postbounds itself by racing a timer instead of usingAbortSignal.timeout— the abort works, but its rejection doesn't survive the trip, so the reason has to come back as a resolved value. -
The network log only sees navigation.
#network_logrecords the document and its subresources. Script-initiated requests — including#post— emit no CDP network events at all, so they never appear. -
No bulk text insertion.
Input.insertTextisn't implemented, so#typedispatches one key event per character viaInput.dispatchKeyEvent. Realkeydown/input/keyupfire;changedoes not, matching browsers, which only fire it on blur. -
Private networks are blocked by default. To drive a local site, start the
browser with
obscura serve --allow-private-network.
Two long-standing limits were lifted in Obscura 0.1.11, so if you've read older
notes: the ~500–700 KB message ceiling is gone (64 MiB now, so #html
returns even a huge document in one round trip), and cookies no longer leak
between connections — each Browser gets its own browser context, and
#clear_cookies is now only about resetting state within one connection.
The transport is built directly on websocket-driver rather than
websocket-client-simple, which reads a byte at a time and wedges on large
frames.
Development
bin/setup
bundle exec rake # rubocop + rspec
bundle exec rake doc # YARD docs into doc/
bin/console # IRB with the gem loadedThe integration specs boot a real obscura serve against a local WEBrick test
site. Point them at your binary:
OBSCURA_BIN=/path/to/obscura bundle exec rspecWithout the binary those specs skip cleanly, so bundle exec rspec still passes.
Contributing
Bug reports and pull requests are welcome at https://github.com/memoxmrdl/obxcura.
License
MIT. See LICENSE.txt.