graph-crawler
A web crawler that builds a graph of a site: pages are nodes, links are edges.
The result is a Graph object you can query, export, save and load again.
Pages are fetched through a chain of drivers with escalation: plain HTTP first, then HTTP with a browser TLS fingerprint, then a real browser, then an "undetected" browser. The browser step can use any engine: CloakBrowser, patchright, stock Playwright or your own binary.
Python 3.11+.
Install
pip install graph-crawler # HTTP only (aiohttp)
pip install "graph-crawler[browser]" # + browser (stock Playwright)
pip install "graph-crawler[antidetect]" # + TLS, patchright, CloakBrowser, nodriver
<<<<<<< HEAD
| extra | what it adds |
|---|---|
browser |
driver="playwright" with bundled Chromium (playwright = alias) |
tls |
curl_cffi — TLS fingerprint tier (driver="tls") |
stealth |
patchright + playwright-stealth — engine "patchright" |
cloak |
cloakbrowser — engine "cloakbrowser" (stealth in the binary) |
undetected |
nodriver — last tier (driver="nodriver") |
antidetect |
all four above |
server |
FastAPI dashboard / REST / WebSocket (graph_crawler.api.server) |
text |
tiktoken + stop-words — count_tokens, extract_keywords |
markdown |
html2md-clean + markdown-it-py — MarkdownPlugin |
mongodb, postgresql, celery, viz, articles, fuzzy, performance, system, all |
storage backends, distributed mode, extras |
| ======= | |
| extra | what it adds |
| -------------- | --------------------------------------------------------------------- |
browser |
driver="playwright" (playwright is an alias of this extra) |
tls |
curl_cffi — TLS fingerprint tier, driver="tls" |
stealth |
patchright + playwright-stealth — engine "patchright" |
cloak |
cloakbrowser — engine "cloakbrowser" (stealth in the binary) |
undetected |
nodriver — last tier, driver="nodriver" |
antidetect |
the four extras above |
server |
FastAPI dashboard / REST / WebSocket |
text |
tiktoken + stop-words |
markdown |
html2md-clean + markdown-it-py — MarkdownPlugin |
fuzzy |
rapidfuzz — graph.search(..., mode="fuzzy") |
mongodb, postgresql, celery, viz, articles, sitemap-fast, performance, system, all |
storage backends, distributed mode and other extras |
d3db85cd9c8826d11c10e33988c837f3e8ffbe88
After installing a browser extra, download the browser once:
playwright install chromium # stock Playwright / patchright
python -m cloakbrowser install # CloakBrowser binary (~200 MB)
# or point to an existing binary: export CLOAKBROWSER_BINARY=/opt/cloak/chrome
Quickstart
import graph_crawler as gc
graph = gc.crawl("https://example.com", max_depth=2, max_pages=50)
print(gc.quick_stats(graph)) # "12 nodes (12 scanned) | 31 edges"
for node in graph.nodes.values():
print(node.depth, node.response_status, node.url)
hits = graph.search("pricing") # [SearchHit(node, score, matched_fields, snippet), ...]
tree = graph.url_tree()
graph.export_nodes("nodes.json")
graph.export_edges("edges.json")
graph.nodes also holds pages that were discovered but not fetched (for example,
beyond max_pages); node.scanned tells them apart.
Async:
graph = await gc.async_crawl("https://example.com", driver="escalating")
Reuse one crawler for several runs (the driver and storage stay open):
with gc.Crawler(driver="escalating", storage="sqlite", storage_config={"path": "site.db"}) as c:
g1 = c.crawl("https://a.example")
g2 = c.crawl("https://b.example")
Crawl only some pages:
from graph_crawler import URLRule
graph = gc.crawl(
"https://shop.example",
url_rules=[URLRule(pattern=r"/product/", should_scan=True),
URLRule(pattern=r"/cart", should_scan=False)],
)
Start from a sitemap:
graph = gc.crawl_sitemap("https://example.com/sitemap.xml", max_urls=500)
Save and load:
gc.save_graph(graph, "site.json") # format="json" (default) or "sqlite"
graph = gc.load_graph("site.json")
CLI:
graph-crawler crawl https://example.com --max-depth 2 --driver escalating --save example
graph-crawler list
graph-crawler info example
Inspecting pages
Every node records how it was fetched:
node = graph.get_node_by_url("https://example.com/")
node.ok # scanned, 2xx/3xx, not blocked, no error
node.is_blocked # classified as an anti-bot block
node.channel # "http" | "tls" | "browser" | "undetected"
node.bot_block_status # None | "none" | "soft_block" | "challenge" | "hard_block" | "captcha"
node.served_by_driver # "aiohttp" | "tls" | "playwright" | "nodriver"
node.served_by_engine # "cloakbrowser" | "patchright" | "playwright" | None
node.escalations # how many tiers were tried before this one
node.fetch_info # all of the above as one dict
A response with status 400 or higher keeps metadata["error"] = "HTTP <code>",
so node.ok is False for error pages.
Drivers
driver= accepts a name or a driver instance.
<<<<<<< HEAD
| name | tier | needs | notes |
|---|---|---|---|
http / async |
http | — | aiohttp, default |
tls / curl_cffi |
tls | [tls] |
Chrome TLS fingerprint, no JS |
playwright |
browser | [playwright] |
real browser; engine chosen by browser_engine |
nodriver |
undetected | [undetected] |
CDP without webdriver, async |
escalating |
all of them | whatever is installed | http → tls → browser → undetected |
| ======= | |||
| name | tier | needs | notes |
| -------------- | ------------- | ----------------------- | ------------------------------------------------- |
aiohttp / http |
http | — | default |
tls |
tls | [tls] |
Chrome TLS fingerprint, no JavaScript |
playwright |
browser | [browser] |
real browser; engine chosen by browser_engine |
nodriver |
undetected | [undetected] |
CDP without webdriver |
escalating |
all of them | whatever is installed | http → tls → browser → undetected |
d3db85cd9c8826d11c10e33988c837f3e8ffbe88
escalating creates tiers lazily: a browser starts only when a cheaper tier is
blocked. Tiers whose dependency is not installed are skipped.
Anti-detect: escalation and browser engines
How escalation decides
Every response goes through classify_block():
| result | when |
|---|---|
none |
normal page |
soft_block |
429, plain 503, or 200 with an empty body |
challenge |
Cloudflare "Just a moment", cf-mitigated: challenge, Incapsula, DDoS-Guard |
| <<<<<<< HEAD | |
hard_block |
403 "Access Denied", Akamai _abck, DataDome headers |
captcha |
PerimeterX, DataDome captcha, reCAPTCHA / hCaptcha / Turnstile markup |
| ======= | |
hard_block |
403/401/405 with a vendor or "Access Denied" marker (Akamai _abck, DataDome/Incapsula headers, x-iinfo, Cloudflare server) |
captcha |
PerimeterX, DataDome captcha, reCAPTCHA / hCaptcha / Turnstile markup |
d3db85cd9c8826d11c10e33988c837f3e8ffbe88
Anything other than none, and network errors, move the request to the next tier.
A bare 403/401/405 without a vendor marker is classified as none (authorization
or method, not a bot block), so escalating does not open a browser for it and
logs the decision at INFO. If such a page is a block, use driver="playwright".
The tier that worked is remembered per host:
- the entry expires after
GC_TIER_TTLseconds (default 1200, about the lifetime of acf_clearancecookie); after that the host is probed from the cheapest tier again; - a block on the remembered tier drops the entry and escalates from the next tier;
- only one request per host escalates at a time, the others wait for its result;
- the memory is per process; workers do not share it.
Each FetchResponse describes what happened in extras (on a node — see
Inspecting pages):
resp.extras["bot_block_status"] # "none" | "soft_block" | "challenge" | "hard_block" | "captcha"
resp.extras["channel"] # "http" | "tls" | "browser" | "undetected"
resp.extras["driver"] # "aiohttp" | "tls" | "playwright" | "nodriver"
resp.extras["engine"] # "cloakbrowser" | "patchright" | "playwright" | None
resp.extras["escalations"] # how many tiers were tried before this one
Using it
import graph_crawler as gc
# default chain; the browser tier uses CloakBrowser if its binary is present
graph = gc.crawl("https://protected.example", driver="escalating")
# choose the browser engine and stop after the browser tier (0=http 1=tls 2=browser 3=undetected)
graph = gc.crawl(
"https://protected.example",
driver="escalating",
driver_config={"browser_engine": "patchright", "max_tier": 2},
)
As a driver:
from graph_crawler.infrastructure.transport.escalation import EscalatingDriver, EscalationPolicy
async with EscalatingDriver(policy=EscalationPolicy.default(browser_engine="cloakbrowser")) as d:
resp = await d.fetch("https://protected.example")
print(resp.status_code, resp.extras["channel"], resp.extras["engine"])
A custom chain:
from graph_crawler.infrastructure.transport import DriverType
from graph_crawler.infrastructure.transport.escalation import EscalationPolicy, EscalationTier
policy = EscalationPolicy(
tiers=[
EscalationTier(channel="tls", driver=DriverType.TLS, config={"impersonate": "chrome"}),
EscalationTier(channel="browser", driver=DriverType.PLAYWRIGHT, engine="cloakbrowser"),
],
remember_per_host=False,
)
Browser engines
<<<<<<< HEAD
The browser tier is stock PlaywrightDriver; the engine decides which
Playwright module and which Chromium binary it launches.
| engine | what it is | falls back to |
|---|---|---|
cloakbrowser |
Chromium with fingerprint patches compiled into the binary | patchright |
patchright |
Playwright fork without automation leaks (Runtime.enable …) |
playwright |
playwright |
stock | — |
| ======= | ||
The browser tier is PlaywrightDriver; the engine decides which Playwright |
||
| module and which Chromium binary it launches. |
| engine | what it is | falls back to |
|---|---|---|
cloakbrowser |
Chromium with fingerprint patches compiled into the binary | patchright |
patchright |
Playwright fork without automation leaks (Runtime.enable …) |
playwright |
playwright |
stock Playwright | — |
d3db85cd9c8826d11c10e33988c837f3e8ffbe88
A fallback is logged once at INFO with the reason. To get an ImportError
instead of a silent downgrade, set GC_ENGINE_STRICT=1, or pass
{"browser_engine": {"strict": True}} (driver_config={"engine_strict": True}
for escalating).
Choose the engine in the config:
gc.crawl(url, driver="playwright", driver_config={"browser_engine": {"type": "cloakbrowser"}})
or with GC_BROWSER_ENGINE=cloakbrowser (used when the config sets no engine).
Register your own binary or Playwright fork:
from graph_crawler.infrastructure.transport.playwright.engine import register_engine
register_engine(
"my-chrome",
executable_resolver=lambda: "/opt/my-chrome/chrome", # None → fall back
launch_hook=lambda args: ([*args, "--my-flag"], {"ignore_default_args": ["--enable-automation"]}),
fallback="playwright",
)
gc.crawl(url, driver="escalating", driver_config={"browser_engine": "my-chrome"})
Pass playwright_module="patchright.async_api" to register_engine if the engine
needs a different Playwright implementation.
Stealth lives in the engine. The library decides when to escalate and which engine to launch.
Page actions
The Playwright driver can click, type and wait before the HTML snapshot is taken:
from graph_crawler import PageAction
graph = gc.crawl(
"https://shop.example/search",
max_depth=0,
driver="playwright",
driver_config={"actions": [
PageAction(type="fill", placeholder="Search", value="laptop"),
{"type": "click", "text": "Apply"},
{"type": "wait_for", "response": "/api/items"},
]},
)
node = graph.get_node_by_url("https://shop.example/search")
node.metadata["actions_log"] # [{"index": 0, "ok": True, ...}, ...]
node.metadata["requests_after_actions"] # XHR/fetch requests made after the actions
Action types: click, fill, select, press, scroll, wait_for, back.
A target is found by selector, text, placeholder or label (nth picks
among visible matches). The first failing action stops the chain and is written
to node.metadata["actions_error"].
Plugins and custom nodes
A node plugin runs on every fetched page and writes to user_data, metadata
or the typed fields of your Node subclass:
import graph_crawler as gc
from graph_crawler import BaseNodePlugin, Node, NodePluginType
class ProductNode(Node):
price: int | None = None
class PriceExtractor(BaseNodePlugin):
url_pattern = r"/product/" # run only on matching URLs (or: domains = ["shop.example"])
expects_nonempty = True # log a WARNING when nothing was extracted
extraction_key = "prices"
@property
def name(self):
return "price_extractor"
@property
def plugin_type(self):
return NodePluginType.ON_HTML_PARSED
def execute(self, ctx):
el = ctx.html_tree.css_first(".price")
if el is not None:
ctx.user_data["prices"] = [el.text()]
ctx.set_node_field("price", el.text()) # validated against the annotation: "10" → 10
return ctx
graph = gc.crawl("https://shop.example", node_class=ProductNode, plugins=[PriceExtractor()])
domains/url_patterncan also be passed per instance:PriceExtractor(config={"domains": ["shop.example"]}). Several instances of one plugin class with different routes run side by side.graph.search("acme", user_data_keys=("prices",))searches plugin data as well as titles and URLs.node.public_user_dataisuser_datawithout the reserved internal keys (RESERVED_USER_DATA_KEYS).gc.load_graph(path)restores theNodesubclass when its module is already imported;load_graph(path, node_class=ProductNode)sets it explicitly.
Storage
storage="memory" (default), "json", "sqlite", "postgresql", "mongodb".
For large crawls use sqlite or a server backend: nodes are evicted from RAM
and loaded back on access.
Environment variables
| variable | effect |
|---|---|
GC_TIER_TTL |
seconds the working tier is remembered per host (default 1200) |
GC_BROWSER_ENGINE |
default browser engine when the config sets none |
GC_ENGINE_STRICT |
1 — raise ImportError instead of falling back to another engine |
CLOAKBROWSER_BINARY |
path to an existing CloakBrowser binary |
Logging
The library adds a NullHandler to the graph_crawler logger and never
configures logging itself. At INFO it writes one line per fetched page, plus the
start of the crawl and warnings such as HTTP 404 for <url>:
import logging
logging.basicConfig(level=logging.INFO)
Metadata
Release files for graph-crawler 5.3.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| graph_crawler-5.3.2.tar.gz | 1.1 MB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| graph_crawler-5.3.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 2.4 MB
Release files / graph_crawler-5.3.2.tar.gz
| Download URL | graph_crawler-5.3.2.tar.gz |
|---|---|
| Size | 1.1 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0520d60bc74f5d5e67b9462026521056fa86c0ee1277ace796c3d6e79a1de65d
|
|
BLAKE2b-256 checksum How to use checksums |
7af2475c43d8a608a1acd2e1d6f564fb42178b9c5a9f745589111e06eaea7923
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / graph_crawler-5.3.2-py3-none-any.whl
| Download URL | graph_crawler-5.3.2-py3-none-any.whl |
|---|---|
| Size | 1.3 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
360992a197d38cb2e8ca03b9202b85b8103e95d90b8f1801a03f1ab139482b1f
|
|
BLAKE2b-256 checksum How to use checksums |
029b5858f861d43d71aa4e4d13fb94f56911e76663e316d2b9a378f9f656e0d8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Security for graph-crawler 5.3.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.