The crawl database
Download your site crawl as a SQLite database
Most crawlers rent you a view of your own site. When you stop paying, the crawl goes with the subscription, and while you are paying you can only ask the questions the interface allows.
Here, a scan is a SQLite database. One file, holding the crawl history, the stored bytes of every page and asset, and the extracted titles, headings, links and images. Download it and it is yours: query it, diff it, keep it for years.
How to get the file
Run a scan and the download button is on your private scan page. Every finished scan also offers the same database from its public report under /domain, so you can take a crawl away without having an account at all.
The file is a normal SQLite database, snapshotted while the scan is quiet so it is consistent. Nothing needs importing.
sqlite3 scan.db
sqlite> .tables
sqlite> SELECT pages_fetched, assets_fetched, total_bytes FROM scan;What is in it
Twelve tables. The crawl is stored as an append-only event stream, and everything else is a projection of it, which means the tables you query are derived and can be rebuilt from the events if a question ever needs different shaping.
| Table | What it holds |
|---|---|
| scan | One row: the scan itself.start_url, domain, status, pages_fetched, assets_fetched, failed, skipped, total_bytes, limit_dropped, requested_at, started_at, completed_at, note |
| urls | Every URL the crawl saw, fetched or not.url, kind ('page', 'asset', 'robots', 'sitemap'), asset_kind, depth, state ('pending', 'fetched', 'failed', 'skipped'), discovered_from, status_code, content_type, content_length, body_hash, redirect_to, headers, fetched_at, duration_ms, attempts, error, skip_reason |
| blobs | The bytes themselves, content-addressed, so a file used on 400 pages is stored once.hash, size, body |
| pages | What was parsed out of each HTML page.url, title, meta_description, canonical, robots_meta, lang, h1_count, h1_text, h2_count, word_count, og_title, og_description, has_viewport, internal_links, external_links, image_count |
| links | Every link on every page, one row per link.from_url, to_url, text, rel, internal |
| resources | Which page used which script, stylesheet or image.page_url, url, kind, internal |
| images | Every image tag and its alt text.page_url, src, alt |
| sitemap_entries | What your sitemaps claimed, for comparison with what was crawled.sitemap_url, url, kind |
| link_graph_nodes | The internal link map the report draws.url, label, title, status_code, inbound, outbound, depth, orphan |
| advice | The fix plan written from this scan.status, model, summary, priorities, generated_at |
| security_review | The passive security review and the checks it was written from.status, model, summary, findings, checks, generated_at |
| events | The append-only event stream the projections above are built from.stream_id, version, event_type, event_data, occurred_at |
Queries worth running
The fifteen checks on the site are SQL over these tables. Here are the ones people write first.
Duplicate titles, worst first
SELECT title, COUNT(*) AS pages
FROM pages
WHERE title IS NOT NULL AND TRIM(title) <> ''
GROUP BY title
HAVING COUNT(*) > 1
ORDER BY pages DESC;Every broken internal link, with the page that links to it
SELECT l.from_url, l.to_url, u.status_code, u.error, l.text
FROM links l
JOIN urls u ON u.url = l.to_url
WHERE l.internal = 1
AND ((u.status_code >= 400 AND u.status_code != 429) OR u.state = 'failed')
ORDER BY l.to_url;Slowest pages the crawler saw
SELECT url, duration_ms, content_length
FROM urls
WHERE kind = 'page' AND state = 'fetched'
ORDER BY duration_ms DESC
LIMIT 20;Pages nothing links to
SELECT p.url, p.title, p.word_count
FROM pages p
LEFT JOIN links l ON l.to_url = p.url AND l.internal = 1
WHERE l.to_url IS NULL
ORDER BY p.word_count DESC;Heaviest assets, and how many pages carry them
SELECT u.url, u.content_length, COUNT(DISTINCT r.page_url) AS used_on
FROM urls u
JOIN resources r ON r.url = u.url
WHERE u.kind = 'asset' AND u.content_length IS NOT NULL
GROUP BY u.url, u.content_length
ORDER BY u.content_length DESC
LIMIT 20;In your sitemap but never crawled, and the other way round
SELECT s.url AS in_sitemap_not_crawled
FROM sitemap_entries s
LEFT JOIN urls u ON u.url = s.url AND u.state = 'fetched'
WHERE u.url IS NULL;The stored HTML of one page, exactly as it was served
SELECT CAST(b.body AS TEXT)
FROM urls u
JOIN blobs b ON b.hash = u.body_hash
WHERE u.url = 'https://example.com/';That last one is the point of the whole design. The crawl is not a report about your site; it is your site, as a search engine received it, on a date, in a file you hold.
Why this is unusual
Storing every fetched byte is not free, which is why most tools keep a summary and throw the rest away. Keeping it turns a crawl from a moment into a record. Two scans a month apart can be diffed properly, not by comparing two dashboards but by joining two tables. A question that occurs to you in March can be asked of January's crawl.
Content addressing makes it affordable: files are keyed by hash, so a logo on every page is stored once. A scan of up to 5,000 pages stays a file you can e-mail to a colleague.
It also means there is no lock-in to argue about. The export is not a feature bolted on beside the product; it is the product, handed over.
Questions people ask
- How do I download the database?
- From your private scan page while or after the crawl runs, and from the public report of any finished scan. It is one file, served as it is on disk.
- How big is the file?
- It holds the bytes of everything fetched, so it scales with the site: a few megabytes for a small site, a few hundred for a large one with heavy images. Identical files are stored once, which takes a surprising amount off sites that reuse assets.
- What can I open it with?
- Anything that reads SQLite: the sqlite3 command line, DB Browser for SQLite, Datasette, Python, R, DuckDB, your language of choice. No driver, no server, no account.
- Do the stored pages include the original HTML?
- Yes, byte for byte, in the blobs table, joined to urls by body_hash. That is what makes an old scan worth keeping: you can go back and ask a question nobody had thought of when the crawl ran.
- Why SQLite rather than a CSV export?
- A CSV export is whatever columns the vendor decided to give you. A database is the crawl itself: the relationships between pages, links and assets are intact, so the questions you can ask are not limited to the ones somebody anticipated.
Run a scan and take the database
Read next
- How this site is built — event sourcing, one SQLite database per user and per scan.
- Free site crawler, no signup — what the crawl collects in the first place.
- The DIY SEO guide — what to do with what the queries turn up.