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.

One email when the scan finishes, with the score and every check. No newsletter.

Crawls up to 5000 pages plus their images, scripts and stylesheets. No account needed: you get a private link to the results.

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.

TableWhat it holds
scanOne 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
urlsEvery 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
blobsThe bytes themselves, content-addressed, so a file used on 400 pages is stored once.hash, size, body
pagesWhat 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
linksEvery link on every page, one row per link.from_url, to_url, text, rel, internal
resourcesWhich page used which script, stylesheet or image.page_url, url, kind, internal
imagesEvery image tag and its alt text.page_url, src, alt
sitemap_entriesWhat your sitemaps claimed, for comparison with what was crawled.sitemap_url, url, kind
link_graph_nodesThe internal link map the report draws.url, label, title, status_code, inbound, outbound, depth, orphan
adviceThe fix plan written from this scan.status, model, summary, priorities, generated_at
security_reviewThe passive security review and the checks it was written from.status, model, summary, findings, checks, generated_at
eventsThe 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

Free, no account, up to 5,000 pages. The file is yours to keep.
Scan a site, free

Read next