Skip to content

Commit 26524a5

Browse files
committed
feat: make browser defaults safer
1 parent 6621767 commit 26524a5

43 files changed

Lines changed: 683 additions & 891 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,18 @@ jobs:
2323
run: uv run --extra dev ruff check .
2424
- name: Format
2525
run: uv run --extra dev ruff format --check .
26-
# Browser and live tests need downloaded browsers and third-party
27-
# services; the deselected suite is the deterministic contract.
26+
- name: Type check
27+
run: uvx --from pyright==1.1.411 pyright --pythonpath .venv/bin/python src/webskrap
28+
- name: Audit dependencies
29+
run: uv run --extra dev --with pip-audit==2.9.0 pip-audit
30+
# Keep third-party live tests opt-in; local browser tests run below.
2831
- name: Test
2932
run: uv run --extra dev pytest -q -m "not browser and not live"
33+
- name: Install browsers
34+
run: |
35+
uv run playwright install --with-deps chromium
36+
uv run patchright install chromium
37+
- name: Browser test
38+
run: uv run --extra dev pytest -q tests/test_browser_integration.py
3039
- name: Build
3140
run: uv run --with build python -m build

.github/workflows/pages.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,14 @@ jobs:
2323
env:
2424
NEXT_PUBLIC_BASE_PATH: /webskrap
2525
steps:
26-
- uses: actions/checkout@v4
26+
- uses: actions/checkout@v6
2727
- uses: oven-sh/setup-bun@v2
2828
- name: Install dependencies
2929
run: bun install --frozen-lockfile
30+
- name: Audit dependencies
31+
run: bun audit
32+
- name: Lint
33+
run: bun run lint
3034
- name: Build static export
3135
run: bun run build
3236
- uses: actions/configure-pages@v5

SKILL.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,11 @@ config = SessionConfig(
7777
)
7878
```
7979

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:
80+
Cookie rejection is opt-in in the Python API (`src/webskrap/consent.py`).
81+
The CLI and MCP server enable it by default:
8282

8383
```python
84-
config = SessionConfig(decline_cookies=False, decline_cookies_timeout_ms=2_000)
84+
config = SessionConfig(decline_cookies=True, decline_cookies_timeout_ms=2_000)
8585
```
8686

8787
`FetchResult.cookie_notice_declined` reports the strategy that clicked (`"cmp"`,
@@ -158,7 +158,7 @@ MCP tools:
158158

159159
- `fetch`: Patchright stealth fetch (headless Chrome, waits for networkidle).
160160
- `stealth_fetch`: stealth fetch with finer fingerprint/WebRTC/UA controls.
161-
- `doctor`: Playwright/Chromium MCP readiness check.
161+
- `doctor`: Patchright/Chromium MCP readiness check.
162162

163163
## Validation
164164

benchmarks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ async def wrapper(*args, **kwargs):
128128

129129

130130
def _config(policy: ResourcePolicy) -> SessionConfig:
131-
return SessionConfig(headless=True, resource_policy=policy)
131+
return SessionConfig(headless=True, resource_policy=policy, decline_cookies=False)
132132

133133

134134
@benchmark

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ classifiers = [
2424
"Typing :: Typed",
2525
]
2626
dependencies = [
27-
"mcp>=1.2",
27+
"mcp>=1.2,<2",
2828
"patchright>=1.60.0",
2929
"playwright>=1.49",
3030
"pydantic>=2.8",

src/webskrap/cli.py

Lines changed: 20 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,16 @@
22

33
import asyncio
44
import json
5-
import os
65
import subprocess
76
import sys
8-
import time
9-
import urllib.request
10-
from importlib import metadata
117
from pathlib import Path
12-
from typing import Annotated, Any, Literal, NoReturn
8+
from typing import Annotated, Any, Literal, NoReturn, TypedDict
139

1410
import typer
1511
from rich.console import Console
1612
from rich.table import Table
1713

18-
from webskrap.client import WebSkrapClient
14+
from webskrap.client import WebSkrapClient, browser_doctor
1915
from webskrap.models import (
2016
FetchResult,
2117
ResourcePolicy,
@@ -33,73 +29,18 @@
3329
app = typer.Typer(help="WebSkrap browser scraping toolkit.")
3430
console = Console()
3531
OutputFormat = Literal["human", "json"]
36-
INSTALL_COMMANDS = (
37-
(sys.executable, "-m", "playwright", "install", "chromium"),
38-
(sys.executable, "-m", "patchright", "install", "chromium"),
39-
)
40-
UPDATE_CHECK_URL = "https://pypi.org/pypi/webskrap/json"
41-
UPDATE_CHECK_INTERVAL = 86_400 # once per day
42-
UPDATE_CHECK_CACHE = Path.home() / ".webskrap" / "update-check.json"
43-
# ponytail: ~/.webskrap not XDG/APPDATA-aware; swap to platformdirs if that matters
44-
45-
46-
def _is_newer(latest: str, current: str) -> bool:
47-
# ponytail: naive X.Y.Z compare; swap to packaging.version if pre-release tags ever ship
48-
try:
49-
return tuple(map(int, latest.split("."))) > tuple(map(int, current.split(".")))
50-
except ValueError:
51-
return False
52-
5332

54-
def _check_for_update() -> None:
55-
"""Best-effort 'update available' notice. Never raises, never touches stdout."""
56-
try:
57-
if (
58-
os.environ.get("WEBSKRAP_NO_UPDATE_CHECK")
59-
or os.environ.get("CI")
60-
or not sys.stderr.isatty()
61-
):
62-
return
63-
64-
current = metadata.version("webskrap")
65-
latest: str | None = None
6633

67-
try:
68-
cached = json.loads(UPDATE_CHECK_CACHE.read_text())
69-
if time.time() - cached["checked_at"] < UPDATE_CHECK_INTERVAL:
70-
latest = cached["latest"]
71-
except Exception:
72-
latest = None
73-
74-
if latest is None:
75-
fetched: str | None = None
76-
try:
77-
with urllib.request.urlopen(UPDATE_CHECK_URL, timeout=2) as response:
78-
fetched = json.load(response)["info"]["version"]
79-
except Exception:
80-
fetched = None
81-
# Stamp the attempt either way so a PyPI outage can't cause hammering.
82-
latest = fetched or current
83-
try:
84-
UPDATE_CHECK_CACHE.parent.mkdir(parents=True, exist_ok=True)
85-
UPDATE_CHECK_CACHE.write_text(
86-
json.dumps({"checked_at": time.time(), "latest": latest})
87-
)
88-
except Exception:
89-
pass
90-
91-
if _is_newer(latest, current):
92-
Console(stderr=True, highlight=False).print(
93-
f"[yellow]webskrap {latest} available[/] (you have {current}) — "
94-
"upgrade: [bold]pip install -U webskrap[/]"
95-
)
96-
except Exception:
97-
return
34+
class InstallResult(TypedDict):
35+
ok: bool
36+
command: list[str]
37+
message: str
9838

9939

100-
@app.callback()
101-
def _main() -> None:
102-
_check_for_update()
40+
INSTALL_COMMANDS = (
41+
(sys.executable, "-m", "playwright", "install", "chromium"),
42+
(sys.executable, "-m", "patchright", "install", "chromium"),
43+
)
10344

10445

10546
@app.command("install")
@@ -171,49 +112,19 @@ def doctor_command(
171112

172113

173114
async def _doctor() -> dict[str, object]:
174-
try:
175-
from patchright.async_api import async_playwright
176-
except Exception as exc:
177-
return {
178-
"ok": False,
179-
"message": f"Patchright import failed: {exc}",
180-
"hint": "Run: webskrap install",
181-
}
182-
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"
198-
return {
199-
"ok": True,
200-
"message": f"Patchright headless {label} is ready.",
201-
"channel": label,
202-
}
203-
204-
return {
205-
"ok": False,
206-
"message": f"Patchright headless Chrome did not launch: {failure}",
207-
"hint": "Run: webskrap install",
208-
}
115+
return await browser_doctor()
209116

210117

211118
@app.command("fetch")
212119
def fetch_command(
213120
url: Annotated[str, typer.Argument(help="URL to fetch.")],
214121
profile: Annotated[
215122
str,
216-
typer.Option("--profile", "-p", help="Bundled profile name."),
123+
typer.Option(
124+
"--profile",
125+
"-p",
126+
help="Bundled profile metadata (requires --patchright-context-profile).",
127+
),
217128
] = "desktop-chrome",
218129
channel: Annotated[
219130
str | None,
@@ -500,7 +411,7 @@ def _print_json(payload: object) -> None:
500411
typer.echo(json.dumps(payload, ensure_ascii=False))
501412

502413

503-
def _run_install_command(command: tuple[str, ...]) -> dict[str, object]:
414+
def _run_install_command(command: tuple[str, ...]) -> InstallResult:
504415
try:
505416
completed = subprocess.run(command, capture_output=True, text=True, check=False)
506417
except OSError as exc:
@@ -517,7 +428,7 @@ def _run_install_command(command: tuple[str, ...]) -> dict[str, object]:
517428
}
518429

519430

520-
def _print_install_result(results: list[dict[str, object]]) -> None:
431+
def _print_install_result(results: list[InstallResult]) -> None:
521432
for result in results:
522433
command = " ".join(str(part) for part in result["command"])
523434
if result["ok"]:
@@ -533,13 +444,7 @@ def _print_doctor_result(result: dict[str, object]) -> None:
533444
if result["ok"]:
534445
console.print(f"[green]{message}[/green]")
535446
return
536-
if message.startswith("Patchright import failed: "):
537-
detail = message.removeprefix("Patchright import failed: ")
538-
console.print(f"[red]Patchright import failed:[/red] {detail}")
539-
else:
540-
console.print(
541-
"[yellow]Patchright is installed, but headless Chrome did not launch.[/yellow]"
542-
)
543-
console.print(message.removeprefix("Patchright headless Chrome did not launch: "))
447+
console.print("[yellow]Patchright is unavailable.[/yellow]")
448+
console.print(message)
544449
if hint := result.get("hint"):
545450
console.print(str(hint))

0 commit comments

Comments
 (0)