Known limits¶
Everything here is deliberate, measured, and open. It is not a bug list: it is the set of places where Sluicer currently stops, written down so nobody has to rediscover them, and so a contributor inherits decided questions instead of surprises.
In the extraction path¶
A reference is followed one hop. A JSON-LD node that names another by
@id gets that node in its place, so an article's author and publisher arrive
whole; a reference inside the node that replaced it stays a reference. Measured
on a Yoast blog post, three hops instead of one took the output from 22 KB to
38 KB, because every record re-expanded the same graph.
The summary answers by rule, not by judgement. Its subject is the first
declared record about a thing, ahead of pages, sites and furniture, and each
question takes the first candidate in a stated list. A page that misuses a term
is answered by that misuse, except where a rule was written for it: Wikipedia
puts its short description in headline, so when only name appears in the
title the page shows, name is the title.
Gap-filling targets the first record of a type. When a page declares several
records sharing a type, a lower-precedence reader fills the first one in document
order. On a page whose @graph opens with a BreadcrumbList, an og:title
lands on the breadcrumb rather than the product. Document order is the only
deterministic signal available; the template contract planned in the roadmap is
where a better one can come from.
A declared encoding can still disagree with the bytes. Bytes are decoded
the way a browser decodes them: a byte order mark, then an XML declaration or a
<meta charset> anywhere in the head, then UTF-8 if the bytes are valid UTF-8,
then windows-1252. A page whose declaration lies about its bytes is read as it
declares, as a browser reads it. A caller holding the transport's charset header
has better information than the page, and nothing currently accepts it. When
only text is available and lxml cannot parse it, the retry reads it as UTF-8
whatever the document claims, because the text has already been decoded.
RDFa is read as Lite, not as a graph. The RDFa reader stops where the graph begins: vocab, prefix, typeof, property and
resource are read, and chained subjects, typed literals and inference are not.
Anyone who needs the full graph is better served by a triple store than by this
pretending.
Microformats is off unless you ask, and flattened when you do. It is the
only reader behind an extra, because it is the only one that cannot be written
in the lxml the base install already carries: mf2py is the reference parser
and costs twelve packages against a base install of three. extract(html,
microformats=True) turns it on and raises MicroformatsExtraMissing when the
extra is absent. What it returns is flat, and a tree is not: a repeated property
keeps its first value, so a second p-category is dropped; a nested item
contributes its name and its link, author and author@url, and nothing else,
so an h-geo holding only a latitude contributes nothing at all. The address is
read from the nested item's url property rather than from its value, which
was measured rather than assumed -- for a p- prefixed item the value is the
name, not the link. metaformats is left off, so OpenGraph and Twitter card
tags are never reported as microformats; each has a reader of its own here.
Eight vocabularies fold onto one flat set of keys, and some of them collide.
og:image:alt and twitter:image:alt both strip to image:alt; a Dublin Core
title lands on the same key as an og:title. RDFa shortens schema.org terms
to the names the other readers use and keeps other vocabularies' full IRIs, so
only schema.org terms collide across vocabularies. The precedence decides who
wins -- JSON-LD, microdata, microformats, RDFa, Dublin Core, OpenGraph, the
Twitter card, HTML's own metadata names -- so the answer is stated and stable
rather than decided by the order the page's author typed. What is lost is the
loser: it is dropped, not kept under a qualified name. Microformats keeps its
own type spelling: an h-entry records @type as h-entry, so it
never folds with a schema.org Article that means the same thing.
In extractors¶
A listing's place is an exact path. A new wrapper or a renamed class above
the rows fails the listing check. That is what makes the failure loud, and
heal finds the new place; it also means one cosmetic change upstream of the
rows is enough to fail a run.
One listing per extractor, the page's most promising repeated group. A page with two listings that both matter needs two extractors, and there is no way yet to point compile at the second.
Healing matches by values. A field is moved only when its new place holds values it held before; a same-shaped field with new values is not a match, so a redesign that changes the markup and every value at once is reported as fields vanished and new, not as moves. The samples are five values, so on a listing whose rows changed completely between learning and healing, heal finds nothing to match and says so.
The thresholds are fixed. 20% of rows may lack a required field, half a field's values must keep its shape, and a shape needs five values to be learnt or checked. Editing the JSON changes what was learnt, not the thresholds.
Two same-shaped text columns can swap unnoticed on a page the extractor was
not learnt from: a title and a stock line are both letters. The values check
catches a swap only when one side becomes the same in every row.
How often extractors survive real redesigns is not measured. The fixtures are written by hand; the benchmark on real before-and-after pages is next on the roadmap.
In announcing ourselves¶
The user agent on the wire is verified by hand, not by the suite. Both the
HTTP and the browser rung were confirmed on 2026-09-22 against a live request,
and the server saw Sluicer/<version> (+https://github.com/Gi0tto/sluicer) from
each. The suite cannot re-check it, because it must never open a socket, and a
faked library accepts whatever keyword you hand it. That gap is real and it has
already cost once: the browser rung was passing the name in extra_headers,
which the browser context silently overrides, so the tests passed while every
site saw Chrome. It now passes useragent, which reaches the wire. The same
check on 2026-09-22 found scrapling's defaults adding Referer:
https://www.google.com/ and a Chrome TLS fingerprint under our name; both are
now turned off, and a local server confirmed neither reaches it. If you change
how a rung is built, ask a real server what it saw.
The robots answer is believed for a day. A site that adds a rule is noticed
within twenty-four hours, not immediately. The process remembers the answers of
the 4,096 sites it used most recently (ROBOTS_CACHE_HOSTS); a site pushed out
is only asked again.
Robots.txt is asked where a redirect landed, after it landed. A page whose
redirects ended on another host is refused if that host's robots.txt says no,
but the hops in between were requested without asking theirs. Addresses are
stricter: with allow_private=False every hop is judged before it is requested,
by both rungs (see the MCP section below).
Crawl-delay is read by nobody. Sluicer fetches one page when you ask for
one page, so there is nothing yet to pace, and that stops being true the day it
crawls.
The stealth rung does not announce itself, deliberately. It exists to not be recognised, and announcing yourself and then evading is incoherent. It is not part of the automatic ladder for the same reason: climbing on a measurement from plain HTTP to a browser is a change of cost, while climbing from announcing yourself to hiding is a change of character, and it should not happen to a caller who never asked for it.
In fetching¶
A challenge is detected by words, on a page that is not content. A title that is the challenge ("Just a moment...") counts on any page; a marker anywhere else counts only on a page that declared nothing about a thing and carries less than 1,500 characters of text. Until 0.3.0 any marker anywhere counted, and an article quoting "just a moment", or any page carrying Cloudflare's bot-detection script, bought a browser it did not need. A short challenge page that declares a record would now be missed; none has been seen. The reason string names the marker, so a mistake either way is visible.
A legitimately empty body is treated as a failed rung. A site that answers 200 with nothing costs a climb. A response with no HTML is not a page, and the ladder already knows how to climb past a failure, so this was the cheap side of the trade.
A failed climb returns the cheaper page. When a rung fails after a cheaper
one brought something back -- a fresh install has no browser -- that page is
returned and the failure is recorded as a climb back down to it, so the caller
can see it is the HTTP rung's answer to a page that wanted a browser. When every
rung failed, FetchFailed says what each one said; nothing is returned that
could pass for a page.
A redirect to a login page is not detected as a refusal. A refusal status, a challenge page and a skeletal body are.
The browser decodes its own pages. The HTTP rung decodes the bytes itself,
with the sniffing load() uses and the response's own charset where the HTML
standard puts it, between a byte order mark and the page's declaration. The
browser rungs hand over the DOM the browser built, decoded by the browser.
The same page is parsed twice on a successful URL fetch, once by the ladder to decide whether to climb and once by the caller. Deterministic, so the cost is time rather than correctness.
In the MCP server¶
The address filter covers every request, and the browser's names are its
own. The server refuses addresses off the public internet before any
request, and both rungs judge every address a redirect names before asking it.
The HTTP rung connects only to the addresses it checked, so DNS rebinding
reaches nothing there. The browser rung routes every request the page makes --
images, frames, fetch(), websockets -- through the same judgement and never
lets the browser follow a redirect itself, and pages get no service workers,
which fetch outside any route. What remains: the browser resolves names in its
own network stack, so a name that answers differently between the check and the
connection is reached from there. SECURITY.md says so too.
A page is bounded at 16 MiB, and what reaches an agent at 200,000
characters. The HTTP rung stops reading past MAX_RESPONSE_BYTES, after
decompression, so a gzip that inflates to gigabytes costs the bound; before
0.3.0 a 200 MB response was measured holding 1.14 GB. The browser rungs are held
to the same bound only once the page is loaded: the browser's own memory is the
browser's.
Every error answer has is_error false. A missing extra, a robots
refusal, a refused address, a failed fetch, a page too heavy and a bad input
come back as results, {"ok": false, "error": {"code", "message",
"retryable"}}, not as protocol failures, so an agent that branches only on the
protocol's flag will not notice; one that checks ok will. The alternative is
worse: a raised exception becomes Error executing tool fetch_page and the
sentence that says what happened is discarded by the SDK. Measured against mcp
2.2.0.
The output schemas stop at the extractor. Every tool publishes an output
schema built from sluicer.mcp_answers, and ok is its one required key.
Records, fields and summary answers are typed; a field's value is any JSON, as
the page declared it, and the extractor object is a plain mapping whose shape
is documented in extractors, not in the schema.
The six tools are pinned by set equality, so a seventh cannot appear unnoticed.
In the shape of the code¶
A new reader answers summary questions only once the summary is taught.
Adding a vocabulary is one entry in sluicer.declared.readers.READERS: its
name, its read function, whether it describes things or the page, and the
extract flag that turns it on if it is optional. The order of that tuple is
the precedence, and the fold, the sources list and ABOUT_A_THING all read
it. The summary still asks the document-level vocabularies by name, since
og:title and twitter:title are questions about what each one says, so a
new document-level reader fills records but answers no summary question until
sluicer.summary knows its keys. Until 0.3.0, a reader was written into three
places and merge took eight parameters.
A hand-built Record can be silently inert. Record(type="Product")
constructed by hand gets an empty types, and the fold reads types, so that
record never folds with anything. Nothing validates that the two agree. A
__post_init__ would close it.
In the tests¶
The no-model test is a floor, not a ceiling. It scans the source for the
network clients and the hosted and local model clients by name -- openai,
anthropic, google.genai, litellm, ollama, transformers, the
langchain and llama_index families and more -- in import statements and in
module names handed to import_extra, import_module and __import__ as
strings. It does not catch http.client, ftplib, or a name built at runtime.
socket is allowed in one file, fetch/address.py, for name resolution.
Determinism is tested within one process. Two calls, one fixture, same answer. Stability across processes and across two pages built from the same template is what the design actually promises, and neither is covered yet.
In the repository¶
The gates cover src, and only what they can see. ruff, mypy --strict
and a 97% coverage floor run on every push and pull request. What they do not
reach is worth naming. mypy checks src and not tests, so a test can still
say something untrue about a type. lxml ships no type information, so every
element this package touches is Any to the checker and the annotations
around it are documentation rather than proof. The optional extras are
imported by name at call time, which is the whole point, and means no checker
ever sees them -- the with-extras job is the only thing that does, and the
microformats reader was confirmed against the real mf2py by hand, in a
throwaway environment, because the suite fakes it. And a
gate answers "is this well formed", never "is this right": four review passes
found things no rule set encodes, and the gates were added so those passes
can spend their attention elsewhere, not so they can stop.