Skip to content

Commit 32f4ca8

Browse files
committed
fix(cli): fallback to chromium and cap consent waits
Default --channel chrome dumped a Playwright traceback on platforms with no Chrome build. Cookie decline could run ~12s despite its 2s option, and its selectors had no coverage against real consent platforms.
1 parent 2c98d81 commit 32f4ca8

13 files changed

Lines changed: 360 additions & 66 deletions

File tree

README.md

Lines changed: 18 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -152,45 +152,27 @@ asyncio.run(main())
152152

153153
## Auto-decline cookie banners
154154

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
155+
Every `fetch()` (and therefore the CLI and the MCP tools) clicks the reject
156+
control of a cookie consent notice before reading the page, so scraped text is
157+
not buried under a banner and no optional cookies are accepted. It knows the
158+
common consent platforms (OneTrust, Cookiebot, Didomi, Usercentrics,
159+
Sourcepoint, ...) and falls back to matching reject wording inside a
160+
cookie/consent container, in every frame. Detection is inspired by Brave's
160161
[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`).
162+
notices; WebSkrap dismisses them.
171163

172164
```python
173165
config = SessionConfig(
174-
decline_cookies=True, # default
175-
decline_cookies_timeout_ms=2_000, # wait for a late-injected notice; 0 = check once
166+
decline_cookies=True, # default
167+
decline_cookies_timeout_ms=2_000, # wait for a late notice; 0 = check once
176168
)
177-
178169
result = await client.fetch("https://example.com", config=config)
179-
print(result.cookie_notice_declined)
170+
print(result.cookie_notice_declined) # "cmp", "text", or None
180171
```
181172

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.
173+
Full reference, including `session.decline_cookies(page)` for pages you drive
174+
yourself:
175+
[docs/user-guide/client](https://kacigaya.github.io/webskrap/docs/user-guide/client/).
194176

195177
## Custom profile
196178

@@ -403,7 +385,11 @@ flags, but pages that need WebGL or canvas export may not work correctly.
403385
## CLI
404386

405387
`webskrap fetch` always runs headless Patchright stealth mode. `webskrap install`
406-
downloads the browser binaries, and `webskrap doctor` verifies this CLI setup.
388+
downloads the browser binaries, and `webskrap doctor` verifies this CLI setup and
389+
reports which browser channel launches.
390+
391+
`--channel` defaults to `chrome`. Where Chrome does not exist (Linux ARM64),
392+
`fetch` warns on stderr and retries with bundled chromium instead of failing.
407393

408394
```bash
409395
pip install webskrap

SKILL.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,9 @@ webskrap fetch https://example.com --stdout --text-only
129129
webskrap fetch https://example.com --quiet --output page.html
130130
```
131131

132-
On Linux ARM64, the `chrome` channel can be unsupported. Prefer `chrome` where
133-
it launches, but use Chromium fallback on Linux ARM64:
132+
On Linux ARM64, the `chrome` channel can be unsupported. `fetch` detects a
133+
launch failure and retries with bundled chromium automatically; pass the channel
134+
explicitly to skip the failed first attempt:
134135

135136
```bash
136137
webskrap fetch https://example.com --channel chromium --format json
@@ -166,9 +167,14 @@ For non-trivial changes run:
166167
```bash
167168
pytest -q
168169
ruff check .
170+
ruff format --check .
169171
python -m build
170172
```
171173

174+
CI (`.github/workflows/ci.yml`) runs the same gate on push and pull request, and
175+
`Publish` calls it before uploading to PyPI. PyPI versions are immutable, so a
176+
red gate must never be bypassed.
177+
172178
Use `WEBSKRAP_LIVE=1 pytest -q -m live` only when explicitly checking public
173-
third-party bot-detection behavior. Those tests are opt-in and can fail when
174-
external demos change.
179+
third-party bot-detection behavior, or `tests/test_consent_live.py` for CMP
180+
selector rot. Those tests are opt-in and can fail when external sites change.

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.7.0"
7+
version = "0.7.1"
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/cli.py

Lines changed: 86 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@
99
import urllib.request
1010
from importlib import metadata
1111
from pathlib import Path
12-
from typing import Annotated, Literal
12+
from typing import Annotated, Any, Literal, NoReturn
1313

1414
import typer
1515
from rich.console import Console
1616
from rich.table import Table
1717

1818
from webskrap.client import WebSkrapClient
1919
from webskrap.models import (
20+
FetchResult,
2021
ResourcePolicy,
2122
SessionConfig,
2223
WaitUntil,
@@ -179,20 +180,32 @@ async def _doctor() -> dict[str, object]:
179180
"hint": "Run: webskrap install",
180181
}
181182

182-
try:
183-
manager = async_playwright()
184-
playwright = await manager.start()
185-
browser = await playwright.chromium.launch(channel="chrome", headless=True)
186-
await browser.close()
187-
await playwright.stop()
188-
except Exception as exc:
183+
# The chrome channel is unavailable on some platforms (Linux ARM64), where
184+
# bundled chromium still works. Report the best channel that launches
185+
# instead of failing the whole check.
186+
failure: Exception | None = None
187+
for channel in ("chrome", None):
188+
try:
189+
manager = async_playwright()
190+
playwright = await manager.start()
191+
browser = await playwright.chromium.launch(channel=channel, headless=True)
192+
await browser.close()
193+
await playwright.stop()
194+
except Exception as exc: # noqa: BLE001 - try the next channel
195+
failure = exc
196+
continue
197+
label = channel or "chromium"
189198
return {
190-
"ok": False,
191-
"message": f"Patchright headless Chrome did not launch: {exc}",
192-
"hint": "Run: webskrap install",
199+
"ok": True,
200+
"message": f"Patchright headless {label} is ready.",
201+
"channel": label,
193202
}
194203

195-
return {"ok": True, "message": "Patchright headless Chrome is ready."}
204+
return {
205+
"ok": False,
206+
"message": f"Patchright headless Chrome did not launch: {failure}",
207+
"hint": "Run: webskrap install",
208+
}
196209

197210

198211
@app.command("fetch")
@@ -373,16 +386,15 @@ async def _fetch(
373386
webrtc_ip_handling_policy=_parse_webrtc_ip_handling_policy(webrtc_ip_handling_policy),
374387
)
375388

376-
async with WebSkrapClient() as client:
377-
result = await client.fetch(
378-
url,
379-
profile=selected_profile,
380-
config=config,
381-
wait_until=_parse_wait_until(wait_until),
382-
screenshot=screenshot or False,
383-
timeout_ms=timeout_ms,
384-
text_only=text_only,
385-
)
389+
result = await _fetch_with_channel_fallback(
390+
config,
391+
url=url,
392+
profile=selected_profile,
393+
wait_until=_parse_wait_until(wait_until),
394+
screenshot=screenshot or False,
395+
timeout_ms=timeout_ms,
396+
text_only=text_only,
397+
)
386398

387399
if output:
388400
output.parent.mkdir(parents=True, exist_ok=True)
@@ -410,6 +422,58 @@ async def _fetch(
410422
console.print(f"[bold]HTML:[/bold] {output}")
411423

412424

425+
LAUNCH_FAILURE_MARKERS = (
426+
"executable doesn't exist",
427+
"is not found at",
428+
"playwright install",
429+
"failed to launch",
430+
"browsertype.launch",
431+
)
432+
433+
434+
def _is_launch_failure(exc: Exception) -> bool:
435+
return any(marker in str(exc).lower() for marker in LAUNCH_FAILURE_MARKERS)
436+
437+
438+
def _fail_launch(exc: Exception) -> NoReturn:
439+
"""Report an unlaunchable browser the way `doctor` does, not as a traceback."""
440+
detail = str(exc).strip().splitlines()
441+
stderr = Console(stderr=True, highlight=False)
442+
stderr.print(f"[red]Browser did not launch:[/red] {detail[0] if detail else exc}")
443+
stderr.print("Run: [bold]webskrap install[/bold]")
444+
raise typer.Exit(code=1)
445+
446+
447+
async def _run_fetch(config: SessionConfig, **kwargs: Any) -> FetchResult:
448+
async with WebSkrapClient() as client:
449+
return await client.fetch(config=config, **kwargs)
450+
451+
452+
async def _fetch_with_channel_fallback(config: SessionConfig, **kwargs: Any) -> FetchResult:
453+
"""Fetch, retrying on bundled chromium when the chosen channel cannot launch.
454+
455+
The default channel is `chrome`, which does not exist on every platform
456+
(Linux ARM64 has no Chrome build). Falling back keeps `webskrap fetch`
457+
working there instead of dumping a Playwright traceback.
458+
"""
459+
try:
460+
return await _run_fetch(config, **kwargs)
461+
except Exception as exc:
462+
if not _is_launch_failure(exc):
463+
raise
464+
if config.channel is None:
465+
_fail_launch(exc)
466+
Console(stderr=True, highlight=False).print(
467+
f"[yellow]channel '{config.channel}' did not launch; retrying with chromium[/yellow]"
468+
)
469+
try:
470+
return await _run_fetch(config.model_copy(update={"channel": None}), **kwargs)
471+
except Exception as retry_exc:
472+
if not _is_launch_failure(retry_exc):
473+
raise
474+
_fail_launch(retry_exc)
475+
476+
413477
def _parse_wait_until(value: str) -> WaitUntil:
414478
try:
415479
return parse_wait_until(value)

src/webskrap/client.py

Lines changed: 6 additions & 1 deletion
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 SETTLED_PAGE_TIMEOUT_MS
1516
from webskrap.consent import decline_cookies as _decline_cookies
1617
from webskrap.models import BrowserProfile, FetchResult, ResourcePolicy, SessionConfig, WaitUntil
1718
from webskrap.profiles import get_profile
@@ -86,7 +87,11 @@ async def fetch(
8687
)
8788
declined = None
8889
if self.config.decline_cookies:
89-
declined = await self.decline_cookies(page)
90+
budget = self.config.decline_cookies_timeout_ms
91+
if wait_until == "networkidle":
92+
# The navigation already waited out the CMP script.
93+
budget = min(budget, SETTLED_PAGE_TIMEOUT_MS)
94+
declined = await self.decline_cookies(page, timeout_ms=budget)
9095
title = await page.title()
9196
text = await page.locator("body").inner_text() if text_only else await page.content()
9297
screenshot_path = await _maybe_screenshot(page, screenshot)

src/webskrap/consent.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import re
2020
from contextlib import suppress
21+
from time import monotonic
2122
from typing import Any
2223

2324
# Reject/deny buttons of common consent management platforms.
@@ -163,8 +164,16 @@
163164
# A consent platform's iframe is visible before its contents finish rendering,
164165
# so retry while one is attached. Only pages that actually carry such a frame
165166
# pay this; everything else gets a single pass.
167+
# ponytail: fixed 3s poll ceiling for iframe render; wait on the reject locator
168+
# inside the frame if slow CMPs start slipping through
166169
POLL_INTERVAL_MS = 250
167170
POLL_ATTEMPTS = 12
171+
# Hard cap on everything after detection (polling plus click attempts), so the
172+
# total cost stays timeout_ms + this instead of growing with candidate count.
173+
POST_DETECT_BUDGET_MS = 3_000
174+
# A caller that already waited for networkidle gave CMP scripts their chance to
175+
# inject, so the detection wait on top of that is mostly dead time.
176+
SETTLED_PAGE_TIMEOUT_MS = 500
168177

169178

170179
async def decline_cookies(page: Any, *, timeout_ms: float = 2_000) -> str | None:
@@ -175,7 +184,16 @@ async def decline_cookies(page: Any, *, timeout_ms: float = 2_000) -> str | None
175184
the strategy that clicked (``"cmp"`` or ``"text"``), or ``None`` when no
176185
notice was found. Never raises: a page without a notice, or a notice this
177186
cannot handle, is not an error.
187+
188+
Total wall time is bounded by ``timeout_ms + POST_DETECT_BUDGET_MS``.
189+
190+
ponytail: only the notice's first layer is handled. Walls with no reject
191+
control there (zeit.de and other pay-or-consent publishers) are left alone,
192+
and walls that answer the reject click with a subscription upsell
193+
(theguardian.com) stay up -- the return value reports the click, not a clean
194+
page. Walking further needs per-CMP second-layer flows.
178195
"""
196+
deadline = monotonic() + (max(timeout_ms, 0.0) + POST_DETECT_BUDGET_MS) / 1000
179197
if timeout_ms > 0:
180198
try:
181199
await page.wait_for_selector(
@@ -186,13 +204,18 @@ async def decline_cookies(page: Any, *, timeout_ms: float = 2_000) -> str | None
186204

187205
for attempt in range(POLL_ATTEMPTS):
188206
for frame in page.frames:
189-
strategy = await _decline_in_frame(frame)
207+
strategy = await _decline_in_frame(frame, deadline)
190208
if strategy is not None:
191209
with suppress(Exception): # settle wait is best-effort
192210
await page.wait_for_timeout(SETTLE_MS)
193211
return strategy
194-
# Nothing left that can still render into a notice.
195-
if timeout_ms <= 0 or attempt + 1 == POLL_ATTEMPTS or not _has_consent_frame(page):
212+
# Nothing left that can still render into a notice, or out of budget.
213+
if (
214+
timeout_ms <= 0
215+
or attempt + 1 == POLL_ATTEMPTS
216+
or monotonic() >= deadline
217+
or not _has_consent_frame(page)
218+
):
196219
break
197220
with suppress(Exception):
198221
await page.wait_for_timeout(POLL_INTERVAL_MS)
@@ -210,7 +233,7 @@ def _is_consent_frame(frame: Any) -> bool:
210233
return bool(CONSENT_FRAME_URL_PATTERN.search(getattr(frame, "url", "") or ""))
211234

212235

213-
async def _decline_in_frame(frame: Any) -> str | None:
236+
async def _decline_in_frame(frame: Any, deadline: float) -> str | None:
214237
text_scope = frame if _is_consent_frame(frame) else frame.locator(CONSENT_CONTAINER_CSS)
215238
candidates = (
216239
("cmp", frame.locator(CMP_REJECT_CSS)),
@@ -222,11 +245,19 @@ async def _decline_in_frame(frame: Any) -> str | None:
222245
except Exception: # noqa: BLE001 - detached frame or bad selector engine
223246
return None
224247
for index in range(total):
248+
click_timeout = min(CLICK_TIMEOUT_MS, (deadline - monotonic()) * 1000)
249+
if click_timeout <= 0:
250+
return None
225251
element = locator.nth(index)
226252
try:
227253
if not await element.is_visible():
228254
continue
229-
await element.click(timeout=CLICK_TIMEOUT_MS)
255+
# Playwright dispatches real browser input events here, not a
256+
# JavaScript el.click(), so this is not a synthesized-event
257+
# tell. Deliberately not human_click: that takes a Page and
258+
# notices live in frames, and consent widgets do not score
259+
# cursor trajectory.
260+
await element.click(timeout=click_timeout)
230261
except Exception: # noqa: BLE001 - covered, detached, or navigating
231262
continue
232263
return strategy

0 commit comments

Comments
 (0)