ea89f050cd Fix puppeteer exposeFunction end-to-end: real bindings, preload timing, URL coercion (#166)
* fix(js): coerce URL constructor input/base to string

The polyfill at bootstrap.js called `base.match(...)` directly, which
threw `TypeError: base.match is not a function` whenever callers passed
a Location object (e.g. `new URL(href, window.location)`), a URL
instance, or any other non-string with a sensible toString. Per the
WHATWG URL Standard both `url` and `base` are stringified before
parsing.

Coerce explicitly so the existing regex paths keep working and the
constructor matches real-browser behaviour. AngularJS, jQuery, and
anything that resolves a relative URL against `window.location` now
loads under Obscura.

* fix(browser,cdp): run preload scripts before page scripts

`Page.addScriptToEvaluateOnNewDocument` was executed AFTER the page's
own scripts (and even after the load event), which silently broke
every CDP contract that depends on early injection — most visibly
puppeteer's `page.exposeFunction`, whose user-facing wrapper has to
override `globalThis[name]` before any `<script>` tag in the document
runs.

Move the registered preload sources onto `Page` and execute them
inside `navigate_single`, right after `__documentReadyState__ =
'loading'` and before the parser-discovered scripts. The CDP layer
hands the current preload set to the page via `set_preload_scripts`
before kicking off navigation, replacing the two post-navigate
`execute_preload_script` loops in `domains/page.rs` and `server.rs`.

* feat(cdp,js): emit Runtime.bindingCalled for addBinding shims

`Runtime.addBinding` was a no-op stub that installed an empty
`globalThis[name] = function () { return null; }` placeholder. Page JS
calls were silently swallowed and the Node-side callback registered
via puppeteer's `page.exposeFunction` never fired.

Wire the binding all the way through:

1. New `op_binding_called(name, payload)` op forwards every page-side
   call into a `pending_binding_calls` queue on `ObscuraState`.
2. `Page::take_pending_binding_calls` drains the queue.
3. `dispatch()` empties every page's queue after each CDP request and
   pushes one `Runtime.bindingCalled` event per entry into
   `ctx.pending_events`, so the writer task forwards them to the
   client like any other CDP event.
4. The `addBinding` handler installs a real shim that calls the op,
   registers it as a preload script (keyed `__obscura_binding__<name>`)
   so it survives navigations, and evaluates it now so the binding is
   usable on the current document.
5. Implement `removeBinding` to mirror `addBinding`.

The shim coerces non-string arguments via `JSON.stringify`, matching
the wire contract that puppeteer's `exposeFunction` wrapper depends
on (`Runtime.bindingCalled.params.payload` is always a string).

* fix(cdp): emit canAccessOpener in all TargetInfo payloads, add aarch64 cross-compile CI

Merges PR #165 and PR #172.

PR #165: The CDP spec marks canAccessOpener as a required boolean on
TargetInfo but Obscura was omitting it. Strict Rust clients like
chromiumoxide reject the event entirely and panic with "Created target
not present" when the field is absent. Added canAccessOpener: false
to every TargetInfo emitted by setDiscoverTargets, getTargets,
createTarget, attachToBrowserTarget, attachToTarget, and getTargetInfo.

PR #172: Added a cross-compile matrix entry for aarch64-unknown-linux-gnu
running on ubuntu-22.04 x86_64, producing obscura-aarch64-linux-cross.
This is a fallback for when GitHub ARM runners are unavailable without
removing the native ARM build.

* fix(cdp): keep HTTP control plane reachable while V8 JS evaluation blocks (#173)

fix(cdp): unblock HTTP control plane during synchronous V8 execution

Fixes #62.

Previously, the TCP accept loop and CDP processor shared a single-threaded
tokio LocalSet. Long synchronous JS evaluations starved the accept loop,
causing incoming connections to hang in the kernel backlog and effectively
killing the HTTP control plane.

This moves the TCP listener and `/json/*` endpoints to a dedicated OS
thread using `std::net::TcpListener`. HTTP requests are now handled
directly via blocking I/O on this thread, completely bypassing V8.

WebSocket connections are handed off to the LocalSet via a bounded mpsc
channel (capacity 128). If V8 stalls and the queue fills, new WS
connections are dropped (ECONNRESET) rather than buffering unboundedly
to prevent OOMs.

Includes a new integration test to verify the control plane stays
responsive during a 5s JS loop, plus inline documentation clarifying
thread lifetime semantics and a minor test TOCTOU race.

* fix(js,dom): add Storage global, CharacterData ChildNode methods, fix remove/isConnected (#176)

fix(dom): add missing DOM/JS shims for modern frameworks

Closes #163.

Adds missing DOM and JS shims required by modern frameworks (Svelte 5, 
Vue, Lit) and consent-management SDKs to execute without errors:

- Define the `Storage` global constructor to resolve `ReferenceError`s 
  and fix `Storage.prototype` access.
- Apply standard ChildNode methods (`before`, `after`, `replaceWith`, 
  `remove`) to `CharacterData.prototype`. This prevents `TypeError`s when 
  frameworks call these on Text or Comment nodes during hydration.
- Update `Element.prototype.isConnected` to perform a genuine parent-walk 
  rather than returning a hardcoded `true`.
- Fix `Element.prototype.remove` to properly clean up the internal ID 
  index so removed elements are no longer queryable.
- Remove outdated no-op stubs on the `Element` class that were shadowing 
  the proper polyfill implementations.

* feat(cli): add --dump assets to inventory page sub-resources (124) (#174)

feat: add `--dump assets` to enumerate page sub-resources

Adds a `--dump assets` flag to output all page-referenced sub-resources 
(scripts, stylesheets, images, iframes, media, embeds, objects) as NDJSON, 
allowing users to easily replay the asset graph.

- Output format: `{"url":"https://example.test/app.js","type":"script"}`
- URLs are resolved against the page origin (matching `--dump links`).
- The `type` tag is derived from the source element. `<link>` elements map 
  their `rel` attribute to specific types (stylesheet, icon, manifest, preload, 
  prefetch, modulepreload, dns-prefetch, preconnect, alternate), falling back 
  to "link" if missing or unknown.
- Adds 7 tests covering URL resolution, rel-mapping, supported selectors, 
  and empty-attribute skipping.
- Updates README dump-format table and examples.

* fix(cdp,js): playwright actionability stubs and objectId resolve (#178)

Playwright fails locator actions on Obscura for two avoidable reasons
that are separate from the missing layout engine. Both have trivial
fixes that unblock the actionability polling loop.

- Element.checkVisibility(opts) was undefined. Playwright >= 1.25 calls
  it before every input event and retries until its action timeout if
  the method isn't a function. Add a stub that returns true. Without a
  layout pass we can't compute it for real, but a permissive stub still
  beats the undefined-function path.

- ARIA reflection properties were missing on Element (role, ariaLabel,
  ariaSelected, ariaExpanded, ariaHidden, ariaChecked, ariaDisabled,
  ariaRoleDescription). Playwright's getByRole and getByLabel locators
  read them. Reflect through to the underlying aria-* attributes for
  both read and write.

- DOM.getBoxModel and DOM.getContentQuads ignored the objectId param.
  Playwright passes only objectId (from a prior DOM.resolveNode), and
  the handlers silently defaulted to node 0, so dispatchMouseEvent
  always clicked the wrong element. Extract a resolve_node_id helper
  that honors nodeId, backendNodeId, and objectId in that order.

- Page.captureScreenshot and Page.captureSnapshot now return a clear
  "not supported, use a real Chromium for the screenshot leg" error
  instead of the generic "Unknown Page method" fallback. Matches the
  pattern already in place for Page.printToPDF.

Partial fix for #45. The architectural blocker (no layout engine, so
multi-element click coordinates are wrong) is unchanged.

* fix(mcp): allow browser MCP clients through CORS preflight (#179)

The OPTIONS preflight hardcoded Access-Control-Allow-Headers to just
Content-Type. Any browser MCP client sending mcp-protocol-version (per
the MCP spec) or Authorization / X-API-Key (typical for hosted
deployments) failed the preflight and the browser blocked the actual
request.

Add those three to the allow-list and a 24h Max-Age so browsers don't
re-preflight every call. Adds an integration test that drives the
server with a real preflight request.

Closes #175.

* fix(js,dom): scope element queries, real timers, form/prepend/equal (#180)

- Element.querySelector / querySelectorAll scope to the receiver subtree
  via a new query_selector_scoped Rust op, not always document root.
  Same for DocumentFragment.
- setTimeout respects its delay argument via a new async op_sleep.
  setInterval is a real repeating timer instead of an alias to setTimeout.
- document.forms / images / links return live querySelectorAll results
  instead of hardcoded [].
- HTMLFormElement subclasses Element with .elements / .length, and form
  nodes are wrapped as HTMLFormElement so instanceof works. submit()
  stays on Element.prototype to preserve the existing submit-event
  dispatch and form-data navigation path.
- Element.prepend has a real implementation.
- Node.isEqualNode does a structural compare (nodeType, nodeName,
  nodeValue, attributes, recursive children) instead of nid identity.
- Fix long-standing insert_before arg-order bug: bootstrap.js was
  passing (parent, new, ref) but _dom only forwards two args, so ref
  was silently dropped. Pass (new, ref) directly. Same fix in
  replaceChild.

Adds 3 selector unit tests and 6 runtime regression tests covering
the JS shim fixes.

Closes #105.

* fix(cdp): unblock puppeteer connect, bound Runtime.evaluate, full TargetInfo (#181)

Three independent gaps that hurt browser-based MCP clients connecting
to obscura's CDP endpoint:

- Runtime.enable returned "No page" when called before a session was
  attached. puppeteer-extra's FrameManager.initialize fires it on the
  browser-level WebSocket BEFORE any page target exists, so the
  standard puppeteer.connect / browser.newPage flow died on connect.
  Now reply with {} when no session is attached and let the next
  Target.attachToTarget emit executionContextCreated.

- Runtime.evaluate had no per-call timeout. A long synchronous JS
  expression held the global V8 lock indefinitely and starved every
  other CDP command on the session. Honor the CDP `timeout` parameter
  and default to 30s (Chrome's protocolTimeout). Returns a clear error
  on expiry so client-side deadlines fire cleanly instead of hanging.

- Target.getTargetInfo for the browser target omitted canAccessOpener.
  Strict CDP clients (chromiumoxide) panic when the field is missing.
  Fill it in (false for the browser target itself).

Partial fix for #122. The remaining items (networkIdle hard-deadline
on SPAs, /json/list hardcoded data, docs on cheap-vs-queued methods)
need bigger or non-code changes and are deferred.

* fix(net,browser): detect response charset, decode non-UTF-8 pages (#182)

obscura was calling String::from_utf8_lossy on every response body, which
silently corrupts every non-UTF-8 page (GBK, Big5, Shift-JIS, Windows-125x,
EUC-KR, ISO-8859-x). Whole language families came through as replacement
characters.

Add an encoding module (obscura-net::encoding) that picks the decoder
the way real browsers do per HTML5 § 8.2.2.4:

  1. HTTP Content-Type charset=... parameter (authoritative when present)
  2. <meta charset> or <meta http-equiv=Content-Type> sniffed from the
     first 1KB of the body (HTML only)
  3. UTF-8 fallback

Use it in:
  - obscura-net Response::text (signature changed: String, not Result)
  - obscura-browser page.rs HTML body decode (the primary scrape path)
  - obscura-browser page.rs JS body decode
  - obscura-browser page.rs CSS body decode

For JS / CSS we deliberately skip the meta sniff: those bodies are not
HTML and any '<meta charset=...>' substring inside them is not a charset
declaration.

Adds 7 unit tests for the encoding module covering header precedence,
quoted charset values, meta charset sniff, http-equiv form, the 1KB
sniff window, and a round-trip decode of GBK bytes. Adds a Python
feature test exercising file:// + meta charset and a live HTTP server
serving Shift_JIS with the wrong meta tag.

Closes #113.

* feat(cdp): implement Network.setCookie + deleteCookies, full field fidelity (#170)

* feat(cdp): implement Network.setCookie + deleteCookies, full field fidelity

Closes #168.

Adds the missing Network.setCookie (singular) and Network.deleteCookies
methods and upgrades the existing Network.setCookies/getCookies handlers
so sameSite, expires, httpOnly, secure, and the url-fallback parameter
all round-trip correctly.

Network and Storage cookie handlers share a single parse_cdp_cookie /
parse_delete_cookies_params helper to avoid drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cdp): fall back to default cookie jar, add Network.getAllCookies

Addresses @SGavrl review on PR #170:

- Network.{set,get,delete}Cookie(s)/clearBrowserCookies now resolve the
  cookie jar via a shared helper that prefers the session page's jar and
  falls back to ctx.default_context.cookie_jar when no target has been
  attached. Mirrors how Storage.setCookies already routes when there is
  no session, so Puppeteer/Playwright flows that issue cookie methods
  before Target.attachToTarget no longer hit "No page".
- Add Network.getAllCookies (separate CDP method per spec); aliases the
  existing getCookies handler so both arms return the full jar.

Tests: 6 new #[tokio::test] cases in domains::network::tests cover the
no-session fallback for set/get/setMany/delete/clear and the new
getAllCookies arm. RED on prod-reverted file (6/6 fail with "No page" /
"Unknown Network method: getAllCookies"), GREEN on patched
(22 -> 30 obscura-cdp lib tests, obscura-net 22/22 baseline preserved).

* fix(net): use bundled webpki roots in stealth client (#184) (#185)

set_default_paths only resolves OpenSSL's compile-time CA dirs, which
  exist on Linux but are empty on Windows. The stealth client's cert store
  ended up empty there and every TLS handshake failed with
  CERTIFICATE_VERIFY_FAILED inside BoringSSL handshake.cc.

  CertStore::default() uses wreq's bundled webpki-root-certs (Mozilla's
  trust list), which behaves the same on every platform and removes the
  dependency on a system CA store.

* fix(js): Element.attributes, on* event defaults, per-nid getBoundingClientRect (#45) (#186)

Three DOM-completeness gaps that broke jQuery 1.11.3 init and Playwright
  actionability:

  1. Element.attributes was undefined, so legacy IE feature-detect code
     like div.attributes[eventName].expando threw TypeError. Added a
     NamedNodeMap-like backed by a new attribute_names DOM op (length,
     numeric index, string name lookup, getNamedItem/setNamedItem,
     iterable).

  2. Standard on* event handler properties were missing from window, so
     ('on' + ev) in window returned false and forced jQuery into the
     legacy IE branch. Initialised the 79 HTML-spec on* handlers to null.

  3. getBoundingClientRect returned the same {x:8,y:8,width:100,height:20}
     literal for every element, so Playwright's hit-testing couldn't tell
     elements apart. Replaced with a deterministic synthesis from _nid
     mapped into a 12-column grid sized to a 1280x720 viewport. Still not
     pixel-accurate (no layout engine), but stable across reads and
     distinct per node.

* feat: cookie jar persistence + --storage-dir flag + fix HTTP cookie header filtering (#114)

* feat: cookie jar persistence + storage-dir + filtering fix (clean v4)

* fix: resolve build errors, add storage_dir param throughout server chain

* fix: storage_dir end-to-end, plus two test-literal field additions

  Five issues found while testing PR 114 end-to-end.

  Build fixes (two test literals out of sync with new struct fields):
  - crates/obscura-net/src/cookies.rs: CookieInfo literal in
    test_domain_matches_subdomain_without_leading_dot was missing the
    same_site and expires fields added in #170.
  - crates/obscura-cli/src/main.rs: Command::Fetch literal in
    matcher_still_uses_fetch_variant was missing the new storage_dir
    field. Without these cargo test --workspace would not compile.

  run_fetch was branching between BrowserContext::with_storage (which
  hardcodes proxy=None, stealth=false, user_agent=None) and
  with_options (which has no storage_dir slot), so --storage-dir +
  --proxy + --stealth silently dropped the proxy and stealth flags.
  Same bug SGavrl flagged on dispatch.rs in the previous round, just on
  the other code path. Switched to with_storage_full which carries all
  four. Verified the fetch now errors out against an invalid proxy.

  run_fetch --eval short-circuited the function before
  context.save_cookies() ran, so cookies set during an eval session
  were never persisted to {storage_dir}/cookies.json. Moved the save
  above the return.

  obscura serve loaded cookies on startup but never saved them
  anywhere, so a session that set cookies via Network.setCookie or
  navigation dropped them on shutdown. Subscribed to Ctrl-C once in
  put a single save_cookies() after the loop so both the Ctrl-C path
  and the channel-closed path persist cookies. Verified end-to-end.

---------

Co-authored-by: zhangyang-crazy-one <zhangyang.crazyone@gmail.com>
Co-authored-by: SGavrl <277843151+SGavrl@users.noreply.github.com>

* fix(cdp): page.goto returns Response over Puppeteer and Playwright (#189) (#190)

Two correlation invariants real Chrome holds were missing, so the
  nav Response was never attached to goto().

  requestId === loaderId for the main Document event. Both clients
  gate isNavigationRequest() on this; we were emitting a synthetic
  per-page id.

  Network.requestWillBeSent for the Document fires before
  Page.frameNavigated. Playwright's FrameManager needs the request
  present as pendingDocument before the frame commits, otherwise
  currentDocument lands with request=undefined.

  Subresource events and the Document's responseReceived /
  loadingFinished stay where they were.

  Verified end-to-end on both clients against example.com (200),
  google.com (200), httpbin/404 (404).

* fix(browser,cdp): run preloads on about:blank, tighten binding shim arity

Two follow-ups from PR #166 review:

- navigate_single's about: branch returned before any preload sources
  ran, so addScriptToEvaluateOnNewDocument scripts and the
  Runtime.addBinding shim never installed on about:blank. Puppeteer
  hits this every session: browser.newPage() lands on about:blank and
  exposeFunction is dead until the first real navigation. Now the
  about: branch runs the preload set after init_js(), guarded by the
  same 5s timeout as regular preloads.

- The Runtime.addBinding shim used JSON.stringify on non-string args
  and silently accepted any arity. Chromium's V8InspectorImpl rejects
  calls without exactly one argument and ToString-coerces that arg
  before emitting Runtime.bindingCalled. Match that: guard on
  arguments.length and use String(arg) for the non-string branch.
  Single-string calls (the path puppeteer's exposeFunction wrapper
  uses) round-trip unchanged.

* fix(cdp): allocate fresh execution context ids per isolated world (#192) (#200)

Page.createIsolatedWorld and do_navigate both hardcoded context id 100
  for every isolated world emission. After the second navigation Playwright
  expected a fresh incrementing id (101, 102) while obscura kept re-using
  100. Runtime.evaluate then failed with 'Cannot find context with
  specified id: 101' because the id was never registered in
  valid_context_ids and the client's bookkeeping had diverged from the
  server's.

  Added next_isolated_context_id counter on CdpContext (starts at 100) and
  a next_isolated_context() helper that claims an id, registers it in
  valid_context_ids, and increments. Both call sites now use it.

* feat(cli): default V8 heap to 4 GB on 64-bit so heavy SPAs don't OOM (#199) (#202)

V8 embedded defaults to ~1.4 GB max-old-space on 64-bit. Pages with
  heavy fingerprinting or analytics bundles (e.g. demo.fingerprint.com/
  web-scraping) blow past it and the worker dies with
  'Fatal JavaScript out of memory' and SIGTRAP, taking the CDP session
  with it.

  Always set --max-old-space-size=4096 (1024 on 32-bit targets) before
  V8 startup, matching headless Chrome's default. User-supplied
  --v8-flags are appended after the default so they win (V8 parses
  left-to-right and the last value wins), which keeps the existing
  escape hatch intact for memory-constrained environments.

---------

Co-authored-by: SGavrl <277843151+SGavrl@users.noreply.github.com>
Co-authored-by: Ben Younes <benyounes.ousama@gmail.com>
Co-authored-by: zhangyang-crazy-one <740884666@qq.com>
2026-05-29 18:20:46 +00:00
2026-04-13 12:30:28 +02:00
2026-04-13 11:20:11 +02:00
2026-05-13 21:38:10 +00:00
2026-04-13 11:20:11 +02:00

Obscura

Obscura

The open-source headless browser for AI agents and web scraping.
Lightweight, stealthy, and built in Rust.


Obscura is a headless browser engine written in Rust, built for web scraping and AI agent automation. It runs real JavaScript via V8, supports the Chrome DevTools Protocol, and acts as a drop-in replacement for headless Chrome with Puppeteer and Playwright.

Why Obscura over headless Chrome?

Designed for automation at scale, not desktop browsing.

Metric Obscura Headless Chrome
Memory 30 MB 200+ MB
Binary size 70 MB 300+ MB
Anti-detect Built-in None
Page load 85 ms ~500 ms
Startup Instant ~2s
Puppeteer Yes Yes
Playwright Yes Yes

🎉 10,000 stars and what's next

I'm working on Obscura Cloud the hosted version, with managed infrastructure, residential proxies, and dedicated support. For people who want the engine without operating it themselves.

The open-source engine stays Apache-2.0, fully featured. No feature gating, ever.

Get on the waitlist →

Install

Download

Grab the latest binary from Releases:

# Linux x86_64
curl -LO https://github.com/h4ckf0r0day/obscura/releases/latest/download/obscura-x86_64-linux.tar.gz
tar xzf obscura-x86_64-linux.tar.gz
./obscura fetch https://example.com --eval "document.title"

# Linux ARM64 (aarch64)
curl -LO https://github.com/h4ckf0r0day/obscura/releases/latest/download/obscura-aarch64-linux.tar.gz
tar xzf obscura-aarch64-linux.tar.gz

# Arch Linux (AUR)
yay -S obscura-browser

# macOS Apple Silicon
curl -LO https://github.com/h4ckf0r0day/obscura/releases/latest/download/obscura-aarch64-macos.tar.gz
tar xzf obscura-aarch64-macos.tar.gz

# macOS Intel
curl -LO https://github.com/h4ckf0r0day/obscura/releases/latest/download/obscura-x86_64-macos.tar.gz
tar xzf obscura-x86_64-macos.tar.gz

# Windows
Download the `.zip` from the releases page and extract it manually.

No Chrome, no Node.js, no dependencies. Release archives include both obscura and obscura-worker; keep them in the same directory for the parallel scrape command.

Linux release builds target Ubuntu 22.04 so the downloaded binary remains usable on common LTS servers with glibc 2.35+.

Docker

docker run -d --name obscura -p 127.0.0.1:9222:9222 h4ckf0r0day/obscura

Image on Docker Hub. Multi-stage build on distroless/cc, no shell, no package manager, ~57 MB compressed.

Build from source

git clone https://github.com/h4ckf0r0day/obscura.git
cd obscura
cargo build --release

# With stealth mode (anti-detection + tracker blocking)
cargo build --release --features stealth

Requires Rust 1.75+ (rustup.rs). First build takes ~5 min (V8 compiles from source, cached after).

Quick Start

Fetch a page

# Get the page title
obscura fetch https://example.com --eval "document.title"

# Extract all links
obscura fetch https://example.com --dump links

# Render JavaScript and dump HTML
obscura fetch https://news.ycombinator.com --dump html

# Write dump or eval output to a file
obscura fetch https://example.com --dump text --output page.txt

# Stream the raw response body verbatim (binary-safe; bypasses the JS/DOM layer).
# Use this for images, JSON, JS, CSS, or any non-HTML resource.
obscura fetch https://picsum.photos/200/300 --dump original > photo.jpg

# List every sub-resource URL the page would fetch (NDJSON; one record per asset)
obscura fetch https://example.com --dump assets

# Fetch through an HTTP or SOCKS proxy
obscura --proxy socks5://127.0.0.1:1080 fetch https://example.com --dump text

# Wait for dynamic content
obscura fetch https://example.com --wait-until networkidle0

# Bound navigation time for slow or broken pages
obscura fetch https://example.com --timeout 10

Start the CDP server

obscura serve --port 9222

# With stealth mode (anti-detection + tracker blocking)
obscura serve --port 9222 --stealth

Scrape in parallel

obscura scrape url1 url2 url3 ... \
  --concurrency 25 \
  --eval "document.querySelector('h1').textContent" \
  --format json

# Suppress scrape progress on stderr for script-friendly output
obscura scrape https://example.com --quiet --format json

# Scrape workers inherit the global proxy
obscura --proxy http://127.0.0.1:8080 scrape https://example.com https://news.ycombinator.com

Puppeteer / Playwright

Puppeteer

npm install puppeteer-core
import puppeteer from 'puppeteer-core';

const browser = await puppeteer.connect({
  browserWSEndpoint: 'ws://127.0.0.1:9222/devtools/browser',
});

const page = await browser.newPage();
await page.goto('https://news.ycombinator.com');

const stories = await page.evaluate(() =>
  Array.from(document.querySelectorAll('.titleline > a'))
    .map(a => ({ title: a.textContent, url: a.href }))
);
console.log(stories);

await browser.disconnect();

Playwright

npm install playwright-core
import { chromium } from 'playwright-core';

const browser = await chromium.connectOverCDP({
  endpointURL: 'ws://127.0.0.1:9222',
});

const page = await browser.newContext().then(ctx => ctx.newPage());
await page.goto('https://en.wikipedia.org/wiki/Web_scraping');
console.log(await page.title());

await browser.close();

Form submission & login

await page.goto('https://quotes.toscrape.com/login');
await page.evaluate(() => {
  document.querySelector('#username').value = 'admin';
  document.querySelector('#password').value = 'admin';
  document.querySelector('form').submit();
});
// Obscura handles the POST, follows the 302 redirect, maintains cookies

Benchmarks

Page load:

Page Obscura Chrome
Static HTML 51 ms ~500 ms
JS + XHR + fetch 84 ms ~800 ms
Dynamic scripts 78 ms ~700 ms

Stealth Mode

Enable with --features stealth.

Anti-fingerprinting

  • Per-session fingerprint randomization (GPU, screen, canvas, audio, battery)
  • Realistic navigator.userAgentData (Chrome 145, high-entropy values)
  • event.isTrusted = true for dispatched events
  • Hidden internal properties (Object.keys(window) safe)
  • Native function masking (Function.prototype.toString() → [native code])
  • navigator.webdriver = undefined (matches real Chrome)

Tracker Blocking

  • 3,520 domains blocked
  • Blocks analytics, ads, telemetry, and fingerprinting scripts
  • Prevents trackers from loading entirely
  • Enabled automatically with --stealth

CDP API

Obscura implements the Chrome DevTools Protocol for Puppeteer/Playwright compatibility.

Domain Methods
Target createTarget, closeTarget, attachToTarget, createBrowserContext, disposeBrowserContext
Page navigate, getFrameTree, addScriptToEvaluateOnNewDocument, lifecycleEvents
Runtime evaluate, callFunctionOn, getProperties, addBinding
DOM getDocument, querySelector, querySelectorAll, getOuterHTML, resolveNode
Network enable, setCookies, getCookies, setExtraHTTPHeaders, setUserAgentOverride
Fetch enable, continueRequest, fulfillRequest, failRequest (live interception)
Storage getCookies, setCookies, deleteCookies
Input dispatchMouseEvent, dispatchKeyEvent
LP getMarkdown (DOM-to-Markdown conversion)

CLI Reference

Tuning V8

Obscura embeds V8 directly. Use --v8-flags to pass raw flags through to V8, same syntax as Chromium's --js-flags and Node's command-line flags. Most common use is raising the heap cap to fix JavaScript heap out of memory on JS-heavy pages:

obscura --v8-flags "--max-old-space-size=4096" fetch <url>

obscura serve

Start a CDP WebSocket server.

Flag Default Description
--port 9222 WebSocket port
--proxy — HTTP/SOCKS5 proxy URL
--stealth off Enable anti-detection + tracker blocking
--workers 1 Number of parallel worker processes
--obey-robots off Respect robots.txt

obscura fetch <URL>

Fetch and render a single page.

Flag Default Description
--dump html Output: html, text, links, markdown, assets (NDJSON of every sub-resource URL the page references), or original (raw response body)
--eval — JavaScript expression to evaluate
--wait-until load Wait: load, domcontentloaded, networkidle0
--timeout 30 Maximum navigation time in seconds
--selector — Wait for CSS selector
--stealth off Anti-detection mode
--output — Write dump or eval output to a file
--quiet off Suppress banner
--proxy — Inherited global HTTP/SOCKS5 proxy URL

obscura scrape <URL...>

Scrape multiple URLs in parallel with worker processes.

Flag Default Description
--concurrency 10 Parallel workers
--eval — JS expression per page
--format json Output: json or text
--quiet off Suppress scrape progress on stderr
--proxy — Inherited global HTTP/SOCKS5 proxy URL for all workers

MCP (Model Context Protocol)

Obscura ships an MCP server that exposes browser automation tools to AI agents (Claude Desktop, Cursor, etc.).

Start

stdio (default) — for Claude Desktop and MCP clients that launch a subprocess:

obscura mcp

HTTP — for clients that connect over the network:

obscura mcp --http --port 8080
# endpoint: http://127.0.0.1:8080/mcp

Optional flags (both transports):

Flag Description
--proxy <URL> HTTP/SOCKS5 proxy
--user-agent <UA> Custom User-Agent string
--stealth Enable anti-detection mode

Claude Desktop config

{
  "mcpServers": {
    "obscura": {
      "command": "obscura",
      "args": ["mcp"]
    }
  }
}

Tools

Tool Description
browser_navigate Navigate to a URL (url, optional waitUntil: load / domcontentloaded / networkidle0)
browser_snapshot Return the current page URL, title, and body text
browser_click Click an element by CSS selector
browser_fill Set an input value (triggers input + change events)
browser_type Append text to an input
browser_press_key Dispatch a keyboard event (key, optional selector)
browser_select_option Select an <option> by value or text
browser_evaluate Evaluate a JavaScript expression and return the result
browser_wait_for Wait for a CSS selector to appear (selector, optional timeout in seconds)
browser_network_requests List network requests made by the current page
browser_console_messages Return console messages logged by the page
browser_close Close the page and reset browser state

License

Apache 2.0


S
Description
Obscura browser engine source used by Naori, based on h4ckf0r0day/obscura
Readme
24 MiB
0 Stars 1 Watchers 0 Forks
Languages
Rust 94.7%
Python 3.6%
HTML 1.4%
JavaScript 0.2%