Write a browser automation once. Run it on any engine — or on none.
Playwright, Patchright, Selenium, undetected-chromedriver, SeleniumBase, nodriver,
zendriver, pydoll, Botasaurus, rebrowser, Camoufox — or engine=http, which fetches with
a real browser's TLS fingerprint and no browser at all. Same graph, no code change.
from browsergraph import Engine, Graph, Spec, run
from browsergraph.drivers import build
from browsergraph.nodes.actions import Click, Extract, Navigate, WaitFor
graph = (Graph("quote")
.add(Navigate("https://example.com"))
.add(WaitFor("#quote"))
.add(Click("#quote"))
.add(WaitFor("#result", name="confirm")) # <- proves the click landed
.add(Extract("#result", into="quote")))
spec = Spec(engine=Engine.PLAYWRIGHT) # swap to HTTP, SELENIUM, ...
print(run(graph, spec, build(spec)).context.data)pip install "browsergraph @ git+https://github.com/aidonerightcorp/browsergraph.git"
browsergraph doctor # what works here, and the command to fix what doesn't
browsergraph bootstrap # only if you want a browser; the core needs noneTo pin, add @v0.4.0 — or take the wheel and its checksums from
the release.
v0.4.0 is the first release that matches this page. The v0.3.0 wheel
predates execute, solve, viz, templates, explore, journal, bounded
and quick — most of what is described below — so following the old install
line gave you a build that could not run the examples on it. If you pinned
0.3.0, upgrade; CHANGELOG.md lists what changed and what was
broken.
The wheel has no dependencies at all — installing it into an empty environment pulls exactly one package. Every engine, OCR backend and plotting library is an optional extra, so a graph can be built, drawn, linted, compiled and mock-run with nothing else installed.
With an engine, if you want one:
pip install "browsergraph[playwright] @ git+https://github.com/aidonerightcorp/browsergraph.git"Steps say what has to happen. Candidates say what could do each one. A route picks one candidate per step, and the picture is what tells you two of these steps are independent:
graph LR
load["Load the table<br/>2 options"]
numeric["Encode the numbers<br/>2 options"]
categorical["Encode the categories<br/>2 options"]
fit["Fit the model<br/>2 options"]
load --> numeric
load --> categorical
numeric -->|numeric| fit
categorical -->|categorical| fit
numeric and categorical sit side by side because nothing connects them — so
they can run at once, and they fail separately. That is derived from the ports,
not asserted by whoever drew it. Sixteen routes through four steps, and the two
arrows into fit each carry the name of the port they land on, because an
unlabelled join is the bug this repository started with.
from browsergraph import demo, viz
bench = demo.tabular()
viz.dag(bench) # the same thing as SVG, in a notebookOr without writing any Python at all:
browsergraph draw job.json -o job.html # every figure, one file
browsergraph solve job.json --runtime mine:RUNTIME --stage extract
browsergraph packs # domains already written
browsergraph packs tabular --solve # ...and run onesolve tries routes, runs them, judges the output, and hands back the best
one plus a fallback. It refuses to run until you say what a good answer looks
like — the default would be "nothing raised", and a route that returns an empty
result passes that with full marks. The full command list is in
AGENTS.md.
Engineering pipelines are not infinitely various. Backend request handling, front-end analytics, cleaning, geographic and temporal enrichment, model training, synthetic data, LLM harnesses — most of it falls into about forty shapes, and the shapes repeat across domains with nothing else in common. docs/PIPELINE_TAXONOMY.md is the map: 41 categories in 9 families, classified by shape and failure mode rather than by subject, because that is the classification that helps.
browsergraph taxonomy --search address # which category is this?
browsergraph taxonomy enrich.geo # its shape, and how it fails
browsergraph taxonomy --coverage # what has code, what does notEvery category records how it fails while reporting success, and that field is the load-bearing one — a category with no characteristic silent failure is a topic with a nice name, and a test refuses to let one exist. Coverage is counted from the registries on every run: 41 categories have a checkable shape, 13 have code that runs, and the 28 gaps are listed rather than rounded up.
If you ship a feature built on a language model you almost certainly have a judge. It probably produces a number every day, and it has probably never been checked against a person.
python -m assay.cli judge --demo
python -m assay.cli judge --data labels.csv --model verdict --human truthJUDGE AUDIT — FAIL
BELOW_CHANCE — kappa -0.207 on 120 items
raw agreement 65.0%, but chance alone gives 71.0% on this distribution. The
judge agrees with people less often than a coin weighted to the commonest
answer would.
RUBRIC-DEPENDENT — kappa spans 0.694 across 3 rubrics
+0.487 is anything in it false
+0.000 rate the overall quality
-0.207 did it answer the question asked
FAIL length
score and answer length correlate at rho +0.95
Four ways a judge is wrong while looking right: it agrees at chance, it scores length, it scores position, or the rubric decides instead of the input. All four fall out of one realistic mechanism in the demo, and every number above is printed by the command rather than typed into this page.
CANNOT_CHECK is a verdict, not an error. A single-class human sample cannot
validate a judge, and reporting that as the judge's fault convicts it of the
evaluation's own sampling.
assay now lives in its own repository —
github.com/aidonerightcorp/assay is
the canonical home. The copy in this tree is vendored and frozen so the wheel
stays self-contained and the published notebooks keep working;
from browsergraph import duecare and from browsergraph import taxonomy
still mean the same objects and will keep doing so.
Example 14 · assay/README.md · canonical repo
The obligations of an evaluation are usually habits. assay.obligations makes
them values: nine obligations, each discharged with evidence, waived with a stated
reason, failed, or visibly outstanding — and a verdict computed while a
blocking obligation is outstanding is PROVISIONAL, never a pass.
from assay import obligations # was browsergraph.duecare; both work
ledger = obligations.Ledger.standard()
ledger.discharge("holdout", "cases 0-199 were never used in development")
ledger.record(obligations.check_negative_control(real=0.91, broken=0.89))
print(ledger.verdict(0.91).text())
# FAIL — 0.910 [50ed4cc56a8c]
# failed: negative_control — a deliberately broken variant scored 0.890
# against 0.910: a gap of 0.020 does not clear 0.050. This
# harness cannot separate the system from a broken one, so its
# previous results do not mean what they appeared toThree states, and the middle one is the point. PASS — every blocking obligation met. PROVISIONAL — the number exists and nobody may act on it yet; this is the honest description of most evaluations. FAIL — a check ran and came back no, which is worse news than not having run it, because it means the previous results were noise.
"Could not check" is a fourth thing and is kept distinct from "checked and failed": a single-class human sample cannot validate a grader, and reporting that as the grader's fault would convict it of the evaluation's own sampling.
A waiver needs a reason — waive() refuses an empty one — and waivers appear
in the report as prominently as discharges. The ledger's digest is a hash of the
standard rather than the score, so obligations.compare() will tell you two numbers
are not comparable when the standard slipped between them.
The other half is the loop: each round's failures become permanent regression cases, and the outcome folds into route evidence, so the search learns from the same observation that graded the output. It reports new failures per round rather than total, because a total that goes down is also what deleting the hard cases looks like.
And a harness with no controls at all cannot report better than
PROVISIONAL — assay.controls enforces that, because "we did not check
whether this harness works" is the accurate description and having a word for it
is what stops it being rendered as a pass.
Example 09 runs all of it.
Every one of these was hit while building this repository, usually while writing a demonstration of the feature it broke. None raised an exception.
browsergraph cases # the twelve
browsergraph cases --id judge-below-chance # one, with its numbers| study | what looked fine | what was happening |
|---|---|---|
judge-below-chance |
73% agreement with people | chance alone gives 76.6%; answering "good" every time beats it |
empty-result-passes |
exits 0 every night for weeks | the selector matches nothing and a 0-byte file overwrites yesterday |
timezone-rolls-the-year |
every timestamp stored in UTC | 23:30 on 31 December in Denver is reported in the following year |
the-copier-wins |
best fidelity and best utility | it is returning the input; the privacy it existed for is gone |
label-belongs-to-a-response |
one human label per case, reused | the label describes the incumbent, so every system is scored on matching it |
blocking-hides-a-pair |
73% fewer comparisons, same duplicates found | pairs split across blocks are unreachable at any threshold |
The full set is docs/CASE_STUDIES.md, which is
generated: each study's numbers are computed when the document is rendered,
and tests/test_casestudies.py fails if the committed copy drifts from what the
code produces.
A template is a shape with typed ports and zero candidates. A pack is the other half: real candidates with real implementations, so a domain goes from expressible to runnable in two calls. Standard library only, and a test runs every route of every pack — not a sample.
browsergraph packs # the catalogue
browsergraph packs harness --solve # run one| pack | the job | the finding it exists to show |
|---|---|---|
harness |
grade a system on cases | four graders, one measuring answer length — and only the controls can tell them apart |
judge |
a model grading other models | 70% raw agreement, and a kappa below zero; and the rubric matters more than the judge |
redteam |
attack a system on purpose | 32 attacks on one family found nothing; 20 across five found four holes |
agents |
supervisor, workers, critic | the four-field answer to a three-field document is the best-looking output and the only wrong one |
synth |
synthetic tabular data | five generators, five ways to score well; the copier tops fidelity and utility |
models |
linear / tree / boosted / MLP / attention | no winner column — the answer depends on the data-generating process |
geo |
addresses into checked places | a format check accepts Denver, XZ 80202 and a nonexistent ZIP |
spacetime |
place × time enrichment | the better rainfall figure is a leak; midnight in Denver lands in next year |
quality |
a gate that may refuse | three adjudicators that disagree on purpose |
migrate |
move data and prove it arrived | equal counts with swapped contents look identical |
files |
do the same thing to every file | one parser per folder is not enough, and only a verifier that counts records knows |
tabular |
fit a model on a table | the model has to carry its own encoding, or the wrong encoder wins |
clean |
repair a messy table | repair.drop empties the table, every check passes, and only the row count says so |
Each pack's docstring carries a table of measured numbers, and
tests/test_packs.py turns every one of those sentences into arithmetic. A pack
that argues something in prose and cannot demonstrate it is a pack making a
claim, which is the thing this repository is arranged against.
Explore it in your browser, installing nothing: the live studio — all 166 candidates across 14 sub-steps, five synchronized views, one offline file.
Try it without installing anything: the Kaggle notebook installs a browser, drives it, and shows the screenshots and video it captured.
Start here. Each takes real input, runs real code, and writes real files you can open afterwards. Standard library plus numpy and Pillow, so they run anywhere.
| Notebook | The job | Run it | |
|---|---|---|---|
| 12 | Browse and scrape | a live page in, clean product rows out | Kaggle |
| 13 | Ingest into a schema | mixed formats in, typed rows plus rejects with reasons | Kaggle |
| 14 | Check and process an image | images in, a report and resized copies out | Kaggle |
| 15 | Clean up messy data | a messy table in, a clean one plus every change recorded | Kaggle |
| 16 | Fit a model | a dataset in, regression and classification scores out | not yet |
| 17 | Batch many files | forty files in, a total and the broken ones named | not yet |
| 18 | Retry and fall back | a flaky source, and a run that succeeds anyway | Kaggle |
| 19 | Find duplicates | one customer list in, matched pairs out | Kaggle |
| 20 | Forecast next month | two years of sales in, three months out | Kaggle |
| 21 | Watch for changes | a thing to check, and noise only when it moved | Kaggle |
| 22 | Sort text into categories | support messages in, the right team out | Kaggle |
What they actually produce, not what they claim to: 3 products parsed from three
different price formats with the priceless one dropped and named; 6 records
accepted and 3 rejected with a reason each; a blank PNG caught by colour spread
where size and file bytes look fine; a $1,340.00 row repaired after its own
comma split it in half; four model routes that score identically, which the
notebook reports as "this decision does not matter" rather than crowning a
winner; a batch that names item 11 instead of failing forty files at once;
blocking that saves 89% of the comparisons and says which pair it can therefore
never find.
The claim that this is a general model is only worth something if it works somewhere else. Each of these runs in seconds, installs nothing but the library, and touches no dataset or network. Every cell is executed and its output committed.
| Notebook | What it argues | Run it | |
|---|---|---|---|
| 01 | Express a problem as a graph | stages, ports, candidates, and the four checks | Kaggle |
| 02 | Search without enumerating | 3.8 trillion routes is 41.8 bits, not an obstacle | Kaggle |
| 03 | A domain that is not browsing | the same machinery on document work | Kaggle |
| 04 | A Kaggle pipeline is a graph | numeric ∥ categorical encoding is a join | Kaggle |
| 05 | Two readings of one document | text and layout are independent extractions | Kaggle |
| 06 | A workflow with no data science | effects, permissions, and one step that reaches outside | Kaggle |
| 07 | A gate that can say no | schema and drift meeting at one decision | Kaggle |
| 08 | Build, verify, release | a gate with two inputs cannot be half-skipped | Kaggle |
| 09 | Retrieval is two searches | dense and lexical recall, joined | Kaggle |
| 10 | The leak you cannot see in CV | the split as a node with two named outputs | Kaggle |
| 11 | The domain this started in | completed ≠ worked | Kaggle |
Twenty-three of the twenty-five are on Kaggle, and those ran there to
completion — the library is installed from this repository in cell one, so what
you see rendered is what the code in main actually does. The two that are not
say "not yet" and link the committed notebook, rather than a kernel that does
not exist. python notebooks/check_published.py --fix reconciles this table
against the account in both directions, because a row claiming "not yet" about a
published notebook misleads exactly as much as a dead link.
Mostly diagrams. Useful if you would rather see the thing than read about it.
| Notebook | What it shows | |
|---|---|---|
| 24 | Every picture and why | seven figures on one job, and what the flat version of each would hide |
| 25 | The shapes a job comes in | nine shapes — chain, fan-out, diamond, map, branch, gate, fallback, reuse, tournament — each drawn, each with the lines that build it |
The eighth figure, the learning curve, needs a loop to draw, so it lives in 23 · Get better by doing it — a hundred and twenty passes of the evidence loop against the best route there is and the one you would get by guessing.
Both found real bugs while being written, which is the argument for drawing
things: 24 caught solve reporting a champion "out of 48 possible" on a space
it could only ever draw 24 routes from, and a per-step evidence chart reading
flat zero because a run's verdict was never reaching the per-candidate
posteriors — so a reader that returned nothing and a reader that returned two
records came out believed equally.
Regenerate and verify them with:
python notebooks/build_notebooks.py && python notebooks/build_domains.py
python notebooks/build_workflows.py
python notebooks/build_gallery.py && python notebooks/build_shapes.py
python notebooks/execute.py # runs every cell, under a memory capThe interesting problem in browser automation is not clicking things. It is that a run which reports success can have accomplished nothing — and you find out weeks later.
This library was written after an incident where 551 emails reported "sent" successfully and produced zero posts. Every layer said success. Nothing checked the destination. So the linter's flagship rule is BG003 — changes remote state, never verifies the outcome — and the rest of the design follows from there.
from browsergraph.lint import lint, report
print(report(lint(graph)))
# [WARN] BG003 click: graph changes remote state but never verifies the outcome
# — a silent failure will look like successHOW_IT_WORKS.md — the whole architecture in plain English: what a stage is, how settings expand into a matrix of concrete options, how four trillion routes get narrowed to one, and what actually happens when a step fails. No jargon, no prior context.
Don't write the steps. Write down what has to be true, list everything that could make it true, and let the program pick — from evidence, with reasons it can show you.
Six stages, but "Acquire inputs" is not one decision — it is three. Work out what to fetch, open a session capable of fetching it, read a payload out of that session. Each has its own matrix.
One stage, its three sub-steps, all 80 candidates, three routes traced. The full 14-sub-step network →
Draw each stage as a single pooled choice and you count 85,747,200 routes. The sub-steps those same stages are actually made of expose 3,802,314,700,800 — the coarse view was hiding 44,343× of the space. Same task, same registry, same code.
6 stages / 14 sub-steps · 57 definitions · 166 atomic candidates
3,802,314,700,800 complete routes · 1,337 adjacent transitions
Sub-steps are recursive — a sub-step can decompose again, to any depth. A stage is either a leaf that holds candidates or a composite that holds sub-steps, never both, because otherwise "one choice per stage" stops being well defined and that sentence is what the whole model rests on.
browsergraph workbench -o studio.html # five interactive views, one offline file
browsergraph route --compare # greedy vs beam vs exhaustive, measured
browsergraph route --gates # what a policy blocks, and whyPolicy is a hard gate that runs before scoring: under a locked-down policy (no browser, no network, no LLM, no external effects, deterministic only) the space drops from 3.8 trillion to 1,959,552,000 routes — 99.95% removed before a single score is computed, every removal stating its reason. Blocked candidates stay visible; filtering them out silently would answer "what could perform this step" with "what the policy left".
Measured, not asserted — the full report includes the profile-ranking bug this found (all four objective profiles were secretly identical) and the beam-search bug that made width buy nothing:
| profile | greedy (71 evals) | beam (512 evals) |
|---|---|---|
| Balanced | 0.8156 | 0.8156 |
| Quality first | 1.0149 | 1.0149 |
| Speed first | 0.9608 | 1.0304 |
| Cost first | 0.3443 | 0.3443 |
Greedy scores each sub-step in isolation; route quality compounds, so it loses whenever the trade-off is real. Decomposition also pushed the gated space past the enumeration limit — exhaustive is no longer an option, which is exactly when the strategy choice starts to matter.
A task decomposes into ordered stages. Each stage offers every candidate that could perform it; a route picks one per stage. That model is written down in full — generalized past browsers, with portable manifests, contract validation, typed feedback and optimization profiles — in UNIVERSAL_GRAPH_SYSTEM.md.
browsergraph workbench -o studio.html # 6 stages, 149 candidates, 32,864,832 routesThe demonstration registry is domain-neutral on purpose: the same primitives
describe document ingestion, image processing, data cleaning and machine
learning. Browser adapter alone expands to 60 atomic candidates
(5 controllers × 6 binaries × 2 display modes) — because drawing that as one box
hides fifty-nine decisions.
A task decomposes into planes. Each plane offers several interchangeable ways to answer it. A route through them is one candidate solution — and the route is chosen from evidence, not fixed in advance.
With no evidence the cheapest route wins: http → dwell → css → click → screenshot → extract — no browser, no model. After a few dozen runs against a defended,
JavaScript-rendered site the same machinery picks patchright → wait_for → healing → …
and can say why: 6/6 steps measured.
The planes are derived from node contracts, not written down. click is on act
because it declares mutates; adding a node adds a candidate and the diagram changes
with nobody editing it.
browsergraph planes --demo # the planes and the chosen route
browsergraph planes --html planes.html # both routes drawn over all the othersWhen a configuration fails, the next one is tried — and the diagnosis chooses what to try next, which is what makes it more than a retry loop.
1. http ok=False timeout wait_retry
2. http ok=False timeout wait_retry
3. playwright ok=True → extracted: $49.00
A missing element on an engine with no JavaScript runtime suggests a different engine,
not a longer wait. Retries are bounded per spec — an unbounded retry never reaches the
rest of the ladder. A terminal diagnosis (CAPTCHA, block) stops immediately rather than
escalating into a ban, and SiteMemory puts the winner first next time.
Measured, not declared — a real launch matrix against a served page.
browsergraph doctor reports the same for your machine.
| engine | chromium | chrome | firefox | webkit | brave | headless | headed | xvfb |
|---|---|---|---|---|---|---|---|---|
| playwright | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| playwright_stealth | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ |
| patchright | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ |
| rebrowser | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ |
| selenium | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ |
| selenium_uc | — | ✅ | — | — | ✅ | ✅ | ✅ | ✅ |
| seleniumbase | — | ✅ | — | — | ✅ | ✅ | ✅ | ✅ |
| botasaurus | — | ✅ | — | — | ✅ | ✅ | ✅ | ✅ |
| camoufox (isolated) | — | — | ✅ | — | — | ✅ | ✅ | ✅ |
| nodriver / zendriver / pydoll (CDP) | — | ✅ | — | — | ✅ | ✅ | ✅ | ✅ |
| http (no browser) | n/a | n/a | n/a | n/a | n/a | ✅ | — | ✅ |
126 verified combinations. What does not work is documented in ENGINES.md with the reason — a bare protocol with no client library, a chromedriver/snap version skew, a dependency that ships broken source.
| Contracts | nodes declare what they read, write and mutate — enforced at import, at composition and at run time (CONTRACTS.md) |
| Linter | BG001–BG009 over a graph, before a browser starts |
| Escalation | diagnose the failure, try the next configuration, remember the winner |
| Learning | outcomes generalise site → org → sector → platform → global |
| Token reduction | 8 preprocessing strategies, then keyword focus with neighbour expansion |
| Extraction | conservative, deterministic contacts / NAICS / articles — no model needed |
| Politeness | per-domain, process-wide rate limiting that honours robots Crawl-delay |
| Isolation | conflicting engines in per-engine virtualenvs, over a worker protocol |
| Notebooks | Jupyter/Kaggle/Colab run cells inside an asyncio loop; the sync API is driven from a worker thread so it just works |
| Universal graph | portable node manifests, atomic candidates, stage/route validation and a five-view studio — UNIVERSAL_GRAPH_SYSTEM.md |
| Evidence | per-candidate, per-context posteriors; Thompson-samples a route at sum cost instead of enumerating, and reports how many bits of the choice remain |
| Pictures | seven figures of any workbench — shape, route space, funnel, per-step evidence, run timeline, solve scoreboard, learning trend — as self-contained SVG, Mermaid, JSON or matplotlib. Domain-neutral: viz takes a graph and knows nothing else |
| Domain packs | thirteen non-browser domains with the code already written. Standard library only, every route of every pack executed by a test: browsergraph packs harness --solve |
| Taxonomy | 41 pipeline categories in 9 families, classified by shape and silent failure; coverage counted from the registries, gaps listed — docs/PIPELINE_TAXONOMY.md |
| Due care | what an evaluation owes, as values: nine obligations, PASS/PROVISIONAL/FAIL, waivers that need a reason, and a feedback loop whose regression cases are permanent |
| Route search | policy gates first, then greedy / beam / exhaustive over the eligible space, reporting how much of it was actually examined |
| Capabilities | each engine declares what it can do — press, select, upload, download, frames, cookies, viewport, PDF — checked against a graph before a browser launches, with the engines that could run it |
| Receipts | every run writes durable evidence: route, engine, per-step timing, artifacts with content hashes, which steps verified, and a pasteable replay line — for failures too |
| Model router | ten jobs (extract, verify, locate, read-image, classify, embed, rerank, code, plan) routed to the right model with a recorded reason, instead of one default for everything |
| Binaries | fetches a browser or a driver matched to the browser it will drive — the fix for "cannot connect to chrome" |
| OCR (optional) | read a page from its pixels when the DOM cannot answer — canvas text, baked-in images, and "does this screenshot contain any text at all" |
| LLM (optional) | Ollama-compatible; the model is resolved from the host by capability, never hardcoded |
| HOW_IT_WORKS.md | start here — the architecture in plain English |
| AGENTS.md | instructions for an LLM harness, written as checkable constraints |
| docs/TOWARD_A_GENERAL_MODEL.md | a critical review — what is still wrong, and what to fix first |
| QUICKSTART.md | first graph, first real browser, first task |
| UNIVERSAL_GRAPH_SYSTEM.md | stages, candidates, routes, contracts, feedback, optimization |
| docs/PIPELINE_TAXONOMY.md | the 41 shapes engineering work comes in, and which have code |
| docs/ROUTE_SEARCH_REPORT.md | policy gating and route search, measured end to end |
| ARCHITECTURE.md | the Protocol-vs-base-class seam |
| CONTRACTS.md | what a node promises, and the three moments it is checked |
| ENGINES.md | every engine, what it is for, and what does not work |
| DIMENSIONS.md | the axes, and why verification matters most |
| ISOLATION.md | conflicting engines in separate virtualenvs |
| PLUGINS.md | the open plugin format |
| CONTRIBUTING.md | how to add an engine, a node or an extraction path |
extractgraph — the other half. browsergraph reaches the page; extractgraph gets the data out of it, with several independent paths, provenance, and the disagreements kept.
Issues and pull requests welcome — see CONTRIBUTING.md. The most useful contributions are a new engine adapter, a new extraction path, or a page that breaks something.
pip install -e ".[dev]"
pytest -q # 1,848 tests; browser suites skip when absent
mypy browsergraph --ignore-missing-imports
ruff check browsergraph testsMIT.

