<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/banner-dark.svg">
<img src="docs/assets/banner-light.svg" width="100%" alt="domq — messy HTML in, clean tree, text, Markdown, or JSON out">
</picture>
A tiny, zero-dependency, never-panicking HTML parser with CSS selectors —
for Rust, your shell, JavaScript (a ~41 KB gzipped WebAssembly module) and Python.
[](https://crates.io/crates/domq)
[](https://www.npmjs.com/package/domq)
[](https://docs.rs/domq)
[](https://github.com/iamsaquib8/domq/actions/workflows/ci.yml)



[](#msrv)
[](./LICENSE)
[**▶ Try it in your browser**](https://iamsaquib8.github.io/domq/) ·
[Rust docs](https://docs.rs/domq) ·
[CLI](./cli) ·
[JavaScript](./wasm) ·
[Roadmap](./ROADMAP.md)
<img src="docs/assets/demo.svg" width="760" alt="Terminal demo: domq lists the navigation links of rust-lang.org, converts its first section to Markdown, counts images without alt text, and pretty-prints a fragment with unclosed list items.">
</div>
> **New name:** up to 0.3 this project was published as `dom-tree-rs` (crates.io,
> PyPI), `domtree-cli` and `dom-tree-wasm` (npm). From 0.4 it is **`domq`**
> everywhere; the old packages point here.
---
```rust
let dom = domq::parse(r#"<ul id=links><li><a href="/a">One</a><li><a href="/b">Two</a></ul>"#);
for a in dom.select("#links a[href]") {
println!("{} -> {}", a.text_content(), a.attr("href").unwrap_or_default());
}
// One -> /a
// Two -> /b (the missing </li>s were recovered)
let md = domq::parse("<h2>Notes</h2><p>Some <b>bold</b> text</p>").to_markdown();
assert_eq!(md, "## Notes\n\nSome **bold** text");
```
## Pick your entry point
<table>
<tr>
<th width="33%">🦀 Rust library</th>
<th width="33%">🖥️ Command line</th>
<th width="33%">🌐 JavaScript / WASM</th>
</tr>
<tr valign="top">
<td>
```toml
[dependencies]
domq = "0.3"
```
```rust
let dom = domq::parse(html);
dom.select_first("h1")
</td>
<td>
```sh
cargo install domq-cli
```
```sh
curl -sL rust-lang.org \
| domq -t h1
```
</td>
<td>
```sh
npm install domq
```
```js
import init, { select }
from "domq";
await init();
select(html, "a[href]");
```
</td>
</tr>
</table>
Also: 🐍 **Python** — `pip install domq` ([docs](./python)) · 🍺 **Homebrew** —
`brew install iamsaquib8/tap/domq` · ⚡ **WASI** — `domq.wasm` on every
[release](https://github.com/iamsaquib8/domq/releases).
## Why domq?
- **Zero dependencies.** One crate in your `cargo tree`. A clean build takes
**0.6 s** — `scraper` pulls in 42 crates and takes 5.3 s.¹
- **Never panics.** Any byte sequence gives you a best-effort tree. Proptested and
fuzzed: `parse`, `select`, `to_text`, `to_markdown`. Deep nesting can't blow the
stack.
- **Real CSS selectors.** Combinators (` `, `>`, `+`, `~`), every attribute
operator, `:nth-child()`, `:not()`, `:is()`, `:has()`, and the scraper's
favourite, `:contains("text")`.
- **Content out, not just a tree.** Readable text (`to_text`), CommonMark/GFM
Markdown (`to_markdown`) for LLM prompts and RAG pipelines, JSON, and HTML.
- **Runs everywhere.** `no_std + alloc`, `#![forbid(unsafe_code)]`, a ~41 KB
gzipped wasm module for browsers, Cloudflare Workers, Deno, Bun and Node, and a
single static binary for your shell.
- **Fast.** ~500 MB/s on markup — about 8× `scraper`, level with or ahead of
[`tl`][tl] — with ~20 allocations per parse (see [Performance](#performance)).
- **Streams, too.** `domq::events` yields start/end/text events with the
same recovery as the tree builder, for documents you don't want to keep.
<sub>¹ Clean debug `cargo build` of an empty crate depending only on the parser (default
features), Apple M4 Pro, 14 cores: domq 0.6 s · tl 0.4 s · html5ever 2.9 s ·
scraper 5.3 s · lol_html 6.9 s. Fewer cores widens the gap.</sub>
## Cookbook
<details open>
<summary><b>Scrape links, titles, and attributes</b></summary>
```rust
let dom = domq::parse(html);
let title = dom.select_first("title").map(|t| t.to_text());
let links: Vec<&str> = dom.select("a[href^='https://']").filter_map(|a| a.attr("href")).collect();
let price = dom.select_first(".product:has(.in-stock) .price").map(|p| p.to_text());
```
</details>
<details open>
<summary><b>Extract structs declaratively</b> (<code>derive</code> feature)</summary>
```rust
use domq::FromHtml;
#[derive(FromHtml)]
#[html(select = ".product")]
struct Product {
#[html(select = "h2")] name: String,
#[html(select = ".price", attr = "data-value")] price: f64,
#[html(select = ".tag")] tags: Vec<String>, // every match
#[html(select = "a.more", attr = "href")] link: Option<String>, // None if missing
}
let products: Vec<Product> = domq::parse(html).extract_all()?;
```
Nested structs, `default`, `with = parse_fn`, and `html` / `outer_html` /
`markdown` sources are supported, and misused attributes are compile errors.
Runtime errors name the field and selector:
`Product.price (".price" @data-value): cannot parse "cheap" as f64: invalid float literal`.
</details>
<details>
<summary><b>Turn a web page into Markdown for an LLM</b></summary>
```rust
let dom = domq::parse(html);
let article = dom.select_first("article, main").map(|n| n.to_markdown());
```
Or from the shell, stripping the chrome first:
```sh
</details>
<details>
<summary><b>Keep just the article, chunked for RAG</b></summary>
```rust
let dom = domq::parse(html);
let article = dom.main_content(); // reader mode: drops nav, ads, comments…
let chunks = match article {
Some(node) => node.to_markdown_chunks(1500),
None => dom.to_markdown_chunks(1500),
};
for chunk in &chunks {
println!("[{}]\n{}", chunk.context(), chunk.markdown); // e.g. "[Guide > Install]"
}
```
</details>
<details>
<summary><b>Links, page metadata, and tables as data</b></summary>
```rust
let dom = domq::parse(html);
for link in dom.links(Some("https://example.com/blog/")) {
println!("{} -> {}", link.text, link.url); // resolved, honours <base href>
}
let meta = dom.metadata(); // title, description, OpenGraph, JSON-LD…
println!("{:?} {:?}", meta.title, meta.canonical);
if let Some(table) = dom.select_first("table#prices") {
let rows = table.table_rows(); // colspan/rowspan expanded
print!("{}", domq::to_csv(&rows));
}
```
</details>
<details>
<summary><b>Compile a selector once, reuse it, and get real errors</b></summary>
```rust
use domq::Selector;
let sel = Selector::parse("li:nth-child(odd) > a").expect("valid selector");
for page in pages {
let dom = domq::parse(page);
let count = dom.select(&sel).count();
}
let err = Selector::parse("a:hover").unwrap_err();
println!("{err}"); // unsupported pseudo-class ':hover' at byte 1
```
</details>
<details>
<summary><b>Walk the tree</b></summary>
```rust
let link = dom.select_first("a").unwrap();
let section = link.closest("section"); // nearest matching ancestor
let parent = link.parent(); // O(1) — arena-backed
let next = link.next_element_sibling();
for node in link.ancestors() { /* … */ }
for node in dom.root().unwrap().descendants() { /* … */ }
```
</details>
<details>
<summary><b>Edit and re-serialize</b></summary>
```rust
let mut dom = domq::parse("<p>Hi <script>track()</script><a href=/x>x</a></p>");
let scripts: Vec<_> = dom.select("script").map(|n| n.id()).collect();
for id in scripts {
dom.remove(id);
}
assert_eq!(dom.to_html(), r#"<p>Hi <a href="/x">x</a></p>"#);
// Insert parsed HTML, wrap, rename, set attributes and text:
let a = dom.select_first("a").unwrap().id();
dom.set_attr(a, "rel", "nofollow");
dom.insert_html_after(a, " <b>new</b>");
dom.wrap(a, "em");
let p = dom.root().unwrap().id();
dom.rename(p, "div");
assert_eq!(
dom.to_html(),
r#"<div>Hi <em><a href="/x" rel="nofollow">x</a></em> <b>new</b></div>"#
);
```
</details>
<details>
<summary><b>Point back into the source</b></summary>
```rust
let src = "<ul>\n <li>one\n <li>two</ul>";
let dom = domq::parse(src);
let li = dom.select("li").nth(1).unwrap();
let range = li.source_range().unwrap(); // byte range in `src`
assert_eq!(&src[range.clone()], "<li>two");
assert_eq!(domq::line_col(src, range.start), (3, 3)); // 1-based line, column
```
</details>
## Command line
`domq` is a maintained, zero-dependency, drop-in replacement for
[`htmlq`](https://github.com/mgdm/htmlq) — same flags, plus Markdown, JSON/JSON
Lines and CSV output, structured extraction, reader mode, encoding detection,
a linter, and modern selectors. [Full CLI docs →](./cli)
```sh
curl -sL https://blog.rust-lang.org | domq -r 'nav, footer' --markdown body # page → Markdown
domq -c 'img:not([alt])' < page.html # a11y check
echo '<ul><li>a<li>b</ul>' | domq -p # pretty-print
# Structured extraction: one JSON object per post
# {"title":"Announcing Rust 1.99.1","url":"https://blog.rust-lang.org/2026/10/15/Rust-1.99.1/"}
# Reader mode → Markdown, tables → CSV, page metadata → JSON
domq --meta < page.html
# Lint HTML in CI (file:line:col, exit 1 on findings)
domq lint public/**/*.html
```
## JavaScript & WebAssembly
The same parser — selectors, text, Markdown and all — in a ~41 KB (gzipped) wasm
module with zero JS dependencies and full TypeScript types. Works in the browser,
Cloudflare Workers, Deno, Bun, and Node (no setup needed in Node).
[Package docs →](./wasm) · [**Playground →**](https://iamsaquib8.github.io/domq/)
```js
import init, { parse, select, toMarkdown, Document } from "domq";
await init(); // browsers and Workers; harmless in Node
const tree = parse("<ul><li>a<li>b</ul>"); // typed tree: { type: "document", children: [...] }
const links = select(html, "a[href]"); // [{ tag, attrs, text, html, nodeIndex }, ...]
const md = toMarkdown(html, "article"); // just the article, as Markdown
const page = new Document(html); // parse once, query many times
const headings = page.select("h2").map((h) => h.text);
page.free();
```
## Python
Same engine, Pythonic API, abi3 wheels for Linux, macOS and Windows (Python ≥ 3.11).
The GIL is released while parsing large documents. [Package docs →](./python)
```python
import domq
doc = domq.parse(html)
links = [a.attr("href") for a in doc.select("a[href]")]
price = doc.select_first(".product:has(.in-stock) .price").text
markdown = doc.select_first("article").to_markdown() # ready for an LLM prompt
# Declarative extraction into dataclasses (mirrors Rust's #[derive(FromHtml)])
from dataclasses import dataclass
from typing import Annotated, Optional
from domq import extract_all, Select, Attr
@dataclass
class Product:
__select__ = ".product"
name: Annotated[str, Select("h2")]
price: Annotated[float, Attr(".price", "data-value")]
link: Annotated[Optional[str], Attr("a.more", "href")]
products = extract_all(Product, html)
```
## How it compares
| Transitive dependencies | **0** | 0 | 42 | 19 | 41 |
| CSS selectors | **✅** | limited | ✅ | ❌ | ✅ |
| Text / Markdown output | **✅** | ❌ | ❌ | ❌ | ❌ |
| JSON output | **✅** | ❌ | ❌ | ❌ | ❌ |
| WASM + npm package | **✅** | ❌ | ❌ | ❌ | ❌ |
| CLI | **✅** | ❌ | ❌ | ❌ | ❌ |
| `no_std` | **✅** | ❌ | ❌ | ❌ | ❌ |
| Edit + re-serialize | **✅** | ❌ | ❌ | ✅ | ✅ (streaming) |
| Streaming events with recovery | **✅** | ❌ | ❌ | ✅ (`TreeSink`) | ✅ |
| Never panics on input | ✅ | ✅ | ✅ | ✅ | ✅ |
| WHATWG spec-compliant tree | ❌ | ❌ | ✅ | ✅ | ❌ |
**When to pick something else:** if you need byte-exact browser parsing, use
[`html5ever`][html5ever]/[`scraper`][scraper]. If raw throughput is all that matters, use [`tl`][tl]. If
you're sanitizing untrusted HTML, use [`ammonia`](https://crates.io/crates/ammonia)
— never a forgiving, non-spec parser.
[tl]: https://crates.io/crates/tl
[scraper]: https://crates.io/crates/scraper
[html5ever]: https://crates.io/crates/html5ever
[lol_html]: https://crates.io/crates/lol_html
## Performance
Fast *for an owned tree* — full recovery, entity decoding, source spans and
all. Apple M4 Pro, `cargo bench` (generated inputs; see
[benchmarks/](./benchmarks) for method and caveats):
| small (0.5 KiB) | **400 MB/s** | 395 MB/s | 53 MB/s | 48 MB/s |
| medium (21 KiB) | **491 MB/s** | 462 MB/s | 60 MB/s | 53 MB/s |
| large (420 KiB) | **512 MB/s** | 358 MB/s | 61 MB/s | 54 MB/s |
| table-heavy (712 KiB) | **422 MB/s** | 326 MB/s | 58 MB/s | 51 MB/s |
| text-heavy (547 KiB) | 7.7 GB/s | **8.5 GB/s** | 1.9 GB/s | 1.6 GB/s |
About 8× faster than `scraper`/`html5ever` on markup, ahead of `tl` on large
documents, and ~10% behind it on prose (`tl` borrows from the input and skips
entity decoding and recovery). A parse makes ~20 allocations in total, and the
optional `simd` feature cuts text-heavy parse time by another ~24%. Run them
yourself: `cd benchmarks && cargo bench`.
<sub>¹ into its reference `RcDom`.</sub>
## What it handles
Void elements, self-closing tags, comments, doctypes, CDATA, raw text
(`<script>`/`<style>`), escapable raw text (`<title>`/`<textarea>`), boolean and
unquoted attributes, hyphenated/namespaced/custom tags, named + numeric entities
(with browser-style Windows-1252 remapping), and implicit closing for the everyday
cases (`<li>`, `<p>`, table cells/rows, `<dt>`/`<dd>`, `<option>`). Everything it
recovers from is recorded in
[`Dom::errors`](https://docs.rs/domq/latest/domq/struct.Dom.html#method.errors).
**Deliberately not done:** full WHATWG tree construction (adoption agency, automatic
`<html>`/`<head>`/`<body>`, foster parenting). See the [non-goals](./ROADMAP.md#non-goals).
## Cargo features
| `json` | ✅ | `to_json()` — zero-dependency JSON |
| `query` | ✅ | CSS selectors: `select`, `select_first`, `Selector`, `closest`, `matches` |
| `markdown` | ✅ | `to_markdown()` and `to_markdown_chunks()` — zero-dependency HTML → Markdown |
| `derive` | | `#[derive(FromHtml)]` — declarative extraction into structs (adds the `domq-derive` proc-macro crate) |
| `entities-full` | | all 2,125 WHATWG named character references (default: the 178 most common) |
| `simd` | | `memchr`-backed scanning (~24% faster on text-heavy pages; adds `memchr`) |
| `serde` | | `serde::Serialize` for the tree (same JSON shape; adds `serde`) |
| `std` | | `std::error::Error` impls; the crate is `no_std + alloc` otherwise |
## MSRV
Rust **1.88** (edition 2024), enforced in CI. Raised only in minor releases.
## Quality
Property-tested ("never panics" + tree invariants), snapshot-tested against a
corpus of messy HTML, fuzzed with `cargo-fuzz`, and CI-checked for fmt, clippy,
`no_std`, MSRV, zero dependencies and a wasm size budget.
## Contributing
Issues and PRs are welcome! Start with [CONTRIBUTING.md](./CONTRIBUTING.md), the
[architecture guide](./docs/ARCHITECTURE.md), and the [roadmap](./ROADMAP.md) —
issues labelled **good first issue** are a friendly way in. Please follow the
[code of conduct](./CODE_OF_CONDUCT.md), and report security problems privately
as described in [SECURITY.md](./SECURITY.md).
If domq saved you some time, a ⭐ helps other people find it.
## License
[MIT](./LICENSE)