Refusals
Every answer the engine gives instead of a page, the shape of its message, the story behind it, and what a caller can do.
A page the engine cannot read is a refusal, never a guess. The rule exists because the
opposite used to happen: Reddit's "You've been blocked by network security" came back as
three blocks, typed listing at 86% confidence, with a green tick; Amazon's WAF challenge
— HTTP 202 and two kilobytes of window.awsWafCookie — was a successful extraction of
zero blocks. Every refusal below is raised from resolve.py (or fetch/robots.py), quotes
the server's own words where it wrote any (skipping its stylesheet, which used to be most
of the quote), and reaches the API as a 502 with the message as detail.
The refusals
Block page — PageBlockedError, kind block.
could not resolve <url>: the site served a block page instead of the content; it said: "<evidence>". Two conditions, both required: the page is under MAX_BLOCK_PAGE_CHARS
(1,500) and says one of the things a wall says — "you have been blocked", "verify you
are human", "just a moment...", "ray id:". Short pages exist, and long pages mention
captchas.
Bot challenge — kind challenge.
could not resolve <url>: the site answered with a Cloudflare bot challenge -- a script a browser must run before the page is served -- and no page. A challenge page has no
visible words, so the phrase test never fires; the evidence is the vendor's script in the
markup (Cloudflare, AWS WAF, Akamai Bot Manager, DataDome, PerimeterX, Imperva, Kasada,
Distil). Only an empty document is judged this way.
Login redirect — kind login (PR #73).
could not resolve <url>: redirected to a login page (<final url>); the page requires a sign-in and nothing of it was served. Three things at once: the fetch ended somewhere
else; the final URL holds a LOGIN_PATH_MARKERS segment (/login, /uas/login, /sso,
…) or a LOGIN_RETURN_PARAMS parameter naming the page asked for (?dest=, ?next=);
and the document is a login page — a password field, or under 150 words. old.reddit.com's
threads (4 words) and linkedin.com/feed (52 words, two password fields) are refused;
news.ycombinator.com, with a "login" link and 711 words, is a page.
Undeclared automated tool — kind undeclared (PR #82).
could not resolve <url>: the site admits automated clients only when they declare who runs them -- a User-Agent naming an operator and a contact address -- and this deployment has nothing to declare; set WEBGRAPH_CONTACT='Name contact@example.com' and the site is asked again in the form it documents. It said: "<demand>". sec.gov answers the
browser-shaped User-Agent with 403 and "Your Request Originates from an Undeclared
Automated Tool … declare your traffic by updating your user agent". That is a question,
not a wall, and with WEBGRAPH_CONTACT set it is answered: the page is fetched again,
both ways, as <contact> webgraph/0.1. The contact goes only to a site that asked, and
it is not a disguise — the client is still named webgraph.
robots.txt disallowed — PageDisallowedError (PR #83).
could not resolve <url>: <origin>/robots.txt disallows <path> for this client (`User-agent: *` / `Disallow: /`): the site does not want automated readers here. The site offers the Stack Exchange API (…); or supply the HTML you already have (`html` on /api/text) and the engine reads that. The page is not fetched at all — reading it and
then refusing would be the request the site asked not to receive. The group and rule
quoted are the ones that decided; ROBOTS_SANCTIONED_SOURCES names the site's API where
the engine can cite one (Stack Overflow, Reddit). fetch.respect_robots on the request is
the caller's explicit override.
Missing — PageMissingError.
HTTP 404: page does not exist (also 410). A 404 page renders perfectly well, and without
this gate a soft-404 became "# Not Found" in the output.
Non-HTML. No dedicated message. The plain fetch yields no document when the content
type is not HTML; the browser refuses the navigation as a download ("the server returned a
file download rather than a page"); _both_failed composes the two. A PDF belongs to a
document pipeline, not this one — which is why discovery
now counts them.
Empty shell — PageShellError.
could not resolve <url>: the page is a JavaScript shell with no readable text until a browser runs it (HTTP 200, 12,345 bytes of markup); rendering was not used for this request. Only on a static-only request; /api/text escalates to the browser when one is
available, and /api/extract reads the shell's hydration payload, which is complete in
the shell. A document with no text and no shell markers is refused as the response produced no readable text (… bytes of markup, none of it visible).
Both fetches failed. One message covering both paths, because they usually fail for
different reasons: could not resolve <url>: HTTP 403 -- the site refused this client; it said: "…"; browser: <first line of the render error>. Reporting only the second cannot
tell "the site refused us" from "the browser could not start". Statuses are said in words
(BLOCKING_STATUSES: 401 requires a sign-in, 403 refused this client, 429 rate-limiting,
451 legal, 503 too busy — which is also how several sites refuse bots).
A 5xx render on its own is not a refusal. flipkart.com/mobiles gave the plain fetch
the listing and Chromium a 503; the static page stands alone with
render_error: "HTTP 503 -- the site said it was too busy…" (PR #89). Only both sides 5xx
refuses. Likewise a wall on one side is left out and named while the other is read, so
long as it has twenty words of its own (PRs #64, #67).
What a caller can do
Bring your own HTML (PR #84). /api/text and /api/text/stream take html: the
page as the caller already has it, from their own signed-in browser, an extension or a
saved file. Nothing is fetched or rendered; the paste is read as Strategy.SUPPLIED and
returns the same text / markdown / content_markdown. This is the answer for sites that refuse every automated fetch — stackoverflow.com answers
both with a Cloudflare challenge, nyc.gov with Akamai's — because the owner's decision is
not to disguise the client; the reader who has the page hands it over.
A paste is still never a false output: a pasted wall raises the same errors a fetch would,
and a pasted login page is caught by its own <link rel="canonical"> or og:url standing
in for where a fetch would have ended. render_error says what the result is: "HTML
supplied by the caller; not fetched or rendered -- reading order is source order, and what
the browser would have hidden may appear".
Use the site's API where the refusal names one. Set WEBGRAPH_CONTACT where the
site asked who is calling. Pass fetch.respect_robots: false only deliberately — the
default is the site's rule.
Filtered content
How content_markdown is drawn from the whole page — landmarks, scopes, site chrome, then a boundary — and why it must not regress.
What a crawl does
Discovery and extraction interleaved, one event per page, the SiteConfig knobs with their defaults, and how the fetch strategy is chosen.