Skip to content

Commit a042f59

Browse files
committed
Auto-decline cookie banners during fetch
- Add consent auto-decline to fetch, CLI, and MCP flows - Expose cookie_notice_declined in fetch results
1 parent 263ae38 commit a042f59

18 files changed

Lines changed: 742 additions & 8 deletions

README.md

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,48 @@ async def main() -> None:
150150
asyncio.run(main())
151151
```
152152

153+
## Auto-decline cookie banners
154+
155+
Every `session.fetch()` (and therefore the CLI and the MCP tools) clicks the
156+
reject control of a cookie consent notice before reading the page, so scraped
157+
text is not buried under a banner and no optional cookies are accepted.
158+
159+
Detection is inspired by Brave's
160+
[cookiecrumbler](https://github.com/brave/cookiecrumbler), which finds consent
161+
notices; WebSkrap dismisses them. Two strategies, tried in every frame:
162+
163+
1. Reject buttons of the common consent platforms (OneTrust, Cookiebot, Didomi,
164+
Usercentrics, Sourcepoint, Quantcast, Osano, Complianz, CookieYes, ...).
165+
2. A clickable whose label matches a reject phrase ("Reject all", "Refuser
166+
tout", "Ablehnen", "Continue without accepting", ...), scoped to a
167+
cookie/consent container so unrelated "Decline" buttons are never touched.
168+
169+
`FetchResult.cookie_notice_declined` reports which strategy clicked (`"cmp"`,
170+
`"text"`, or `None`).
171+
172+
```python
173+
config = SessionConfig(
174+
decline_cookies=True, # default
175+
decline_cookies_timeout_ms=2_000, # wait for a late-injected notice; 0 = check once
176+
)
177+
178+
result = await client.fetch("https://example.com", config=config)
179+
print(result.cookie_notice_declined)
180+
```
181+
182+
The timeout is the per-fetch cost on pages without a notice. Pages that carry a
183+
consent-platform iframe are retried for up to 3s while it renders. For pages you
184+
drive yourself, call it directly:
185+
186+
```python
187+
page = await session.context.new_page()
188+
await page.goto("https://example.com", wait_until="domcontentloaded")
189+
await session.decline_cookies(page)
190+
```
191+
192+
Consent walls with no reject control on the first layer (pay-or-consent) are
193+
left alone.
194+
153195
## Custom profile
154196

155197
```python
@@ -179,6 +221,8 @@ config = SessionConfig(
179221
storage_state=None,
180222
proxy=ProxyConfig(server="http://127.0.0.1:8080"),
181223
resource_policy=ResourcePolicy.LITE,
224+
decline_cookies=True,
225+
decline_cookies_timeout_ms=2_000,
182226
ignore_https_errors=True,
183227
java_script_enabled=True,
184228
service_workers="allow",
@@ -375,6 +419,8 @@ webskrap fetch https://example.com --profile desktop-chrome
375419
webskrap fetch https://example.com --format json --max-chars 12000
376420
webskrap fetch https://example.com --stdout --text-only
377421
webskrap fetch https://example.com --screenshot example.png
422+
webskrap fetch https://example.com --no-decline-cookies
423+
webskrap fetch https://example.com --decline-cookies-timeout-ms 4000
378424
webskrap fetch https://example.com --channel chrome \
379425
--user-data-dir .webskrap/headless-profile
380426
webskrap fetch https://amiunique.org/fr/fingerprint \
@@ -405,8 +451,10 @@ clean visible page text by default, with no HTML tags, scripts, or style noise,
405451
so agents spend tokens on content, not markup (typically 5-10x fewer tokens than
406452
raw HTML). Pass `text_only=False` when you actually need the HTML. Use
407453
`stealth_fetch` for finer control over fingerprint surface, WebRTC, UA masking,
408-
and persistent profiles. Every result carries `status`, `final_url`, `title`,
409-
`text_length`, and truncation flags so the model knows exactly what it got.
454+
and persistent profiles. Both auto-decline cookie banners (`decline_cookies=False`
455+
to keep them). Every result carries `status`, `final_url`, `title`,
456+
`text_length`, `cookie_notice_declined`, and truncation flags so the model knows
457+
exactly what it got.
410458

411459
```bash
412460
pip install webskrap

SKILL.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Prefer current repo sources over memory:
1616
- `src/webskrap/client.py`: `WebSkrapClient`, sessions, fetch flow, screenshots.
1717
- `src/webskrap/models.py`: public Pydantic models, `SessionConfig`, result shape.
1818
- `src/webskrap/profiles.py`: bundled browser profiles.
19+
- `src/webskrap/consent.py`: cookie-banner auto-decline selectors and strategies.
1920
- `src/webskrap/cli.py`: current `webskrap` command behavior.
2021
- `src/webskrap/mcp_server.py`: MCP tools and argument shape.
2122
- `tests/`: behavior contracts when changing parsing, state, CLI, stealth, or safety.
@@ -76,6 +77,17 @@ config = SessionConfig(
7677
)
7778
```
7879

80+
Every `fetch()` auto-declines cookie consent notices before reading the page
81+
(`src/webskrap/consent.py`). Turn it off or retune the wait per call:
82+
83+
```python
84+
config = SessionConfig(decline_cookies=False, decline_cookies_timeout_ms=2_000)
85+
```
86+
87+
`FetchResult.cookie_notice_declined` reports the strategy that clicked (`"cmp"`,
88+
`"text"`, or `None`). `session.decline_cookies(page)` does the same on a page you
89+
drive yourself.
90+
7991
Headed Patchright is strongest for strict detection surfaces:
8092

8193
```python
@@ -126,7 +138,7 @@ webskrap fetch https://example.com --channel chromium --format json
126138

127139
`fetch --format json` prints bounded JSON to stdout using the MCP-compatible
128140
shape: `url`, `final_url`, `status`, `ok`, `title`, `headers`, `text`,
129-
`text_length`, `text_truncated`, and `elapsed_ms`.
141+
`text_length`, `text_truncated`, `elapsed_ms`, and `cookie_notice_declined`.
130142

131143
Use `--stdout` for raw fetched content, and combine it with `--text-only` for
132144
readable body text.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "webskrap"
7-
version = "0.6.1"
7+
version = "0.7.0"
88
description = "A Playwright-based Python scraping framework with coherent browser profiles and session controls."
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/webskrap/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from webskrap.client import WebSkrapClient, WebSkrapError, WebSkrapSession
2+
from webskrap.consent import decline_cookies
23
from webskrap.models import (
34
BrowserProfile,
45
FetchResult,
@@ -21,6 +22,7 @@
2122
"WebSkrapClient",
2223
"WebSkrapError",
2324
"WebSkrapSession",
25+
"decline_cookies",
2426
"get_profile",
2527
"list_profiles",
2628
]

src/webskrap/cli.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,21 @@ def fetch_command(
250250
ResourcePolicy,
251251
typer.Option("--resource-policy", help="Resource routing preset."),
252252
] = ResourcePolicy.ALL,
253+
decline_cookies: Annotated[
254+
bool,
255+
typer.Option(
256+
"--decline-cookies/--no-decline-cookies",
257+
help="Click the reject button of a cookie consent notice after load.",
258+
),
259+
] = True,
260+
decline_cookies_timeout_ms: Annotated[
261+
float,
262+
typer.Option(
263+
"--decline-cookies-timeout-ms",
264+
min=0,
265+
help="How long to wait for a cookie consent notice to appear.",
266+
),
267+
] = 2_000,
253268
patchright_context_profile: Annotated[
254269
bool,
255270
typer.Option(
@@ -305,6 +320,8 @@ def fetch_command(
305320
wait_until=wait_until,
306321
timeout_ms=timeout_ms,
307322
resource_policy=resource_policy,
323+
decline_cookies=decline_cookies,
324+
decline_cookies_timeout_ms=decline_cookies_timeout_ms,
308325
patchright_context_profile=patchright_context_profile,
309326
reduce_fingerprint_surface=reduce_fingerprint_surface,
310327
mask_headless_user_agent=mask_headless_user_agent,
@@ -330,6 +347,8 @@ async def _fetch(
330347
wait_until: str,
331348
timeout_ms: float,
332349
resource_policy: ResourcePolicy,
350+
decline_cookies: bool,
351+
decline_cookies_timeout_ms: float,
333352
patchright_context_profile: bool,
334353
reduce_fingerprint_surface: bool,
335354
mask_headless_user_agent: bool,
@@ -345,6 +364,8 @@ async def _fetch(
345364
user_data_dir=user_data_dir,
346365
navigation_timeout_ms=timeout_ms,
347366
resource_policy=resource_policy,
367+
decline_cookies=decline_cookies,
368+
decline_cookies_timeout_ms=decline_cookies_timeout_ms,
348369
patchright_context_profile=patchright_context_profile,
349370
reduce_fingerprint_surface=reduce_fingerprint_surface,
350371
mask_headless_user_agent=mask_headless_user_agent,
@@ -381,6 +402,8 @@ async def _fetch(
381402
console.print(f"[bold]Status:[/bold] {result.status}")
382403
console.print(f"[bold]Final URL:[/bold] {result.final_url}")
383404
console.print(f"[bold]Title:[/bold] {result.title}")
405+
if result.cookie_notice_declined:
406+
console.print(f"[bold]Cookie notice:[/bold] declined ({result.cookie_notice_declined})")
384407
if result.screenshot_path:
385408
console.print(f"[bold]Screenshot:[/bold] {result.screenshot_path}")
386409
if output:

src/webskrap/client.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from playwright.async_api import Browser, BrowserContext, Page, Playwright
1414

15+
from webskrap.consent import decline_cookies as _decline_cookies
1516
from webskrap.models import BrowserProfile, FetchResult, ResourcePolicy, SessionConfig, WaitUntil
1617
from webskrap.profiles import get_profile
1718

@@ -83,6 +84,9 @@ async def fetch(
8384
wait_until=wait_until,
8485
timeout=timeout_ms or self.config.navigation_timeout_ms,
8586
)
87+
declined = None
88+
if self.config.decline_cookies:
89+
declined = await self.decline_cookies(page)
8690
title = await page.title()
8791
text = await page.locator("body").inner_text() if text_only else await page.content()
8892
screenshot_path = await _maybe_screenshot(page, screenshot)
@@ -101,10 +105,23 @@ async def fetch(
101105
cookies=cookies,
102106
timings={"elapsed_ms": elapsed_ms},
103107
screenshot_path=screenshot_path,
108+
cookie_notice_declined=declined,
104109
)
105110
finally:
106111
await page.close()
107112

113+
async def decline_cookies(self, page: Page, *, timeout_ms: float | None = None) -> str | None:
114+
"""Click a cookie consent notice's reject control on ``page``.
115+
116+
Called automatically by :meth:`fetch` unless
117+
``SessionConfig.decline_cookies`` is False. Call it directly for pages
118+
you drive yourself. Returns the strategy that clicked, or None.
119+
"""
120+
self._ensure_open()
121+
if timeout_ms is None:
122+
timeout_ms = self.config.decline_cookies_timeout_ms
123+
return await _decline_cookies(page, timeout_ms=timeout_ms)
124+
108125
async def human_click(
109126
self,
110127
page: Page,

0 commit comments

Comments
 (0)