Crawling a site, politely¶
extract reads the page you name. A site is many pages, and there are three
ways to read one: from the list its sitemaps give (map), by following its
links (crawl), or from a list you already have (batch). Every page goes
through the same fetch ladder as one page does, so robots.txt, the address
guard and the 16 MiB bound apply to each of them, and every site is asked one
request at a time with its delay between. Politeness is not an option here; it
is what the feature is.
Map, crawl, batch¶
sluicer map https://shop.example/ # its addresses, from its sitemaps
sluicer map https://shop.example/ --plain | sluicer batch - -o pages.jsonl # read each one
sluicer crawl https://shop.example/ --max-pages 200 -o shop.jsonl # follow its links instead
sluicer crawl https://shop.example/ --max-pages 500 -o shop.jsonl --resume # and continue later
sluicer crawl https://shop.example/ --template sitemap -o pages.jsonl # what its sitemaps list
sluicer crawl https://shop.example/ --template shopify --format csv > products.csv # a Shopify shop's products
sluicer batch urls.txt --jobs 8 --format csv > pages.csv # eight sites at once, as a table
From Python:
from sluicer.crawl import crawl, extract_many, map_site
site = map_site("https://shop.example/")
print(site.source, len(site.urls), [read.error for read in site.sitemaps])
run = crawl("https://shop.example/", max_pages=50, state="shop.jsonl")
for page in run:
if page.ok:
print(page.url, page.extraction.summary.get("price"))
else:
print(page.url, page.error.code, page.error.message)
print(run.stopped) # "done", "max_pages" or "time_budget"
for page in extract_many(["https://shop.example/p/1", "https://shop.example/p/2"]):
...
from sluicer.crawl import shopify_products, sitemap_pages
for product in shopify_products("https://shop.example/"):
print(product.url, product.extraction.summary["price"].value)
A page that failed is an answer like any other: it comes back with its
error, and the crawl goes on. On a terminal, stderr shows one bar of the
pages taken, the last one’s status and address beside it; anywhere else,
a log, one line a page.
What polite means here¶
- robots.txt, for every request. Every page and every sitemap is asked of
the site’s
robots.txtfirst, and a crawl has no switch to skip it: a crawler that ignores it is the thing it exists to stop. Arobots.txtthat cannot be read – no answer, or a 5xx – means nothing on that site is fetched, as RFC 9309 says; the page saysfetch_failed, worth retrying. - One request at a time per site. A site is a host with or without
www., over http or https, with its port when that is not the default:www.example.comandexample.comare usually the same machines, and pacing them apart would ask them twice as often. Several sites are asked at once, four by default and 32 at most (--jobs,concurrency=), never more than once each. One at a time holds for the whole process, not only for one crawl: two crawls of one site, a map beside them, or an agent’s parallelextract_declaredcalls wait for each other (sluicer.fetch.gate). A single fetch –sluicer extract, an MCP or HTTP API call – is one visit: itsrobots.txt, the page and any climb follow each other, and the next caller waits a second after it ends. Measured on a local site, two crawls and four fetches at once: before, pairs of requests 0.000 s apart and two in flight at once; now none closer than the site’sCrawl-delay, never two at once. - A delay counted from the end. No request to a site starts sooner than a
second (
--delay,min_delay) after the last one ended, or itsCrawl-delaywhen that is longer, or the interval itsRequest-rateimplies. Counted from the end, so a slow answer is never a reason to ask again sooner. - Every request counts. The site’s
robots.txt, each sitemap, each hop of a redirect, and each rung the ladder climbs to is a request, and waits its turn. The record of when each site was last asked is kept for the whole process, as therobots.txtanswers are, so a map followed by a crawl, or an agent calling the crawl tool twice, keeps the delay between them too. - A delay too long is an answer. A site asking for more than a minute
between requests (
max_delay; ten seconds for the MCP tools) is not crawled for a week: its pages saycrawl_delay_too_long, with the delay it asked for. - A site that says “too many” is heard. A 429 or a 503 is the site asking
to be asked less often. With a
Retry-After, the next request to it waits that long, a date counted from the response’s ownDateso this machine’s clock does not matter; one longer thanmax_delayanswers the site’s next pagesrate_limited, retryable later. Without one, the site’s delay doubles for the rest of the crawl, up tomax_delay. Scrapy retries both statuses without readingRetry-After; Crawlee reads it on a 429 only, and only when the crawler is set to pace the site (aThrottlingRequestManager, orsameDomainDelaySecsin its JavaScript version). - A slow site is asked as slowly. A crawl times each request, and a
site’s pace is how long it has lately taken to answer: each request’s
seconds averaged with the pace before, as Scrapy’s AutoThrottle does when
it keeps one request in flight. The next request waits the pace when it is
longer than the delay, never more than
max_delay, and never instead of the floor or theCrawl-delay: a site answering in 3 s is asked every 3 s, not every second. Measured on a local site with a 0.3 s delay: the request after a page that took 2.0 s waited 1.01 s, the next 0.51 s, then 0.31 s. A redirect hop’s rest is not counted as the site’s slowness. - Asked again, a few times and later each time. A page whose request
did not answer – a connection refused or reset, no route to the host or
its network for now, a timeout, an answer cut short, a name the resolver
could not look up for now, a
robots.txtnobody could read – or answered 429 or a 5xx is asked again, twice at most by default and ten at most at all (--retries,retries=; 0 asks once): the first time twice the site’s delay after the failed request ended, the second four times it, or the site’sRetry-Afterwhen that is longer, never more thanmax_delayand never past the time budget. A 4xx other than 429 is the site’s answer about the page, with a body or empty, and is never asked again; nor is a failure asking again would meet again, a redirect loop, an encoding the fetch cannot read, a page with nothing in it, whose line saysfetch_failedwithretryablefalse. A site whose page failed through all its retries is asked once a page until one of its pages answers, so a site that is down costs its retries once, not once a page. The page’s line says each time inretries,[{"reason": "it answered 503", "after": 1.0}], and is what its last request came to. Measured on a local site with a 0.5 s delay: a page whose connection was reset was asked again 1.0 s later, and one that answered 503 twice 1.0 s and then 2.0 s later; a 404 beside them was asked once. - Asked once, not twice. A page that plain HTTP brought back as an empty
shell is asked again of the browser, which is two requests for one page. So
a site that needed the browser once starts its next pages there, for the
rest of the process and at most a day, and each such page’s first climb
says so: a JS site’s crawl asks each page once. A rung that merely failed
teaches nothing, and a remembered rung that fails is forgotten. Within a
crawl the memory is also kept for each part of the site – an address’s
directory,
/p/for/p/1– so a shop whose listings are plain HTML and whose products a script draws keeps its listings on plain HTTP after its first product needed the browser: a part whose page came back from plain HTTP starts there, one that needed the browser starts at it, and one not seen yet starts where the site’s memory says. Measured on a local shop of 7 listings and 21 script-drawn products, crawled with the real browser: 30 document requests, as with the site’s memory alone, and none of the listings rendered in the browser, where three were before (0.7 s each, against 2 ms over plain HTTP); with no memory at all, 50. - One connection per site. Plain HTTP keeps the connection a page came on, while the server keeps it open, and asks the site’s next page on it: one handshake for a site’s pages rather than one each. Measured on a local server, twenty pages cost one connection instead of twenty.
- Under our own name. Every request says
Sluicer/<version>. The stealth rung is never part of a crawl.--headerand--cookie(headers=,cookies=) add to what a crawl sends – a site’s own login, for a site you may read behind it – and never replace the name. They go to the origin the crawl starts at alone, scheme, host and port: its pages onwww.or over plain http, a robots.txt, a sitemap on another host are asked without them.
Measured by the site being crawled, not by the crawler: tests/live/crawl_check.py
serves a local site whose robots.txt asks for a Crawl-delay of 0.5 s and
writes down when each request arrived and when its answer went out. Over the
nine requests of a crawl – a redirect chain, a page that takes a second to
answer, a link loop and a redirect to another site among them – the shortest
gap was 0.506 s, the request after the slow page waited 0.510 s from the end
of its answer, no two requests were ever in flight at once, and the other site
was never asked. Ignoring the Crawl-delay, or letting redirect hops go
unjudged, fails the check; both were tried.
On public sites that invite scraping, on 2026-09-23, timing every call the
crawler made to the web – a robots.txt, a sitemap, or one rung’s fetch of a
page – with the default one-second delay:
| site | what | calls | seconds | shortest gap |
|---|---|---|---|---|
| books.toscrape.com | crawl, 15 pages | 16 | 26.3 | 1.00 s |
| quotes.toscrape.com | crawl, 10 pages | 12 | 20.8 | 1.00 s |
web-scraping.dev, Crawl-delay: 2 |
crawl, 8 pages, one climb to the browser | 10 | 29.5 | 2.00 s |
| web-scraping.dev | map, 3 sitemaps | 4 | 9.0 | 2.00 s |
| scrapeme.live | map of 12 sitemaps to 400 addresses, then 5 products | 13 | 22.6 | 1.00 s |
A redirect’s hops are inside one call and are paced too, which the local check measures; the browser’s own requests for a page’s images and scripts are inside its call and are not.
The first runs found three gaps of 0.00 s, each now closed: a robots.txt the
ladder read the moment a page redirected to the other scheme, a rung it
climbed to the moment the last came back, and a batch that began the moment
the map before it ended. The five scrapeme.live products each came back as a
Product with its price, read from its JSON-LD.
Several sites at once¶
--jobs N on batch and crawl asks up to N sites at the same moment, each
still one request at a time with its delay between: a crawl kept to its site
asks one site whatever N is, so it matters to a batch and to --any-site.
The pages come back in the order given either way. Measured on eight local
sites of five pages each, with --delay 0.5: 6.2 s with --jobs 1, 3.3 s
with 4, 2.7 s with 8, the order kept every time, never two requests in
flight at one site, and no gap at a site shorter than 0.50 s.
Ready crawls¶
--template names a crawl whose pages a site already lists.
sitemapreads every address the site’s sitemaps list, in their order, the waysluicer map URL --plain | sluicer batch -does, but in one process: the map’s last request and the first page’s are one site’s, and wait its delay between (the pipe’s second command starts as the first ends, and knows nothing of it).--includeand--excludechoose among the addresses, and--max-pagesbounds them; the run says it stopped atmax_pageswhen the sitemaps listed more. A site with no sitemap gives the start page’s links, as a map does. It resumes as a batch does.shopifyreads a Shopify shop’s products from the file every Shopify shop serves,/products.json?limit=250&page=N, from page 1 until a page is short or--max-pagespages were asked: each page throughrobots.txt, after the site’s delay, and asked again as a page is. Each product is a line of its own, at its page’s address,/products/<handle>, withfound_onthe products.json page. Itsrecordshold oneProductof the fields Shopify wrote, as Shopify named them,sourceshopify, each with its JSON pointer in that page (/products/3/variants/0/price); itssummaryanswers the title, the description as text, the first image, the publication and update dates, the vendor as brand, the price – or, when the variants differ,price_lowandprice_high– the regular price (compare_at_price), the availability (in stock when any variant is) and, for a product of one variant, its SKU. The file names no currency, so the summary has none. A site that answers another status, or not a products.json, is answeredbad_input: not a Shopify shop, or one that serves none.--resumeasks the last page its file holds again and writes none of that page’s products twice; another shop’s file is refused. Measured on a local shop of 517 products with aCrawl-delayof 0.5: three requests afterrobots.txt, a second apart (the default delay being the longer), and 517 rows.
An option a template does not use – --max-depth and --any-site, and for
shopify --include, --exclude, --induce and --respect – is refused,
not ignored. From Python, sitemap_pages() and shopify_products() in
sluicer.crawl take crawl’s arguments.
Rights a site reserves¶
--respect tdm (respect_tdm=True, and for an agent respect_tdm on
crawl_site, extract_declared and page_markdown) gives a page whose text
and data mining rights are reserved as an error, tdm_reserved, never its
data. The reservation is TDMRep’s, a W3C Community Group final report of
2024-05-10 written for the EU’s DSM Directive, Article 4, read in its own
order: the site’s /.well-known/tdmrep.json first – read once per site,
through its robots.txt and its delay – then the page’s TDM-Reservation
header, then its <meta name="tdm-reservation">, each later one superseding
the earlier, an absent one resetting nothing. In the file the first rule
whose location matches is the one, as the report says, not the longest.
The page is fetched either way: a reservation in its meta tags is only seen
in the page. The error says which declaration reserved it and the policy it
names, if any. Without --respect, nothing is refused, and the reservation
is still reported in each page’s rights.
What a crawl follows¶
Breadth first from the start, max_depth links deep (3 by default), taking at
most max_pages addresses (100) whatever becomes of them, so a page that
failed counts. A link is followed when:
- it is an
<a href>or<area href>, resolved against the page’s<base>, on a page that answered below 400: an error page’s links, and its canonical, are the error page’s, so a 404 is reported with its status and nothing is admitted from it; - it is not
rel="nofollow", and its page’s<meta name="robots">(orname="sluicer") does not saynofollowornone; - it is on the start’s site, unless
--any-site(same_site=False); - it does not name a file by its extension –
.pdf,.jpg,.zipand the rest insluicer/crawl/urls.py: fetching a video to learn it declares nothing costs the site the video; - one
--includepattern is found in it, when any is given, and no--excludepattern is. Patterns are regular expressions searched in the whole address; they never apply to the start; - it has not been seen. Where a page’s redirects landed, and the canonical it declares on its own site, are marked seen too, so neither is fetched again under the other name.
A redirect that leaves the start’s site is refused before the other site is
asked: the page says redirected_off_site and names its target. With
--any-site, and always in a batch, that target is taken in its own turn, as
an address of its own, so its site too is asked one request at a time.
One spelling per address¶
Two spellings of one address are one address. The rules are few, because each is a claim about what a server does:
- the scheme and host are lowercased, a host’s trailing dot dropped, a name outside ASCII written in its IDNA form, and a default port removed;
- the fragment is dropped: it never reaches the server;
- an empty path is
/, and.and..segments are resolved; - characters an address cannot carry are percent-encoded, every escape in capitals;
- the query is kept as written, its order included, since a server may read
?a=1&b=2and?b=2&a=1differently; - a trailing slash is kept:
/aand/a/are two addresses, because a server may answer them differently. Most sites redirect one to the other, and where a redirect landed is marked seen, so the second spelling is fetched again only when it was queued before the first was read.
An address with a user name or password in it, or a scheme other than http and https, is never taken.
The output is the state¶
Each page is one line of JSON, written the moment its turn comes:
{"url": "https://shop.example/p/1", "ok": true, "depth": 1,
"found_on": "https://shop.example/", "landed": "https://shop.example/p/1",
"fetch": {"rung": "http", "status": 200, "seconds": 0.212, "climbs": []},
"canonical": "https://shop.example/p/1",
"summary": {"title": {"value": "Brake pad set", "source": "jsonld", "key": "Product.name",
"where": "/html/head/script[1]#/name"}},
"records": [...], "sources": ["jsonld"],
"links": ["https://shop.example/c/brakes", "..."]}
{"url": "https://shop.example/cart", "ok": false, "depth": 1,
"found_on": "https://shop.example/",
"error": {"code": "refused_by_robots", "message": "https://shop.example/cart is refused: its robots.txt disallows it", "retryable": false}}
summary, records and sources are what sluicer extract gives for the
page. links is every address it lets a crawl follow, whether or not the
crawl took it, so the file is also the site’s link graph.
Handed the same file with --resume (state= in Python), a crawl replays its
lines to rebuild what it had seen and taken, and continues where they end,
fetching none of them again. That works because the order is decided, not
raced: every address is numbered as it is admitted, and what a page admits is
decided when its turn comes, never when its fetch happens to finish. The same
site crawled twice gives the same pages in the same order, whatever the
concurrency; a crawl stopped after four pages and resumed ends with the same
file as one that ran through, and the live check compares the two. A file
written by a crawl of another site or with other options is refused, not mixed
– with one exception that is safe: a larger --max-pages continues where the
smaller stopped. A last line cut off by a stop mid-write is dropped and its
page taken again, once the rest of the file is known to be this crawl’s; a
file that is not is refused and left as it was. Without --resume, a file
that already holds pages is never appended to.
A batch resumes the same way, skipping every address its file holds.
As a table¶
--format csv on crawl and batch writes a row per page instead of a JSON
line, to stdout or --out, for a spreadsheet. The columns are fixed, so every
file has the same header, written before the first page is fetched:
| columns | what they hold |
|---|---|
url, ok, depth, found_on, landed, status, rung, seconds, canonical |
the page, as its line says |
climbs, retries, records, links |
how many its line lists |
error, message |
its error’s code and sentence, empty when it has none |
sources, types |
the readers that found something and the types its records declared, each once, a space between |
summary.title … summary.breadcrumb |
each summary question, in sluicer.summary.FIELDS’s order: the answer’s value alone |
The summary’s reader, key and place, and the records themselves, are in the
JSON line and not in the table. True and false are true and false, and
nothing is an empty cell. The pages are other people’s, and a cell that
begins with =, +, -, @, a tab or a carriage return would run as a
formula in a spreadsheet: it is written after a ', as OWASP advises, unless
it is a number of ASCII digits. It is judged behind the spaces, line breaks
and no-break spaces a spreadsheet may trim, and with a fullwidth = or +
read as the sign it looks like. A table holds no page’s links, so it cannot be resumed:
--resume refuses it; crawl as JSON Lines and make the table after.
sluicer map --format csv is a row per address: url, lastmod,
sitemap. From Python, sluicer.crawl.table.page_row() flattens a page’s
line.
Exit codes¶
The single-page commands’ three meanings, over the whole run, pages resumed
included: 0 when a page gave something – a record or a summary answer – 1
when pages were read and none did, 2 when none could be read or the command
could not start. map exits 0 when it found an address. Ctrl-C exits 130 with
what was written intact, and --resume continues it.
Sitemaps¶
The sitemaps a map reads are the ones robots.txt names, in its order; when it
names none, /sitemap.xml, and /sitemap_index.xml if that could not be
read. An
index is followed to the sitemaps it lists on the same site; one robots.txt
names may live anywhere, since the site vouched for it. Only addresses on the
site are kept, each once, in the order the sitemaps list them, with their
lastmod as written. When the sitemaps give no address on the site, the start
page’s links stand in, and source says so.
A sitemap is XML from a place you do not control, so it is read as an attack
would be written. No entity is resolved and nothing is fetched from inside it,
and a document that declares any document type is refused outright: a sitemap
never needs one, and billion laughs and external entities both arrive through
one. Gzip is told by its first bytes, not its name or a header, and inflated
no further than 16 MiB. A sitemap that stops being well-formed XML – an
unescaped & in an address is the commonest bug – keeps the entries before
the break and says where it broke. A sitemap that is a list of addresses, one
a line, is read too. A soft 404, the site’s HTML where a sitemap was expected,
is reported as not a sitemap.
Every bound is stated: at most 50 sitemap files per map and 50,000 addresses,
the protocol’s own limit for one file, and a sitemap listing more says so; a
sitemap named again and again is asked for once. sluicer map --time-budget
SECONDS (time_budget=) asks for no further sitemap once the time is spent;
none by default, and a minute for the MCP tool. The answer’s truncated is
true whenever a bound – the limit, the number of sitemaps, the time –
stopped the map before the sitemaps were read out.
For an agent¶
Three MCP tools, each small and on a clock, each answering with ok like the
rest:
map_site(url, limit=100)– up to 1,000 addresses from up to ten sitemaps, within a minute.crawl_site(url, max_pages=10, max_depth=2, include, exclude)– up to 25 pages, three links deep, onurl’s site, within a minute. Each page comes back with its summary and the types it declared, not its records; callextract_declaredon a page for those.includeandexcludeare plain text an address must or must not contain, not patterns. A site asking for more than ten seconds between requests is answeredcrawl_delay_too_longrather than holding the agent a minute a page.okis false only when no page could be read, anderrorthen says why.extract_many(urls, records=false, induce, respect_tdm)– up to 25 addresses the agent already has, on one site or several, in the order given, within a minute: each site one request at a time, several sites at once, where a loop ofextract_declaredcalls waits a second behind each one and retries nothing. Each page comes back ascrawl_site’s do, and with its records whenrecordsis true. Measured on the 140 pages of the product benchmark, a page’s summary weighs 2.3 KB at the median and its records 4.2 KB more: 25 summaries fit the 75,000-byte bound an answer is held to, 25 pages’ records do not, so past the bound the heaviest pages’ records go first, counted inrecords_left_out.
A page of any of them asked again says so in retries.
All three refuse addresses off the public internet, every page and every sitemap,
unless the server was started with SLUICER_ALLOW_PRIVATE=1. The minute stops
the tool starting requests; a page already started finishes.
What it does not do¶
The places where the crawler stops, and why, are in known limits.