feat(api): add obscura-api crate with Browser + Page + Element + CookieStore (#191)

* feat(api): add obscura-api crate with Browser + Page API

Adds a Rust library crate exposing Obscura as a programmable browser:
- Browser: build with config (proxy, stealth, UA, storage_dir)
- Page: goto, url, evaluate, content
- BrowserBuilder with fluent API
- examples/basic.rs

Part of #169

* feat(api): add Element API + CookieStore + wait_for_selector

- Element: text(), attribute(), click() via JS evaluation
- CookieStore: set/get_all/get_for_url/save_to_file/load_from_file
- Page::wait_for_selector with 100ms polling
- Page::query_selector for single element lookup
- Updated examples/basic.rs with full interaction demo

Part of #169

* feat(api): add Browser::cookies() method

* refactor(api): move to obscura crate with feature flag

- Renamed obscura-api → obscura (per maintainer feedback #169)
- API enabled by default via 'api' feature flag
- Usage: cargo add obscura (default=api) or cargo add obscura --features api
- Removed separate obscura-api crate

* chore: exclude docker workflow from fork

* fix(api): make the obscura crate build, run, and document install correctly

- examples/basic.rs and the lib.rs doctest imported `obscura_api`, but the crate
  is named `obscura`, so both failed to compile. Use the real crate name.
- wait_for_selector / query_selector read the node id with `Value::as_u64`, but
  `evaluate` returns JS numbers as f64, so it was always None and every
  wait_for_selector timed out. Accept an integer or a non-negative finite float.
- restore .github/workflows/docker.yml, which the PR removed (a fork-specific
  change that should not land upstream).
- add crates/obscura/README.md with honest install instructions: the crate is not
  on crates.io, so depend on it via git (and it builds the embedded V8 from
  source). The `cargo add obscura` form does not work yet.

Verified: workspace + example build, the lib doctest passes, the example runs end
to end (goto, wait_for_selector, text, attribute), and the integration suite is
28/28 with no regressions.

---------

Co-authored-by: zhangyang-crazy-one <[email protected]>
Co-authored-by: SGavrl <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-06-04 18:20:51 +00:00
1 parent a703b7ac30
commit 2e731a2ab9
12 files changed
+781 -140

No files matched your search

+8 -2
View File
@@ -167,7 +167,10 @@ pub async fn handle(
"removeNode" => Ok(json!({})),
"getBoxModel" => {
let page = ctx.get_session_page_mut(session_id).ok_or("No page")?;
let node_id = resolve_node_id(page, params)?;
let node_id = match resolve_node_id(page, params) {
Ok(nid) => nid,
Err(_) => return Ok(json!(null)),
};
let code = format!(
"(function() {{\
var el = globalThis._wrap && globalThis._wrap({0});\
@@ -202,7 +205,10 @@ pub async fn handle(
}
"getContentQuads" => {
let page = ctx.get_session_page_mut(session_id).ok_or("No page")?;
let node_id = resolve_node_id(page, params)?;
let node_id = match resolve_node_id(page, params) {
Ok(nid) => nid,
Err(_) => return Ok(json!(null)),
};
let code = format!(
"(function() {{\
var el = globalThis._wrap && globalThis._wrap({0});\
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "obscura"
version = "0.1.0"
edition = "2021"
description = "Rust API for the Obscura headless browser"
readme = "README.md"
repository = "https://github.com/h4ckf0r0day/obscura"
license = "Apache-2.0"
[features]
default = ["api"]
api = ["obscura-browser", "obscura-net", "tokio"]
[dependencies]
obscura-browser = { path = "../obscura-browser", optional = true }
obscura-net = { path = "../obscura-net", optional = true }
anyhow = "1"
tokio = { version = "1", features = ["rt"], optional = true }
thiserror = "1"
serde_json = "1"
serde = { version = "1", features = ["derive"] }
url = "2"
+43
View File
@@ -0,0 +1,43 @@
# obscura
Embeddable Rust API for the [Obscura](https://github.com/h4ckf0r0day/obscura)
headless browser. Drive a real V8 + DOM browser (`Browser`, `Page`, `Element`,
`CookieStore`) directly from Rust, with no separate process or CDP round-trips.
## Install
This crate is not published to crates.io, so depend on it via git. Building it
compiles Obscura from source, including its embedded V8 (`deno_core`), so the
first build is large and slow.
```toml
[dependencies]
obscura = { git = "https://github.com/h4ckf0r0day/obscura", features = ["api"] }
tokio = { version = "1", features = ["rt", "macros"] }
anyhow = "1"
```
## Usage
```rust,no_run
use obscura::Browser;
use std::time::Duration;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let browser = Browser::builder()
.stealth(true)
.storage_dir("/tmp/cookies")
.build()?;
let mut page = browser.new_page().await?;
page.goto("https://example.com").await?;
let el = page.wait_for_selector("a", Duration::from_secs(5)).await?;
println!("{} -> {:?}", el.text(), el.attribute("href"));
Ok(())
}
```
See `examples/basic.rs` for a runnable version (`cargo run --example basic`).
+22
View File
@@ -0,0 +1,22 @@
/// Full example: launch, navigate, interact, check cookies.
use obscura::Browser;
use std::time::Duration;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let browser = Browser::builder()
.stealth(true)
.storage_dir("/tmp/obscura-api-test")
.build()?;
let mut page = browser.new_page().await?;
page.goto("https://example.com").await?;
println!("URL: {}", page.url());
println!("Content length: {}", page.content().len());
let el = page.wait_for_selector("a", Duration::from_secs(5)).await?;
println!("Link text: {}", el.text());
println!("Link href: {:?}", el.attribute("href"));
Ok(())
}
+104
View File
@@ -0,0 +1,104 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use obscura_browser::BrowserContext;
use obscura_net::CookieJar;
use crate::config::BrowserConfig;
use crate::cookie::CookieStore;
use crate::error::Error;
use crate::page::Page;
static NEXT_PAGE_ID: AtomicU64 = AtomicU64::new(1);
pub struct Browser {
context: Arc<BrowserContext>,
cookie_jar: Arc<CookieJar>,
}
impl Browser {
pub fn new() -> Result<Self, Error> {
Self::build(BrowserConfig::default())
}
pub fn build(config: BrowserConfig) -> Result<Self, Error> {
let cookie_jar = Arc::new(CookieJar::new());
if let Some(ref dir) = config.storage_dir {
let cookie_path = dir.join("cookies.json");
if cookie_path.exists() {
let _ = cookie_jar.load_from_file(&cookie_path);
}
}
let context = if let Some(ref dir) = config.storage_dir {
BrowserContext::with_storage_full(
"api".to_string(),
config.proxy,
config.stealth,
config.user_agent,
Some(dir.clone()),
)
} else {
BrowserContext::with_full_options(
"api".to_string(),
config.proxy,
config.stealth,
config.user_agent,
)
};
Ok(Browser {
context: Arc::new(context),
cookie_jar,
})
}
pub fn builder() -> BrowserBuilder {
BrowserBuilder::default()
}
pub async fn new_page(&self) -> Result<Page, Error> {
let id = NEXT_PAGE_ID.fetch_add(1, Ordering::Relaxed);
let page = obscura_browser::Page::new(
format!("page-{}", id),
self.context.clone(),
);
Ok(Page {
inner: page,
context: self.context.clone(),
})
}
/// Access the cookie store for this browser session.
pub fn cookies(&self) -> CookieStore {
CookieStore::new(self.cookie_jar.clone())
}
}
#[derive(Default)]
pub struct BrowserBuilder {
config: BrowserConfig,
}
impl BrowserBuilder {
pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
self.config.proxy = Some(proxy.into());
self
}
pub fn stealth(mut self, stealth: bool) -> Self {
self.config.stealth = stealth;
self
}
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.config.user_agent = Some(ua.into());
self
}
pub fn storage_dir(mut self, dir: impl Into<std::path::PathBuf>) -> Self {
self.config.storage_dir = Some(dir.into());
self
}
pub fn build(self) -> Result<Browser, Error> {
Browser::build(self.config)
}
}
+61
View File
@@ -0,0 +1,61 @@
use std::path::PathBuf;
/// Configuration for launching a Browser instance.
pub struct BrowserConfig {
/// Proxy URL (e.g., "socks5://127.0.0.1:1080")
pub proxy: Option<String>,
/// Enable stealth mode (fingerprint spoofing)
pub stealth: bool,
/// Custom User-Agent string
pub user_agent: Option<String>,
/// Directory for persistent cookie storage
pub storage_dir: Option<PathBuf>,
}
impl Default for BrowserConfig {
fn default() -> Self {
Self {
proxy: None,
stealth: false,
user_agent: None,
storage_dir: None,
}
}
}
impl BrowserConfig {
pub fn builder() -> BrowserConfigBuilder {
BrowserConfigBuilder::default()
}
}
#[derive(Default)]
pub struct BrowserConfigBuilder {
config: BrowserConfig,
}
impl BrowserConfigBuilder {
pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
self.config.proxy = Some(proxy.into());
self
}
pub fn stealth(mut self, stealth: bool) -> Self {
self.config.stealth = stealth;
self
}
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.config.user_agent = Some(ua.into());
self
}
pub fn storage_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.config.storage_dir = Some(dir.into());
self
}
pub fn build(self) -> BrowserConfig {
self.config
}
}
+96
View File
@@ -0,0 +1,96 @@
use std::sync::Arc;
use obscura_net::CookieJar;
use serde::{Deserialize, Serialize};
/// A cookie as exposed to the Rust API.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cookie {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
pub secure: bool,
pub http_only: bool,
}
impl Cookie {
/// Create a cookie from name=value pair with defaults.
pub fn new(name: impl Into<String>, value: impl Into<String>, domain: impl Into<String>) -> Self {
Self {
name: name.into(),
value: value.into(),
domain: domain.into(),
path: "/".into(),
secure: false,
http_only: false,
}
}
}
/// Cookie management for a browser session.
pub struct CookieStore {
jar: Arc<CookieJar>,
}
impl CookieStore {
pub(crate) fn new(jar: Arc<CookieJar>) -> Self {
Self { jar }
}
/// Set a cookie via Set-Cookie header string.
///
/// Example: `store.set("session=abc123; Domain=example.com; Path=/; HttpOnly")?;`
pub fn set(&self, set_cookie_str: &str, url: &str) -> Result<(), crate::error::Error> {
let parsed = url::Url::parse(url)
.map_err(|e| crate::error::Error::Internal(e.into()))?;
self.jar.set_cookie(set_cookie_str, &parsed);
Ok(())
}
/// Get all cookies as a serializable list.
pub fn get_all(&self) -> Vec<Cookie> {
self.jar.get_all_cookies()
.into_iter()
.map(|c| Cookie {
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
secure: c.secure,
http_only: c.http_only,
})
.collect()
}
/// Get cookies for a specific URL.
pub fn get_for_url(&self, url: &str) -> Result<Vec<Cookie>, crate::error::Error> {
let parsed = url::Url::parse(url)
.map_err(|e| crate::error::Error::Internal(e.into()))?;
let header = self.jar.get_cookie_header(&parsed);
Ok(header
.split("; ")
.filter(|s| !s.is_empty())
.filter_map(|pair| {
let mut parts = pair.splitn(2, '=');
Some(Cookie {
name: parts.next()?.to_string(),
value: parts.next().unwrap_or("").to_string(),
domain: parsed.host_str()?.to_string(),
path: "/".into(),
secure: false,
http_only: false,
})
})
.collect())
}
/// Save cookies to a file (JSON format).
pub fn save_to_file(&self, path: &std::path::Path) -> Result<(), crate::error::Error> {
self.jar.save_to_file(path).map_err(|e| crate::error::Error::Internal(e.into()))
}
/// Load cookies from a file.
pub fn load_from_file(&self, path: &std::path::Path) -> Result<usize, crate::error::Error> {
self.jar.load_from_file(path).map_err(|e| crate::error::Error::Internal(e.into()))
}
}
+22
View File
@@ -0,0 +1,22 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("navigation error: {0}")]
Navigation(String),
#[error("JS evaluation error: {0}")]
JsEval(String),
#[error("timeout: {0}")]
Timeout(String),
#[error("element not found: {0}")]
ElementNotFound(String),
#[error("no page session")]
NoPage,
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
+28
View File
@@ -0,0 +1,28 @@
//! Rust API for the Obscura headless browser.
//!
//! ```rust,no_run
//! use obscura::Browser;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let browser = Browser::builder()
//! .stealth(true)
//! .build()?;
//! let mut page = browser.new_page().await?;
//! page.goto("https://example.com").await?;
//! println!("Content: {} bytes", page.content().len());
//! Ok(())
//! }
//! ```
mod browser;
mod config;
mod cookie;
mod error;
mod page;
pub use browser::Browser;
pub use config::BrowserConfig;
pub use cookie::{Cookie, CookieStore};
pub use error::Error;
pub use page::Page;
+137
View File
@@ -0,0 +1,137 @@
use std::sync::Arc;
use std::time::Duration;
use obscura_browser::lifecycle::WaitUntil;
use obscura_browser::{BrowserContext, Page as InnerPage};
use serde_json::Value;
use crate::error::Error;
/// Read a DOM node id from a JS `evaluate` result. obscura serializes JS numbers
/// as f64, so `Value::as_u64` returns None for an integer-valued result; accept
/// either an integer or a non-negative finite float. null / non-numbers -> None.
fn nid_from_value(v: &Value) -> Option<u64> {
v.as_u64()
.or_else(|| v.as_f64().filter(|f| f.is_finite() && *f >= 0.0).map(|f| f as u64))
}
/// A browser tab/page.
pub struct Page {
pub(crate) inner: InnerPage,
pub(crate) context: Arc<BrowserContext>,
}
impl Page {
/// Navigate to URL and wait for load.
pub async fn goto(&mut self, url: &str) -> Result<(), Error> {
self.inner
.navigate_with_wait(url, WaitUntil::Load)
.await
.map_err(|e| Error::Navigation(e.to_string()))
}
/// Get current URL.
pub fn url(&self) -> String {
self.inner.url_string()
}
/// Execute JS in the page.
pub fn evaluate(&mut self, expression: &str) -> Value {
self.inner.evaluate(expression)
}
/// Get page HTML content.
pub fn content(&mut self) -> String {
let val = self.evaluate("document.documentElement.outerHTML");
val.as_str().unwrap_or("").to_string()
}
/// Query a single element by CSS selector.
pub fn query_selector(&mut self, selector: &str) -> Option<Element> {
let escaped = selector.replace('\\', "\\\\").replace('\'', "\\'");
let js = format!(
"(function() {{ var el = document.querySelector('{}'); return el ? el._nid : null; }})()",
escaped
);
let val = self.evaluate(&js);
nid_from_value(&val).map(|nid| Element { node_id: nid, page: self as *const Page })
}
/// Wait for CSS selector to appear (polls every 100ms).
pub async fn wait_for_selector(
&mut self,
selector: &str,
timeout: Duration,
) -> Result<Element, Error> {
let start = std::time::Instant::now();
let escaped = selector.replace('\\', "\\\\").replace('\'', "\\'");
loop {
let js = format!(
"(function() {{ var el = document.querySelector('{}'); return el ? el._nid : null; }})()",
escaped
);
let val = self.evaluate(&js);
if let Some(nid) = nid_from_value(&val) {
return Ok(Element { node_id: nid, page: self as *const Page });
}
if start.elapsed() > timeout {
return Err(Error::Timeout(format!(
"wait_for_selector({}) timed out after {}ms",
selector,
timeout.as_millis()
)));
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
}
/// Handle to a DOM element.
///
/// Created via [`Page::query_selector`] or [`Page::wait_for_selector`].
pub struct Element {
node_id: u64,
page: *const Page,
}
impl Element {
/// Get text content of this element.
pub fn text(&self) -> String {
let page = unsafe { &mut *(self.page as *mut Page) };
let val = page.evaluate(&format!(
"(function() {{ var el = globalThis._wrap && globalThis._wrap({}); return el ? el.textContent : ''; }})()",
self.node_id
));
val.as_str().unwrap_or("").to_string()
}
/// Get an attribute value.
pub fn attribute(&self, name: &str) -> Option<String> {
let page = unsafe { &mut *(self.page as *mut Page) };
let val = page.evaluate(&format!(
"(function() {{ var el = globalThis._wrap && globalThis._wrap({}); return el ? el.getAttribute('{}') : null; }})()",
self.node_id, name
));
if val.is_null() { None } else { Some(val.as_str().unwrap_or("").to_string()) }
}
/// Click this element.
pub fn click(&self) -> Result<(), Error> {
let page = unsafe { &mut *(self.page as *mut Page) };
// Scroll into view
page.evaluate(&format!(
"(function() {{ var el = globalThis._wrap && globalThis._wrap({}); if (el) el.scrollIntoView({{block:'center'}}); }})()",
self.node_id
));
// Click
let result = page.evaluate(&format!(
"(function() {{ var el = globalThis._wrap && globalThis._wrap({}); if (el) {{ el.click(); return true; }} return false; }})()",
self.node_id
));
if result.as_bool().unwrap_or(false) {
Ok(())
} else {
Err(Error::ElementNotFound("click failed".into()))
}
}
}