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 obxcura
You also need the obscura binary on your PATH
(releases).
Usage
Start the browser first (defaults to port 9222):
obscura serve
Then:
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.quit
Querying 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") # => nil
Running 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; }
JS
POSTing from inside the page
Obscura routes XMLHttpRequest but not fetch, so #xhr_post runs the POST
from the page's context (reusing its cookies). Values cross as arguments:
result = page.xhr_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 text
Any 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. Some anti-bot endpoints tarpit non-stealth clients — try
obscura serve --stealth.
API
Obxcura.start(**opts) is sugar for Obxcura::Browser.new.
Obxcura::Browser—.new(host:, port:, timeout:),#create_page(url),#go_to/#goto,#targets,#version,#command,#close/#quit. Readers:#client,#pages,#host,#port.Obxcura::Page—#goto/#go_to,#evaluate,#evaluate_func,#html/#body,#title,#current_url,#at_css,#css,#xhr_post,#cookies,#refresh/#reload,#command,#close,#close_connection. Readers:#frame,#target_id,#session_id,#client.Obxcura::Node(from#at_css/#css) —#text,#[](attribute),#outer_html,#object_id.Obxcura::Frame— the main frame behind a Page; carries the DOM/Runtime methods Page delegates (#evaluate,#at_css,#read_string, …).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. - ~500–700 KB message ceiling. Obscura won't send a single CDP message
larger than that.
Page#htmlworks around it by snapshottingouterHTMLinto a page global and pulling it back in 400 KB slices. Don't return giant values from#evaluatedirectly. - 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. - XHR, not fetch. Obscura routes
XMLHttpRequestbut notfetch, so#xhr_postuses XHR from the page context. It also ignoresXMLHttpRequest#timeout, so the effective bound is the CDP-leveltimeout:. - Persistent cookies.
obscura serveis long-lived and its cookie jar survives#quit(that only drops the WebSocket). Read them withpage.cookies; closing a page clears the browser's cookie jar. - Private networks are blocked by default. To drive a local site, start the
browser with
obscura serve --allow-private-network.
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 loaded
The 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 rspec
Without 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.