webskrap 3.2.2__tar.gz → 3.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {webskrap-3.2.2 → webskrap-3.2.3}/CHANGELOG.md +21 -1
- {webskrap-3.2.2 → webskrap-3.2.3}/PKG-INFO +1 -1
- {webskrap-3.2.2 → webskrap-3.2.3}/SECURITY.md +5 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/docs/diagnostics/datadome.md +24 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/pyproject.toml +1 -1
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/browser_session.py +4 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/cdp.py +4 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/install.py +6 -1
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/search.py +13 -3
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/urls.py +4 -2
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_browser_session_unit.py +15 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_install.py +12 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_mcp.py +2 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_search.py +24 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_urls.py +18 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/uv.lock +1 -1
- {webskrap-3.2.2 → webskrap-3.2.3}/.agents/skills/webskrap/SKILL.md +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/.agents/skills/webskrap/references/cli.md +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/.agents/skills/webskrap/references/mcp.md +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/.agents/skills/webskrap/references/python-api.md +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/.claude/skills/webskrap/SKILL.md +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/.codex/skills/webskrap/SKILL.md +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/.gitattributes +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/.gitignore +0 -0
- {webskrap-3.2.2/.package-dist.dZyl3m → webskrap-3.2.3/.package-dist.zjESut}/.gitignore +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/CONTRIBUTING.md +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/LICENSE +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/README.md +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/benchmarks.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/docs/design/cdp-driver.md +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/docs/migration-3.0.md +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/scripts/driver_parity.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/scripts/live_stealth_report.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/__init__.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/browser_cli.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/cdp_dom.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/cdp_session.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/cdp_setup.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/challenges.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/cli.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/cli_output.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/client.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/consent.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/diagnostics.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/display.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/errors.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/fetch_runtime.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/guide.md +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/human.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/mcp_server.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/models.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/parsing.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/paths.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/profiles.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/proxy_auth.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/py.typed +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/src/webskrap/text_search.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/conftest.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/fixtures/blocks/accepted-202.html +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/fixtures/blocks/akamai-invalid-url-400.html +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/fixtures/blocks/akamai-reference-403.html +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/fixtures/blocks/akamai-sec-cpt.html +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/fixtures/blocks/api-403.html +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/fixtures/blocks/article-access-denied.html +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/fixtures/blocks/cloudflare-1020.html +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/fixtures/blocks/cloudflare-custom-block.html +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/fixtures/blocks/not-found-404.html +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/fixtures/search/bing.html +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/fixtures/search/bing_challenge.html +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/fixtures/search/bing_no_results.html +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/fixtures/search/ddg.html +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/fixtures/search/ddg_challenge.html +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/fixtures/search/ddg_no_results.html +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/live_stealth_helpers.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_bot_detection.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_bot_detection_headless.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_browser_cli.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_browser_integration.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_browser_vision.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_cdp.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_cdp_browser.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_cdp_child_targets.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_cdp_session.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_cdp_setup.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_challenges.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_cli.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_client_unit.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_consent.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_consent_live.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_diagnostics.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_display.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_docs.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_errors.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_fetch_runtime.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_human.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_live_stealth_helpers.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_live_stealth_report.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_mcp_efficiency.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_models.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_parsing.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_paths.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_profiles.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_search_live.py +0 -0
- {webskrap-3.2.2 → webskrap-3.2.3}/tests/test_text_search.py +0 -0
|
@@ -9,6 +9,25 @@ history, so they summarize each release rather than list every change.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [3.2.3] - 2026-10-30
|
|
13
|
+
|
|
14
|
+
### Security
|
|
15
|
+
|
|
16
|
+
- MCP URL checks now decode host escapes and treat backslashes as path
|
|
17
|
+
separators before checking for private addresses, as Chromium does.
|
|
18
|
+
- Browser installation rejects ZIP entries with Windows separators or drive
|
|
19
|
+
components that could write outside the install folder.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- On macOS, Chrome for Testing no longer asks for the "Chromium Safe Storage"
|
|
24
|
+
keychain password at each launch. Launches now use `--use-mock-keychain`.
|
|
25
|
+
Upgrade note: on macOS, persistent sessions and `user_data_dir` profiles
|
|
26
|
+
made before this release used the real Keychain key. They lose their saved
|
|
27
|
+
cookies and logins once, and you must sign in again.
|
|
28
|
+
- Search skips malformed result URLs instead of discarding all results from
|
|
29
|
+
the page. This also applies to URLs decoded from search-engine redirects.
|
|
30
|
+
|
|
12
31
|
## [3.2.2] - 2026-10-30
|
|
13
32
|
|
|
14
33
|
### Fixed
|
|
@@ -961,7 +980,8 @@ entries below.
|
|
|
961
980
|
|
|
962
981
|
Earlier releases (0.1.0 - 0.4.9) are recorded in the git history and tags only.
|
|
963
982
|
|
|
964
|
-
[Unreleased]: https://github.com/kacigaya/webskrap/compare/v3.2.
|
|
983
|
+
[Unreleased]: https://github.com/kacigaya/webskrap/compare/v3.2.3...HEAD
|
|
984
|
+
[3.2.3]: https://github.com/kacigaya/webskrap/compare/v3.2.2...v3.2.3
|
|
965
985
|
[3.2.2]: https://github.com/kacigaya/webskrap/compare/v3.2.1...v3.2.2
|
|
966
986
|
[3.2.1]: https://github.com/kacigaya/webskrap/compare/v3.2.0...v3.2.1
|
|
967
987
|
[3.2.0]: https://github.com/kacigaya/webskrap/compare/v3.1.0...v3.2.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: webskrap
|
|
3
|
-
Version: 3.2.
|
|
3
|
+
Version: 3.2.3
|
|
4
4
|
Summary: Async Python scraping on Chromium over its own CDP client, with coherent browser profiles and session controls.
|
|
5
5
|
Project-URL: Homepage, https://github.com/kacigaya/webskrap
|
|
6
6
|
Project-URL: Repository, https://github.com/kacigaya/webskrap
|
|
@@ -65,6 +65,11 @@ to permit them. The Python API and CLI take URLs from the operator and skip
|
|
|
65
65
|
the DNS check; point them at internal hosts deliberately, not with
|
|
66
66
|
page-derived input.
|
|
67
67
|
|
|
68
|
+
This check applies to the supplied URL. It does not filter redirects,
|
|
69
|
+
subresources, or requests from page scripts. DNS can also change after the
|
|
70
|
+
check. Use network-level restrictions when the browser must not reach private
|
|
71
|
+
services. The URL check alone does not provide full SSRF isolation.
|
|
72
|
+
|
|
68
73
|
### Chromium sandbox
|
|
69
74
|
|
|
70
75
|
Persistent sessions keep Chromium's OS sandbox. That sandbox is what stops a
|
|
@@ -66,6 +66,30 @@ A temporary headed browser session and loopback VNC listener on port 15900 were
|
|
|
66
66
|
to prepare manual completion. Both were closed when manual completion was
|
|
67
67
|
declined. Xvfb alone does not give remote access.
|
|
68
68
|
|
|
69
|
+
### Reuse a solved session
|
|
70
|
+
|
|
71
|
+
A persistent session keeps its cookies in its profile on disk
|
|
72
|
+
(`~/.webskrap/browser/<session>/user-data`). The DataDome cookie from a
|
|
73
|
+
manual solve stays there. No extra flag or export step is necessary.
|
|
74
|
+
|
|
75
|
+
1. Open the session by the same name:
|
|
76
|
+
`webskrap browser open --session <name> --headed <url>`.
|
|
77
|
+
2. Keep the same exit IP. The cookie belongs to the session that passed
|
|
78
|
+
the check.
|
|
79
|
+
3. Do not use the profile headless. A headless visit can receive a new
|
|
80
|
+
challenge or a hard block, and DataDome can then replace the cookie.
|
|
81
|
+
|
|
82
|
+
On 2026-10-30, a macOS run checked this on a DataDome website from a
|
|
83
|
+
residential IP. A person solved the CAPTCHA once in a headed session. A
|
|
84
|
+
reload then returned HTTP 200. The session was closed, and a new browser
|
|
85
|
+
process opened the same profile. A direct load of the protected page
|
|
86
|
+
returned HTTP 200 again, without a challenge. This run did not measure how
|
|
87
|
+
long the cookie stays valid. DataDome can challenge the session again at
|
|
88
|
+
any time.
|
|
89
|
+
|
|
90
|
+
WebSkrap does not export the cookie or copy it into other browsers. That is
|
|
91
|
+
token reuse, which is out of scope.
|
|
92
|
+
|
|
69
93
|
## Verification limits
|
|
70
94
|
|
|
71
95
|
The final Linux ARM64 run passed 858 tests. It excluded 30 opt-in live tests
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webskrap"
|
|
7
|
-
version = "3.2.
|
|
7
|
+
version = "3.2.3"
|
|
8
8
|
description = "Async Python scraping on Chromium over its own CDP client, with coherent browser profiles and session controls."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -499,6 +499,10 @@ def launch_browser(
|
|
|
499
499
|
f"--user-data-dir={user_data_dir}",
|
|
500
500
|
"--no-first-run",
|
|
501
501
|
"--no-default-browser-check",
|
|
502
|
+
# Chrome for Testing is unsigned on macOS, so every launch asks the
|
|
503
|
+
# user for the "Chromium Safe Storage" keychain item. The mock keychain
|
|
504
|
+
# keeps cookie encryption profile-local; pages cannot see it.
|
|
505
|
+
*(["--use-mock-keychain"] if sys.platform == "darwin" else []),
|
|
502
506
|
# A bfcache restore re-fires no load events, stranding the
|
|
503
507
|
# go_back/go_forward load wait. Each command attaches anew and cannot
|
|
504
508
|
# track restores across attachments, so trade bfcache for
|
|
@@ -861,6 +861,10 @@ async def launch(
|
|
|
861
861
|
f"--user-data-dir={profile}",
|
|
862
862
|
"--no-first-run",
|
|
863
863
|
"--no-default-browser-check",
|
|
864
|
+
# Chrome for Testing is unsigned on macOS, so every launch asks the
|
|
865
|
+
# user for the "Chromium Safe Storage" keychain item. The mock keychain
|
|
866
|
+
# keeps cookie encryption profile-local; pages cannot see it.
|
|
867
|
+
*(["--use-mock-keychain"] if sys.platform == "darwin" else []),
|
|
864
868
|
# Chromium builds (not branded Chrome) apply their testing field
|
|
865
869
|
# trials by default. Stable Chrome never runs that config, and one of
|
|
866
870
|
# its trials trims navigator.languages to a single entry.
|
|
@@ -164,7 +164,12 @@ def _download(url: str, target: IO[bytes]) -> int:
|
|
|
164
164
|
def _safe_member(name: str) -> PurePosixPath:
|
|
165
165
|
"""Return ``name`` as a relative path, refusing absolute or ``..`` paths."""
|
|
166
166
|
path = PurePosixPath(name)
|
|
167
|
-
if
|
|
167
|
+
if (
|
|
168
|
+
path.is_absolute()
|
|
169
|
+
or ".." in path.parts
|
|
170
|
+
or "\\" in name
|
|
171
|
+
or any(":" in part for part in path.parts)
|
|
172
|
+
):
|
|
168
173
|
msg = f"refusing archive entry outside the install folder: {name}"
|
|
169
174
|
raise WebSkrapError(msg, code=ErrorCode.BROWSER_LAUNCH)
|
|
170
175
|
return path
|
|
@@ -165,7 +165,10 @@ def _ddg_unwrap(href: str) -> str:
|
|
|
165
165
|
"""
|
|
166
166
|
if href.startswith("//"):
|
|
167
167
|
href = "https:" + href
|
|
168
|
-
|
|
168
|
+
try:
|
|
169
|
+
parts = urlsplit(href)
|
|
170
|
+
except ValueError:
|
|
171
|
+
return href
|
|
169
172
|
if parts.netloc.endswith("duckduckgo.com") and parts.path.startswith("/l/"):
|
|
170
173
|
return parse_qs(parts.query).get("uddg", [""])[0]
|
|
171
174
|
return href
|
|
@@ -200,7 +203,10 @@ def _bing_unwrap(href: str) -> str:
|
|
|
200
203
|
URL-safe base64 of the destination, without padding. Anything that does
|
|
201
204
|
not decode comes back as given rather than failing the whole page.
|
|
202
205
|
"""
|
|
203
|
-
|
|
206
|
+
try:
|
|
207
|
+
parts = urlsplit(href)
|
|
208
|
+
except ValueError:
|
|
209
|
+
return href
|
|
204
210
|
if not (parts.netloc.endswith("bing.com") and parts.path == "/ck/a"):
|
|
205
211
|
return href
|
|
206
212
|
encoded = parse_qs(parts.query).get("u", [""])[0]
|
|
@@ -292,7 +298,11 @@ def parse_results(engine: SearchEngine, html: str) -> list[SearchHit]:
|
|
|
292
298
|
seen: set[str] = set()
|
|
293
299
|
hits: list[SearchHit] = []
|
|
294
300
|
for hit in spec.hits(root):
|
|
295
|
-
|
|
301
|
+
try:
|
|
302
|
+
scheme = urlsplit(hit.url).scheme
|
|
303
|
+
except ValueError:
|
|
304
|
+
continue
|
|
305
|
+
if scheme not in ("http", "https") or hit.url in seen:
|
|
296
306
|
continue
|
|
297
307
|
seen.add(hit.url)
|
|
298
308
|
hits.append(hit)
|
|
@@ -19,7 +19,7 @@ import asyncio
|
|
|
19
19
|
import ipaddress
|
|
20
20
|
import os
|
|
21
21
|
import socket
|
|
22
|
-
from urllib.parse import urlsplit
|
|
22
|
+
from urllib.parse import unquote, urlsplit
|
|
23
23
|
|
|
24
24
|
from webskrap.errors import ErrorCode, WebSkrapError
|
|
25
25
|
|
|
@@ -122,7 +122,9 @@ async def reject_private_network(url: str) -> None:
|
|
|
122
122
|
if urlsplit(url).scheme.lower() not in ("http", "https"):
|
|
123
123
|
# data: and about:blank perform no network request; nothing to guard.
|
|
124
124
|
return
|
|
125
|
-
|
|
125
|
+
# Chromium treats backslashes as path separators and decodes host escapes.
|
|
126
|
+
# Check the host it will contact, not urllib's literal authority.
|
|
127
|
+
hostname = unquote(urlsplit(url.replace("\\", "/")).hostname or "")
|
|
126
128
|
if _host_is_blocked_literal(hostname):
|
|
127
129
|
blocked = True
|
|
128
130
|
else:
|
|
@@ -5,6 +5,7 @@ import os
|
|
|
5
5
|
import signal
|
|
6
6
|
import stat
|
|
7
7
|
import subprocess
|
|
8
|
+
import sys
|
|
8
9
|
import threading
|
|
9
10
|
from datetime import datetime, timedelta
|
|
10
11
|
from pathlib import Path
|
|
@@ -62,6 +63,20 @@ def _launch(tmp_path: Path, **kwargs: Any) -> None:
|
|
|
62
63
|
browser_session.launch_browser(tmp_path, executable="/bin/chromium", **kwargs)
|
|
63
64
|
|
|
64
65
|
|
|
66
|
+
def test_launch_uses_mock_keychain_on_macos_only(
|
|
67
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
68
|
+
) -> None:
|
|
69
|
+
commands = _capture_launch(monkeypatch)
|
|
70
|
+
|
|
71
|
+
monkeypatch.setattr(sys, "platform", "darwin")
|
|
72
|
+
_launch(tmp_path, headless=True)
|
|
73
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
74
|
+
_launch(tmp_path, headless=True)
|
|
75
|
+
|
|
76
|
+
assert "--use-mock-keychain" in commands[0]
|
|
77
|
+
assert "--use-mock-keychain" not in commands[1]
|
|
78
|
+
|
|
79
|
+
|
|
65
80
|
def test_launch_sandboxes_by_default(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
66
81
|
commands = _capture_launch(monkeypatch)
|
|
67
82
|
|
|
@@ -183,3 +183,15 @@ def test_install_refuses_an_archive_without_the_browser(
|
|
|
183
183
|
def test_browsers_dir_honors_the_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
184
184
|
monkeypatch.setenv("WEBSKRAP_BROWSERS_PATH", str(tmp_path))
|
|
185
185
|
assert install.browsers_dir() == tmp_path
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pytest.mark.parametrize(
|
|
189
|
+
"name",
|
|
190
|
+
[r"..\escape", r"\escape", r"\\server\share\escape", "folder/C:/escape", "folder/C:escape"],
|
|
191
|
+
)
|
|
192
|
+
def test_extract_refuses_windows_path_escapes(tmp_path: Path, name: str) -> None:
|
|
193
|
+
archive = _archive(tmp_path, [(name, b"x", stat.S_IFREG | 0o644)])
|
|
194
|
+
with pytest.raises(WebSkrapError) as error:
|
|
195
|
+
install.extract(archive, tmp_path / "out")
|
|
196
|
+
assert error.value.code is ErrorCode.BROWSER_LAUNCH
|
|
197
|
+
assert not (tmp_path / "out").exists()
|
|
@@ -734,6 +734,8 @@ def test_guide_documents_every_error_code() -> None:
|
|
|
734
734
|
pytest.param("ftp://example.test/x", id="ftp"),
|
|
735
735
|
pytest.param("https://user:pass@example.test/", id="userinfo"),
|
|
736
736
|
pytest.param("http://169.254.169.254/", id="link-local"),
|
|
737
|
+
pytest.param("http://%31%32%37.0.0.1/", id="encoded-loopback"),
|
|
738
|
+
pytest.param(r"http://127.0.0.1\example.com/", id="backslash-host"),
|
|
737
739
|
],
|
|
738
740
|
)
|
|
739
741
|
def test_fetch_rejects_untrusted_targets_before_launch(monkeypatch: Any, url: str) -> None:
|
|
@@ -7,7 +7,9 @@ engine changes its markup, refresh the fixture and fix the one extractor.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import base64
|
|
10
11
|
from pathlib import Path
|
|
12
|
+
from urllib.parse import quote
|
|
11
13
|
|
|
12
14
|
import pytest
|
|
13
15
|
|
|
@@ -241,3 +243,25 @@ def test_unclosed_and_void_tags_do_not_swallow_later_results() -> None:
|
|
|
241
243
|
assert [hit.url for hit in hits] == ["https://a.example/", "https://b.example/"]
|
|
242
244
|
assert hits[0].title == "A one"
|
|
243
245
|
assert hits[0].snippet == "first"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@pytest.mark.parametrize("engine", list(SearchEngine))
|
|
249
|
+
@pytest.mark.parametrize("wrapped", [False, True])
|
|
250
|
+
def test_malformed_result_url_keeps_valid_neighbors(engine: SearchEngine, wrapped: bool) -> None:
|
|
251
|
+
href = "http://[broken"
|
|
252
|
+
if wrapped:
|
|
253
|
+
if engine is SearchEngine.DDG:
|
|
254
|
+
href = "https://duckduckgo.com/l/?uddg=" + quote(href, safe="")
|
|
255
|
+
else:
|
|
256
|
+
payload = base64.urlsafe_b64encode(href.encode()).decode().rstrip("=")
|
|
257
|
+
href = "https://www.bing.com/ck/a?u=a1" + payload
|
|
258
|
+
if engine is SearchEngine.DDG:
|
|
259
|
+
template = '<div class="result"><a class="result__a" href="{}">hit</a></div>'
|
|
260
|
+
else:
|
|
261
|
+
template = '<li class="b_algo"><h2><a href="{}">hit</a></h2></li>'
|
|
262
|
+
html = '<ol id="b_results">' + template.format(href) + template.format("https://example.com/")
|
|
263
|
+
html += "</ol>"
|
|
264
|
+
|
|
265
|
+
assert parse_results(engine, html) == [
|
|
266
|
+
SearchHit(title="hit", url="https://example.com/", snippet="")
|
|
267
|
+
]
|
|
@@ -75,6 +75,10 @@ def test_private_net_opt_in_values(monkeypatch: pytest.MonkeyPatch, value: str)
|
|
|
75
75
|
"url",
|
|
76
76
|
[
|
|
77
77
|
pytest.param("http://127.0.0.1:8000/", id="loopback-v4"),
|
|
78
|
+
pytest.param("http://%31%32%37.0.0.1/", id="encoded-loopback"),
|
|
79
|
+
pytest.param("http://%6cocalhost/", id="encoded-localhost"),
|
|
80
|
+
pytest.param(r"http://127.0.0.1\example.com/", id="backslash-host"),
|
|
81
|
+
pytest.param(r"http://%31%32%37.0.0.1\example.com/", id="encoded-backslash-host"),
|
|
78
82
|
pytest.param("http://[::1]/", id="loopback-v6"),
|
|
79
83
|
pytest.param("http://10.0.0.1/", id="rfc1918-10"),
|
|
80
84
|
pytest.param("http://192.168.1.1/", id="rfc1918-192"),
|
|
@@ -145,3 +149,17 @@ def test_validate_mcp_url_combines_both_checks(monkeypatch: pytest.MonkeyPatch)
|
|
|
145
149
|
asyncio.run(validate_mcp_url("http://169.254.169.254/"))
|
|
146
150
|
|
|
147
151
|
assert asyncio.run(validate_mcp_url("https://example.com/")) == "https://example.com/"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_private_network_resolves_decoded_public_hostname(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
155
|
+
monkeypatch.delenv(PRIVATE_NET_ENV, raising=False)
|
|
156
|
+
hosts: list[str] = []
|
|
157
|
+
|
|
158
|
+
def resolve(hostname: str) -> bool:
|
|
159
|
+
hosts.append(hostname)
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
monkeypatch.setattr("webskrap.urls._resolve_blocked", resolve)
|
|
163
|
+
url = "https://%65xample.com/path%20name"
|
|
164
|
+
assert asyncio.run(validate_mcp_url(url)) == url
|
|
165
|
+
assert hosts == ["example.com"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|