A tiny, zero-dependency, never-panicking HTML parser with CSS selectors — for Rust, your shell, JavaScript (a ~41 KB gzipped WebAssembly module) and Python.
▶ Try it in your browser · Guide · Rust docs · CLI · JavaScript · Roadmap
New name: up to 0.3 this project was published as
dom-tree-rs(crates.io, PyPI),domtree-clianddom-tree-wasm(npm). From 0.4 it isdomqeverywhere; the old packages point here.
let dom = parse;
for a in dom.select
// One -> /a
// Two -> /b (the missing </li>s were recovered)
let md = parse.to_markdown;
assert_eq!;
Pick your entry point
[]
= "0.5"
let dom = parse;
dom.select_first
.map;
|
import init
from "domq-wasm";
await ;
;
Also: 🤖 AI agents — domq mcp (setup) · 🐍 Python —
pip install domq (docs) · 🍺 Homebrew —
brew install iamsaquib8/tap/domq · ⚡ WASI — domq.wasm on every
release.
Why domq?
- Zero dependencies. One crate in your
cargo tree. A clean build takes 0.6 s —scraperpulls 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 oftl— with ~20 allocations per parse (see Performance). - Streams, too.
domq::eventsyields start/end/text events with the same recovery as the tree builder, for documents you don't want to keep. - Agent-ready.
domq mcpis an MCP server, so Claude, Cursor or any MCP client can turn the HTML it has into Markdown, fields or links.
¹ 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.
Cookbook
let dom = parse;
let title = dom.select_first.map;
let links: = dom.select.filter_map.collect;
let price = dom.select_first.map;
use FromHtml;
let products: = parse.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.
let dom = parse;
let article = dom.select_first.map;
Or from the shell, stripping the chrome first:
|
let dom = parse;
let article = dom.main_content; // reader mode: drops nav, ads, comments…
let chunks = match article ;
for chunk in &chunks
let dom = parse;
for link in dom.links
let meta = dom.metadata; // title, description, OpenGraph, JSON-LD…
println!;
if let Some = dom.select_first
use Selector;
let sel = parse.expect;
for page in pages
let err = parse.unwrap_err;
println!; // unsupported pseudo-class ':hover' at byte 1
let link = dom.select_first.unwrap;
let section = link.closest; // 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
let mut dom = parse;
let scripts: = dom.select.map.collect;
for id in scripts
assert_eq!;
// Insert parsed HTML, wrap, rename, set attributes and text:
let a = dom.select_first.unwrap.id;
dom.set_attr;
dom.insert_html_after;
dom.wrap;
let p = dom.root.unwrap.id;
dom.rename;
assert_eq!;
let src = "<ul>\n <li>one\n <li>two</ul>";
let dom = parse;
let li = dom.select.nth.unwrap;
let range = li.source_range.unwrap; // byte range in `src`
assert_eq!;
assert_eq!; // 1-based line, column
Command line
domq is a maintained, zero-dependency, drop-in replacement for
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 →
| |
| |
# 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
|
# Lint HTML in CI (file:line:col, exit 1 on findings)
AI agents (MCP)
domq mcp runs the CLI as a Model Context Protocol
server over stdio. An agent that has fetched a page hands its HTML to domq and
gets back what it needs, instead of reading raw markup:
| Tool | Returns |
|---|---|
markdown |
the page (or each match of a selector) as Markdown; main keeps just the article |
text |
readable text |
select |
elements matching a CSS selector: tag, attributes, text (and HTML) |
extract |
named fields as JSON, one object per item: {"name": "h2", "url": "a@href"} |
chunks |
Markdown chunks with their heading paths, for RAG |
links, metadata, tables |
links with resolved URLs; title, OpenGraph, JSON-LD…; tables as rows |
lint |
accessibility and markup problems, with line and column |
Every tool is read-only and works only on the HTML in its arguments: domq never fetches URLs or reads files. It speaks MCP 2026-07-28 and the earlier handshake-based revisions (2025-11-25 back to 2024-11-05).
For Claude Desktop, Cursor, VS Code, Windsurf and other clients, add it to the MCP config:
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 → · Playground →
import init from "domq-wasm";
await ; // browsers and Workers; harmless in Node
const tree = ; // typed tree: { type: "document", children: [...] }
const links = ; // [{ tag, attrs, text, html, nodeIndex }, ...]
const md = ; // just the article, as Markdown
const page = ; // parse once, query many times
const headings = page..;
page.;
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 →
=
=
= .
= # ready for an LLM prompt
# Declarative extraction into dataclasses (mirrors Rust's #[derive(FromHtml)])
=
:
:
:
=
How it compares
| domq | tl | scraper | html5ever | lol_html | |
|---|---|---|---|---|---|
| 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/scraper. If raw throughput is all that matters, use tl. If
you're sanitizing untrusted HTML, use ammonia
— never a forgiving, non-spec parser.
Performance
Fast for an owned tree — full recovery, entity decoding, source spans and
all. Apple M4 Pro, cargo bench (generated inputs; see
benchmarks/ for method and caveats):
| input | domq | tl | scraper | html5ever¹ |
|---|---|---|---|---|
| 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.
¹ into its reference RcDom.
What it handles
Void elements, self-closing tags, comments (including browsers' recovery of
<!--> and --!>), doctypes, CDATA, raw text (<script>, <style>, <xmp>,
<iframe>, <noembed>, <noframes>, <plaintext>), escapable raw text
(<title>/<textarea>), boolean and unquoted attributes,
hyphenated/namespaced/custom tags, named + numeric entities (with browser-style
Windows-1252 remapping, and a missing ; recovered where browsers do:
© 2026, ©), and implicit closing as browsers scope it: a <li>,
<dd>/<dt>, table cell or row closes its predecessor even past unclosed
inline elements (<li><a>One<li><a>Two), a block closes an open <p> past them
(<p><b>x<div>), a stray </p> makes an empty paragraph, </br> is a <br>.
Everything it recovers from is recorded in
Dom::errors.
How close the trees are to a browser's is measured in
docs/conformance.md.
Deliberately not done: full WHATWG tree construction (adoption agency and
reopening formatting elements, automatic <html>/<head>/<body>, foster
parenting, namespaces). See the non-goals.
Cargo features
| feature | default | what it adds |
|---|---|---|
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.
Measured against browsers: CI runs the 1,959 HTML tree-construction tests
(html5lib, in web-platform-tests) and publishes the results in
docs/conformance.md: 0 panics, and 61.8% of trees
match a browser's structure in domq's model (no implied <html>/<body>),
with the gaps explained area by area. Reader mode is scored against Mozilla
Readability's 130 labelled pages in docs/reader-mode.md:
95.2% F1 against Firefox's reader view, up from 82.1% for the whole page.
Contributing
Issues and PRs are welcome! Start with CONTRIBUTING.md, the architecture guide, and the roadmap — issues labelled good first issue are a friendly way in. Please follow the code of conduct, and report security problems privately as described in SECURITY.md.
If domq saved you some time, a ⭐ helps other people find it.