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:
12 files changed
+781
-140
No files matched your search
@@ -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});\
|
||||
|
||||
@@ -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"
|
||||
@@ -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`).
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user