Your first crawl¶
This tutorial crawls Books to Scrape, a bookstore built for practicing scrapers. In about ten minutes you will write a config, check what it extracts, run a crawl in two short sessions, and query the result. Every output below comes from a real run.
You need Python 3.14 or later and uv.
1. Install databrew¶
Databrew installs from its repository:
uv tool install "databrew @ git+https://github.com/eyayaw/databrew"
databrew --version
Use it from a project instead
Run uv add "databrew @ git+https://github.com/eyayaw/databrew" and prefix the commands below
with uv run.
2. Start a config¶
Make a directory for the crawl and ask databrew for a starter file:
$ mkdir bookstore && cd bookstore
$ databrew init books --url https://books.toscrape.com/catalogue/page-1.html
Created config: books.toml
Next steps:
1. Edit books.toml to customize extraction rules
2. Run: databrew check books.toml
3. Run: databrew run books.toml -n 5
The file it writes has placeholder selectors and commented-out options. Replace its contents with
this config, which reads the bookstore. The same file is
examples/configs/books.toml
in the repository.
name = "books"
start_urls = ["https://books.toscrape.com/catalogue/page-1.html"]
# Listing pages show 20 books, each in an article.product_pod card.
[listing.items]
selector = "article.product_pod"
[listing.items.fields]
url = { selector = "h3 a", attribute = "href" }
title = { selector = "h3 a", attribute = "title" }
price = { selector = ".price_color", parser = "parse_float" }
[listing.links]
items = "url" # the card field that links to the book's page
pagination = ["li.next a"] # the link to the next listing page
# Item pages show one book.
[item.items.fields]
title = ".product_main h1"
price = { selector = ".product_main .price_color", parser = "parse_float" }
availability = ".product_main .availability"
A crawl has two kinds of page. Listing pages show many records as cards and link to more
listing pages; the start URLs are listing pages. Item pages show one record each. [listing]
tells databrew how to read the first kind and [item] the second:
[listing.items].selectorpicks the cards: each element that matches it is one card.[listing.items.fields]reads each card. A field written as a selector string reads the text; one written as a table, such as{ selector = "h3 a", attribute = "href" }, reads an attribute, andparser = "parse_float"turns£51.77into51.77.[listing.links]says what to queue: the card field that links to each book's page, and the link to the next listing page.[item.items.fields]reads the book's own page.
The selectors and parsers are extractkit's.
3. Check what it extracts¶
databrew check loads the config, builds everything a run needs, and
prints the plan:
$ databrew check books.toml
Valid config: /home/you/bookstore/books.toml
Name: books
Listing type: html
Start URLs: 1
Fetch backend: httpx
Concurrency: 5
Storage: data/books (no state yet)
databrew parse fetches one page and prints what the config reads from
it, as JSON, without storing anything. Try the start URL:
$ databrew parse books.toml https://books.toscrape.com/catalogue/page-1.html
The output lists 20 cards, the next-page link, and 20 item links. The first card reads:
{"url": "a-light-in-the-attic_1000/index.html", "title": "A Light in the Attic", "price": 51.77}
Now an item page. --page-type item reads it with [item]:
$ databrew parse books.toml --page-type item \
https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
Its one item reads:
{
"url": "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
"title": "A Light in the Attic",
"price": 51.77,
"availability": "In stock (22 available)"
}
If a field comes back empty, fix its selector and parse again. --save page.html keeps the fetched
page, and --file page.html reads it back, so you can iterate without fetching.
4. Run a short crawl¶
-n 20 caps the run at 20 requests, which keeps this first run polite and quick:
$ databrew run books.toml -n 20
books: success, +19 new items, 20 observations, 0 failed URLs, traversal paused (Reached max requests limit (20))
total: 1 run, 0 failed, +19 new items, 20 observations, 0 failed URLs
Databrew fetched the first listing page, which gave 20 observations, one per card, and queued the next listing page and 20 book pages. New book pages go first, so the other 19 requests stored 19 books. The budget ran out before the rest, so the walk through the listing pages, the traversal, is paused.
databrew status shows what the storage path holds:
$ databrew status books.toml
Status for: books
Storage: data/books
Item files: 1
Items: 19
URLs pending: 2
URLs completed: 20
URLs failed: 0
Traversal: 1, exhaustive, paused
Listing pages to go: 1
The two pending URLs are the 20th book and the second listing page.
5. Resume¶
Run the same command again:
$ databrew run books.toml -n 20
books: success, +19 new items, 20 observations, 0 failed URLs, traversal paused (Reached max requests limit (20))
total: 1 run, 0 failed, +19 new items, 20 observations, 0 failed URLs
The run continued from the queue: it fetched the waiting book, the second listing page, and the
books that page linked to. databrew status now counts 38 items and 40 completed URLs. Without
-n, a run continues until the queue is empty, which completes the traversal. The next run then
starts an incremental one: it stops following pagination at a page it has read in full before whose
books are all stored.
6. Query the data¶
The records are Parquet files under data/books/:
data/books/
├── items/part_000001.parquet # one row per book version
├── items/part_000002.parquet
├── observations/items/part_000001.parquet # one row per card seen
├── observations/items/part_000002.parquet
├── .state.db # the queue and the traversal
├── .last_run.json # the last run's report
└── … # itemstore's catalogs
Each row holds key, item_key, url, fetched_at, and the extracted fields as a JSON column,
data. DuckDB reads them directly:
SELECT data->>'$.title' AS title,
CAST(data->>'$.price' AS DOUBLE) AS price,
data->>'$.availability' AS availability
FROM read_parquet('data/books/items/part_*.parquet', union_by_name = true)
ORDER BY price DESC
LIMIT 3;
DuckDB prints, with the long title cut for width:
┌───────────────────────────────────────────────────────────────────────┬────────┬─────────────────────────┐
│ title │ price │ availability │
├───────────────────────────────────────────────────────────────────────┼────────┼─────────────────────────┤
│ Our Band Could Be Your Life: Scenes from the American Indie Undergro… │ 57.25 │ In stock (19 available) │
│ Birdsong: A Story in Pictures │ 54.64 │ In stock (19 available) │
│ Sapiens: A Brief History of Humankind │ 54.23 │ In stock (20 available) │
└───────────────────────────────────────────────────────────────────────┴────────┴─────────────────────────┘
What you learned¶
- A config names the start URLs and says how to read listing pages and item pages.
checkandparseshow what a config does before any crawl state exists.- A run can stop at any point, and the next run resumes it.
- Records are plain files that any Parquet reader can query.
Next, write a config for your own site, or read how a crawl
works to see what databrew does between runs. To drive the same crawl from
Python, see databrew.run.