WebGraph

Sites

stream_site, every SiteConfig field with its default and what turning it does, the events the crawl yields, and how to stop, seed and save a crawl.

stream_site

from webgraph import stream_site, SiteConfig

for event in stream_site(
    root,
    config=SiteConfig(),   # SiteConfig | None
    schema=None,           # dict | None -- a JSON Schema to extract facts against
    should_stop=None,      # Callable[[], bool] | None -- polled as each page lands
    seeds=None,            # Iterable[str] | None -- addresses queued at depth 1 beside the sitemap's
    builder=None,          # GraphBuilder | None -- filled in as pages arrive
):
    ...

A generator: it profiles the site, reads robots.txt and the sitemaps, then crawls and extracts interleaved — every page's links extend the queue, breadth-first, so the first result arrives in seconds and the crawl reaches what the sitemap forgot. Each yielded event is a dict with a type; page events carry the page. It is exactly what /api/site/stream sends, one event per line.

ParameterTypeDefaultWhat it does
rootstr—Where to start. Redirects are followed and the crawl is scoped to where the root lands (docs.pydantic.dev redirects to pydantic.dev/docs/..., and scoping to the request would reject every link)
configSiteConfig | NoneNone = SiteConfig()Limits, scope, politeness, fetch and render settings — below
schemadict | NoneNoneA JSON Schema. Each page is also mapped against it with provenance for every value, and the done event carries site_facts / fact_sources. See the API's extract for the schema conventions
should_stopCallable[[], bool] | NoneNonePolled after each page. Return True to end the run cleanly: the pages in flight finish and are reported, then done arrives with stopped: true. A generator cannot be interrupted from another thread while a render is in flight, so this flag is how a client that went away stops a crawl
seedsIterable[str] | NoneNoneAddresses queued at depth 1 beside the sitemap's, cited via: "seed". A watch passes the previous run's URLs, so a page nothing links to any more is still fetched and its 404 recorded as the page going away
builderGraphBuilder | NoneNoneA webgraph.graph builder to fill in as pages arrive; it stays yours, so the graph is usable during a long crawl and after a stopped one

The root is fetched once: the profiling fetch is the crawl's first result.

Reading the events

from webgraph import SiteConfig, stream_site

pages = {}
for event in stream_site("https://docs.python.org/3/tutorial/", config=SiteConfig(max_pages=3, max_depth=1, within_path=True)):
    match event["type"]:
        case "analysis":
            print("built with", [t["name"] for t in event["technologies"]], "| strategy", event["strategy"])
        case "discovery":
            print("robots:", event["robots"]["found"], "| sitemap urls:", event["sitemaps"]["total_urls"], "| seeded:", event["seeds"])
        case "page":
            if event["ok"]:
                pages[event["url"]] = event["markdown"]          # the whole page; content_markdown is the opt-in reduction
            else:
                print("refused", event["url"], event["error"])
        case "done":
            print(event["pages_ok"], "of", event["pages_total"], "pages;", "stopped by", event["stopped_by"], "in", event["duration_seconds"], "s")

Measured as written on 19 September: three pages in 11.3 s, stopped_by: "pages", 39 addresses discovered, at one page a second per host.

typeWhenCarries
stageThree times: analyze, enumerate, extractstage, message; the extract stage adds the applied max_pages, max_seconds, max_queue, unlimited
analysisOnce, after the root was fetched both waystechnologies, frameworks, strategy (the one the crawl will use), static_chars / rendered_chars / union_chars, render_required, render_loses_content, metadata (the root's <head>, see PageMetadata)
discoveryOncerobots (found, the rules for this client, crawl_delay), sitemaps (each attempt with status and URL count), seeds accepted, common_crawl (what Common Crawl's index last saw of the host, or null)
frontierOnce, after seedingqueued, discovered, depth_counts, discovered_kinds (page, pdf, image, …), refused by reason
fetchingBefore each batchurls in flight, queued, extracted, failed
pageOne per page, refused or noturl, title, depth, citation (how the address was found: via, found_on, anchor), ok, error, markdown, content_markdown, content_methods, content_blocks, page_type and its confidence, strategy, static_chars, rendered_chars, links_out, canvas, render_note, running counters
warningWhen three distinct URLs return byte-identical textcode: "identical-content", urls
doneLast, alwayspages_ok, pages_total, failed, discovered, remaining_queued, exhausted, stopped, stopped_by ("pages", "time", "queue" or None), refused / refused_total / refused_urls, chrome_blocks, total_chars, duration_seconds, the graph counts, site_facts when a schema was given
errorInstead of everything, when the root itself cannot be crawledmessage

Every field of every event is documented on the API page; the dicts are the same objects.

markdown is the whole page and the default thing to keep. content_markdown is the opt-in reduction (landmarks, chrome and boilerplate removed) and is empty when nothing was removed — the content is the page — so a consumer who wants it takes event["content_markdown"] or event["markdown"].

SiteConfig

A frozen dataclass. Every field's default is a constant in webgraph.config and is what the API applies when a request leaves the option out.

Limits

FieldTypeDefaultWhat it does
max_pagesint500Pages to attempt, refusals included, before stopping. 0 is an explicit ask for an unbounded crawl. done.stopped_by == "pages" when this ended the run
max_secondsfloat3600Wall-clock budget from the start of the analysis. 0 is no limit. Checked as each page lands, so the run overshoots by at most concurrency pages. stopped_by == "time"
max_queueint20000Queued addresses beyond which the frontier turns new ones away (counted as queue-cap in refused). 0 is no limit. A run that drained a capped queue reports stopped_by == "queue" and is not exhausted
max_depthint12Links away from the root. 0 is the root alone; 1 adds what it links to or its sitemap lists. Breadth-first, so a page budget is spent near the root, where the pages that describe a site live

Scope

FieldTypeDefaultWhat it does
strict_domainboolTrueStay on the root's host (www.example.com and example.com are one host). False follows subdomains too — blog., shop., status. — which are usually separate applications; pair it with max_pages
within_pathboolFalseStay under the root's path: from example.com/docs/guide, /docs/... is followed and /blog/... is turned away as not-included
include_pathsstr""Comma-separated regular expressions searched in the path (never the host). When set, only matching addresses are crawled; the rest are counted not-included. A pattern that does not compile raises
exclude_pathsstr""Comma-separated regular expressions; a match is turned away as excluded, whatever else says
fetch_filesboolFalseQueue links to PDFs and other files. Off, they are counted in discovered_kinds and listed under skipped_urls, never requested
sitemap_limitint50000URLs read from sitemaps at most; sitemap indexes are followed up to 20 files
common_crawlboolTrueAsk Common Crawl's index, in the background, what it last saw on the host; reported in discovery.common_crawl. Nothing is asked of the site
seed_from_common_crawlboolFalseAlso queue what Common Crawl listed, at depth 1. Off because a stale listing is full of pages that are gone, each fetched and recorded as a 404

Politeness

FieldTypeDefaultWhat it does
respect_robotsboolFalseOff by default: the crawl explores every page it can discover. True turns away at the frontier what robots.txt disallows for this client; the file is read for its sitemaps either way, and the Site Report always obeys it. The per-page check inside resolve_page is fetch.respect_robots
host_interval_secondsfloat1.0At most one page per this many seconds from one host, across every worker. The site's Crawl-delay replaces it when larger. This is the number that makes a crawl take as long as it takes
delay_secondsfloat0.3Pause before each fetch, per worker
concurrencyint4Workers fetching at once. On one host the interval above is the real bound; concurrency helps when the crawl follows into subdomains

What comes out

FieldTypeDefaultWhat it does
remove_chromeboolTrueProduce content_markdown on each page: landmarks, cross-page site chrome and boilerplate removed. Chrome needs six pages before it can say anything and applies from then on
main_contentboolTrueAlso draw the main-content boundary when producing content_markdown. Off for a site whose pages are link hubs by design
strategyStrategy | NoneNoneForce STATIC_ONLY, RENDERED_ONLY or UNION for every page. None uses the verdict of the root's analysis: STATIC_ONLY when the static HTML was measured complete, UNION otherwise
fetchFetchConfigFetchConfig()The plain fetch, see Pages
renderRenderConfigRenderConfig()The browser, see Pages

Discovery (batch path only)

FieldTypeDefaultWhat it does
verify_inventoryboolTrueCheck each sitemap URL with a cheap request before spending a page on it
follow_linksboolTrueDiscover by following links as well as reading the sitemap
discovery_limitint400URLs harvested by link-following before verification

These three drive webgraph.site.extract_site, the enumerate-then-fetch path behind the CLI's webgraph site. stream_site discovers continuously and does not read them.

Saving a crawl

from pathlib import Path
from webgraph import SiteConfig, stream_site

out = Path("crawl"); out.mkdir(exist_ok=True)
config = SiteConfig(max_pages=200, within_path=True, exclude_paths=r"/print/|\?page=")
for event in stream_site("https://docs.python.org/3/tutorial/", config=config):
    if event["type"] == "page" and event["ok"]:
        name = event["url"].split("://", 1)[1].strip("/").replace("/", "__") or "index"
        (out / f"{name}.md").write_text(event["markdown"])

Stopping a crawl

import threading
stop = threading.Event()

def crawl():
    for event in stream_site(root, should_stop=stop.is_set):
        ...

threading.Thread(target=crawl).start()
...
stop.set()      # the pages in flight finish; `done` arrives with stopped=True

max_pages=0 with strict_domain=False is a crawl of everything a site links to within its subdomains, for as long as max_seconds allows. Politeness still holds — one page a second per host, robots.txt honoured — but the queue can reach max_queue on a large site, and the done event will say so.