[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"project-81851":3},{"id":4,"name":5,"fullName":6,"owner":7,"repo":5,"description":8,"homepage":9,"htmlUrl":10,"language":11,"languages":10,"totalLinesOfCode":10,"stars":12,"forks":13,"watchers":12,"openIssues":14,"contributorsCount":15,"subscribersCount":15,"size":15,"stars1d":15,"stars7d":15,"stars30d":15,"stars90d":15,"forks30d":15,"starsTrendScore":15,"compositeScore":16,"rankGlobal":10,"rankLanguage":10,"license":17,"archived":18,"fork":18,"defaultBranch":19,"hasWiki":20,"hasPages":18,"topics":21,"createdAt":10,"pushedAt":10,"updatedAt":22,"readmeContent":23,"aiSummary":24,"trendingCount":15,"starSnapshotCount":15,"syncStatus":25,"lastSyncTime":26,"discoverSource":27},81851,"ferrous-browser","theoxfaber\u002Fferrous-browser","theoxfaber","Fast, async Rust browser automation via the Chrome DevTools Protocol — no Node.js required.","",null,"Rust",25,3,1,0,1.81,"MIT License",false,"main",true,[],"2026-06-12 02:04:20","# ferrous-browser\n\n**Fast, async Rust browser automation via the Chrome DevTools Protocol — no Node.js required.**\n\n[![Crates.io](https:\u002F\u002Fimg.shields.io\u002Fcrates\u002Fv\u002Fferrous-browser.svg)](https:\u002F\u002Fcrates.io\u002Fcrates\u002Fferrous-browser)\n[![License: MIT OR Apache-2.0](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002Flicense-MIT%20OR%20Apache--2.0-blue.svg)](LICENSE-MIT)\n[![Build](https:\u002F\u002Fimg.shields.io\u002Fgithub\u002Factions\u002Fworkflow\u002Fstatus\u002Ftheoxfaber\u002Fferrous-browser\u002Fci.yml?branch=main)](https:\u002F\u002Fgithub.com\u002Ftheoxfaber\u002Fferrous-browser\u002Factions)\n\n---\n\n## Why ferrous-browser?\n\nEvery Rust browser-automation library either wraps Node.js (slow, heavy) or is unmaintained. ferrous-browser is a pure-Rust, async-first CDP client with:\n\n- **Zero Node.js** — pure Rust, ships as a single binary\n- **Async-first** — built on Tokio; naturally integrates with any async Rust project\n- **Correct multi-page isolation** — CDP session IDs are tracked; concurrent pages don't cross-contaminate events\n- **Race-condition-free** — event handlers are registered *before* the commands that trigger them\n- **Ergonomic API** — Playwright-inspired `locator()`, `evaluate()`, `WaitUntil`\n\n---\n\n## Installation\n\n```toml\n[dependencies]\nferrous-browser = \"0.1\"\ntokio = { version = \"1\", features = [\"full\"] }\n```\n\nRequires **Google Chrome** or **Chromium** installed locally.\n\n---\n\n## Quick start\n\n```rust\nuse ferrous_browser::{Browser, BrowserConfig, WaitUntil};\n\n#[tokio::main]\nasync fn main() -> Result\u003C(), Box\u003Cdyn std::error::Error>> {\n    \u002F\u002F Launch Chrome automatically (headless by default)\n    let browser = Browser::launch_chrome(None).await?;\n    let page = browser.new_page().await?;\n\n    page.goto(\"https:\u002F\u002Fexample.com\", WaitUntil::Load).await?;\n\n    \u002F\u002F Locator API\n    let heading = page.locator(\"h1\").inner_text().await?;\n    println!(\"Heading: {heading}\");\n\n    \u002F\u002F Raw JS evaluation\n    let title: String = page.evaluate(\"document.title\").await?;\n    println!(\"Title: {title}\");\n\n    \u002F\u002F Screenshot to file\n    let png = page.screenshot().await?;\n    std::fs::write(\"screenshot.png\", png)?;\n\n    Ok(())\n}\n```\n\n---\n\n## Navigation wait modes\n\n```rust\n\u002F\u002F Wait for DOM parsed (fast, sub-resources still loading)\npage.goto(url, WaitUntil::DomContentLoaded).await?;\n\n\u002F\u002F Wait for all resources loaded (default)\npage.goto(url, WaitUntil::Load).await?;\n\n\u002F\u002F Wait until no network activity for 500 ms (best for SPAs)\npage.goto(url, WaitUntil::NetworkIdle).await?;\n```\n\n---\n\n## Locator API\n\n```rust\nlet page = browser.new_page().await?;\npage.goto(\"https:\u002F\u002Fexample.com\", WaitUntil::Load).await?;\n\n\u002F\u002F Click\npage.locator(\"button#submit\").click().await?;\n\n\u002F\u002F Type\npage.locator(\"input[name=q]\").type_text(\"ferrous browser\").await?;\n\n\u002F\u002F Wait until visible\npage.locator(\".result-list\").wait_for().await?;\n\n\u002F\u002F Read text \u002F attribute\nlet text = page.locator(\"h1\").inner_text().await?;\nlet href = page.locator(\"a.main\").get_attribute(\"href\").await?;\n```\n\n---\n\n## Evaluate JavaScript\n\n```rust\nlet count: u64 = page.evaluate(\"document.querySelectorAll('a').length\").await?;\nlet is_logged_in: bool = page.evaluate(\"!!document.cookie.includes('session')\").await?;\nlet title: String = page.evaluate(\"document.title\").await?;\n```\n\n---\n\n## Browser configuration\n\n```rust\nuse ferrous_browser::{Browser, BrowserConfig};\nuse std::time::Duration;\n\nlet config = BrowserConfig {\n    headless: false,                        \u002F\u002F visible window\n    timeout: Duration::from_secs(60),       \u002F\u002F startup timeout\n    viewport: (1920, 1080),                 \u002F\u002F window size\n    args: vec![\"--disable-extensions\".to_string()],\n};\n\nlet browser = Browser::launch_chrome(Some(config)).await?;\n```\n\n| Field | Default | Description |\n|-------|---------|-------------|\n| `headless` | `true` | Headless mode |\n| `timeout` | 30 s | Chrome startup deadline |\n| `viewport` | 1280×720 | Window size in logical pixels |\n| `args` | `[]` | Extra Chrome CLI flags |\n\n---\n\n## Error handling\n\nEvery error carries structured context — no more \"something went wrong\":\n\n```rust\nuse ferrous_browser::{BrowserError, ResultExt};\n\nmatch page.goto(\"https:\u002F\u002Fbad-url\", WaitUntil::Load).await {\n    Err(BrowserError::NavigationFailed { url, reason }) =>\n        eprintln!(\"Navigation to {url} failed: {reason}\"),\n    Err(BrowserError::Timeout { operation, secs }) =>\n        eprintln!(\"{operation} timed out after {secs}s\"),\n    Err(e) => eprintln!(\"Error: {e}\"),\n    Ok(_) => {}\n}\n```\n\n`.context()` for chaining context onto any `Result`:\n\n```rust\npage.goto(\"https:\u002F\u002Fexample.com\", WaitUntil::Load)\n    .await\n    .context(\"loading homepage\")?;\n```\n\n---\n\n## Benchmarks\n\nApples-to-apples, **same Chrome binary** (Chrome for Testing 131.0.6778.204), same machine, same Linux host, headless, warm browser unless noted, 20 iterations per metric, 3 runs, median of medians. Bench harnesses for every library live under [`bench\u002F`](bench\u002F); feel free to reproduce.\n\n| Operation | ferrous-browser | Puppeteer | Playwright | chromiumoxide | headless_chrome |\n|-----------|----------------:|----------:|-----------:|--------------:|----------------:|\n| `launch_chrome` (cold) | 357 ms | 162 ms | **93 ms** | 134 ms | 239 ms |\n| `new_page` (warm browser) | **14 ms** | 23 ms | 28 ms | 24 ms | 517 ms³ |\n| `goto` (`about:blank`, warm) | 6.2 ms | 5.1 ms | 4.7 ms | **4.3 ms** | 2137 ms³ |\n| `screenshot` (PNG) | **37 ms** | 41 ms | 50 ms | 38 ms | 120 ms |\n| `evaluate` (`document.title`) | 0.22 ms | 0.45 ms | 0.79 ms | **0.18 ms** | 104 ms³ |\n| `wait_for_selector` reaction gap¹ | **1.1 ms** | 3.4 ms | 102 ms | 17.5 ms² | 2404 ms³ |\n\n¹ *Reaction gap* is the time between an element being inserted into the DOM and `wait_for_selector` returning. This is the cost of polling vs. observing, and the difference users actually feel in real tests. See [Selector waits, in detail](#selector-waits-in-detail) below.\n\n² chromiumoxide has no built-in `wait_for_selector`; the canonical user pattern is a manual retry loop. The number above uses `sleep(50 ms)` between checks, which is what its examples suggest.\n\n³ `headless_chrome` ships a synchronous API whose internal transport polls the websocket response channel every 5 ms and whose `Wait` primitives default to a 100 ms sleep. `wait_until_navigated` waits for `networkAlmostIdle` (no public option for `load`-only), so its `goto` measurement isn't directly comparable to the `waitUntil: 'load'` semantics used by the other rows. The floor on `evaluate` (~104 ms) is one poll cycle of that internal `Wait`.\n\n### What this actually tells you\n\n- **`launch_chrome`** is slower than Playwright and Puppeteer on this run. The Node libraries skip a chunk of in-process setup that the Rust crates pay synchronously. ferrous reads Chrome's `DevTools listening on ws:\u002F\u002F...` line off stderr instead of polling the `\u002Fjson\u002Fversion` HTTP endpoint, which removes a 200 ms backoff loop, but there is still room to close the gap vs Playwright.\n- **`new_page`** is where library design starts to show. ferrous-browser uses `Target.setAutoAttach` so a new tab's session is bound without a second roundtrip, and lazy-enables the `Page` domain exactly once per session rather than on every `goto` (saves one CDP round-trip per navigation; the win scales with RTT). `headless_chrome`'s 517 ms here is its sync transport waiting on the new-target attachment via its 100 ms `Wait` primitive.\n- **`goto`** to `about:blank` is dominated by Chrome (4–6 ms across the modern async libraries). Real navigation is dominated by the network, not the library. `headless_chrome`'s 2.1 s is not slow Chrome; it's its sync `wait_until_navigated` waiting for `networkAlmostIdle` through a 100 ms-resolution polling loop.\n- **`screenshot`** is mostly Chrome's own work; the four modern libraries land between 37 and 50 ms. Library overhead here is small. `headless_chrome` is ~3x slower because each CDP method call goes through its polling transport.\n- **`evaluate`** in ferrous, Puppeteer, Playwright, and chromiumoxide is sub-millisecond — they all do a single CDP round-trip and pick up the response off an event loop or channel. `headless_chrome`'s 104 ms is *exactly* one cycle of its internal 100 ms `Wait` sleep.\n- **`wait_for_selector` reaction gap** is the biggest gap among the async libraries, and it's the one users notice on every test. ferrous-browser pushes the wait into the page itself via a MutationObserver-backed Promise that Chrome holds open until the selector matches, so reaction latency is bounded by one CDP round-trip rather than by anyone's poll interval.\n\n### Selector waits, in detail\n\nIn real test suites and scrapers, `wait_for_selector` is called dozens to hundreds of times. Every extra millisecond of reaction latency stacks up, and most libraries lose tens of milliseconds per call to polling.\n\nHere's how each library reacts to an element that gets inserted at a known instant in the page:\n\n```\nferrous-browser   median 1.1 ms     max ~1.3 ms     ← in-page MutationObserver, awaited via CDP\nPuppeteer         median 3.4 ms     max ~5   ms     ← polls on requestAnimationFrame\nchromiumoxide     median 17.5 ms    max ~30  ms     ← no built-in; user-written 50 ms poll loop\nPlaywright        median 102 ms     max ~105 ms     ← internal polling, sits on a 100 ms cadence\nheadless_chrome   median 2,404 ms   max ~2.4 s      ← sync transport, 100 ms Wait cycles compound\n```\n\nSo on a test that does 100 `waitFor`s, ferrous-browser saves roughly **10 seconds vs Playwright**, **1.6 seconds vs chromiumoxide**, and minutes vs `headless_chrome` purely from lower reaction latency, with no change in your code.\n\n### Earlier benchmarks (macOS, Chrome 147)\n\nKept for continuity; these numbers used a different rig (macOS Apple Silicon, system-installed Chrome 147) and a smaller library set, so they are **not directly comparable** to the Linux\u002FCfT table above.\n\n| Operation | ferrous-browser | Puppeteer | chromiumoxide |\n|-----------|-----------------|-----------|---------------|\n| **New Page** (`about:blank`) | ~466 ms | ~75 ms | ~100 ms |\n| **Navigate + Content** (`example.com`, load event) | ~735 ms | ~314 ms | ~277 ms |\n| **Screenshot** (Full page PNG) | ~646 ms | ~138 ms | ~180 ms |\n\n---\n\n## Real-world examples\n\n### Web scraper\n\n```rust\nuse ferrous_browser::{Browser, WaitUntil};\n\n#[tokio::main]\nasync fn main() -> Result\u003C(), Box\u003Cdyn std::error::Error>> {\n    let browser = Browser::launch_chrome(None).await?;\n    let page = browser.new_page().await?;\n\n    page.goto(\"https:\u002F\u002Fnews.ycombinator.com\", WaitUntil::Load).await?;\n\n    let title_count: u64 = page\n        .evaluate(\"document.querySelectorAll('.titleline').length\")\n        .await?;\n    println!(\"Found {title_count} stories\");\n\n    Ok(())\n}\n```\n\n### End-to-end test\n\n```rust\nuse ferrous_browser::{Browser, BrowserConfig, WaitUntil};\n\n#[tokio::test]\nasync fn test_login_flow() -> Result\u003C(), Box\u003Cdyn std::error::Error>> {\n    let browser = Browser::launch_chrome(Some(BrowserConfig {\n        headless: true,\n        ..Default::default()\n    })).await?;\n    let page = browser.new_page().await?;\n\n    page.goto(\"http:\u002F\u002Flocalhost:3000\u002Flogin\", WaitUntil::Load).await?;\n    page.locator(\"input[name=email]\").type_text(\"user@example.com\").await?;\n    page.locator(\"input[name=password]\").type_text(\"secret\").await?;\n    page.locator(\"button[type=submit]\").click().await?;\n    page.locator(\".dashboard\").wait_for().await?;\n\n    let url: String = page.evaluate(\"location.href\").await?;\n    assert!(url.contains(\"\u002Fdashboard\"));\n    Ok(())\n}\n```\n\n### Screenshot utility\n\n```rust\nuse ferrous_browser::{Browser, WaitUntil};\n\n#[tokio::main]\nasync fn main() -> Result\u003C(), Box\u003Cdyn std::error::Error>> {\n    let browser = Browser::launch_chrome(None).await?;\n    let page = browser.new_page().await?;\n    page.goto(\"https:\u002F\u002Fexample.com\", WaitUntil::NetworkIdle).await?;\n    let png = page.screenshot().await?;\n    std::fs::write(\"out.png\", png)?;\n    println!(\"Saved out.png\");\n    Ok(())\n}\n```\n\n---\n\n## Comparison\n\n| | ferrous-browser | chromiumoxide | headless_chrome |\n|---|---|---|---|\n| Language | Rust | Rust | Rust |\n| Async runtime | tokio | tokio | none (sync) |\n| Node.js required | ❌ | ❌ | ❌ |\n| Actively maintained | ✅ | ⚠️ stale | ✅ (community fork)¹ |\n| Multi-page session isolation | ✅ | ✅ | ⚠️ |\n| `page.evaluate::\u003CT>()` | ✅ | ✅ | ⚠️ returns `RemoteObject` |\n| Locator API | ✅ | ❌ | ❌ |\n| `WaitUntil::NetworkIdle` | ✅ configurable | ❌ | ⚠️ hard-coded only |\n| Structured errors | ✅ | ⚠️ | ⚠️ |\n\n¹ The original `atroche\u002Frust-headless-chrome` stopped seeing commits in Feb 2024; the crate is now maintained by the `rust-headless-chrome` GitHub org, latest release `1.0.21` on 2026-02-03. Note that its sync transport polls at 100 ms — see the benchmark footnote.\n\n---\n\n## Roadmap\n\n- [x] `page.set_cookies()` \u002F `page.cookies()` — session persistence\n- [x] `page.pdf()` — PDF export\n- [x] `page.evaluate_handle()` — remote object references\n- [x] Structured trace\u002FHAR capture\n- [x] CI matrix: Linux + macOS + Windows \u002F stable + beta Chrome\n- [x] Cross-platform: replace `nix` for Windows support\n\n---\n\n## License\n\nDual licensed under [MIT](LICENSE-MIT) OR Apache-2.0 at your option.\n","ferrous-browser 是一个使用 Chrome DevTools Protocol 实现的快速异步浏览器自动化工具，无需依赖 Node.js。它采用纯 Rust 开发，基于 Tokio 异步框架，能够无缝集成到任何异步 Rust 项目中。其核心功能包括多页面隔离、无竞态条件处理以及受 Playwright 启发设计的易用 API（如 locator, evaluate, WaitUntil 等）。此外，该项目还支持多种导航等待模式以适应不同类型的网页加载需求。ferrous-browser 非常适合需要进行高效网页抓取、自动化测试或构建爬虫服务等场景，并且要求解决方案轻量级、高性能及易于维护的应用开发。",2,"2026-06-11 04:06:56","CREATED_QUERY"]