—Changelog
What changed, release by release.
Rendered from CHANGELOG.md in the repo, so it cannot drift from the released package.
0.8.3 - 2026-08-29
Docs only. No change to any code path.
Fixed
- The MCP tool count was stale in two places.
grip-mcpregisters nineteen tools, not the eight the README named or the twelvedocs/mcp.mdlisted —select,hover,wait_for,scroll,press,upload,linksandpopups_blockedwere added after both were last updated. Both now name and list all nineteen.
0.8.2 - 2026-08-17
Packaging only. No change to any code path: grip/ is byte-identical to
0.8.1, and this release exists because PyPI will not accept new metadata for
a version already uploaded.
Fixed
- The PyPI page carried no metadata.
[project]had noclassifiers, nokeywords, noauthors, nolicensefield and no[project.urls], so the published page had no links back to the repository, the changelog, the issue tracker or the site, and the package did not appear under any trove classifier. All five are now declared, with the classifier list restricted to what is actually true:Development Status :: 4 - Beta, the four Python versions CI tests (3.11 through 3.14), andTyping :: Typed, whichgrip/py.typedhas always backed.
Added
- A release workflow that fails closed on version drift. Publishing runs
on a
v*tag through PyPI trusted publishing (OIDC, no stored token) and refuses to build unless the tag,pyproject.tomland the newest heading in this file all state the same version. The three had no mechanism keeping them in agreement before. - CI for the site (type check, lint and build, none of which ran before a push), Dependabot for pip, npm and Actions, and a pre-commit config mirroring the versions CI installs.
0.8.1 - 2026-08-12
Measurement release. Two things the site called "unmeasured" got measured, and the measuring found real bugs.
Fixed
- The slider solver never worked.
SLIDER_PROBE_JSresolved the track withhandle.closest('[class*="slider"]'), which matched the handle itself (its own class contains "slider"), so the drag distance collapsed to roughly zero and every attempt timed out — including on markup matching grip's own documented geetest example. The track is now resolved from a genuine ancestor wider than the handle, and bails with a stated reason instead of silently dragging nothing. Benchmarked 0/5 → 5/5. - Two challenge classifier false positives, both deterministic: ordinary
prose containing "solve a captcha puzzle" classified as a challenge, and a
documentation page merely quoting the
cf-turnstileembed snippet as text classified as Turnstile. False-positive rate on the negative fixtures went 10/40 → 0/40. - The stealth user agent was pinned to a Chrome version that was not
running.
_STEALTH_UAhardcodedChrome/149while the launched binary was 151 — self-inconsistent, and worse with every Chrome release. It is now derived fromBrowser.getVersion()at runtime, applied viaNetwork.setUserAgentOverrideso it fixes the outgoing request header and not onlynavigator.userAgent, and re-applied to popup targets.
Added
benchmarks/bench_challenges.pywith 26 local fixtures: measures classification accuracy (including negatives, so false positives count), solve rate, and whether a "solved" claim is ever false. Across 30 solve runs, zero false-solved — the "reports solved only when verified" claim holds up.benchmarks/bench_stealth_signals.py, which reads bot.sannysoft's own 57-row result table rather than regexing page text.
Measured
On real Chrome for Testing 151, macOS arm64, --headless=new: 5 of 57
sannysoft signals failed with stealth off, 0 of 57 with it on. All five were
user-agent related. Every other commonly-cited leak — plugins, mimeTypes,
languages, WebGL vendor/renderer, permissions consistency, window.chrome,
screen dimensions — already passed before any change, on this host. No
page-world JS shims were added: patching signals that already pass is how
you manufacture a new tell.
TLS/JA3 was never a gap. grip drives real Chromium, so the handshake is Chromium's own.
Still true
navigator.userAgentData is left undefined under UA override rather than
fabricated. A page that detects the DevTools session itself is below
anything an injected script can reach — unfixable for any CDP-based tool.
IP reputation remains an egress problem. Challenge solve rates outside
these synthetic fixtures remain unmeasured, and Cloudflare's public test
sitekeys short-circuit the real widget flow, so production Turnstile's
click path is still untested here.
0.8.0 - 2026-08-12
Three audits of the agent-facing surface (ergonomics, browser capability coverage, robustness), then the fixes. Most of these are defects an autonomous agent hits and a human driver routes around.
Security
- Typed passwords no longer reach snapshot text, the model, or trace
output.
gripOwnTextfell through toel.valuefor password inputs. The value-capture path and the accessible-text path now share one exclusion set, so the two cannot drift apart. - Page-authored element handles can no longer collide with grip's.
A page could pre-author
data-grip-hto shadow a live handle or break selector resolution; handles are only trusted if grip minted them this session, and selectors go throughCSS.escape. - Downloads landing outside the configured directory are dropped.
Fixed — actions that reported success while doing nothing
click()returned ok on disabled, off-screen and overlay-covered elements. It now hit-tests at the element centre and names the occluding element when something is in the way.type()bypassed React/Vue value trackers (rawel.valueassignment, no key events, so typeahead never fired) and still reported success. It now uses the native value setter, brackets the write with key events, and verifies the value took.- Every action snapshotted before the page settled, so a click that navigated returned the pre-click page and the agent clicked again.
CONTENTwas truncated at 2000 characters with no marker, so the agent believed it had read the whole page.page_errorandprompt_injectionwere computed and then dropped in rendering: an agent blocked by an anti-bot wall or auth wall was told nothing at all.
Fixed — wrong element, wrong conclusion
click("Save")could hit "Save draft". Exact match now wins outright; genuine ambiguity raisesAMBIGUOUS_TARGETlisting the candidates instead of silently guessing.- A stale ref from a previous document silently resolved to a different element. Refs from a superseded snapshot are now rejected with a re-snapshot hint.
- Failed navigations returned as success.
Page.navigate'serrorTextwas ignored and the load timeout was swallowed, so DNS failures and connection refusals surfaced as a loaded page with status 0. - A browser crash was re-classified by string match into
ELEMENT_NOT_FOUND, looping the agent straight back into a dead connection. Typed errors are no longer re-classified.
Added — state and content agents could not see
- Element state in the snapshot:
disabled,required,checked,selectedandvalue(password values excluded). Verifying that a box is ticked or a submit button is enabled is most of task verification. - Inputs labelled only by sibling text are now addressable by label,
via an inference chain (label-for/wrapping label,
aria-label, placeholder, title, sibling text, humanized name/id). - Scroll position and page height, plus
scroll()targeting the nearest scrollable ancestor rather than only the window, so inner panes, virtual lists and infinite scroll can be reached. - Closed shadow roots, via an
attachShadowpatch installed before navigation. They were previously invisible with no signal. - iframes surfaced as rows (no cross-frame traversal yet), canvas rects and labelled SVG, and comboboxes with their options.
Added — capabilities
- JavaScript dialogs are handled by policy. Nothing subscribed to
Page.javascriptDialogOpening, so analert()/confirm()froze the tab until timeout. wait_for()for a text/ref condition, and same-document navigation now invalidates the cached snapshot, sopushStateno longer leaves a stale one behind.hover(), for menus that only open on pointer events.select()falls back to open, re-snapshot, pick for non-native comboboxes instead of failing outright.- Conservative cookie-banner dismissal, once per navigation.
- File-chooser interception, for drop zones that create the input on click, and popup adoption for OAuth flows.
- Viewport and device emulation, and permission control with notifications and geolocation denied by default so a prompt cannot stall a task.
Fixed — MCP
- The server died on every tool call when an LLM SDK was not installed.
The adapter was resolved eagerly at browser construction, so
openandclick— which need no model — failed with anImportErroron any host that sets provider keys without grip's optional extras. It is now resolved lazily, only for theruntool. press,upload,links,popups_blocked,wait_for,hoverandscrollexposed; several existed in the Python API but not over MCP.- Error recovery hints now reach the client instead of being dropped at the MCP boundary.
screenshotreturns an image block rather than raw base64 as tool text.- Overlapping tool calls could act on the wrong tab; calls are serialized.
Fixed — robustness
- The Chrome process and temp profile leaked whenever
kill()timed out, and on any teardown path that was not an explicitclose(). - CDP commands had a fixed 30s timeout with no override.
- Crashes and socket drops surfaced as a generic
ConnectionError; both now map to a typedBROWSER_CRASHED. Fetch.enablepaused every subresource to enforce a policy that only concerns navigations.- The trace grew unbounded on a long-lived server.
Still not done
Cross-origin iframe traversal (iframes are surfaced, not entered), TLS/JA3
fingerprint parity (below the DevTools Protocol, unreachable from a Python
client), and challenge solve rates remain unmeasured. enable_downloads is
deliberately not exposed over MCP: it would let a client create a directory
at an arbitrary server-side path.
0.7.0 - 2026-08-12
Added
- Non-semantic clickable element discovery. Elements with a JS click
listener but no interactive tag or ARIA role (e.g. a
<div>withaddEventListener('click')) are now found and clickable by description. Detection uses CDPDOMDebugger.getEventListeners, bounded by a cheap scoring pass (PRE_RANK_LIMIT=150) with listener probes only on survivors (MAX_LISTENER_PROBE_NODES=40), issued concurrently. Measured snapshot latency cost: Wikipedia 8.5ms → 10.7ms, Hacker News 9.1ms → 12.5ms, no token-count change on pages without such elements. allow_popupsonNavigationPolicy(defaultFalse— existing secure behaviour unchanged). Opting in disables popup blocking; the popup's CDP target has no Fetch interception, soNavigationPolicyis not enforced inside it.
Changed
- Popup blocking is now an explicit
NavigationPolicyoption, not an unreviewed side effect of v0.6.0.window.open()/target="_blank"still fails under the default policy — that has not changed — but it is now a documented choice (NavigationPolicy(allow_popups=True)/Browser(..., allow_popups=True)) rather than a silent one. - A blocked popup is no longer silent. It now logs a
WARNINGnaming the URL and the flag, is counted onPage.popups_blocked, and each block is recorded as a"popup_blocked"entry inPage'sTrace.
Known limitations
- Click listeners attached to an ancestor (event delegation) are not
detected;
getEventListenersat the node cannot see them. click_atremains unregistered as an agent tool by design — it takes coordinates, which a model without vision cannot usefully produce.
0.6.0 - 2026-08-10
Security
NavigationPolicywas enforced only inBrowser.open().Page.goto()had no check, and redirects were never re-checked, soallow_private=Falsewas bypassable. It is now enforced via CDP Fetch interception atRequestStage.Request— a refused request fails before Chrome resolves DNS or opens a connection — and is armed for the page lifetime rather than re-applied per navigation.- The private-address block missed IPv4 spellings Chrome accepts but
Python's
ipaddressrejects:2130706433,0177.0.0.1,0x7f000001,127.1. All are now canonicalized before the check. - Popup targets (
window.open) are closed on attach under a restrictive policy, since a new CDP target carries none of the parent's interception. - Known limits, stated rather than hidden: WebSocket handshakes are not intercepted (CDP's Fetch domain does not cover them); DNS rebinding remains out of scope.
Changed — breaking
window.open()now fails under a restrictive policy, which is the default.- Session file format changed to
{"cookies": [...], "origins": {...}}. Old bare-list (cookie-only) files still load.
Added
gripCLI:open,snapshot,read,screenshot,run,doctor.- File upload/download:
Page.upload(),Page.enable_downloads(),Page.wait_for_download(). - localStorage in
save_session/load_session(was cookies-only). Browser.pages/Browser.get_page(); MCPlist_tabs/switch_tab/close_tab.- Gemini adapter;
base_urlon the OpenAI adapter for Ollama/vLLM/LM Studio/OpenRouter;adapter_from_env(). - MCP:
goto,screenshot,runtools (5 -> 8), install docs atdocs/mcp.md.
Fixed
- The MCP server never closed the
Browser— the clean stdio-exit path stranded Chrome and its profile dir. - The snapshot/delta protocol was implemented twice against private page
state; now one
Page.payload(). enable_downloads()registered its listener after enabling events, so a download completing in that window was dropped andwait_for_download()timed out with the file already on disk.- Adapter selection was duplicated in
cli.pyandmcp/server.py, sogrip runand the MCP run tool could not reach Gemini or OpenAI-compatible endpoints. assert self._llm is not NoneinBrowser.run()vanished underpython -O.- CI: stealth tests pinned Chrome-build-dependent booleans; the workflow claimed offline while seven tests hit live URLs.
0.5.1 - 2026-08-10
Fixed
- A delta could cost more than the page it replaced. On a click-driven
navigation where the reported URL trailed the document,
build_deltadid not see a URL change, diffed two unrelated pages, and emitted a wholesale replacement — 5,701 tokens where the full snapshot was 2,963, in 6 of 22 runs. Two independent guards now bound it: a delta that is not meaningfully smaller than the snapshot loses to it at the point the payload is chosen, and a restamped-document check compares the elements behind shared handles, since a handle stamped per document names a different element after a restart. Found by grip's own benchmark, not by a user. - A launch failure said only "timed out". Chrome's stderr went to
/dev/null, so a failed launch could not say why. The error now carries the process state, exit code, port-file path and the tail of Chrome's own complaint, and distinguishes a Chrome that died before writing the port from one still running when the deadline passed. The deadline is configurable viaGRIP_CHROME_LAUNCH_TIMEOUT, because ten seconds is tight for a cold Chrome on a loaded CI runner. - A reused profile kept the previous run's
DevToolsActivePort, so_read_portcould return a dead port instead of waiting for the one Chrome was about to write — a defect in the persistent-profile feature added in 0.5.0. find_chrome()trustedCHROME_EXECUTABLEwithout checking it existed, so a stale value produced an opaquePopenfailure instead of the clear "Chrome/Chromium not found" error.
Changed — measured claims corrected
0.5.0 shipped with numbers that were more confident than the measurement supported. All of them came from a synthetic single-page loop or a run whose data was never saved. Re-measured against four live sites and eight real pages:
- The snapshot delta was described as a 75% per-turn saving. Its median
per-turn contribution across a mixed run is 1.0x, because
build_deltareturnsNoneon a URL change and a realistic agent run is mostly navigation. Where it does fire — inside one document, filling a form, driving an SPA — it is a 9.1x median saving, range 0.5x–175x. - The honest end-to-end figure is ~18x fewer prompt tokens over a six-turn run (16.9x–18.4x across repeat runs), and it is dominated by compression, not by the delta.
- Compression against raw HTML was stated as 19x. That figure came from a run whose data no longer exists. Re-measured on the same corpus by the same statistic: 16.0x, median of per-page ratios, range 3.3x–68.4x.
- Competitor cells that read "not measured" are measured: grip 1,998 tokens
median against Playwright MCP 11,597, Puppeteer's accessibility tree 27,489
and
page.content()58,280. grip is smallest on 8 of 8 pages.
Every figure now carries its statistic, its date and its range. Full method in
benchmarks/RESULTS_AB.md and benchmarks/RESULTS_COMPETITORS.md.
Changed — tests
- Two tests passed only on a machine with no Chrome in
PATH, which is why the 0.5.0 branch stayed green locally while CI failed. One mockedsubprocess.runafter the code had moved toshutil.which; the other asserted something vacuously true wherefind_chrome()returnsNone. The lint job also linted a dependency set users never install, so five decorator errors only appeared on CI.
0.5.0 - 2026-08-10
Correction to this entry
The delta figure below — "628 tokens per turn becomes 42, a 75% cut" — is a single-page best case reported as if it were typical. It came from a synthetic five-turn loop that never navigated away from one document, which is the one condition under which the delta always fires. The original wording is left in place because 0.5.0 is published; this note corrects the record rather than erasing it.
Measured since on four live sites, six real turns each (medians of per-scenario
ratios, tiktoken cl100k_base):
- compression, grip snapshot vs raw HTML, per turn: 11.3x (2.9x–22.0x)
- delta vs full snapshot every turn, per turn: 1.0x (1.0x–8.8x) —
build_deltareturnsNoneon a URL change, so navigation turns send a full snapshot by design - pruning, cumulative: 1.4x (1.0x–2.2x)
- end to end, raw HTML vs grip delta + pruning, cumulative: 17.8x (4.6x–41.8x per scenario; 16.9x–18.4x across repeat runs)
On the 8 turns of 24 where a delta could fire, the saving was 9.1x median,
range 0.5x–175.0x. Method, per-scenario tables and caveats:
benchmarks/RESULTS_AB.md.
Removed
Page.extract()andPage.observe(). Breaking.extract()returned the identicaltext_contentfor every key in the schema it was given, andobserve()discarded itsquestionargument and returnedformat(snapshot)— the snapshot tool under a second name. Both were advertised to the model in the agent tool list, so a run could spend a turn and its tokens on either and learn nothing new. Useread()for prose andsnapshot()for what is actionable;Browser.run(goal, llm=...)remains the structured-extraction path.RefRegistryfrom the public exports. It has one internal caller and was never usable on its own.
Added
- Snapshot deltas. From the second observation of a page onward, an agent
can be handed only what changed: elements added, removed and retitled keyed by
ref, plus word-run diffs of the body text.
Page.deltaexposes it and the agent loop uses it automatically. Measured on a five-turn click loop over a 28-element page: 628 tokens per turn becomes 42, a 75% cut in per-turn payload. The loop also stops re-sending superseded page states, which moves prompt cost from quadratic in turn count to linear. NavigationPolicy.Browsernow refuses non-http(s) schemes, private and loopback addresses, and cloud metadata endpoints by default.allow_privateandallow_fileopt back in per browser. Callers that pass user-supplied URLs toopen()were previously exposed to SSRF and local file disclosure.- Persistent profiles and remote attach.
Browser(user_data_dir=...)reuses a profile directory across runs, carrying localStorage, IndexedDB and service workers that cookie JSON never could.Browser(cdp_url=...)attaches to a browser grip did not launch, local or remote. - Challenge handling.
page.detect_challenge()classifies the stage;page.solve_challenge()attempts checkbox, Turnstile and slider in-process with human-shaped pointer motion, and reports success only where it can verify it. Image-grid and text challenges return to the caller's model with a screenshot. No third-party solving service is involved. Solve rates are unmeasured. grip.input— Bézier pointer paths with eased velocity, for interactions where constant-velocity straight-line motion is itself a tell.- Optional MCP server.
pip install "grip-browser[mcp]"thengrip-mcp. Serves the delta payload the same way the agent loop does. PageSnapshot.prompt_injectionso a caller can tell a page whose text was stripped from one that was clean.
Fixed
-
click()andtype()could still act on the wrong element. 0.4.2 made the three JS collectors agree on which elements are addressable, but the index itself remained positional: it was resolved against a cached snapshot and then re-derived against the live DOM at action time. Anything inserted or removed above the target in between shifted it onto a different element, silently, and the action reported success. Elements now carry adata-grip-hhandle stamped at discovery, actions resolve that handle and verify the element's tag and text before acting, and a mismatch raisesELEMENT_STALEinstead of clicking something else. -
The cached snapshot outlived its document. It was written once and never invalidated, so an action after
goto()resolved against the previous page. -
type()reported success unconditionally. The CDP result was discarded andsuccess: Truewritten into the trace regardless of what happened. -
Duplicate labels collapsed onto one ref. Refs were
md5(tag:text), so two "Delete" buttons shared one and the second was unreachable. A hidden element sharing a visible control's label could absorb clicks meant for it. Refs are now keyed on the element handle. -
Hidden text reached the model. Visibility was tested with
getComputedStyle().opacity, which does not inherit, so a child of anopacity:0parent passed as visible. UsescheckVisibility()now.Two consequences worth knowing, both deliberate. Elements positioned fully off-canvas to the left or top are now treated as hidden — that is the fix for the off-screen decoy, where an invisible control sharing a visible one's label absorbed clicks meant for it. Only fully off-canvas counts; below-the-fold elements are still collected, since treating them as hidden would gut snapshots of long pages. The visible cost is that 1×1 accessibility skip links (
Jump to contentand friends) no longer appear in snapshots. Separately,color: transparentreads as hidden except when paired withbackground-clip: text, which is how gradient-text CTAs are built — without that exception the primary button on many sites would vanish from snapshots. -
Chrome and its profile directory leaked when a connect failed, when
close()hit a raisingdisconnect(), and once per extra caller when several coroutines opened the first tab concurrently — the pattern the README itself documents. -
A dead CDP transport left every in-flight call to time out. Pending futures now fail immediately with the connection error rather than each waiting out the full 30 seconds and reporting a misleading timeout.
-
goto(timeout=...)bounded only the load wait, not the CDP calls in front of it, so a one-second timeout could block for ninety. -
launch()andterminate()blocked the event loop, stalling every other tab in a concurrent run. -
One tool error ended the whole agent run. Errors now return to the model as tool results carrying their suggested recovery, and the loop continues. The LLM call is bounded; it previously had no timeout inside a twenty-step loop.
-
Prompt injection. The guard normalises confusable characters, zero-width joiners and whitespace before matching, closing nine measured bypasses, and scans the page title, element text and placeholders — channels that reached the model entirely unscanned. Page content is now delimited and framed as untrusted data in the system prompt, which is the defense that does not depend on enumerating attacks; the pattern list remains a filter, not a control.
-
Typed text was persisted to traces at 0644, passwords included. It is redacted at entry. Session cookie files are created 0600.
-
find_chrome()trustedCHROME_EXECUTABLEwithout checking it existed, so a stale value produced an opaque failure deep inlaunch()instead of the clear "Chrome/Chromium not found" error.
Changed
- Browser-dependent tests skip when no Chrome is present instead of failing. 98 of them used to produce a wall of errors that read as a broken library.
ruffandmypyare now configured. Both previously passed by running with default rule sets over a codebase with 68 and 25 real findings respectively; a gate that reports safety nobody checked is worse than no gate. Both are clean.- CI tests Python 3.14.
- Removed dead code: the fingerprint-only
diff.py(superseded by deltas, and it truncated at 500 characters while snapshots carry 8000),ElementCache(never read),HiddenElementFilter(never called, and the fields it read were never populated),Element.snapshot_version(never compared), and a duplicate tokenization pass per snapshot.
Known limitations
- TLS/JA3 fingerprints and full headless fingerprint parity are below the
DevTools Protocol and out of reach from Python driving stock Chromium. A site
blocking on IP reputation needs a residential or mobile proxy, which
proxy=supports. - Whether
stealth=Truehelps is unmeasured.evaluation/stealth_measurement.pyanswers it where a browser has network access. - A
SIGKILLed process still strands its temp profile directory;terminate()cannot run if the interpreter never regains control.
0.4.2 - 2026-08-06
Fixed
-
click()andtype()could act on the wrong element. Both are handed an index produced bysnapshot(), but each built its own candidate list by different rules.DISCOVER_ELEMENTS_JStreated an element as hidden on six conditions (display, visibility, opacity, aria-hidden, zero width, zero height);CLICK_ELEMENT_JSchecked two;TYPE_ELEMENT_JScollected an entirely different, input-only set and ignored the snapshot's ordering altogether.On any page containing an
aria-hiddenoropacity:0control, click landed on a different element than the one matched. Typing was worse: an input preceded by buttons or links sat at a different index inTYPE's list than the snapshot reported, so text went to the wrong field. Existing tests passed only because every fixture happened to put the input first.All three now index into one shared collector, so the lists cannot drift apart again.
type()additionally returns false rather than silently doing nothing if the addressed element is not typable. Verified by regression tests that fail against the previous implementation (4 of 5) and pass against this one.
Changed
- The tracking-iframe unit test asserted internal JavaScript variable names and broke on a pure refactor while the behaviour was intact. It now asserts the host list, with the behaviour itself covered by an integration test.
Note on 0.4.1's performance claim
0.4.1 reported element discovery 1.14x-2.27x faster on real pages, which holds. It is
worth recording what it does not claim: on a deliberately dense benchmark fixture
(3000 interactive elements, ~60% of all nodes), the change is inside measurement
noise. Roughly 95% of that fixture's cost is getComputedStyle,
getBoundingClientRect and innerText on elements that genuinely are candidates —
load-bearing for the output and not removable without changing what is returned. The
speedup comes from skipping that work on non-candidates, so it scales with how
sparse the page is, and real pages are sparse.
0.4.1 - 2026-08-06
Changed
DISCOVER_ELEMENTS_JSnow resolves computed style and layout only for elements that are interactive candidates, instead of for every node it walks.getComputedStyle,offsetWidth/offsetHeightandgetBoundingClientRectforce style resolution and layout, and were previously paid on every element in the DOM even though the result was only ever consulted inside the tag/role check. Measured 1.14x-2.27x faster on real pages (Python docs 25.8ms -> 11.6ms, react.dev 17.3ms -> 7.6ms), with element output verified byte-identical across five sites.
Fixed
- Cleared the entire lint and type backlog in
grip/: 39 ruff findings and 10 mypy errors down to zero, with no test modified. Notably a real forward-reference bug (RunResultundefined inbrowser.py) and a stale websockets type annotation (WebSocketClientProtocol, which the installed version no longer exports).CDPEngine.send()now raises a clear error when called beforeconnect()rather than failing on aNoneattribute. - CI's lint job is now a real gate at zero rather than a ratchet against a backlog.
0.4.0 - 2026-08-06
Added
Browser(block_resources=True)— opt-in blocking of images, fonts and media viaNetwork.setBlockedURLs. CSS and XHR are deliberately not blocked: layout decides which elements count as visible, and content routinely arrives over XHR. Measured across 50 real pages, blocking is what makes browser-driven retrieval cheaper than a static-fetch vendor for text-oriented content (seeevaluation/PAGE_WEIGHT.md).ErrorType.NO_CONTENT— a third fetch outcome distinct from success and from a block: the page loaded, was not blocked, and has no usable content. Detected from soft-404 titles and from a content-shape signal comparing raw page text against what survives chrome stripping. Validated against 33 real pages with zero false positives.
Changed
snapshot()now issues its three independent CDP calls concurrently rather than sequentially — measured 15-30% faster on local fixtures, no behaviour change.- The content-shape probe runs once per URL rather than once per snapshot. It costs a second JS evaluation, and its verdict describes the fetch, so re-deriving it after an agent has been interacting with the page cannot change the answer (16.1ms -> 10.2ms median).
Fixed
Browser.open()no longer leaks a tab and its websocket when navigation fails or the caller cancels mid-navigate. Previously thePagewas registered beforegoto()ran, so a cancelledopen()left a tab open with no handle for the caller to close it — at ~219 MB per tab, this accumulated for the lifetime of the browser.read(interact=True)now respects safe mode. Revealing content means clicking, and the interaction path bypassed the guard thatclick()/type()/press()enforce.- Removed
"please wait"and"forbidden"from anti-bot title detection. Both match ordinary titles ("Forbidden fruit"), and a false positive causes a legitimate page to be dropped unread. Real 403s are caught by status code. - The source distribution no longer bundles the separate
grip-searchpackage. The wheel's package filter does not apply to sdists.
0.3.0 - 2026-08-05
Added
- Concurrent pages: every
browser.open()gets its own tab and its own CDP connection, so pages can be driven in parallel. Addedpage.goto()to navigate an existing tab in place, andpage.close()to close a tab.Browser.close()now closes any tabs left open. Element.hrefandPageSnapshot.links— links now report their destination (absolute, http(s) only).page.read()read mode: main-content isolation, chrome stripping, and citable blocks carrying a heading breadcrumb, so a claim can be cited back to a location. Removed the old 8000-char truncation.Browser(stealth=True): opt-in removal ofnavigator.webdriverand theHeadlessChromeUA string. Off by default.- Chrome discovery now falls back to Playwright's or Puppeteer's cached Chrome for Testing build if no system Chrome is found.
- Added
"just a moment"and other interstitial page titles to block detection.
Changed
- Breaking: a second
open()now returns an independent tab instead of clobbering the first. - Breaking:
save_session/load_sessionmoved from theNetworkto theStorageCDP domain. The browser-level endpoint has noNetworkdomain, andStoragealso returns every cookie rather than only the ones scoped to one tab.
Fixed
- Load-event race:
open()subscribed toPage.loadEventFiredafter callingPage.navigate, losing the race on every fast page and waiting out the full 30s timeout. A real-page test went from 59s to 2.2s. - Blocked fetches no longer report success: the real HTTP status is now
captured from
Network.responseReceived. Previously status was hardcoded to 0, making the 429 and 403 branches unreachable dead code. - Launcher robustness:
terminate()now escalates tokill(), and_read_port()no longer parses a partially-writtenDevToolsActivePortfile.