FAQ
Refusals, the two fetches, Cloudflare, robots.txt, PDFs, JavaScript-only sites, right-to-left pages, speed and memory.
Why was my page refused?
Because the engine could not read it and will not pretend otherwise. The refusal names the
cause in the site's own words where it has any: a block page ("you have been blocked"), a
bot challenge with the vendor identified (Cloudflare, Akamai, DataDome, PerimeterX, AWS
WAF…), a redirect to a login page, a robots.txt rule for this client, a 404 or 410, or a
JavaScript shell that was not rendered. On /api/text that is HTTP 502 with the message as
detail; on the streams it is an error event. The types and their kind values are in
Concepts: refusals. If the page renders fine in
your own browser, see the Cloudflare question below.
Why does it fetch every page twice?
Because neither fetch is complete on its own and you cannot tell in advance which one will
be short. Frameworks replace server-rendered markup on hydration; consent scripts remove
articles (bbc.co.uk/news: 19,908 characters static, 9,279 rendered); lazy sections never
mount. A requires_render heuristic was the original design and, on the seven sites that
measurably lost content on the static path, returned False for all seven (README,
Stage 3). So UNION fetches both and merges, and static_coverage on the resolve event
tells you how much the plain fetch alone would have given. Leave render at its default
false on /api/text, or pass "complete": false on a site, to choose one fetch and know
what you chose.
Why not just use a headless browser for everything?
Rendering loses content on several measured sites — the union beat rendered-alone on 4 of
10 (README, Stage 3) — and a browser is the expensive half. Render alone is available as
Strategy.RENDERED_ONLY for a page whose static HTML is known to be a decoy, but it is not
the default.
Does it bypass Cloudflare or login walls?
No. A site that refuses every automated fetch — stackoverflow.com answers both fetches with
a Cloudflare challenge, nyc.gov with Akamai's — is refused, and the engine does not disguise
the client to get past it; resolve_supplied's docstring calls that "a contest the engine
would lose to the next rule change anyway". What you can do (PR #84) is hand over the page
you already have: send its source as html on /api/text or /api/text/stream, from your
own signed-in browser, an extension or a saved file. Nothing is fetched; url stays
required so links and images can be made absolute. The output is the same text /
markdown, marked strategy: supplied, with render_error saying that reading order is
source order and hidden text may appear. A pasted wall is refused exactly like a fetched
one, including a pasted login page whose <link rel="canonical"> gives it away.
Does it respect robots.txt?
On request. Since 19 September 2026 the default is to read every page the engine can
reach; respect_robots: true (on fetch for a page, on crawl for a site, or in
Settings) asks the host's robots.txt before fetching — one fetch of the file per host per
hour, matching rules for the token webgraph (ROBOTS_AGENT_TOKEN) — and a disallowed
page is then not fetched at all: the refusal quotes the group and rule that decided and
says what the site offers instead. Two things do not depend on the switch: politeness
(one page a second per host, and the file's Crawl-delay when larger), and the
site report, which measures what the site declares and always obeys
it.
Does it read PDFs?
No. The engine reads HTML (FetchResult.is_html); a PDF URL is refused as a page rather
than extracted. A crawl still discovers them: discovered_kinds.pdf counts them on every
frontier and page event, and the UI's "URLs found by kind" row warns when files
outnumber pages. On vtu.ac.in 7,907 of 17,126 discovered URLs were PDFs (PR #87).
What about JavaScript-only sites?
An empty <div id="root"> is a shell, and the union renders it. With "render": false,
/api/text escalates a shell to the browser on its own; /api/text/stream does not, and
reports PageShellError as an error event. With no browser available the page is refused
on /api/text too, while /api/extract still reads the shell's hydration payload (__NEXT_DATA__, JSON-LD), which is often complete without a render. What a render
cannot reach is interaction-gated content — accordions, modals, "load more" — which is
invisible to the engine (README, Known limitations).
Does it handle other languages and right-to-left pages?
Text in any language passes through unchanged. Reading order needs the direction, because
XY-cut reads columns right to left on an RTL page: dom.blocks.is_rtl_document takes dir
on <html> or <body> first and lang (he, ar, …) second, since sites such as
ynet.co.il declare no dir at all. The streaming route and the crawl detect it. On
/api/text the request's rtl field defaults to false and is passed through as a forced
value, so pass "rtl": true for an RTL page there.
How fast is it?
A single page behind a browser render can take about ten seconds (webgraph.page); the
plain fetch alone is much faster. Defaults: 20 s fetch timeout, 30 s render timeout with a
900 ms settle after load (webgraph.config). A crawl reuses one browser per worker
thread; measured on 12 renders, one worker went from 8.5 to 11.6 pages a minute with reuse
and six workers from 21.9 to 39.1 (README, Stage 3). pages_per_minute on every page
event reports the live rate.
How much memory does it need?
About 150 MB of resident memory per live Chromium, capped process-wide by
WEBGRAPH_MAX_BROWSERS (6 by default, 4 in the container image). docs/DEPLOY.md budgets
"roughly a gigabyte of memory before the Python process is counted" and sizes the
container at 2 vCPU / 4 GiB. The image itself carries about 400 MB of browser.